From 8a35987d9feaca74b69a9c67a4f892079ba45b7a Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 10:46:23 +0000
Subject: [PATCH 001/338] feat(agent): support bounded binary stdin
---
.../Sources/DoryCore/DoryCore.swift | 42 +++++++++++
.../Sources/DorydKit/AgentControl.swift | 50 +++++++++++++
.../DorydKitTests/AgentControlTests.swift | 4 ++
dory-core/agent/src/exec.rs | 70 ++++++++++++++++++-
dory-core/ffi/src/agent_forward.rs | 35 ++++++++++
dory-core/ffi/src/remote.rs | 23 ++++++
dory-core/pb/proto/agent.proto | 3 +
dory-core/remote/src/agent_client.rs | 14 ++++
8 files changed, 240 insertions(+), 1 deletion(-)
diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift
index 4680d49c..28b9e08d 100644
--- a/dory-core-swift/Sources/DoryCore/DoryCore.swift
+++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift
@@ -380,6 +380,27 @@ public final class DoryAgentControlHandle: @unchecked Sendable {
return DoryExecResult(raw)
}
+ public func execWithInput(
+ argv: [String],
+ stdin: Data,
+ cwd: String = "",
+ env: [DoryExecEnvironment] = [],
+ timeoutMs: UInt64 = 30_000,
+ outputLimitBytes: UInt64 = 1024 * 1024
+ ) throws -> DoryExecResult {
+ let raw = try withControl {
+ try $0.execWithInput(
+ argv: argv,
+ cwd: cwd,
+ env: env.map(\.ffiValue),
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes,
+ stdin: stdin
+ )
+ }
+ return DoryExecResult(raw)
+ }
+
public func close() {
lock.lock()
control = nil
@@ -462,6 +483,27 @@ public final class DoryRemoteAgentHandle: @unchecked Sendable {
return DoryExecResult(raw)
}
+ public func execWithInput(
+ argv: [String],
+ stdin: Data,
+ cwd: String = "",
+ env: [DoryExecEnvironment] = [],
+ timeoutMs: UInt64 = 30_000,
+ outputLimitBytes: UInt64 = 1024 * 1024
+ ) throws -> DoryExecResult {
+ let raw = try withRemote {
+ try $0.execWithInput(
+ argv: argv,
+ cwd: cwd,
+ env: env.map(\.ffiValue),
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes,
+ stdin: stdin
+ )
+ }
+ return DoryExecResult(raw)
+ }
+
public func close() {
lock.lock()
remote = nil
diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift
index 721cb00c..8ff581f9 100644
--- a/dory-core-swift/Sources/DorydKit/AgentControl.swift
+++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift
@@ -32,9 +32,37 @@ public protocol AgentControlClient: Sendable {
timeoutMs: UInt64,
outputLimitBytes: UInt64
) throws -> DoryExecResult
+ func execWithInput(
+ argv: [String],
+ stdin: Data,
+ cwd: String,
+ env: [DoryExecEnvironment],
+ timeoutMs: UInt64,
+ outputLimitBytes: UInt64
+ ) throws -> DoryExecResult
func close()
}
+public extension AgentControlClient {
+ func execWithInput(
+ argv: [String],
+ stdin: Data,
+ cwd: String,
+ env: [DoryExecEnvironment],
+ timeoutMs: UInt64,
+ outputLimitBytes: UInt64
+ ) throws -> DoryExecResult {
+ guard stdin.isEmpty else { throw AgentControlError.standardInputUnsupported }
+ return try exec(
+ argv: argv,
+ cwd: cwd,
+ env: env,
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes
+ )
+ }
+}
+
extension DoryAgentControlHandle: AgentControlClient {}
public final class AgentControl: @unchecked Sendable {
@@ -104,6 +132,24 @@ public final class AgentControl: @unchecked Sendable {
)
}
+ public func execWithInput(
+ argv: [String],
+ stdin: Data,
+ cwd: String = "",
+ env: [DoryExecEnvironment] = [],
+ timeoutMs: UInt64 = 30_000,
+ outputLimitBytes: UInt64 = 1024 * 1024
+ ) throws -> DoryExecResult {
+ try connectedClient().execWithInput(
+ argv: argv,
+ stdin: stdin,
+ cwd: cwd,
+ env: env,
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes
+ )
+ }
+
public func disconnect() {
lock.lock()
let current = client
@@ -138,6 +184,10 @@ public final class AgentControl: @unchecked Sendable {
}
}
+public enum AgentControlError: Error, Sendable {
+ case standardInputUnsupported
+}
+
public enum LocalAgentControlError: Error, Sendable, Equatable, CustomStringConvertible {
case missingEndpoint
case pathTooLong(String)
diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
index 1b74a30e..bfaf6862 100644
--- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
@@ -25,6 +25,10 @@ final class AgentControlTests: XCTestCase {
let exec = try control.exec(argv: ["/bin/echo", "ok"], cwd: "/tmp")
XCTAssertEqual(exec.exitCode, 0)
XCTAssertEqual(String(data: exec.stdout, encoding: .utf8), "ok\n")
+ XCTAssertThrowsError(try control.execWithInput(
+ argv: ["/bin/cat"],
+ stdin: Data("payload".utf8)
+ ))
XCTAssertEqual(counter.value, 1)
control.disconnect()
diff --git a/dory-core/agent/src/exec.rs b/dory-core/agent/src/exec.rs
index 3748b87c..87b3aa7b 100644
--- a/dory-core/agent/src/exec.rs
+++ b/dory-core/agent/src/exec.rs
@@ -1,13 +1,14 @@
use dory_pb::agent::{ExecRequest, ExecResponse};
use std::collections::HashMap;
use thiserror::Error;
-use tokio::io::AsyncReadExt;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
const MAX_TIMEOUT_MS: u64 = 10 * 60_000;
const DEFAULT_OUTPUT_LIMIT: usize = 1024 * 1024;
const MAX_OUTPUT_LIMIT: usize = 16 * 1024 * 1024;
+const MAX_STDIN_BYTES: usize = 16 * 1024 * 1024;
// After the child is reaped, any bytes still in the pipes are bounded by the kernel buffer — unless
// a descendant inherited the write end. Bound the drain so such a descendant can never hang the RPC.
const DRAIN_TIMEOUT_MS: u64 = 5_000;
@@ -50,6 +51,12 @@ pub async fn run(req: ExecRequest) -> Result {
return Err(ExecError::EmptyProgram);
}
+ if req.stdin.len() > MAX_STDIN_BYTES {
+ return Err(ExecError::InvalidConstraint(format!(
+ "stdin exceeds {MAX_STDIN_BYTES} bytes"
+ )));
+ }
+ let stdin = req.stdin;
let (environment, constraints) = parse_environment_and_constraints(req.env)?;
let mut command = Command::new(program);
command.args(req.argv.iter().skip(1));
@@ -63,6 +70,7 @@ pub async fn run(req: ExecRequest) -> Result {
}
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
+ command.stdin(std::process::Stdio::piped());
// Own process group so a timeout can kill the whole tree, not just the direct child — a
// backgrounded descendant would otherwise survive the kill and hold the output pipes open.
#[cfg(unix)]
@@ -72,8 +80,16 @@ pub async fn run(req: ExecRequest) -> Result {
let _wait_guard = crate::reaper::managed_child_wait_guard().await;
let mut child = command.spawn()?;
let group_pid = child.id();
+ let child_stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
+ let stdin_task = tokio::spawn(async move {
+ if let Some(mut stream) = child_stdin {
+ stream.write_all(&stdin).await?;
+ stream.shutdown().await?;
+ }
+ Ok::<(), std::io::Error>(())
+ });
let limit = output_limit(req.output_limit_bytes);
let stdout_task = tokio::spawn(async move {
match stdout {
@@ -102,6 +118,18 @@ pub async fn run(req: ExecRequest) -> Result {
let (stdout, stdout_truncated) = drain_output(stdout_task, group_pid).await?;
let (stderr, stderr_truncated) = drain_output(stderr_task, group_pid).await?;
+ // Commands are allowed to exit without consuming all stdin. The process status/stderr is the
+ // authoritative result; never let a closed input pipe turn a completed command into an RPC
+ // failure or leave the writer task alive.
+ if tokio::time::timeout(
+ std::time::Duration::from_millis(DRAIN_TIMEOUT_MS),
+ stdin_task,
+ )
+ .await
+ .is_err()
+ {
+ kill_process_group(group_pid);
+ }
Ok(ExecResponse {
exit_code: status.code().unwrap_or(if timed_out { 124 } else { 128 }),
@@ -329,6 +357,7 @@ mod tests {
env: Vec::new(),
timeout_ms: 5_000,
output_limit_bytes: 1024,
+ stdin: Vec::new(),
})
.await
.unwrap();
@@ -355,6 +384,7 @@ mod tests {
}],
timeout_ms: 5_000,
output_limit_bytes: 4,
+ stdin: Vec::new(),
})
.await
.unwrap();
@@ -379,6 +409,7 @@ mod tests {
env: Vec::new(),
timeout_ms: 60_000,
output_limit_bytes: 1024,
+ stdin: Vec::new(),
})
.await
.unwrap();
@@ -401,6 +432,7 @@ mod tests {
env: Vec::new(),
timeout_ms: 50,
output_limit_bytes: 1024,
+ stdin: Vec::new(),
})
.await
.unwrap();
@@ -488,6 +520,7 @@ mod tests {
],
timeout_ms: 5_000,
output_limit_bytes: 1_024,
+ stdin: Vec::new(),
})
.await
.unwrap();
@@ -498,4 +531,39 @@ mod tests {
format!("{uid} 64 unset")
);
}
+
+ #[tokio::test]
+ async fn exec_delivers_binary_stdin() {
+ let payload = vec![0, 1, 2, 0xff, b'\n'];
+ let out = run(ExecRequest {
+ argv: vec!["/bin/cat".into()],
+ cwd: String::new(),
+ env: Vec::new(),
+ timeout_ms: 5_000,
+ output_limit_bytes: 1_024,
+ stdin: payload.clone(),
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(out.exit_code, 0);
+ assert_eq!(out.stdout, payload);
+ }
+
+ #[tokio::test]
+ async fn exec_rejects_oversized_stdin() {
+ let error = run(ExecRequest {
+ argv: vec!["/bin/cat".into()],
+ cwd: String::new(),
+ env: Vec::new(),
+ timeout_ms: 5_000,
+ output_limit_bytes: 1_024,
+ stdin: vec![0; MAX_STDIN_BYTES + 1],
+ })
+ .await
+ .unwrap_err();
+
+ assert!(matches!(error, ExecError::InvalidConstraint(_)));
+ assert_eq!(error.code(), 400);
+ }
}
diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs
index 29e7a203..6f63e5ab 100644
--- a/dory-core/ffi/src/agent_forward.rs
+++ b/dory-core/ffi/src/agent_forward.rs
@@ -174,6 +174,28 @@ impl AgentControl {
))?;
Ok(exec_result(out))
}
+
+ pub fn exec_with_input(
+ &self,
+ argv: Vec,
+ cwd: String,
+ env: Vec,
+ timeout_ms: u64,
+ output_limit_bytes: u64,
+ stdin: Vec,
+ ) -> Result {
+ let guard = self.runtime.lock().unwrap();
+ let runtime = guard.as_ref().ok_or_else(shutdown_error)?;
+ let out = runtime.block_on(self.client.exec_with_input(
+ argv,
+ cwd,
+ env.into_iter().map(|item| (item.key, item.value)).collect(),
+ timeout_ms,
+ output_limit_bytes,
+ stdin,
+ ))?;
+ Ok(exec_result(out))
+ }
}
impl Drop for AgentControl {
@@ -271,6 +293,19 @@ mod tests {
.expect("exec");
assert_eq!(exec.exit_code, 0);
assert_eq!(exec.stdout, b"ffi-exec");
+ let input = vec![0, 1, 2, 0xff, b'\n'];
+ let echoed = control
+ .exec_with_input(
+ vec!["/bin/cat".into()],
+ String::new(),
+ Vec::new(),
+ 5_000,
+ 1024,
+ input.clone(),
+ )
+ .expect("exec with input");
+ assert_eq!(echoed.exit_code, 0);
+ assert_eq!(echoed.stdout, input);
let _ = std::fs::remove_file(&path);
}
diff --git a/dory-core/ffi/src/remote.rs b/dory-core/ffi/src/remote.rs
index 9cf4b111..b17f5807 100644
--- a/dory-core/ffi/src/remote.rs
+++ b/dory-core/ffi/src/remote.rs
@@ -204,6 +204,29 @@ impl RemoteAgent {
Ok(exec_result(out))
}
+ /// Run a bounded command and deliver a binary-safe stdin payload through the agent RPC.
+ pub fn exec_with_input(
+ &self,
+ argv: Vec,
+ cwd: String,
+ env: Vec,
+ timeout_ms: u64,
+ output_limit_bytes: u64,
+ stdin: Vec,
+ ) -> Result {
+ let guard = self.runtime.lock().unwrap();
+ let runtime = guard.as_ref().ok_or_else(shutdown_error)?;
+ let out = runtime.block_on(self.agent.client.exec_with_input(
+ argv,
+ cwd,
+ env.into_iter().map(|item| (item.key, item.value)).collect(),
+ timeout_ms,
+ output_limit_bytes,
+ stdin,
+ ))?;
+ Ok(exec_result(out))
+ }
+
/// Push `local_root` to `remote_root`, making the remote an exact replica (host-authoritative).
pub fn push(
&self,
diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto
index 5dc679d0..7fe17388 100644
--- a/dory-core/pb/proto/agent.proto
+++ b/dory-core/pb/proto/agent.proto
@@ -64,6 +64,9 @@ message ExecRequest {
repeated ExecEnv env = 3;
uint64 timeout_ms = 4;
uint64 output_limit_bytes = 5;
+ // Optional bounded stdin payload. This is binary-safe so desktop integration can transfer
+ // clipboard images without placing their contents in argv or the environment.
+ bytes stdin = 6;
}
message ExecResponse {
int32 exit_code = 1;
diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs
index 0a1d3310..02ba3209 100644
--- a/dory-core/remote/src/agent_client.rs
+++ b/dory-core/remote/src/agent_client.rs
@@ -133,6 +133,19 @@ impl AgentClient {
env: Vec<(String, String)>,
timeout_ms: u64,
output_limit_bytes: u64,
+ ) -> Result {
+ self.exec_with_input(argv, cwd, env, timeout_ms, output_limit_bytes, Vec::new())
+ .await
+ }
+
+ pub async fn exec_with_input(
+ &self,
+ argv: Vec,
+ cwd: String,
+ env: Vec<(String, String)>,
+ timeout_ms: u64,
+ output_limit_bytes: u64,
+ stdin: Vec,
) -> Result {
let env = env
.into_iter()
@@ -152,6 +165,7 @@ impl AgentClient {
env,
timeout_ms,
output_limit_bytes,
+ stdin,
}),
server_timeout + EXEC_GRACE,
)
From 491b69e2d6e8711043e338e3e6e70aeb01cc9a20 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 10:48:30 +0000
Subject: [PATCH 002/338] feat(desktop): add shared runtime and clipboard
contracts
---
dory-core-swift/Package.swift | 7 +-
.../DoryCore/DoryDesktopRuntimeContract.swift | 9 +
.../DoryDesktopClipboardPolicy.swift | 33 ++
.../DoryDesktopRuntimeContract.swift | 100 ++++++
.../DoryDesktopClipboardCoordinator.swift | 320 ++++++++++++++++++
.../DoryDesktopRuntimeContractTests.swift | 48 +++
.../DoryDesktopClipboardPolicyTests.swift | 26 ++
7 files changed, 541 insertions(+), 2 deletions(-)
create mode 100644 dory-core-swift/Sources/DoryCore/DoryDesktopRuntimeContract.swift
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift
create mode 100644 dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift
create mode 100644 dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift
diff --git a/dory-core-swift/Package.swift b/dory-core-swift/Package.swift
index ad2214e7..6554d82e 100644
--- a/dory-core-swift/Package.swift
+++ b/dory-core-swift/Package.swift
@@ -21,7 +21,10 @@ let package = Package(
],
targets: [
.binaryTarget(name: "DoryFFI", path: "artifacts/DoryFFI.xcframework"),
- .target(name: "DoryOperations"),
+ .target(
+ name: "DoryOperations",
+ linkerSettings: [.linkedLibrary("z")]
+ ),
.target(
name: "DoryCore",
dependencies: ["DoryFFI", "DoryOperations"]
@@ -37,7 +40,7 @@ let package = Package(
),
.target(
name: "DoryVMMKit",
- dependencies: ["DoryCore", "DorydKit"],
+ dependencies: ["DoryCore", "DorydKit", "DoryOperations"],
linkerSettings: [
.linkedFramework("AppKit"),
.linkedFramework("Virtualization"),
diff --git a/dory-core-swift/Sources/DoryCore/DoryDesktopRuntimeContract.swift b/dory-core-swift/Sources/DoryCore/DoryDesktopRuntimeContract.swift
new file mode 100644
index 00000000..d238bc26
--- /dev/null
+++ b/dory-core-swift/Sources/DoryCore/DoryDesktopRuntimeContract.swift
@@ -0,0 +1,9 @@
+import DoryOperations
+
+// Keep the contract available through DoryCore for helper clients that already import it, while
+// the canonical definitions live in DoryOperations so the app UI and the daemon use identical
+// values without making the app link the FFI-backed core module.
+public typealias DoryDesktopVMMPreference = DoryOperations.DoryDesktopVMMPreference
+public typealias DoryDesktopGraphicsPreference = DoryOperations.DoryDesktopGraphicsPreference
+public typealias DoryDesktopGraphicsBackend = DoryOperations.DoryDesktopGraphicsBackend
+public typealias DoryDesktopRuntimeContractError = DoryOperations.DoryDesktopRuntimeContractError
diff --git a/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift b/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift
new file mode 100644
index 00000000..698c1235
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryDesktopClipboardPolicy.swift
@@ -0,0 +1,33 @@
+public enum DoryDesktopClipboardPolicy: String, CaseIterable, Sendable {
+ case off
+ case hostToGuest = "host-to-guest"
+ case guestToHost = "guest-to-host"
+ case bidirectional
+
+ public static let environmentKey = "DORY_CLIPBOARD_POLICY"
+
+ public init(environment: [String: String]) {
+ guard let rawValue = environment[Self.environmentKey] else {
+ self = .bidirectional
+ return
+ }
+ self = Self(rawValue: rawValue) ?? .off
+ }
+
+ public var allowsHostToGuest: Bool {
+ self == .hostToGuest || self == .bidirectional
+ }
+
+ public var allowsGuestToHost: Bool {
+ self == .guestToHost || self == .bidirectional
+ }
+
+ public var displayName: String {
+ switch self {
+ case .off: "Off"
+ case .hostToGuest: "Mac to Linux"
+ case .guestToHost: "Linux to Mac"
+ case .bidirectional: "Bidirectional"
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift b/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift
new file mode 100644
index 00000000..b428e971
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift
@@ -0,0 +1,100 @@
+import Foundation
+
+/// Stable host/guest contract for choosing the desktop virtualization implementation.
+///
+/// `automatic` is the product default: doryd prefers Dory's accelerated raw-Hypervisor runtime
+/// when it is available, while retaining Virtualization.framework as the selectable compatibility
+/// runtime. The explicit values are useful for qualification and for recovering a machine on a
+/// host whose accelerated graphics stack is temporarily unavailable.
+public enum DoryDesktopVMMPreference: String, CaseIterable, Sendable, Codable {
+ public static let environmentKey = "DORY_DESKTOP_VMM"
+
+ case automatic = "auto"
+ case accelerated
+ case compatible
+
+ public init(environment: [String: String]) throws {
+ let raw = environment[Self.environmentKey] ?? Self.automatic.rawValue
+ guard let value = Self(rawValue: raw) else {
+ throw DoryDesktopRuntimeContractError.invalidValue(
+ key: Self.environmentKey,
+ value: raw,
+ allowed: Self.allCases.map(\.rawValue)
+ )
+ }
+ self = value
+ }
+}
+
+/// Requested graphics behavior for Dory's raw-Hypervisor desktop runtime.
+///
+/// The default first attempts the combined VirGL2 + Venus backend: VirGL accelerates the desktop
+/// compositor while Venus gives Vulkan applications a real Apple-GPU path. Hosts without a
+/// qualified Venus renderer fall back to classic VirGL, then to virtio-gpu 2D, so graphics
+/// acceleration cannot make the Linux desktop unbootable.
+public enum DoryDesktopGraphicsPreference: String, CaseIterable, Sendable, Codable {
+ public static let environmentKey = "DORY_DESKTOP_GRAPHICS"
+ public static let legacyClassicOnlyEnvironmentKey = "DORY_VIRGL_CLASSIC_ONLY"
+
+ case automatic = "auto"
+ case virgl
+ case virglVenus = "virgl-venus"
+ case software
+
+ public init(environment: [String: String]) throws {
+ if let raw = environment[Self.environmentKey] {
+ guard let value = Self(rawValue: raw) else {
+ throw DoryDesktopRuntimeContractError.invalidValue(
+ key: Self.environmentKey,
+ value: raw,
+ allowed: Self.allCases.map(\.rawValue)
+ )
+ }
+ self = value
+ return
+ }
+
+ // Preserve machines created while the experimental toggle was the only public contract.
+ switch environment[Self.legacyClassicOnlyEnvironmentKey] {
+ case "1": self = .virgl
+ case "0": self = .virglVenus
+ default: self = .automatic
+ }
+ }
+}
+
+/// The graphics backend actually attached to a running raw-Hypervisor desktop.
+public enum DoryDesktopGraphicsBackend: String, Sendable, Codable {
+ case virgl
+ case virglVenus = "virgl-venus"
+ case software
+
+ public var isAccelerated: Bool { self != .software }
+
+ /// Kernel token read by the versioned guest integration scripts.
+ public var kernelArgument: String { "dory.graphics=\(rawValue)" }
+
+ /// Compatibility token for desktop images predating `dory.graphics`.
+ public var legacyKernelArgument: String? {
+ isAccelerated ? "dory.gpu=venus" : nil
+ }
+
+ public var displayName: String {
+ switch self {
+ case .virgl: "VirGL2"
+ case .virglVenus: "VirGL2 + Venus"
+ case .software: "software"
+ }
+ }
+}
+
+public enum DoryDesktopRuntimeContractError: Error, Equatable, LocalizedError {
+ case invalidValue(key: String, value: String, allowed: [String])
+
+ public var errorDescription: String? {
+ switch self {
+ case let .invalidValue(key, value, allowed):
+ "Invalid \(key)=\(value); expected one of \(allowed.joined(separator: ", "))"
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift b/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift
new file mode 100644
index 00000000..d19662da
--- /dev/null
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift
@@ -0,0 +1,320 @@
+import AppKit
+import DoryCore
+import DoryOperations
+import Foundation
+
+private struct DoryDesktopClipboardPayload: Sendable, Equatable {
+ // Leave headroom for the protobuf envelope under dory-proto's 16 MiB frame ceiling.
+ static let maximumBytes = 15 * 1024 * 1024
+
+ let mimeType: String
+ let data: Data
+
+ init?(mimeType: String, data: Data) {
+ guard data.count <= Self.maximumBytes else { return nil }
+ self.mimeType = mimeType
+ self.data = data
+ }
+}
+
+/// Host-side clipboard integration shared by the raw Hypervisor.framework and
+/// Virtualization.framework desktop paths. Guest commands travel over Dory's authenticated agent
+/// channel; AppKit access stays on the main thread and blocking work stays on one private queue.
+public final class DoryDesktopClipboardCoordinator: @unchecked Sendable {
+ public typealias Executor = @Sendable (
+ _ argv: [String],
+ _ stdin: Data,
+ _ timeoutMs: UInt64,
+ _ outputLimitBytes: UInt64
+ ) throws -> DoryExecResult
+ public typealias ShortcutSender = @MainActor @Sendable (_ linuxKeyCode: UInt16) -> Void
+
+ private let policy: DoryDesktopClipboardPolicy
+ private let execute: Executor
+ private let sendShortcut: ShortcutSender
+ private let pasteboard: NSPasteboard
+ private let startupRetryDelay: TimeInterval
+ private let startupRetryLimit: Int
+ private let queue = DispatchQueue(label: "dev.dory.desktop-clipboard", qos: .userInitiated)
+ private let log: @Sendable (String) -> Void
+ private var observations = [NSObjectProtocol]()
+ private var guestReady = false
+ private var lastPushedHostChangeCount = -1
+
+ public convenience init(
+ policy: DoryDesktopClipboardPolicy,
+ execute: @escaping Executor,
+ sendShortcut: @escaping ShortcutSender,
+ log: @escaping @Sendable (String) -> Void
+ ) {
+ self.init(
+ policy: policy,
+ execute: execute,
+ sendShortcut: sendShortcut,
+ pasteboard: .general,
+ startupRetryDelay: 1,
+ startupRetryLimit: 60,
+ log: log
+ )
+ }
+
+ init(
+ policy: DoryDesktopClipboardPolicy,
+ execute: @escaping Executor,
+ sendShortcut: @escaping ShortcutSender,
+ pasteboard: NSPasteboard,
+ startupRetryDelay: TimeInterval,
+ startupRetryLimit: Int,
+ log: @escaping @Sendable (String) -> Void
+ ) {
+ self.policy = policy
+ self.execute = execute
+ self.sendShortcut = sendShortcut
+ self.pasteboard = pasteboard
+ self.startupRetryDelay = startupRetryDelay
+ self.startupRetryLimit = max(0, startupRetryLimit)
+ self.log = log
+ }
+
+ @MainActor
+ public func start() {
+ lastPushedHostChangeCount = pasteboard.changeCount
+ observations.append(NotificationCenter.default.addObserver(
+ forName: NSApplication.didBecomeActiveNotification,
+ object: NSApplication.shared,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.pushHostClipboardIfChanged(force: false) }
+ })
+ observations.append(NotificationCenter.default.addObserver(
+ forName: NSApplication.didResignActiveNotification,
+ object: NSApplication.shared,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.pullGuestClipboard() }
+ })
+ }
+
+ @MainActor
+ public func markGuestReady() {
+ queue.async { [weak self] in
+ guard let self else { return }
+ let available: Bool
+ do {
+ let result = try self.execute(
+ ["/usr/bin/test", "-x", "/usr/lib/dory/clipboard"],
+ Data(),
+ 5_000,
+ 4_096
+ )
+ available = result.exitCode == 0 && !result.timedOut
+ } catch {
+ available = false
+ self.log("clipboard capability probe failed: \(error)")
+ }
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ self.guestReady = available
+ if available {
+ // The agent commonly becomes ready a few seconds before GDM creates the
+ // user's Wayland/X11 clipboard. Keep retrying the initial transfer so a
+ // Mac clipboard copied before boot is not silently dropped.
+ self.pushHostClipboardIfChanged(
+ force: true,
+ startupRetriesRemaining: self.startupRetryLimit
+ )
+ } else {
+ self.log("clipboard integration is unavailable until guest tools are updated")
+ }
+ }
+ }
+ }
+ }
+
+ @MainActor
+ public func stop() {
+ guestReady = false
+ for observation in observations {
+ NotificationCenter.default.removeObserver(observation)
+ }
+ observations.removeAll()
+ }
+
+ /// Returns true when a macOS Command+C/X/V gesture was translated to its Linux Ctrl shortcut.
+ @MainActor
+ public func handleMacShortcut(_ event: NSEvent) -> Bool {
+ guard event.modifierFlags.contains(.command),
+ let character = event.charactersIgnoringModifiers?.lowercased() else {
+ return false
+ }
+ switch character {
+ case "c":
+ sendShortcut(46)
+ scheduleGuestReadIfAllowed()
+ return true
+ case "x":
+ sendShortcut(45)
+ scheduleGuestReadIfAllowed()
+ return true
+ case "v":
+ guard policy.allowsHostToGuest, guestReady,
+ let payload = Self.readHostClipboard(from: pasteboard) else {
+ sendShortcut(47)
+ return true
+ }
+ queue.async { [weak self] in
+ guard let self else { return }
+ let didWrite = self.writeGuestClipboard(payload)
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ if didWrite {
+ self.lastPushedHostChangeCount = self.pasteboard.changeCount
+ }
+ self.sendShortcut(47)
+ }
+ }
+ }
+ return true
+ default:
+ return false
+ }
+ }
+
+ @MainActor
+ private func scheduleGuestReadIfAllowed() {
+ guard guestReady, policy.allowsGuestToHost else { return }
+ queue.asyncAfter(deadline: .now() + 0.15) { [weak self] in
+ self?.readGuestClipboardAndPublishToHost()
+ }
+ }
+
+ @MainActor
+ private func pushHostClipboardIfChanged(
+ force: Bool,
+ startupRetriesRemaining: Int = 0
+ ) {
+ guard guestReady, policy.allowsHostToGuest else { return }
+ let changeCount = pasteboard.changeCount
+ guard force || changeCount != lastPushedHostChangeCount,
+ let payload = Self.readHostClipboard(from: pasteboard) else { return }
+ queue.async { [weak self] in
+ guard let self else { return }
+ let didWrite = self.writeGuestClipboard(payload)
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ if didWrite, self.pasteboard.changeCount == changeCount {
+ self.lastPushedHostChangeCount = changeCount
+ } else if !didWrite,
+ self.guestReady,
+ startupRetriesRemaining > 0 {
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + self.startupRetryDelay
+ ) { [weak self] in
+ MainActor.assumeIsolated {
+ self?.pushHostClipboardIfChanged(
+ force: true,
+ startupRetriesRemaining: startupRetriesRemaining - 1
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @MainActor
+ private func pullGuestClipboard() {
+ guard guestReady, policy.allowsGuestToHost else { return }
+ queue.async { [weak self] in self?.readGuestClipboardAndPublishToHost() }
+ }
+
+ private func writeGuestClipboard(_ payload: DoryDesktopClipboardPayload) -> Bool {
+ do {
+ let result = try execute(
+ ["/usr/lib/dory/clipboard", "set", payload.mimeType],
+ payload.data,
+ 5_000,
+ 64 * 1024
+ )
+ guard result.exitCode == 0, !result.timedOut else {
+ log("clipboard write failed (exit=\(result.exitCode), timedOut=\(result.timedOut))")
+ return false
+ }
+ return true
+ } catch {
+ log("clipboard write failed: \(error)")
+ return false
+ }
+ }
+
+ private func readGuestClipboardAndPublishToHost() {
+ guard let payload = readGuestClipboard() else { return }
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ Self.writeHostClipboard(payload, to: self.pasteboard)
+ self.lastPushedHostChangeCount = self.pasteboard.changeCount
+ }
+ }
+ }
+
+ private func readGuestClipboard() -> DoryDesktopClipboardPayload? {
+ for mimeType in ["image/png", "text/plain;charset=utf-8", "text/plain"] {
+ do {
+ let result = try execute(
+ ["/usr/lib/dory/clipboard", "get", mimeType],
+ Data(),
+ 5_000,
+ UInt64(DoryDesktopClipboardPayload.maximumBytes)
+ )
+ guard result.exitCode == 0, !result.timedOut, !result.stdoutTruncated else { continue }
+ if mimeType == "image/png", result.stdout.isEmpty { continue }
+ if let payload = DoryDesktopClipboardPayload(mimeType: mimeType, data: result.stdout) {
+ return payload
+ }
+ } catch {
+ log("clipboard read failed: \(error)")
+ return nil
+ }
+ }
+ return nil
+ }
+
+ @MainActor
+ private static func readHostClipboard(
+ from pasteboard: NSPasteboard
+ ) -> DoryDesktopClipboardPayload? {
+ if let png = pasteboard.data(forType: .png),
+ let payload = DoryDesktopClipboardPayload(mimeType: "image/png", data: png) {
+ return payload
+ }
+ if let tiff = pasteboard.data(forType: .tiff),
+ let bitmap = NSBitmapImageRep(data: tiff),
+ let png = bitmap.representation(using: .png, properties: [:]),
+ let payload = DoryDesktopClipboardPayload(mimeType: "image/png", data: png) {
+ return payload
+ }
+ guard let string = pasteboard.string(forType: .string) else { return nil }
+ return DoryDesktopClipboardPayload(
+ mimeType: "text/plain;charset=utf-8",
+ data: Data(string.utf8)
+ )
+ }
+
+ @MainActor
+ private static func writeHostClipboard(
+ _ payload: DoryDesktopClipboardPayload,
+ to pasteboard: NSPasteboard
+ ) {
+ pasteboard.clearContents()
+ if payload.mimeType == "image/png" {
+ pasteboard.setData(payload.data, forType: .png)
+ } else {
+ pasteboard.setString(String(decoding: payload.data, as: UTF8.self), forType: .string)
+ }
+ }
+}
diff --git a/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift b/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift
new file mode 100644
index 00000000..a9072c24
--- /dev/null
+++ b/dory-core-swift/Tests/DoryCoreTests/DoryDesktopRuntimeContractTests.swift
@@ -0,0 +1,48 @@
+@testable import DoryCore
+import XCTest
+
+final class DoryDesktopRuntimeContractTests: XCTestCase {
+ func testDesktopVMMPreferenceDefaultsAndValidates() throws {
+ XCTAssertEqual(try DoryDesktopVMMPreference(environment: [:]), .automatic)
+ XCTAssertEqual(
+ try DoryDesktopVMMPreference(environment: [
+ DoryDesktopVMMPreference.environmentKey: "compatible",
+ ]),
+ .compatible
+ )
+ XCTAssertThrowsError(try DoryDesktopVMMPreference(environment: [
+ DoryDesktopVMMPreference.environmentKey: "fastest",
+ ]))
+ }
+
+ func testGraphicsPreferencePreservesLegacyMachinesButPrefersNewContract() throws {
+ XCTAssertEqual(try DoryDesktopGraphicsPreference(environment: [:]), .automatic)
+ XCTAssertEqual(
+ try DoryDesktopGraphicsPreference(environment: [
+ DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey: "1",
+ ]),
+ .virgl
+ )
+ XCTAssertEqual(
+ try DoryDesktopGraphicsPreference(environment: [
+ DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey: "1",
+ DoryDesktopGraphicsPreference.environmentKey: "virgl-venus",
+ ]),
+ .virglVenus
+ )
+ XCTAssertThrowsError(try DoryDesktopGraphicsPreference(environment: [
+ DoryDesktopGraphicsPreference.environmentKey: "metal",
+ ]))
+ }
+
+ func testResolvedGraphicsBackendsPublishTruthfulKernelTokens() {
+ XCTAssertEqual(DoryDesktopGraphicsBackend.virgl.kernelArgument, "dory.graphics=virgl")
+ XCTAssertEqual(
+ DoryDesktopGraphicsBackend.virglVenus.kernelArgument,
+ "dory.graphics=virgl-venus"
+ )
+ XCTAssertEqual(DoryDesktopGraphicsBackend.software.kernelArgument, "dory.graphics=software")
+ XCTAssertNotNil(DoryDesktopGraphicsBackend.virgl.legacyKernelArgument)
+ XCTAssertNil(DoryDesktopGraphicsBackend.software.legacyKernelArgument)
+ }
+}
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift
new file mode 100644
index 00000000..0f937f93
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryDesktopClipboardPolicyTests.swift
@@ -0,0 +1,26 @@
+import Testing
+@testable import DoryOperations
+
+@Suite("Desktop clipboard policy")
+struct DoryDesktopClipboardPolicyTests {
+ @Test("defaults existing desktops to bidirectional sharing")
+ func defaultPolicy() {
+ let policy = DoryDesktopClipboardPolicy(environment: [:])
+ #expect(policy == .bidirectional)
+ #expect(policy.allowsHostToGuest)
+ #expect(policy.allowsGuestToHost)
+ }
+
+ @Test("directional and off policies enforce both boundaries")
+ func directionalPolicies() {
+ #expect(DoryDesktopClipboardPolicy.hostToGuest.allowsHostToGuest)
+ #expect(!DoryDesktopClipboardPolicy.hostToGuest.allowsGuestToHost)
+ #expect(!DoryDesktopClipboardPolicy.guestToHost.allowsHostToGuest)
+ #expect(DoryDesktopClipboardPolicy.guestToHost.allowsGuestToHost)
+ #expect(!DoryDesktopClipboardPolicy.off.allowsHostToGuest)
+ #expect(!DoryDesktopClipboardPolicy.off.allowsGuestToHost)
+ #expect(DoryDesktopClipboardPolicy(environment: [
+ DoryDesktopClipboardPolicy.environmentKey: "invalid"
+ ]) == .off)
+ }
+}
From 0363ef0869d4f0a2c9a39f45558adb659982660f Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 10:50:16 +0000
Subject: [PATCH 003/338] feat(hv): add accelerated Linux desktop runtime
---
Packages/ContainerizationEngine/Package.swift | 11 +
Packages/ContainerizationEngine/README.md | 2 +-
.../DoryHV/GVProxyDesktopLaunchPlan.swift | 19 +
.../DoryHV/GuestVsockSocketBridge.swift | 66 +
.../Sources/DoryHV/Machine.swift | 72 +-
.../Sources/DoryHV/VirglRenderer.swift | 758 ++++++++---
.../Sources/DoryHV/VirtioGPU.swift | 1020 ++++++++++++++-
.../Sources/DoryHV/VirtioInput.swift | 289 +++++
.../Sources/DoryHV/VirtioMMIO.swift | 16 +-
.../Sources/DoryHV/VirtioSound.swift | 564 +++++++++
.../Sources/DoryHV/Virtqueue.swift | 32 +-
.../Sources/DoryHV/X86BootPlan.swift | 1 +
.../Sources/dory-hv/DesktopAudioBackend.swift | 498 ++++++++
.../Sources/dory-hv/DesktopMetalDisplay.swift | 440 +++++++
.../Sources/dory-hv/DesktopMode.swift | 674 ++++++++++
.../Sources/dory-hv/EngineMode.swift | 3 +-
.../Sources/dory-hv/main.swift | 90 +-
.../Tests/DoryHVTests/DeviceLogicTests.swift | 1116 ++++++++++++++++-
.../GVProxyDesktopLaunchPlanTests.swift | 20 +
.../GuestShutdownCommandTests.swift | 20 +
.../Tests/DoryHVTests/VirtqueueTests.swift | 20 +
.../dory-hv.entitlements | 8 +-
.../DoryCore/GuestShutdownCommand.swift | 17 +
patches/moltenvk-dory-native-arrays.patch | 37 +
.../virglrenderer-macos-fragment-coord.patch | 177 +++
25 files changed, 5769 insertions(+), 201 deletions(-)
create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift
create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift
create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift
create mode 100644 Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift
create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift
create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift
create mode 100644 Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
create mode 100644 Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift
create mode 100644 patches/moltenvk-dory-native-arrays.patch
create mode 100644 patches/virglrenderer-macos-fragment-coord.patch
diff --git a/Packages/ContainerizationEngine/Package.swift b/Packages/ContainerizationEngine/Package.swift
index 4ecd0cb4..8a75d2ce 100644
--- a/Packages/ContainerizationEngine/Package.swift
+++ b/Packages/ContainerizationEngine/Package.swift
@@ -35,6 +35,7 @@ let package = Package(
.linkedFramework("CoreServices"),
.linkedFramework("IOKit"),
.linkedFramework("IOUSBHost"),
+ .linkedFramework("OpenGL"),
]
),
.executableTarget(
@@ -42,6 +43,16 @@ let package = Package(
dependencies: [
"DoryHV",
.product(name: "DoryCore", package: "dory-core-swift"),
+ .product(name: "DorydKit", package: "dory-core-swift"),
+ .product(name: "DoryOperations", package: "dory-core-swift"),
+ .product(name: "DoryVMMKit", package: "dory-core-swift"),
+ ],
+ linkerSettings: [
+ .linkedFramework("AppKit"),
+ .linkedFramework("AVFAudio"),
+ .linkedFramework("AVFoundation"),
+ .linkedFramework("Metal"),
+ .linkedFramework("MetalKit"),
]
),
.testTarget(
diff --git a/Packages/ContainerizationEngine/README.md b/Packages/ContainerizationEngine/README.md
index 06f5d3de..8320a765 100644
--- a/Packages/ContainerizationEngine/README.md
+++ b/Packages/ContainerizationEngine/README.md
@@ -12,7 +12,7 @@ The full process, storage, networking, and trust-boundary contract is documented
- Arm64 and x86_64 raw-HV boot/device implementations. Public 0.4 releases remain Apple-silicon
only until an Intel candidate passes dedicated physical qualification.
-- Virtio block, network, vsock, rng, balloon, GPU-preview, and VirtioFS devices.
+- Virtio block, network, vsock, rng, balloon, VirGL/Venus GPU, and VirtioFS devices.
- A copyless guest networking path through the provenance-pinned `gvproxy` helper.
- Host-share coherence, bounded FSEvents batching, queue/backpressure telemetry, and recovery.
- Published-port, SSH-agent, host-AI, and guest-control bridges.
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift
new file mode 100644
index 00000000..8644902d
--- /dev/null
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift
@@ -0,0 +1,19 @@
+/// Builds the isolated gvproxy command line used by persistent desktop VMs.
+///
+/// Desktop control and shell access travel over dedicated per-machine vsock bridges. Keeping
+/// gvproxy on private Unix sockets—and explicitly disabling its legacy SSH forward—prevents one
+/// desktop from claiming a host-global port needed by another desktop or by its own restart.
+package enum GVProxyDesktopLaunchPlan {
+ package static func arguments(
+ mtu: Int,
+ datapathSocket: String,
+ apiSocket: String
+ ) -> [String] {
+ [
+ "-mtu", String(mtu),
+ "-listen-vfkit", "unixgram://\(datapathSocket)",
+ "-listen", "unix://\(apiSocket)",
+ "-ssh-port", "-1",
+ ]
+ }
+}
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift
new file mode 100644
index 00000000..1183ee22
--- /dev/null
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift
@@ -0,0 +1,66 @@
+import Darwin
+import Foundation
+
+/// Publishes one private host Unix socket and relays each accepted connection to a fixed guest
+/// vsock port. Desktop machines use it for the agent control and shell endpoints expected by doryd.
+public final class GuestVsockSocketBridge: @unchecked Sendable {
+ private let socketPath: String
+ private let guestPort: UInt32
+ private let log: @Sendable (String) -> Void
+
+ public init(
+ socketPath: String,
+ guestPort: UInt32,
+ log: @escaping @Sendable (String) -> Void = { _ in }
+ ) {
+ self.socketPath = socketPath
+ self.guestPort = guestPort
+ self.log = log
+ }
+
+ public static func validateSocketPath(_ socketPath: String) throws {
+ try VsockUnixRelay.validateSocketPath(socketPath)
+ }
+
+ public func attach(to vsock: VirtioVsock) throws {
+ let listener = try VsockUnixRelay.makeListener(socketPath: socketPath, mode: 0o600)
+ let path = socketPath
+ let port = guestPort
+ let log = log
+ let box = VsockBox(vsock)
+ Thread.detachNewThread {
+ while true {
+ let client = accept(listener, nil, nil)
+ guard client >= 0 else {
+ if errno == EINTR { continue }
+ log("vsock bridge accept failed on \(path): errno \(errno)")
+ break
+ }
+ var noSigpipe: Int32 = 1
+ _ = setsockopt(
+ client,
+ SOL_SOCKET,
+ SO_NOSIGPIPE,
+ &noSigpipe,
+ socklen_t(MemoryLayout.size)
+ )
+ let connection = ConnectionBox(box.vsock.connect(port: port))
+ Thread.detachNewThread {
+ VsockUnixRelay.serve(client: client, connection: connection.value)
+ }
+ }
+ close(listener)
+ }
+ log("vsock bridge serving \(socketPath) over guest port \(guestPort)")
+ }
+
+ private final class VsockBox: @unchecked Sendable {
+ let vsock: VirtioVsock
+ init(_ vsock: VirtioVsock) { self.vsock = vsock }
+ }
+
+ private final class ConnectionBox: @unchecked Sendable {
+ let value: VsockConnection
+ init(_ value: VsockConnection) { self.value = value }
+ }
+}
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift
index 5f0f3ccc..735e4d83 100644
--- a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift
@@ -18,17 +18,29 @@ public enum GuestLayout {
public static let virtioFirstIRQ: UInt32 = 16 // SPI numbers 16... (intid 48...)
public static let ramBase: UInt64 = 0x8000_0000
public static let dtbOffset: UInt64 = 256 << 20
+ /// Direct-boot initrds live beyond the kernel/DTB reservation while remaining well inside
+ /// the minimum supported 1-GiB guest. Keeping this deterministic also makes the DTB contract
+ /// straightforward to test and diagnose.
+ public static let initrdOffset: UInt64 = 320 << 20
public static let daxWindowBase: UInt64 = 0xC_0000_0000
}
public struct MachineConfiguration {
public var kernelPath: String
+ public var initrdPath: String?
public var commandLine: String
public var memoryBytes: UInt64
public var cpuCount: Int
- public init(kernelPath: String, commandLine: String, memoryBytes: UInt64, cpuCount: Int) {
+ public init(
+ kernelPath: String,
+ initrdPath: String? = nil,
+ commandLine: String,
+ memoryBytes: UInt64,
+ cpuCount: Int
+ ) {
self.kernelPath = kernelPath
+ self.initrdPath = initrdPath
self.commandLine = commandLine
self.memoryBytes = memoryBytes
self.cpuCount = cpuCount
@@ -125,11 +137,29 @@ public final class Machine: @unchecked Sendable {
guard kernel.textOffset + kernel.imageSize < GuestLayout.dtbOffset else {
throw VMError.bootFailure("kernel image overlaps DTB placement")
}
- let dtb = try buildDeviceTree()
+ let initrdRange = try loadInitrdIfPresent()
+ let dtb = try buildDeviceTree(initrdRange: initrdRange)
try memory.write(dtb, at: dtbAddress)
}
- private func buildDeviceTree() throws -> [UInt8] {
+ private func loadInitrdIfPresent() throws -> Range? {
+ guard let path = configuration.initrdPath else { return nil }
+ let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)
+ guard !data.isEmpty else {
+ throw VMError.bootFailure("initrd is empty: \(path)")
+ }
+ let start = GuestLayout.ramBase + GuestLayout.initrdOffset
+ let (end, overflowed) = start.addingReportingOverflow(UInt64(data.count))
+ guard !overflowed,
+ end > start,
+ end <= GuestLayout.ramBase + configuration.memoryBytes else {
+ throw VMError.bootFailure("initrd does not fit in guest memory")
+ }
+ try memory.write(Array(data), at: start)
+ return start..?) throws -> [UInt8] {
let gicPhandle: UInt32 = 1
let clockPhandle: UInt32 = 2
let virtualTimer = try Self.reservedIntid(HV_GIC_INT_EL1_VIRTUAL_TIMER)
@@ -148,6 +178,10 @@ public final class Machine: @unchecked Sendable {
fdt.beginNode("chosen")
fdt.property("bootargs", string: configuration.commandLine)
fdt.property("stdout-path", string: "/pl011@\(String(GuestLayout.uartBase, radix: 16))")
+ if let initrdRange {
+ fdt.property("linux,initrd-start", cells64: [initrdRange.lowerBound])
+ fdt.property("linux,initrd-end", cells64: [initrdRange.upperBound])
+ }
fdt.endNode()
fdt.beginNode("memory@\(String(GuestLayout.ramBase, radix: 16))")
@@ -596,12 +630,20 @@ public enum GuestLayout {
public struct MachineConfiguration {
public var kernelPath: String
+ public var initrdPath: String?
public var commandLine: String
public var memoryBytes: UInt64
public var cpuCount: Int
- public init(kernelPath: String, commandLine: String, memoryBytes: UInt64, cpuCount: Int) {
+ public init(
+ kernelPath: String,
+ initrdPath: String? = nil,
+ commandLine: String,
+ memoryBytes: UInt64,
+ cpuCount: Int
+ ) {
self.kernelPath = kernelPath
+ self.initrdPath = initrdPath
self.commandLine = commandLine
self.memoryBytes = memoryBytes
self.cpuCount = cpuCount
@@ -648,6 +690,21 @@ public final class Machine: @unchecked Sendable {
entryPoint = try kernel.load(into: memory)
startInfoAddress = X86GuestLayout.pvhStartInfo
+ let initrdData = try configuration.initrdPath.map {
+ try Data(contentsOf: URL(fileURLWithPath: $0), options: .mappedIfSafe)
+ }
+ if let initrdData, initrdData.isEmpty {
+ throw VMError.bootFailure("initrd is empty: \(configuration.initrdPath ?? "")")
+ }
+ let initrdAddress = X86GuestLayout.initrd
+ if let initrdData {
+ let (end, overflowed) = initrdAddress.addingReportingOverflow(UInt64(initrdData.count))
+ guard !overflowed, end <= configuration.memoryBytes else {
+ throw VMError.bootFailure("initrd does not fit in guest memory")
+ }
+ try memory.write(Array(initrdData), at: initrdAddress)
+ }
+
let plan = X86BootPlanBuilder.build(
baseCommandLine: configuration.commandLine,
memoryBytes: configuration.memoryBytes,
@@ -658,11 +715,16 @@ public final class Machine: @unchecked Sendable {
commandLinePhysicalAddress: X86GuestLayout.pvhCommandLine,
modulesPhysicalAddress: X86GuestLayout.pvhModules,
memoryMapPhysicalAddress: X86GuestLayout.pvhMemoryMap,
- modules: [],
+ modules: initrdData.map {
+ [PVHModule(physicalAddress: initrdAddress, size: UInt64($0.count))]
+ } ?? [],
memoryMap: plan.memoryMap
)
try memory.write(Array(pvh.startInfo), at: X86GuestLayout.pvhStartInfo)
try memory.write(Array(pvh.commandLine), at: X86GuestLayout.pvhCommandLine)
+ if !pvh.modules.isEmpty {
+ try memory.write(Array(pvh.modules), at: X86GuestLayout.pvhModules)
+ }
try memory.write(Array(pvh.memoryMap), at: X86GuestLayout.pvhMemoryMap)
let mpTable = MPTableBuilder.build(
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift
index c1b311e9..f958e0e1 100644
--- a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift
@@ -1,21 +1,50 @@
import Darwin
import Foundation
+import OpenGL
+import OpenGL.GL3
public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable {
public let libraryPath: String
- public let moltenVKICDPath: String
+ public let moltenVKICDPath: String?
public let capsets: [VirtioGPUCapset]
private let handle: UnsafeMutableRawPointer
private let callbacks: UnsafeMutablePointer
+ private let glContextManager: VirglCGLContextManager
private let functions: Functions
+ private let submissionSyncMode: SubmissionSyncMode
+ private let classicOnly: Bool
+ private let rendererLock = NSRecursiveLock()
+ private var pollSource: DispatchSourceRead?
+ private var pollTimer: DispatchSourceTimer?
+ // Each guest renderer context gets one rolling GL fence. Submissions only flush the producer
+ // context; scanout readback waits for the latest fence from every producer in ctx0. This keeps
+ // shared textures coherent without blocking the vCPU on glFinish after every command buffer.
+ private var pendingSubmissionSyncs: [UInt32: GLsync] = [:]
+ // Retain guest-provided labels so a renderer failure identifies Mutter, Firefox, or the Vulkan
+ // client that supplied the command stream without relying on process IDs.
+ private var contextLabels: [UInt32: String] = [:]
+ // Fence creation in classic VirGL must happen in the producer's OpenGL context. Keep each
+ // context's negotiated capset so Venus fences can stay on their native renderer timeline while
+ // VirGL fences explicitly restore the producer context after Dory clears thread-local CGL state.
+ private var contextFlags: [UInt32: UInt32] = [:]
+ // virglrenderer retains the *iovec array pointer* supplied at resource creation/attach; it
+ // does not copy those descriptors. Swift's temporary Array storage therefore cannot be used
+ // for resource backing even though the guest-memory pointers inside each descriptor are
+ // stable. Keep an owned C allocation alive for exactly as long as the renderer resource.
+ private var resourceIOVecs: [UInt32: OwnedIOVecs] = [:]
- // Fence completions arrive on virglrenderer's internal threads (ASYNC_FENCE_CB); the C
- // callbacks have no per-instance cookie routing here, so a single active renderer is registered
- // globally (one renderer per engine process) and forwards into the device's sink.
+ // The C callbacks have no per-instance cookie routing here, so a single active renderer is
+ // registered globally (one renderer per engine process). A callback can run synchronously from
+ // virgl_renderer_poll while rendererLock is held. Calling the virtio device from there would
+ // invert rendererLock and the transport's queue lock: a vCPU creating the next fence holds the
+ // queue lock while waiting for rendererLock, and the poll callback would hold rendererLock while
+ // waiting for that queue lock. Buffer completions here and deliver them only after the renderer
+ // API boundary releases rendererLock.
private static let fenceLock = NSLock()
nonisolated(unsafe) private static weak var activeRenderer: VirglRenderer?
nonisolated(unsafe) private var fenceSink: ((UInt32, UInt32, UInt64) -> Void)?
+ nonisolated(unsafe) private var pendingFenceSignals: [(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64)] = []
public var onFenceSignaled: ((UInt32, UInt32, UInt64) -> Void)? {
get { Self.fenceLock.lock(); defer { Self.fenceLock.unlock() }; return fenceSink }
@@ -24,33 +53,46 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable {
fileprivate static func signalFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64) {
fenceLock.lock()
- let sink = activeRenderer?.fenceSink
+ activeRenderer?.pendingFenceSignals.append((contextID, ringIndex, fenceID))
fenceLock.unlock()
- sink?(contextID, ringIndex, fenceID)
}
public static func discover(
environment: [String: String] = ProcessInfo.processInfo.environment
) throws -> VirglRenderer {
+ let classicOnly = environment["DORY_VIRGL_CLASSIC_ONLY"] == "1"
guard let libraryPath = firstExistingPath(candidates: virglRendererCandidates(environment: environment)) else {
throw VMError.invalidConfiguration(
- "gpu=venus requires libvirglrenderer.dylib; set DORY_VIRGLRENDERER_PATH or bundle it in Contents/Frameworks"
+ "accelerated graphics require libvirglrenderer.dylib; set DORY_VIRGLRENDERER_PATH or bundle it in Contents/Frameworks"
)
}
- guard let moltenVKICD = firstExistingPath(candidates: moltenVKICDCandidates(environment: environment)) else {
+ let moltenVKICD = firstExistingPath(candidates: moltenVKICDCandidates(environment: environment))
+ guard classicOnly || moltenVKICD != nil else {
throw VMError.invalidConfiguration(
- "gpu=venus requires MoltenVK_icd.json; set DORY_MOLTENVK_ICD or bundle it in Contents/Resources/vulkan/icd.d"
+ "VirGL + Venus graphics require MoltenVK_icd.json; set DORY_MOLTENVK_ICD or bundle it in Contents/Resources/vulkan/icd.d"
)
}
- return try VirglRenderer(libraryPath: libraryPath, moltenVKICDPath: moltenVKICD)
+ return try VirglRenderer(
+ libraryPath: libraryPath,
+ moltenVKICDPath: moltenVKICD,
+ environment: environment
+ )
}
- public init(libraryPath: String, moltenVKICDPath: String) throws {
+ public init(
+ libraryPath: String,
+ moltenVKICDPath: String? = nil,
+ environment: [String: String] = ProcessInfo.processInfo.environment
+ ) throws {
+ let classicOnly = environment["DORY_VIRGL_CLASSIC_ONLY"] == "1"
guard FileManager.default.fileExists(atPath: libraryPath) else {
throw VMError.invalidConfiguration("virglrenderer library not found: \(libraryPath)")
}
- guard FileManager.default.fileExists(atPath: moltenVKICDPath) else {
- throw VMError.invalidConfiguration("MoltenVK ICD not found: \(moltenVKICDPath)")
+ if !classicOnly {
+ guard let moltenVKICDPath,
+ FileManager.default.fileExists(atPath: moltenVKICDPath) else {
+ throw VMError.invalidConfiguration("VirGL + Venus graphics require a valid MoltenVK ICD")
+ }
}
guard let handle = dlopen(libraryPath, RTLD_NOW | RTLD_LOCAL) else {
let message = dlerror().map { String(cString: $0) } ?? "unknown dlopen failure"
@@ -59,13 +101,28 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable {
do {
let functions = try Functions(handle: handle)
+ let glContextManager = try VirglCGLContextManager()
+ if !classicOnly,
+ dlsym(handle, "dory_virglrenderer_macos_venus_fence_fix") == nil {
+ throw VMError.invalidConfiguration(
+ "libvirglrenderer at \(libraryPath) is missing Dory's macOS Venus fence fix; rebuild or install the pinned Dory renderer"
+ )
+ }
+ if !classicOnly,
+ dlsym(handle, "dory_moltenvk_spirv_native_array_fix") == nil {
+ throw VMError.invalidConfiguration(
+ "libvirglrenderer at \(libraryPath) is not linked to Dory's patched MoltenVK runtime; the ICD path alone cannot replace a directly linked MoltenVK dylib"
+ )
+ }
guard functions.resourceMap != nil || functions.resourceGetMapPtr != nil else {
throw VMError.invalidConfiguration(
"libvirglrenderer at \(libraryPath) exports neither virgl_renderer_resource_map nor virgl_renderer_resource_get_map_ptr; Dory's Venus path needs one to expose host-visible blobs to the guest"
)
}
- setenv("VK_ICD_FILENAMES", moltenVKICDPath, 1)
+ if let moltenVKICDPath {
+ setenv("VK_ICD_FILENAMES", moltenVKICDPath, 1)
+ }
if let sym = dlsym(handle, "virgl_set_log_callback") {
typealias SetLogCallback = @convention(c) (
@@ -80,9 +137,9 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable {
callbacks.initialize(to: VirglRendererCallbacks(
version: 4,
writeFence: doryVirglWriteFence,
- createGLContext: nil,
- destroyGLContext: nil,
- makeCurrent: nil,
+ createGLContext: doryVirglCreateGLContext,
+ destroyGLContext: doryVirglDestroyGLContext,
+ makeCurrent: doryVirglMakeCurrent,
getDRMFD: nil,
writeContextFence: doryVirglWriteContextFence,
getServerFD: nil,
@@ -91,42 +148,72 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable {
// Host-allocated HOST3D blobs (the zero-copy map model libkrun uses): do NOT set
// USE_GUEST_VRAM, which would make virglrenderer choose guest-backed storage that returns
- // no mappable host pointer. ASYNC_FENCE_CB delivers fence completions from the renderer's
- // own threads, so no poll loop is needed for real fence signalling.
- let flags = RendererFlags.venus | RendererFlags.noVirgl | RendererFlags.asyncFenceCB
- let initStatus = functions.initialize(nil, flags, UnsafeMutableRawPointer(callbacks))
+ // no mappable host pointer. Keep both renderers active: VirGL gives the Linux desktop a
+ // reliable accelerated OpenGL compositor through CGL, while Venus remains available to
+ // Vulkan applications and compute workloads. The macOS renderer build does not expose
+ // the Linux eventfd needed by virglrenderer's threaded/async fence mode, so Dory polls
+ // its fence timelines explicitly below. This covers both VirGL and Venus contexts.
+ let flags = classicOnly ? 0 : RendererFlags.venus
+ let rendererCookie = Unmanaged.passUnretained(glContextManager).toOpaque()
+ let initStatus = functions.initialize(rendererCookie, flags, UnsafeMutableRawPointer(callbacks))
guard initStatus == 0 else {
callbacks.deinitialize(count: 1)
callbacks.deallocate()
- throw VMError.invalidConfiguration("virgl_renderer_init(Venus) failed with status \(initStatus)")
+ throw VMError.invalidConfiguration("virgl_renderer_init(VirGL + Venus) failed with status \(initStatus)")
}
- var maxVersion: UInt32 = 0
- var maxSize: UInt32 = 0
- functions.getCapSet(VirtioGPUCapsetID.venus, &maxVersion, &maxSize)
- // Venus reports maxVersion == 0 (it negotiates its protocol via the capset data, not the
- // capset version number, unlike virgl). Gate on maxSize only.
- guard maxSize > 0 else {
+ var discoveredCapsets = [VirtioGPUCapset]()
+ for capsetID in [VirtioGPUCapsetID.virgl, VirtioGPUCapsetID.virgl2, VirtioGPUCapsetID.venus] {
+ var maxVersion: UInt32 = 0
+ var maxSize: UInt32 = 0
+ functions.getCapSet(capsetID, &maxVersion, &maxSize)
+ guard maxSize > 0 else { continue }
+ var capsetData = [UInt8](repeating: 0, count: Int(maxSize))
+ capsetData.withUnsafeMutableBytes { buffer in
+ functions.fillCaps(capsetID, maxVersion, buffer.baseAddress)
+ }
+ discoveredCapsets.append(VirtioGPUCapset(
+ id: capsetID,
+ maxVersion: maxVersion,
+ data: capsetData
+ ))
+ }
+ let hasVirgl2 = discoveredCapsets.contains(where: { $0.id == VirtioGPUCapsetID.virgl2 })
+ let hasVenus = discoveredCapsets.contains(where: { $0.id == VirtioGPUCapsetID.venus })
+ guard hasVirgl2, classicOnly || hasVenus else {
functions.cleanup(nil)
callbacks.deinitialize(count: 1)
callbacks.deallocate()
- throw VMError.invalidConfiguration("virglrenderer did not report a Venus capset")
+ throw VMError.invalidConfiguration(
+ classicOnly
+ ? "virglrenderer did not report the VirGL2 capset"
+ : "virglrenderer did not report both VirGL2 and Venus capsets"
+ )
}
- var capsetData = [UInt8](repeating: 0, count: Int(maxSize))
- capsetData.withUnsafeMutableBytes { buffer in
- functions.fillCaps(VirtioGPUCapsetID.venus, maxVersion, buffer.baseAddress)
- }
+ // CGL contexts are current per host thread. Initialization runs on the app thread,
+ // while virtio notifications can subsequently arrive on any vCPU thread. Leave no
+ // renderer context attached to the initializer so each command can bind it safely on
+ // the thread that is actually executing the command.
+ glContextManager.clearCurrent()
self.libraryPath = libraryPath
self.moltenVKICDPath = moltenVKICDPath
- self.capsets = [VirtioGPUCapset(id: VirtioGPUCapsetID.venus, maxVersion: maxVersion, data: capsetData)]
+ self.capsets = discoveredCapsets
self.handle = handle
self.callbacks = callbacks
+ self.glContextManager = glContextManager
self.functions = functions
+ self.submissionSyncMode = SubmissionSyncMode(environment: environment)
+ self.classicOnly = classicOnly
Self.fenceLock.lock()
Self.activeRenderer = self
Self.fenceLock.unlock()
+ FileHandle.standardError.write(Data(
+ "dory-gpu: renderer mode=\(classicOnly ? "classic" : "virgl+venus") "
+ .appending("cross-context synchronization mode=\(submissionSyncMode.rawValue)\n").utf8
+ ))
+ startFencePolling()
} catch {
dlclose(handle)
throw error
@@ -134,81 +221,180 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable {
}
deinit {
+ pollSource?.cancel()
+ pollSource = nil
+ pollTimer?.cancel()
+ pollTimer = nil
Self.fenceLock.lock()
if Self.activeRenderer === self { Self.activeRenderer = nil }
Self.fenceLock.unlock()
+ rendererLock.lock()
+ functions.forceContext0()
+ deletePendingSubmissionSyncs()
functions.cleanup(nil)
+ resourceIOVecs.removeAll()
+ glContextManager.clearCurrent()
+ rendererLock.unlock()
callbacks.deinitialize(count: 1)
callbacks.deallocate()
dlclose(handle)
}
- /// Registers a host fence so virglrenderer signals it (via the async callbacks) once all GPU
- /// work submitted before it has completed. Context fences carry Venus's per-ring ordering;
- /// plain fences ride the global ctx0 timeline.
+ /// Registers a host fence so virglrenderer signals it once all GPU work submitted before it has
+ /// completed. Context fences carry Venus's per-ring ordering; plain fences ride the global ctx0
+ /// timeline.
public func createFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64, contextFence: Bool) throws {
- if contextFence, let contextCreateFence = functions.contextCreateFence {
+ try withRendererContext(forceContextZero: false) {
+ let capsetID = contextFlags[contextID].map { $0 & 0xff }
+ // Besides binding ctx0 on this host thread, force_ctx_0 invalidates virglrenderer's
+ // process-global current-context cache. Without that invalidation a zero-length submit
+ // to the previous producer can be treated as an already-complete switch even though
+ // Dory cleared the thread-local CGL context at the prior API boundary.
+ functions.forceContext0()
+ if contextID != 0, capsetID != VirtioGPUCapsetID.venus {
+ // virgl_renderer_create_fence records a ctx0 timeline entry but inserts glFenceSync
+ // into whichever GL context is current. QEMU naturally leaves the producer current;
+ // Dory deliberately clears CGL at each API boundary because vCPU calls can migrate
+ // across host threads. A zero-length submit performs virglrenderer's normal context
+ // switch without changing guest state, restoring the producer before fence creation.
+ try check(
+ functions.submitCommand(nil, Int32(bitPattern: contextID), 0),
+ "virgl_renderer_submit_cmd(context restore for fence)"
+ )
+ }
+ if contextFence, let contextCreateFence = functions.contextCreateFence {
+ try check(
+ contextCreateFence(contextID, 0, ringIndex, fenceID),
+ "virgl_renderer_context_create_fence"
+ )
+ return
+ }
try check(
- contextCreateFence(contextID, 0, ringIndex, fenceID),
- "virgl_renderer_context_create_fence"
+ functions.createFence(Int32(truncatingIfNeeded: fenceID), contextID),
+ "virgl_renderer_create_fence"
)
- return
}
- try check(
- functions.createFence(Int32(truncatingIfNeeded: fenceID), contextID),
- "virgl_renderer_create_fence"
- )
}
public func createContext(id: UInt32, flags: UInt32, name: String) throws {
- let status = name.withCString { pointer in
- functions.contextCreateWithFlags(id, flags, UInt32(name.utf8.count), pointer)
+ try withRendererContext {
+ let status = name.withCString { pointer in
+ functions.contextCreateWithFlags(id, flags, UInt32(name.utf8.count), pointer)
+ }
+ guard status == 0 else {
+ throw VMError.invalidConfiguration(
+ "virgl_renderer_context_create_with_flags failed with status \(status) "
+ + "(context=\(id), flags=0x\(String(flags, radix: 16)), name=\(name))"
+ )
+ }
+ contextLabels[id] = name
+ contextFlags[id] = flags
}
- try check(status, "virgl_renderer_context_create_with_flags")
}
public func destroyContext(id: UInt32) throws {
- functions.contextDestroy(id)
+ try withRendererContext {
+ functions.contextDestroy(id)
+ contextLabels.removeValue(forKey: id)
+ contextFlags.removeValue(forKey: id)
+ }
}
public func attachResource(contextID: UInt32, resourceID: UInt32) throws {
- functions.contextAttachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID))
+ try withRendererContext {
+ functions.contextAttachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID))
+ }
}
public func detachResource(contextID: UInt32, resourceID: UInt32) throws {
- functions.contextDetachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID))
+ try withRendererContext {
+ functions.contextDetachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID))
+ }
}
public func submit3D(contextID: UInt32, command: [UInt8]) throws {
- var command = command
- while command.count % 4 != 0 { command.append(0) }
- let dwordCount = Int32(command.count / 4)
- let status = command.withUnsafeMutableBytes { buffer in
- functions.submitCommand(buffer.baseAddress, Int32(bitPattern: contextID), dwordCount)
+ try withRendererContext {
+ var command = command
+ while command.count % 4 != 0 { command.append(0) }
+ let dwordCount = Int32(command.count / 4)
+ let status = command.withUnsafeMutableBytes { buffer in
+ functions.submitCommand(buffer.baseAddress, Int32(bitPattern: contextID), dwordCount)
+ }
+ guard status == 0 else {
+ let label = contextLabels[contextID] ?? "unknown"
+ let summary = Self.commandSummary(command)
+ throw VMError.invalidConfiguration(
+ "virgl_renderer_submit_cmd failed with status \(status) "
+ + "(context=\(contextID), name=\(label), bytes=\(command.count), commands=\(summary))"
+ )
+ }
+ // Contexts share textures, but cross-context visibility still needs explicit ordering.
+ // Keep the latest completion fence for each producer and resolve it at the readback
+ // boundary. glFinish is retained as a diagnostic mode for isolating driver bugs.
+ if submissionSyncMode == .finishAfterSubmit {
+ glFinish()
+ return
+ }
+ if let previous = pendingSubmissionSyncs.removeValue(forKey: contextID) {
+ glDeleteSync(previous)
+ }
+ if let sync = glFenceSync(GLenum(GL_SYNC_GPU_COMMANDS_COMPLETE), 0) {
+ pendingSubmissionSyncs[contextID] = sync
+ glFlush()
+ } else {
+ // Extremely old/limited OpenGL implementations still remain correct.
+ glFinish()
+ }
+ }
+ }
+
+ private static func commandSummary(_ command: [UInt8]) -> String {
+ var offset = 0
+ var descriptions = [String]()
+ while offset + 4 <= command.count, descriptions.count < 24 {
+ let header = UInt32(command[offset])
+ | (UInt32(command[offset + 1]) << 8)
+ | (UInt32(command[offset + 2]) << 16)
+ | (UInt32(command[offset + 3]) << 24)
+ let opcode = header & 0xff
+ let length = Int(header >> 16)
+ descriptions.append("\(offset / 4):\(opcode)/\(length)")
+ let advance = (length + 1) * 4
+ guard advance > 0, advance <= command.count - offset else { break }
+ offset += advance
}
- try check(status, "virgl_renderer_submit_cmd")
+ if offset < command.count { descriptions.append("...") }
+ return descriptions.joined(separator: ",")
}
public func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws {
- var args = VirglRendererResourceCreateArgs(
- handle: resource.resourceID,
- target: resource.target,
- format: resource.format,
- bind: resource.bind,
- width: resource.width,
- height: resource.height,
- depth: resource.depth,
- arraySize: resource.arraySize,
- lastLevel: resource.lastLevel,
- samples: resource.samples,
- flags: resource.flags
- )
- let status = try withIOVecs(entries) { pointer, count in
- withUnsafeMutablePointer(to: &args) { argsPointer in
- functions.resourceCreate(UnsafeMutableRawPointer(argsPointer), pointer, count)
+ try withRendererContext {
+ var args = VirglRendererResourceCreateArgs(
+ handle: resource.resourceID,
+ target: resource.target,
+ format: resource.format,
+ bind: resource.bind,
+ width: resource.width,
+ height: resource.height,
+ depth: resource.depth,
+ arraySize: resource.arraySize,
+ lastLevel: resource.lastLevel,
+ samples: resource.samples,
+ flags: resource.flags
+ )
+ let backing = entries.isEmpty ? nil : OwnedIOVecs(entries: entries)
+ let status = withUnsafeMutablePointer(to: &args) { argsPointer in
+ functions.resourceCreate(
+ UnsafeMutableRawPointer(argsPointer),
+ backing?.pointer,
+ backing?.count ?? 0
+ )
+ }
+ try check(status, "virgl_renderer_resource_create")
+ if let backing {
+ resourceIOVecs[resource.resourceID] = backing
}
}
- try check(status, "virgl_renderer_resource_create")
}
public func createBlob(
@@ -220,108 +406,253 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable {
size: UInt64,
entries: [VirtioGPUMemoryEntry]
) throws {
- var args = VirglRendererResourceCreateBlobArgs(
- resourceHandle: resourceID,
- contextID: contextID,
- blobMemory: blobMemory,
- blobFlags: blobFlags,
- blobID: blobID,
- size: size,
- iovecs: nil,
- iovecCount: 0
- )
- let status = try withIOVecs(entries) { pointer, count in
- args.iovecs = pointer
- args.iovecCount = count
- return withUnsafePointer(to: &args) { argsPointer in
+ try withRendererContext {
+ let backing = entries.isEmpty ? nil : OwnedIOVecs(entries: entries)
+ var args = VirglRendererResourceCreateBlobArgs(
+ resourceHandle: resourceID,
+ contextID: contextID,
+ blobMemory: blobMemory,
+ blobFlags: blobFlags,
+ blobID: blobID,
+ size: size,
+ iovecs: backing?.pointer,
+ iovecCount: backing?.count ?? 0
+ )
+ let status = withUnsafePointer(to: &args) { argsPointer in
functions.resourceCreateBlob(UnsafeRawPointer(argsPointer))
}
+ try check(status, "virgl_renderer_resource_create_blob")
+ if let backing {
+ resourceIOVecs[resourceID] = backing
+ }
}
- try check(status, "virgl_renderer_resource_create_blob")
}
public func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws {
- let status = try withIOVecs(entries) { pointer, count in
- functions.resourceAttachIOV(Int32(bitPattern: resourceID), pointer, Int32(count))
+ try withRendererContext {
+ guard !entries.isEmpty else {
+ throw VMError.invalidConfiguration("cannot attach empty backing to renderer resource \(resourceID)")
+ }
+ let backing = OwnedIOVecs(entries: entries)
+ let status = functions.resourceAttachIOV(
+ Int32(bitPattern: resourceID),
+ backing.pointer,
+ Int32(backing.count)
+ )
+ try check(status, "virgl_renderer_resource_attach_iov")
+ resourceIOVecs[resourceID] = backing
}
- try check(status, "virgl_renderer_resource_attach_iov")
}
public func detachBacking(resourceID: UInt32) throws {
- var detached: UnsafeMutablePointer?
- var count: Int32 = 0
- functions.resourceDetachIOV(Int32(bitPattern: resourceID), &detached, &count)
+ try withRendererContext {
+ var detached: UnsafeMutablePointer?
+ var count: Int32 = 0
+ functions.resourceDetachIOV(Int32(bitPattern: resourceID), &detached, &count)
+ resourceIOVecs.removeValue(forKey: resourceID)
+ }
}
public func unrefResource(resourceID: UInt32) throws {
- functions.resourceUnref(resourceID)
+ try withRendererContext {
+ functions.resourceUnref(resourceID)
+ resourceIOVecs.removeValue(forKey: resourceID)
+ }
}
public func mapBlob(resourceID: UInt32) throws -> VirtioGPUBlobMapping {
- var pointer: UnsafeMutableRawPointer?
- var size: UInt64 = 0
- // On macOS the blob is MoltenVK-backed (Apple handle); virgl_renderer_resource_map returns
- // -EINVAL for it by design. get_map_ptr returns the vkMapMemory host VA to hv_vm_map into the
- // guest — the exact path libkrun/krunkit use. Fall back to resource_map only if absent.
- if let getPtr = functions.resourceGetMapPtr {
- var address: UInt64 = 0
- try check(getPtr(resourceID, &address), "virgl_renderer_resource_get_map_ptr")
- pointer = UnsafeMutableRawPointer(bitPattern: UInt(address))
- } else if let map = functions.resourceMap {
- try check(map(resourceID, &pointer, &size), "virgl_renderer_resource_map")
- } else {
- throw VMError.invalidConfiguration("virglrenderer has no blob map entrypoint")
- }
- guard let hostPointer = pointer else {
- throw VMError.invalidConfiguration("virglrenderer returned a null host pointer for blob resource \(resourceID)")
+ try withRendererContext {
+ var pointer: UnsafeMutableRawPointer?
+ var size: UInt64 = 0
+ // On macOS the blob is MoltenVK-backed (Apple handle); virgl_renderer_resource_map returns
+ // -EINVAL for it by design. get_map_ptr returns the vkMapMemory host VA to hv_vm_map into the
+ // guest — the exact path libkrun/krunkit use. Fall back to resource_map only if absent.
+ let requiresRendererUnmap: Bool
+ if let getPtr = functions.resourceGetMapPtr {
+ var address: UInt64 = 0
+ try check(getPtr(resourceID, &address), "virgl_renderer_resource_get_map_ptr")
+ pointer = UnsafeMutableRawPointer(bitPattern: UInt(address))
+ // get_map_ptr exposes the existing Vulkan allocation without changing virglrenderer's
+ // resource-map state. Calling resource_unmap for this path is therefore invalid.
+ requiresRendererUnmap = false
+ } else if let map = functions.resourceMap {
+ try check(map(resourceID, &pointer, &size), "virgl_renderer_resource_map")
+ requiresRendererUnmap = true
+ } else {
+ throw VMError.invalidConfiguration("virglrenderer has no blob map entrypoint")
+ }
+ guard let hostPointer = pointer else {
+ throw VMError.invalidConfiguration("virglrenderer returned a null host pointer for blob resource \(resourceID)")
+ }
+ var mapInfo: UInt32 = 0
+ try check(functions.resourceGetMapInfo(resourceID, &mapInfo), "virgl_renderer_resource_get_map_info")
+ return VirtioGPUBlobMapping(
+ hostPointer: hostPointer,
+ size: size,
+ mapInfo: mapInfo & 0x0f,
+ requiresRendererUnmap: requiresRendererUnmap
+ )
}
- var mapInfo: UInt32 = 0
- try check(functions.resourceGetMapInfo(resourceID, &mapInfo), "virgl_renderer_resource_get_map_info")
- return VirtioGPUBlobMapping(hostPointer: hostPointer, size: size, mapInfo: mapInfo & 0x0f)
}
public func unmapBlob(resourceID: UInt32) throws {
- try check(functions.resourceUnmap(resourceID), "virgl_renderer_resource_unmap")
+ try withRendererContext {
+ try check(functions.resourceUnmap(resourceID), "virgl_renderer_resource_unmap")
+ }
}
public func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws {
- var box = VirglBox(values: transfer.box)
- let status = try withIOVecs(entries) { pointer, count in
- withUnsafePointer(to: &box) { boxPointer in
- functions.transferWriteIOV(
- transfer.resourceID,
- transfer.contextID,
- Int32(bitPattern: transfer.level),
- transfer.stride,
- transfer.layerStride,
- UnsafeRawPointer(boxPointer),
- transfer.offset,
- pointer,
- count
- )
+ try withRendererContext {
+ var box = VirglBox(values: transfer.box)
+ let status = try withIOVecs(entries) { pointer, count in
+ withUnsafePointer(to: &box) { boxPointer in
+ functions.transferWriteIOV(
+ transfer.resourceID,
+ transfer.contextID,
+ Int32(bitPattern: transfer.level),
+ transfer.stride,
+ transfer.layerStride,
+ UnsafeRawPointer(boxPointer),
+ transfer.offset,
+ pointer,
+ count
+ )
+ }
}
+ try check(status, "virgl_renderer_transfer_write_iov")
}
- try check(status, "virgl_renderer_transfer_write_iov")
}
public func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws {
- var box = VirglBox(values: transfer.box)
- let status = try withIOVecs(entries) { pointer, count in
- withUnsafePointer(to: &box) { boxPointer in
- functions.transferReadIOV(
- transfer.resourceID,
- transfer.contextID,
- transfer.level,
- transfer.stride,
- transfer.layerStride,
- UnsafeRawPointer(boxPointer),
- transfer.offset,
- pointer,
- Int32(count)
+ try withRendererContext {
+ try waitForPendingSubmissions()
+ var box = VirglBox(values: transfer.box)
+ let status = try withIOVecs(entries) { pointer, count in
+ withUnsafePointer(to: &box) { boxPointer in
+ functions.transferReadIOV(
+ transfer.resourceID,
+ transfer.contextID,
+ transfer.level,
+ transfer.stride,
+ transfer.layerStride,
+ UnsafeRawPointer(boxPointer),
+ transfer.offset,
+ pointer,
+ Int32(count)
+ )
+ }
+ }
+ try check(status, "virgl_renderer_transfer_read_iov")
+ }
+ }
+
+ private func waitForPendingSubmissions() throws {
+ for sync in pendingSubmissionSyncs.values {
+ switch submissionSyncMode {
+ case .clientWaitBeforeReadback:
+ // glWaitSync only orders commands subsequently issued by its *current* context.
+ // virglrenderer may switch from ctx0 to a resource context during transfer_read,
+ // so a server-side wait in ctx0 does not protect that readback. A client wait
+ // establishes completion before any context switch and is valid for shared sync
+ // objects created by Firefox, Mutter, or another guest GL producer.
+ try waitForCompletion(sync)
+ case .serverWaitBeforeReadback:
+ glWaitSync(sync, 0, GLuint64(GL_TIMEOUT_IGNORED))
+ case .finishAfterSubmit:
+ break
+ }
+ glDeleteSync(sync)
+ }
+ pendingSubmissionSyncs.removeAll(keepingCapacity: true)
+ }
+
+ private func waitForCompletion(_ sync: GLsync) throws {
+ let deadline = ProcessInfo.processInfo.systemUptime + 5
+ while true {
+ let result = glClientWaitSync(sync, 0, 100_000_000)
+ switch result {
+ case GLenum(GL_ALREADY_SIGNALED), GLenum(GL_CONDITION_SATISFIED):
+ return
+ case GLenum(GL_TIMEOUT_EXPIRED):
+ guard ProcessInfo.processInfo.systemUptime < deadline else {
+ throw VMError.invalidConfiguration(
+ "timed out waiting for a shared OpenGL submission before scanout readback"
+ )
+ }
+ default:
+ throw VMError.invalidConfiguration(
+ "glClientWaitSync failed before scanout readback (status=0x\(String(result, radix: 16)))"
)
}
}
- try check(status, "virgl_renderer_transfer_read_iov")
+ }
+
+ private func deletePendingSubmissionSyncs() {
+ for sync in pendingSubmissionSyncs.values { glDeleteSync(sync) }
+ pendingSubmissionSyncs.removeAll()
+ }
+
+ /// virglrenderer caches its active context globally because QEMU normally calls it from one
+ /// event-loop thread. Dory's MMIO notifications can originate on any vCPU thread, while CGL's
+ /// current context is thread-local. Resetting to ctx0 at each serialized API boundary makes
+ /// virglrenderer issue the appropriate make-current callback on the actual calling thread.
+ private func withRendererContext(
+ forceContextZero: Bool = true,
+ _ body: () throws -> T
+ ) throws -> T {
+ rendererLock.lock()
+ if forceContextZero {
+ functions.forceContext0()
+ }
+ defer {
+ glContextManager.clearCurrent()
+ rendererLock.unlock()
+ deliverPendingFenceSignals()
+ }
+ return try body()
+ }
+
+ /// Runs the device-facing part of a fence completion outside rendererLock. Besides avoiding the
+ /// lock inversion documented above, this keeps virglrenderer's C callback short and prevents
+ /// guest used-ring writes or virtual interrupts from re-entering the renderer while it polls.
+ private func deliverPendingFenceSignals() {
+ Self.fenceLock.lock()
+ let signals = pendingFenceSignals
+ pendingFenceSignals.removeAll(keepingCapacity: true)
+ let sink = fenceSink
+ Self.fenceLock.unlock()
+ guard let sink else { return }
+ for signal in signals {
+ sink(signal.contextID, signal.ringIndex, signal.fenceID)
+ }
+ }
+
+ /// virglrenderer's threaded fence worker signals this descriptor when the caller must run
+ /// `virgl_renderer_poll` (notably to service GL queries before retiring a fence). Without an
+ /// event-loop subscriber, a compositor can wait forever after submitting its first frame.
+ private func startFencePolling() {
+ let descriptor = functions.getPollFD()
+ let queue = DispatchQueue(label: "dev.dory.virgl.poll", qos: .userInteractive)
+ let poll: @Sendable () -> Void = { [weak self] in
+ guard let self else { return }
+ try? self.withRendererContext {
+ self.functions.poll()
+ }
+ }
+ if descriptor >= 0 {
+ let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: queue)
+ source.setEventHandler(handler: poll)
+ pollSource = source
+ source.resume()
+ } else {
+ // CGL builds have no eventfd. One millisecond keeps guest fence latency below a frame
+ // without burning a vCPU in a busy loop; poll itself is non-blocking.
+ let timer = DispatchSource.makeTimerSource(queue: queue)
+ timer.schedule(deadline: .now(), repeating: .milliseconds(1), leeway: .milliseconds(1))
+ timer.setEventHandler(handler: poll)
+ pollTimer = timer
+ timer.resume()
+ }
}
private func withIOVecs(
@@ -393,13 +724,48 @@ public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable {
}
private enum RendererFlags {
+ static let threadSync: Int32 = 1 << 1
static let venus: Int32 = 1 << 6
- static let noVirgl: Int32 = 1 << 7
static let asyncFenceCB: Int32 = 1 << 8
static let useGuestVRAM: Int32 = 1 << 14
}
+private enum SubmissionSyncMode: String {
+ case clientWaitBeforeReadback = "client-wait"
+ case serverWaitBeforeReadback = "server-wait"
+ case finishAfterSubmit = "finish"
+
+ init(environment: [String: String]) {
+ let requested = environment["DORY_VIRGL_SYNC_MODE"]?.lowercased()
+ self = SubmissionSyncMode(rawValue: requested ?? "") ?? .serverWaitBeforeReadback
+ }
+}
+
+private final class OwnedIOVecs {
+ let pointer: UnsafeMutablePointer
+ let count: UInt32
+
+ init(entries: [VirtioGPUMemoryEntry]) {
+ precondition(!entries.isEmpty)
+ count = UInt32(entries.count)
+ pointer = .allocate(capacity: entries.count)
+ for (index, entry) in entries.enumerated() {
+ pointer.advanced(by: index).initialize(to: iovec(
+ iov_base: entry.pointer,
+ iov_len: entry.length
+ ))
+ }
+ }
+
+ deinit {
+ pointer.deinitialize(count: Int(count))
+ pointer.deallocate()
+ }
+}
+
private enum VirtioGPUCapsetID {
+ static let virgl: UInt32 = 1
+ static let virgl2: UInt32 = 2
static let venus: UInt32 = 4
}
@@ -430,6 +796,97 @@ private let doryVirglWriteContextFence: WriteContextFenceCallback = { _, context
VirglRenderer.signalFence(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID)
}
+/// virglrenderer's classic 3D path needs the VMM to supply host OpenGL contexts when it is not
+/// using EGL or GLX. CGL is the native offscreen OpenGL context API on macOS and remains backed by
+/// the Apple GPU, so the guest compositor avoids llvmpipe without adding another window surface.
+private final class VirglCGLContextManager: @unchecked Sendable {
+ private let lock = NSLock()
+ private let pixelFormat: CGLPixelFormatObj
+ private var shareContext: CGLContextObj?
+
+ init() throws {
+ var format: CGLPixelFormatObj?
+ var formatCount: GLint = 0
+ var attributes: [CGLPixelFormatAttribute] = [
+ kCGLPFAAccelerated,
+ kCGLPFAOpenGLProfile,
+ CGLPixelFormatAttribute(kCGLOGLPVersion_GL4_Core.rawValue),
+ CGLPixelFormatAttribute(0),
+ ]
+ let status = CGLChoosePixelFormat(&attributes, &format, &formatCount)
+ guard status == kCGLNoError, formatCount > 0, let format else {
+ throw VMError.invalidConfiguration("cannot create an accelerated macOS OpenGL pixel format (CGL status \(status.rawValue))")
+ }
+ pixelFormat = format
+ }
+
+ deinit {
+ CGLDestroyPixelFormat(pixelFormat)
+ }
+
+ func createContext(shared: Bool) -> UnsafeMutableRawPointer? {
+ lock.lock()
+ defer { lock.unlock() }
+ var context: CGLContextObj?
+ let sharedContext = shared ? shareContext : nil
+ let status = CGLCreateContext(pixelFormat, sharedContext, &context)
+ guard status == kCGLNoError, let context else {
+ FileHandle.standardError.write(Data(
+ "dory-gpu: CGL context creation failed shared=\(shared) status=\(status.rawValue)\n".utf8
+ ))
+ return nil
+ }
+ if shareContext == nil {
+ shareContext = context
+ }
+ return UnsafeMutableRawPointer(context)
+ }
+
+ func destroyContext(_ pointer: UnsafeMutableRawPointer) {
+ let context = pointer.assumingMemoryBound(to: CGLContextObj.Pointee.self)
+ lock.lock()
+ let wasRoot = shareContext == context
+ if wasRoot {
+ shareContext = nil
+ }
+ lock.unlock()
+ if CGLGetCurrentContext() == context {
+ _ = CGLSetCurrentContext(nil)
+ }
+ CGLDestroyContext(context)
+ }
+
+ func makeCurrent(_ pointer: UnsafeMutableRawPointer?) -> Int32 {
+ let context = pointer.map { $0.assumingMemoryBound(to: CGLContextObj.Pointee.self) }
+ return CGLSetCurrentContext(context) == kCGLNoError ? 0 : -1
+ }
+
+ func clearCurrent() {
+ _ = CGLSetCurrentContext(nil)
+ }
+}
+
+private func doryVirglContextManager(_ cookie: UnsafeMutableRawPointer?) -> VirglCGLContextManager? {
+ cookie.map { Unmanaged.fromOpaque($0).takeUnretainedValue() }
+}
+
+private let doryVirglCreateGLContext: CreateGLContextCallback = { cookie, _, parameters in
+ guard let manager = doryVirglContextManager(cookie), let parameters else { return nil }
+ // C layout: int version; bool shared; 3 bytes padding; int major; int minor; int compat.
+ let shared = parameters.load(fromByteOffset: 4, as: UInt8.self) != 0
+ return manager.createContext(shared: shared)
+}
+
+private let doryVirglDestroyGLContext: DestroyGLContextCallback = { cookie, context in
+ guard let manager = doryVirglContextManager(cookie), let context else { return }
+ manager.destroyContext(context)
+}
+
+private let doryVirglMakeCurrent: MakeCurrentCallback = { cookie, _, context in
+ guard let manager = doryVirglContextManager(cookie) else { return -1 }
+ return manager.makeCurrent(context)
+}
+
private struct VirglRendererCallbacks {
var version: Int32
var writeFence: WriteFenceCallback?
@@ -493,6 +950,7 @@ private struct Functions {
typealias FillCaps = @convention(c) (UInt32, UInt32, UnsafeMutableRawPointer?) -> Void
typealias ContextCreateWithFlags = @convention(c) (UInt32, UInt32, UInt32, UnsafePointer?) -> Int32
typealias ContextDestroy = @convention(c) (UInt32) -> Void
+ typealias ForceContext0 = @convention(c) () -> Void
typealias ContextResource = @convention(c) (Int32, Int32) -> Void
typealias SubmitCommand = @convention(c) (UnsafeMutableRawPointer?, Int32, Int32) -> Int32
typealias ResourceCreate = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt32) -> Int32
@@ -529,6 +987,8 @@ private struct Functions {
) -> Int32
typealias CreateFence = @convention(c) (Int32, UInt32) -> Int32
typealias ContextCreateFence = @convention(c) (UInt32, UInt32, UInt32, UInt64) -> Int32
+ typealias Poll = @convention(c) () -> Void
+ typealias GetPollFD = @convention(c) () -> Int32
let initialize: Initialize
let cleanup: Cleanup
@@ -536,6 +996,7 @@ private struct Functions {
let fillCaps: FillCaps
let contextCreateWithFlags: ContextCreateWithFlags
let contextDestroy: ContextDestroy
+ let forceContext0: ForceContext0
let contextAttachResource: ContextResource
let contextDetachResource: ContextResource
let submitCommand: SubmitCommand
@@ -553,6 +1014,8 @@ private struct Functions {
let transferReadIOV: TransferReadIOV
let createFence: CreateFence
let contextCreateFence: ContextCreateFence?
+ let poll: Poll
+ let getPollFD: GetPollFD
init(handle: UnsafeMutableRawPointer) throws {
initialize = try Self.required(handle, "virgl_renderer_init")
@@ -561,6 +1024,7 @@ private struct Functions {
fillCaps = try Self.required(handle, "virgl_renderer_fill_caps")
contextCreateWithFlags = try Self.required(handle, "virgl_renderer_context_create_with_flags")
contextDestroy = try Self.required(handle, "virgl_renderer_context_destroy")
+ forceContext0 = try Self.required(handle, "virgl_renderer_force_ctx_0")
contextAttachResource = try Self.required(handle, "virgl_renderer_ctx_attach_resource")
contextDetachResource = try Self.required(handle, "virgl_renderer_ctx_detach_resource")
submitCommand = try Self.required(handle, "virgl_renderer_submit_cmd")
@@ -578,6 +1042,8 @@ private struct Functions {
transferReadIOV = try Self.required(handle, "virgl_renderer_transfer_read_iov")
createFence = try Self.required(handle, "virgl_renderer_create_fence")
contextCreateFence = Self.optional(handle, "virgl_renderer_context_create_fence")
+ poll = try Self.required(handle, "virgl_renderer_poll")
+ getPollFD = try Self.required(handle, "virgl_renderer_get_poll_fd")
}
private static func required(_ handle: UnsafeMutableRawPointer, _ name: String) throws -> T {
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift
index 3fa9c245..301380a0 100644
--- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift
@@ -25,16 +25,24 @@ public struct VirtioGPUMemoryEntry {
}
/// A host-visible blob mapping produced by the renderer: the host pointer virglrenderer owns (to be
-/// hv_vm_map'd into the guest window), its size, and the guest-facing cache map info.
+/// hv_vm_map'd into the guest window), its size, the guest-facing cache map info, and whether the
+/// renderer API created map state which must later be explicitly released.
public struct VirtioGPUBlobMapping {
public var hostPointer: UnsafeMutableRawPointer
public var size: UInt64
public var mapInfo: UInt32
+ public var requiresRendererUnmap: Bool
- public init(hostPointer: UnsafeMutableRawPointer, size: UInt64, mapInfo: UInt32) {
+ public init(
+ hostPointer: UnsafeMutableRawPointer,
+ size: UInt64,
+ mapInfo: UInt32,
+ requiresRendererUnmap: Bool = true
+ ) {
self.hostPointer = hostPointer
self.size = size
self.mapInfo = mapInfo
+ self.requiresRendererUnmap = requiresRendererUnmap
}
}
@@ -62,6 +70,55 @@ public struct VirtioGPUTransfer3D {
public var box: [UInt32]
}
+public struct VirtioGPURect: Sendable, Equatable {
+ public var x: UInt32
+ public var y: UInt32
+ public var width: UInt32
+ public var height: UInt32
+
+ public init(x: UInt32, y: UInt32, width: UInt32, height: UInt32) {
+ self.x = x
+ self.y = y
+ self.width = width
+ self.height = height
+ }
+}
+
+/// One copied scanout update ready for a host display surface. `width` and `height` describe the
+/// complete scanout, while `bytes` contains only `dirtyRect` rows at `stride` bytes per row. The
+/// device never exposes guest pointers to the UI layer, and small browser repaints therefore avoid
+/// copying the rest of a 4K framebuffer merely to update one damaged rectangle.
+public struct VirtioGPUScanoutFrame: Sendable, Equatable {
+ public var scanoutID: UInt32
+ public var resourceID: UInt32
+ public var format: UInt32
+ public var width: UInt32
+ public var height: UInt32
+ public var stride: UInt32
+ public var dirtyRect: VirtioGPURect
+ public var bytes: Data
+
+ public init(
+ scanoutID: UInt32,
+ resourceID: UInt32,
+ format: UInt32,
+ width: UInt32,
+ height: UInt32,
+ stride: UInt32,
+ dirtyRect: VirtioGPURect,
+ bytes: Data
+ ) {
+ self.scanoutID = scanoutID
+ self.resourceID = resourceID
+ self.format = format
+ self.width = width
+ self.height = height
+ self.stride = stride
+ self.dirtyRect = dirtyRect
+ self.bytes = bytes
+ }
+}
+
public protocol VirtioGPURenderer: AnyObject {
var capsets: [VirtioGPUCapset] { get }
func createContext(id: UInt32, flags: UInt32, name: String) throws
@@ -175,11 +232,60 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
public let sharedMemoryRegions: [VirtioSharedMemoryRegion]
private let scanoutCount: UInt32
+ private let displayLock = NSLock()
+ private var scanoutWidth: UInt32
+ private var scanoutHeight: UInt32
+ private var pendingDisplayEvents: UInt32 = 0
+ private let onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)?
+ private let onScanoutResourceReleased: (@Sendable (UInt32) -> Void)?
private let renderer: VirtioGPURenderer?
private let capsets: [VirtioGPUCapset]
private let hostVisibleMemory: VirtioGPUHostVisibleMemory?
private var resourceEntries: [UInt32: [VirtioGPUMemoryEntry]] = [:]
private var blobResources: [UInt32: BlobResource] = [:]
+ /// UUIDs back VIRTIO_GPU_F_RESOURCE_UUID, which Linux exposes as the cross-device DRM
+ /// capability required by Mesa Venus before it will create a Vulkan instance. Keep an assigned
+ /// UUID stable for the lifetime of each renderer resource; the value is an opaque identity to
+ /// the guest and does not imply a host dma-buf export on macOS.
+ private var resourceUUIDs: [UInt32: [UInt8]] = [:]
+
+ private struct Resource2D {
+ var format: UInt32
+ var width: UInt32
+ var height: UInt32
+ var backing: [VirtioGPUMemoryEntry] = []
+ }
+
+ private struct Resource3D {
+ var format: UInt32
+ var width: UInt32
+ var height: UInt32
+ }
+
+ private struct ScanoutBinding {
+ enum Source {
+ case resource2D
+ case resource3D
+ case blob(format: UInt32, width: UInt32, height: UInt32, stride: UInt32, offset: UInt32)
+ }
+
+ var resourceID: UInt32
+ var rect: VirtioGPURect
+ var source: Source
+ }
+
+ private var resources2D: [UInt32: Resource2D] = [:]
+ private var resources3D: [UInt32: Resource3D] = [:]
+ private var published3DResources: Set = []
+ /// Keep one full-resource readback allocation per renderer resource. A partial renderer
+ /// transfer writes its first pixel at `offset` and then advances by `stride`; the source box's
+ /// x/y coordinates are not implicitly added to the destination address. Supplying the matching
+ /// full-resource offset lets later scanout extraction use ordinary resource coordinates.
+ private var rendererReadbackBuffers: [UInt32: Data] = [:]
+ private var scanouts: [UInt32: ScanoutBinding] = [:]
+ private var commandFailureCounts: [UInt32: Int] = [:]
+ private let traceResourceLifecycle: Bool
+ private var resourceTraceSequence: UInt64 = 0
// Real fence signalling: a fenced command's descriptor is held here and completed only when the
// renderer signals the fence (from its own thread), per the virtio-gpu contract — responding
@@ -215,6 +321,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
static let resourceDetachBacking: UInt32 = 0x0107
static let getCapsetInfo: UInt32 = 0x0108
static let getCapset: UInt32 = 0x0109
+ static let resourceAssignUUID: UInt32 = 0x010B
static let resourceCreateBlob: UInt32 = 0x010C
static let setScanoutBlob: UInt32 = 0x010D
static let ctxCreate: UInt32 = 0x0200
@@ -236,6 +343,7 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
static let okDisplayInfo: UInt32 = 0x1101
static let okCapsetInfo: UInt32 = 0x1102
static let okCapset: UInt32 = 0x1103
+ static let okResourceUUID: UInt32 = 0x1105
static let okMapInfo: UInt32 = 0x1106
static let errorUnspecified: UInt32 = 0x1200
static let errorInvalidParameter: UInt32 = 0x1202
@@ -253,7 +361,10 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
}
private struct BlobResource {
+ var memory: UInt32
var size: UInt64
+ var mapping: VirtioGPUBlobMapping?
+ var guestMapped = false
}
/// - Parameters:
@@ -263,33 +374,83 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
hostMemoryBase: UInt64,
hostMemorySize: UInt64 = 256 * 1024 * 1024,
scanoutCount: UInt32 = 0,
+ scanoutWidth: UInt32 = 1_280,
+ scanoutHeight: UInt32 = 800,
renderer: VirtioGPURenderer? = nil,
- hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil
+ hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil,
+ traceResourceLifecycle: Bool = ProcessInfo.processInfo.environment["DORY_GPU_TRACE_RESOURCES"] == "1",
+ onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? = nil,
+ onScanoutResourceReleased: (@Sendable (UInt32) -> Void)? = nil
) {
+ let boundedScanoutCount = min(scanoutCount, 16)
self.renderer = renderer
self.capsets = renderer?.capsets ?? []
+ self.traceResourceLifecycle = traceResourceLifecycle
self.deviceFeatures = renderer == nil
? 0
: Feature.virgl | Feature.resourceUUID | Feature.resourceBlob | Feature.contextInit
self.sharedMemoryRegions = [
VirtioSharedMemoryRegion(id: 1, guestBase: hostMemoryBase, length: hostVisibleMemory?.length ?? hostMemorySize)
]
- self.scanoutCount = scanoutCount
+ self.scanoutCount = boundedScanoutCount
+ self.scanoutWidth = max(1, scanoutWidth)
+ self.scanoutHeight = max(1, scanoutHeight)
self.hostVisibleMemory = hostVisibleMemory
+ self.onScanoutFrame = onScanoutFrame
+ self.onScanoutResourceReleased = onScanoutResourceReleased
renderer?.onFenceSignaled = { [weak self] contextID, ringIndex, fenceID in
self?.fenceSignaled(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID)
}
}
public var configSpace: [UInt8] {
+ displayLock.lock()
+ let events = pendingDisplayEvents
+ let count = scanoutCount
+ displayLock.unlock()
var config = [UInt8]()
- config.appendLE(UInt32(0)) // events_read
+ config.appendLE(events) // events_read
config.appendLE(UInt32(0)) // events_clear
- config.appendLE(scanoutCount) // num_scanouts
+ config.appendLE(count) // num_scanouts
config.appendLE(UInt32(capsets.count)) // num_capsets
return config
}
+ /// Publishes a new preferred scanout size and raises VIRTIO_GPU_EVENT_DISPLAY. The Linux DRM
+ /// driver responds by re-reading GET_DISPLAY_INFO and issuing a real modeset, so the guest
+ /// compositor renders at the Retina window's pixel dimensions instead of scaling one fixed
+ /// framebuffer on the host.
+ public func updateScanoutSize(
+ width: UInt32,
+ height: UInt32,
+ transport: VirtioMMIOTransport
+ ) {
+ let boundedWidth = min(16_384, max(1, width))
+ let boundedHeight = min(16_384, max(1, height))
+ displayLock.lock()
+ let changed = scanoutWidth != boundedWidth || scanoutHeight != boundedHeight
+ if changed {
+ scanoutWidth = boundedWidth
+ scanoutHeight = boundedHeight
+ pendingDisplayEvents |= 1 // VIRTIO_GPU_EVENT_DISPLAY
+ }
+ displayLock.unlock()
+ if changed { transport.notifyConfigChange() }
+ }
+
+ public func writeConfig(offset: UInt64, value: UInt64, width: Int) {
+ guard width > 0, width <= 8, offset < 8, offset + UInt64(width) > 4 else { return }
+ var cleared: UInt32 = 0
+ for byte in 0..> UInt64(byte * 8)) & 0xFF) << UInt32((position - 4) * 8)
+ }
+ displayLock.lock()
+ pendingDisplayEvents &= ~cleared
+ displayLock.unlock()
+ }
+
public func handleKick(queue: Int, transport: VirtioMMIOTransport) {
guard queue == 0 || queue == 1 else { return }
fenceLock.lock()
@@ -325,18 +486,33 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
let contextID = request.leUInt32(at: 16)
let ringIndex = UInt32(request[20])
let contextFence = flags & HeaderFlag.infoRingIndex != 0
- do {
- try renderer.createFence(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID, contextFence: contextFence)
- } catch {
- return false
- }
// ctx0 fences signal without context/ring coordinates, so they queue under (0, 0).
let key = contextFence
? FenceKey(contextID: contextID, ringIndex: ringIndex)
: FenceKey(contextID: 0, ringIndex: 0)
+ // Publish the waiter before creating the renderer fence. With a fast host GPU (and in
+ // particular after a synchronizing readback), virglrenderer may invoke its completion
+ // callback from createFence itself or immediately on another thread. Registering after
+ // createFence loses that edge forever and stalls the guest compositor on its first frame.
fenceLock.lock()
pendingFences[key, default: []].append(PendingFence(fenceID: fenceID, response: response, chain: chain))
fenceLock.unlock()
+ do {
+ try renderer.createFence(
+ contextID: contextID,
+ ringIndex: ringIndex,
+ fenceID: fenceID,
+ contextFence: contextFence
+ )
+ } catch {
+ fenceLock.lock()
+ if var waiting = pendingFences[key] {
+ waiting.removeAll { $0.fenceID == fenceID }
+ pendingFences[key] = waiting.isEmpty ? nil : waiting
+ }
+ fenceLock.unlock()
+ return false
+ }
return true
}
@@ -388,13 +564,219 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
switch command {
case Command.getDisplayInfo:
+ displayLock.lock()
+ let width = scanoutWidth
+ let height = scanoutHeight
+ let count = scanoutCount
+ displayLock.unlock()
var response = responseHeader(type: Response.okDisplayInfo, request: request)
- response.append(contentsOf: repeatElement(UInt8(0), count: 16 * 24))
+ for index in 0..<16 {
+ response.appendLE(UInt32(0))
+ response.appendLE(UInt32(0))
+ response.appendLE(index < count ? width : 0)
+ response.appendLE(index < count ? height : 0)
+ response.appendLE(index < count ? UInt32(1) : 0)
+ response.appendLE(UInt32(0))
+ }
return response
+ case Command.resourceCreate2D:
+ return scanoutCommand(request: request) {
+ try requireLength(request, 40)
+ let resourceID = request.leUInt32(at: 24)
+ let format = request.leUInt32(at: 28)
+ let width = request.leUInt32(at: 32)
+ let height = request.leUInt32(at: 36)
+ guard resourceID != 0,
+ resources2D[resourceID] == nil,
+ resources3D[resourceID] == nil,
+ blobResources[resourceID] == nil,
+ width > 0, height > 0,
+ width <= 16_384, height <= 16_384,
+ Self.isSupportedScanoutFormat(format),
+ UInt64(width) * UInt64(height) * 4 <= UInt64(Int.max) else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu 2D resource")
+ }
+ // VirGL clients may use a RESOURCE_CREATE_2D allocation as a texture after
+ // attaching it to a renderer context. Keep the renderer's global resource table in
+ // lockstep with the device-side scanout table, matching QEMU's virgl path. Without
+ // this registration virgl_renderer_ctx_attach_resource silently ignores the ID and
+ // a later sampler-view creation fails as an illegal resource.
+ if let renderer {
+ try renderer.createResource3D(
+ VirtioGPUResourceCreate3D(
+ resourceID: resourceID,
+ target: 2,
+ format: format,
+ bind: 1 << 1,
+ width: width,
+ height: height,
+ depth: 1,
+ arraySize: 1,
+ lastLevel: 0,
+ samples: 0,
+ flags: 1
+ ),
+ entries: []
+ )
+ }
+ resources2D[resourceID] = Resource2D(
+ format: format,
+ width: width,
+ height: height
+ )
+ return responseHeader(type: Response.okNoData, request: request)
+ }
+ case Command.setScanout:
+ return scanoutCommand(request: request) {
+ try requireLength(request, 48)
+ let scanoutID = request.leUInt32(at: 40)
+ let resourceID = request.leUInt32(at: 44)
+ guard scanoutID < scanoutCount else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu scanout id")
+ }
+ // Linux disables a scanout with resource_id=0 while changing modes. The rectangle
+ // is ignored by the protocol in that case and is commonly all zeroes, so do not
+ // reject the legitimate disable before inspecting the resource id.
+ if resourceID == 0 {
+ let previous = scanouts.removeValue(forKey: scanoutID)
+ if let previous, case .blob = previous.source, let renderer {
+ try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer)
+ }
+ return responseHeader(type: Response.okNoData, request: request)
+ }
+ let rect = try scanoutRect(from: request, at: 24)
+ let source: ScanoutBinding.Source
+ let resourceWidth: UInt32
+ let resourceHeight: UInt32
+ if let resource = resources2D[resourceID] {
+ source = .resource2D
+ resourceWidth = resource.width
+ resourceHeight = resource.height
+ } else if let resource = resources3D[resourceID] {
+ source = .resource3D
+ resourceWidth = resource.width
+ resourceHeight = resource.height
+ } else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu scanout resource")
+ }
+ guard Self.contains(rect: rect, width: resourceWidth, height: resourceHeight) else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu scanout rectangle")
+ }
+ let previous = scanouts.updateValue(
+ ScanoutBinding(
+ resourceID: resourceID,
+ rect: rect,
+ source: source
+ ),
+ forKey: scanoutID
+ )
+ if let previous, case .blob = previous.source, let renderer {
+ try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer)
+ }
+ // A compositor may render and flush its next buffer before atomically binding it
+ // to the KMS scanout (notably when returning from a fullscreen/direct-scanout
+ // surface). A host display that only observes flushes would then keep showing the
+ // old application buffer forever. Publish the newly bound resource immediately;
+ // renderer readback waits for outstanding producer work before copying it.
+ let frames: [VirtioGPUScanoutFrame]
+ switch source {
+ case .resource2D:
+ guard let resource = resources2D[resourceID] else {
+ throw VMError.invalidConfiguration("virtio-gpu 2D scanout resource disappeared")
+ }
+ frames = try scanoutFrames(resourceID: resourceID, resource: resource, dirtyRect: rect)
+ case .resource3D:
+ guard let renderer, let resource = resources3D[resourceID] else {
+ throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable")
+ }
+ frames = try rendererScanoutFrames(
+ resourceID: resourceID,
+ resource: resource,
+ dirtyRect: rect,
+ renderer: renderer
+ )
+ case .blob:
+ frames = []
+ }
+ for frame in frames { onScanoutFrame?(frame) }
+ return responseHeader(type: Response.okNoData, request: request)
+ }
+ case Command.transferToHost2D:
+ return scanoutCommand(request: request) {
+ try requireLength(request, 56)
+ let rect = try scanoutRect(from: request, at: 24)
+ let offset = request.leUInt64(at: 40)
+ let resourceID = request.leUInt32(at: 48)
+ guard let resource = resources2D[resourceID],
+ Self.contains(rect: rect, width: resource.width, height: resource.height),
+ offset < UInt64(resource.width) * UInt64(resource.height) * 4 else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu 2D transfer")
+ }
+ // Guest backing is directly mapped into this process. The later RESOURCE_FLUSH is
+ // the ownership boundary at which Dory copies a coherent frame for the host UI.
+ return responseHeader(type: Response.okNoData, request: request)
+ }
+ case Command.resourceFlush:
+ return scanoutCommand(request: request) {
+ try requireLength(request, 48)
+ let rect = try scanoutRect(from: request, at: 24)
+ let resourceID = request.leUInt32(at: 40)
+ if let resource = resources2D[resourceID] {
+ guard Self.contains(rect: rect, width: resource.width, height: resource.height) else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu resource flush")
+ }
+ let frames = try scanoutFrames(resourceID: resourceID, resource: resource, dirtyRect: rect)
+ for frame in frames {
+ onScanoutFrame?(frame)
+ }
+ return responseHeader(type: Response.okNoData, request: request)
+ }
+ if let resource = resources3D[resourceID] {
+ guard Self.contains(rect: rect, width: resource.width, height: resource.height) else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu 3D resource flush")
+ }
+ guard let renderer else {
+ throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable")
+ }
+ let frames = try rendererScanoutFrames(
+ resourceID: resourceID,
+ resource: resource,
+ dirtyRect: rect,
+ renderer: renderer
+ )
+ for frame in frames {
+ onScanoutFrame?(frame)
+ }
+ return responseHeader(type: Response.okNoData, request: request)
+ }
+ guard let blob = blobResources[resourceID] else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu resource flush")
+ }
+ let frames = try blobScanoutFrames(resourceID: resourceID, blob: blob, dirtyRect: rect)
+ for frame in frames {
+ onScanoutFrame?(frame)
+ }
+ return responseHeader(type: Response.okNoData, request: request)
+ }
case Command.getCapsetInfo:
return capsetInfoResponse(request: request)
case Command.getCapset:
return capsetResponse(request: request)
+ case Command.resourceAssignUUID:
+ return scanoutCommand(request: request) {
+ try requireLength(request, 32)
+ let resourceID = request.leUInt32(at: 24)
+ guard resources2D[resourceID] != nil
+ || resources3D[resourceID] != nil
+ || blobResources[resourceID] != nil else {
+ throw VMError.invalidConfiguration("virtio-gpu UUID request for unknown resource")
+ }
+ let uuid = resourceUUIDs[resourceID] ?? Self.makeResourceUUID()
+ resourceUUIDs[resourceID] = uuid
+ var response = responseHeader(type: Response.okResourceUUID, request: request)
+ response.append(contentsOf: uuid)
+ return response
+ }
case Command.ctxCreate:
return rendererCommand(request: request) { renderer in
guard request.count >= 96 else { throw VMError.unexpectedExit("short virtio-gpu ctx_create") }
@@ -403,24 +785,38 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
let contextInit = request.leUInt32(at: 28)
let nameBytes = request[32..<(32 + nameLength)].prefix { $0 != 0 }
let name = String(decoding: nameBytes, as: UTF8.self)
- try renderer.createContext(id: contextID, flags: contextInit & 0xff, name: name)
+ try renderer.createContext(
+ id: contextID,
+ flags: Self.rendererContextFlags(requested: contextInit, capsets: renderer.capsets),
+ name: name
+ )
+ traceResourceEvent("context-create", contextID: contextID, detail: "name=\(name) init=0x\(String(contextInit, radix: 16))")
return responseHeader(type: Response.okNoData, request: request)
}
case Command.ctxDestroy:
return rendererCommand(request: request) { renderer in
- try renderer.destroyContext(id: request.leUInt32(at: 16))
+ let contextID = request.leUInt32(at: 16)
+ try renderer.destroyContext(id: contextID)
+ traceResourceEvent("context-destroy", contextID: contextID)
return responseHeader(type: Response.okNoData, request: request)
}
case Command.ctxAttachResource:
return rendererCommand(request: request) { renderer in
try requireLength(request, 32)
- try renderer.attachResource(contextID: request.leUInt32(at: 16), resourceID: request.leUInt32(at: 24))
+ let contextID = request.leUInt32(at: 16)
+ let resourceID = request.leUInt32(at: 24)
+ traceResourceEvent("attach-begin", contextID: contextID, resourceID: resourceID)
+ try renderer.attachResource(contextID: contextID, resourceID: resourceID)
+ traceResourceEvent("attach-end", contextID: contextID, resourceID: resourceID)
return responseHeader(type: Response.okNoData, request: request)
}
case Command.ctxDetachResource:
return rendererCommand(request: request) { renderer in
try requireLength(request, 32)
- try renderer.detachResource(contextID: request.leUInt32(at: 16), resourceID: request.leUInt32(at: 24))
+ let contextID = request.leUInt32(at: 16)
+ let resourceID = request.leUInt32(at: 24)
+ try renderer.detachResource(contextID: contextID, resourceID: resourceID)
+ traceResourceEvent("detach", contextID: contextID, resourceID: resourceID)
return responseHeader(type: Response.okNoData, request: request)
}
case Command.submit3D:
@@ -447,23 +843,55 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
samples: request.leUInt32(at: 60),
flags: request.leUInt32(at: 64)
)
+ guard resource.resourceID != 0,
+ resources2D[resource.resourceID] == nil,
+ resources3D[resource.resourceID] == nil,
+ blobResources[resource.resourceID] == nil else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu 3D resource")
+ }
try renderer.createResource3D(resource, entries: resourceEntries[resource.resourceID] ?? [])
+ resources3D[resource.resourceID] = Resource3D(
+ format: resource.format,
+ width: resource.width,
+ height: resource.height
+ )
+ traceResourceEvent("create-3d", contextID: request.leUInt32(at: 16), resourceID: resource.resourceID)
return responseHeader(type: Response.okNoData, request: request)
}
case Command.resourceAttachBacking:
- return rendererCommand(request: request) { renderer in
+ return scanoutCommand(request: request) {
try requireLength(request, 32)
let resourceID = request.leUInt32(at: 24)
let entries = try memoryEntries(from: request, count: request.leUInt32(at: 28), offset: 32, transport: transport)
+ if var resource = resources2D[resourceID] {
+ if let renderer {
+ try renderer.attachBacking(resourceID: resourceID, entries: entries)
+ }
+ resource.backing = entries
+ resources2D[resourceID] = resource
+ return responseHeader(type: Response.okNoData, request: request)
+ }
resourceEntries[resourceID] = entries
- try renderer.attachBacking(resourceID: resourceID, entries: entries)
+ try rendererCommandThrowing { renderer in
+ try renderer.attachBacking(resourceID: resourceID, entries: entries)
+ }
return responseHeader(type: Response.okNoData, request: request)
}
case Command.resourceDetachBacking:
- return rendererCommand(request: request) { renderer in
+ return scanoutCommand(request: request) {
try requireLength(request, 32)
let resourceID = request.leUInt32(at: 24)
- try renderer.detachBacking(resourceID: resourceID)
+ if var resource = resources2D[resourceID] {
+ if let renderer {
+ try renderer.detachBacking(resourceID: resourceID)
+ }
+ resource.backing = []
+ resources2D[resourceID] = resource
+ return responseHeader(type: Response.okNoData, request: request)
+ }
+ try rendererCommandThrowing { renderer in
+ try renderer.detachBacking(resourceID: resourceID)
+ }
resourceEntries.removeValue(forKey: resourceID)
return responseHeader(type: Response.okNoData, request: request)
}
@@ -472,6 +900,14 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
try requireLength(request, 56)
let resourceID = request.leUInt32(at: 24)
let size = request.leUInt64(at: 48)
+ guard resourceID != 0,
+ size > 0,
+ size <= UInt64(Int.max),
+ resources2D[resourceID] == nil,
+ resources3D[resourceID] == nil,
+ blobResources[resourceID] == nil else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu blob resource")
+ }
let entries = try memoryEntries(from: request, count: request.leUInt32(at: 36), offset: 56, transport: transport)
try renderer.createBlob(
resourceID: resourceID,
@@ -483,7 +919,17 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
entries: entries
)
resourceEntries[resourceID] = entries
- blobResources[resourceID] = BlobResource(size: size)
+ blobResources[resourceID] = BlobResource(
+ memory: request.leUInt32(at: 28),
+ size: size,
+ mapping: nil
+ )
+ traceResourceEvent(
+ "create-blob",
+ contextID: request.leUInt32(at: 16),
+ resourceID: resourceID,
+ detail: "memory=\(request.leUInt32(at: 28)) blob=\(request.leUInt64(at: 40)) size=\(size)"
+ )
return responseHeader(type: Response.okNoData, request: request)
}
case Command.resourceMapBlob:
@@ -497,13 +943,17 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
}
// virglrenderer owns the blob's host memory; ask it to map, then expose that pointer to
// the guest by hv_vm_mapping it into the window at the requested offset.
- let mapping = try renderer.mapBlob(resourceID: resourceID)
+ let mapping = try ensureBlobMapping(resourceID: resourceID, renderer: renderer)
try hostVisibleMemory.map(
resourceID: resourceID,
hostPointer: mapping.hostPointer,
offset: offset,
size: mapping.size != 0 ? mapping.size : blob.size
)
+ if var updated = blobResources[resourceID] {
+ updated.guestMapped = true
+ blobResources[resourceID] = updated
+ }
var response = responseHeader(type: Response.okMapInfo, request: request)
response.appendLE(mapping.mapInfo)
response.appendLE(UInt32(0))
@@ -514,17 +964,44 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
try requireLength(request, 32)
let resourceID = request.leUInt32(at: 24)
hostVisibleMemory?.unmap(resourceID: resourceID)
- try renderer.unmapBlob(resourceID: resourceID)
+ guard var blob = blobResources[resourceID] else {
+ throw VMError.invalidConfiguration("virtio-gpu unmap of unknown blob")
+ }
+ blob.guestMapped = false
+ blobResources[resourceID] = blob
+ try releaseBlobMappingIfUnused(resourceID: resourceID, renderer: renderer)
return responseHeader(type: Response.okNoData, request: request)
}
case Command.resourceUnref:
- return rendererCommand(request: request) { renderer in
+ return scanoutCommand(request: request) {
try requireLength(request, 32)
let resourceID = request.leUInt32(at: 24)
+ resourceUUIDs.removeValue(forKey: resourceID)
+ traceResourceEvent("unref-begin", contextID: request.leUInt32(at: 16), resourceID: resourceID)
+ if resources2D.removeValue(forKey: resourceID) != nil {
+ if let renderer {
+ try renderer.unrefResource(resourceID: resourceID)
+ }
+ scanouts = scanouts.filter { $0.value.resourceID != resourceID }
+ onScanoutResourceReleased?(resourceID)
+ traceResourceEvent("unref-end", resourceID: resourceID, detail: "kind=2d")
+ return responseHeader(type: Response.okNoData, request: request)
+ }
hostVisibleMemory?.unmap(resourceID: resourceID)
- try renderer.unrefResource(resourceID: resourceID)
+ try rendererCommandThrowing { renderer in
+ if blobResources[resourceID]?.mapping?.requiresRendererUnmap == true {
+ try renderer.unmapBlob(resourceID: resourceID)
+ }
+ try renderer.unrefResource(resourceID: resourceID)
+ }
+ scanouts = scanouts.filter { $0.value.resourceID != resourceID }
resourceEntries.removeValue(forKey: resourceID)
+ resources3D.removeValue(forKey: resourceID)
+ published3DResources.remove(resourceID)
+ rendererReadbackBuffers.removeValue(forKey: resourceID)
blobResources.removeValue(forKey: resourceID)
+ onScanoutResourceReleased?(resourceID)
+ traceResourceEvent("unref-end", resourceID: resourceID, detail: "kind=renderer")
return responseHeader(type: Response.okNoData, request: request)
}
case Command.transferToHost3D:
@@ -539,9 +1016,73 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
try renderer.transferFromHost3D(transfer, entries: resourceEntries[transfer.resourceID] ?? [])
return responseHeader(type: Response.okNoData, request: request)
}
- case Command.resourceCreate2D, Command.setScanout, Command.resourceFlush,
- Command.transferToHost2D, Command.setScanoutBlob:
- return responseHeader(type: Response.okNoData, request: request)
+ case Command.setScanoutBlob:
+ return scanoutCommand(request: request) {
+ try requireLength(request, 96)
+ let scanoutID = request.leUInt32(at: 40)
+ let resourceID = request.leUInt32(at: 44)
+ let width = request.leUInt32(at: 48)
+ let height = request.leUInt32(at: 52)
+ let format = request.leUInt32(at: 56)
+ let stride = request.leUInt32(at: 64)
+ let offset = request.leUInt32(at: 80)
+ guard scanoutID < scanoutCount else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu blob scanout id")
+ }
+ if resourceID == 0 {
+ let previous = scanouts.removeValue(forKey: scanoutID)
+ if let previous, case .blob = previous.source, let renderer {
+ try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer)
+ }
+ return responseHeader(type: Response.okNoData, request: request)
+ }
+ let rect = try scanoutRect(from: request, at: 24)
+ guard let blob = blobResources[resourceID],
+ width > 0, height > 0,
+ width <= 16_384, height <= 16_384,
+ Self.isSupportedScanoutFormat(format),
+ Self.contains(rect: rect, width: width, height: height),
+ stride >= width * 4,
+ request.leUInt32(at: 68) == 0,
+ request.leUInt32(at: 72) == 0,
+ request.leUInt32(at: 76) == 0,
+ request.leUInt32(at: 84) == 0,
+ request.leUInt32(at: 88) == 0,
+ request.leUInt32(at: 92) == 0,
+ Self.scanoutByteRangeIsValid(
+ width: width,
+ height: height,
+ stride: stride,
+ offset: offset,
+ resourceSize: blob.size
+ ) else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu blob scanout resource")
+ }
+ let previous = scanouts.updateValue(
+ ScanoutBinding(
+ resourceID: resourceID,
+ rect: rect,
+ source: .blob(
+ format: format,
+ width: width,
+ height: height,
+ stride: stride,
+ offset: offset
+ )
+ ),
+ forKey: scanoutID
+ )
+ if let previous,
+ previous.resourceID != resourceID,
+ case .blob = previous.source,
+ let renderer {
+ try releaseBlobMappingIfUnused(resourceID: previous.resourceID, renderer: renderer)
+ }
+ for frame in try blobScanoutFrames(resourceID: resourceID, blob: blob, dirtyRect: rect) {
+ onScanoutFrame?(frame)
+ }
+ return responseHeader(type: Response.okNoData, request: request)
+ }
default:
return responseHeader(type: Response.errorInvalidParameter, request: request)
}
@@ -581,10 +1122,433 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi
do {
return try body(renderer)
} catch {
+ logCommandFailure(request: request, error: error)
return responseHeader(type: Response.errorInvalidParameter, request: request)
}
}
+ private func rendererCommandThrowing(
+ _ body: (VirtioGPURenderer) throws -> Void
+ ) throws {
+ guard let renderer else {
+ throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable")
+ }
+ try body(renderer)
+ }
+
+ private func scanoutCommand(
+ request: [UInt8],
+ _ body: () throws -> [UInt8]
+ ) -> [UInt8] {
+ do {
+ return try body()
+ } catch {
+ logCommandFailure(request: request, error: error)
+ return responseHeader(type: Response.errorInvalidParameter, request: request)
+ }
+ }
+
+ /// Linux may retry a failed renderer command many times per second. Preserve the status and
+ /// identifying fields needed for diagnosis without flooding the VM's serial log.
+ private func logCommandFailure(request: [UInt8], error: Error) {
+ guard request.count >= 4 else { return }
+ let command = request.leUInt32(at: 0)
+ let count = commandFailureCounts[command, default: 0] + 1
+ commandFailureCounts[command] = count
+ guard count <= 3 else { return }
+ let contextID = request.count >= 20 ? request.leUInt32(at: 16) : 0
+ let detail: String
+ if command == Command.submit3D {
+ let size = request.count >= 28 ? request.leUInt32(at: 24) : 0
+ detail = "bytes=\(size)"
+ } else {
+ let resourceID = request.count >= 28 ? request.leUInt32(at: 24) : 0
+ detail = "resource=\(resourceID)"
+ }
+ FileHandle.standardError.write(Data(
+ "dory-gpu: command=0x\(String(command, radix: 16)) context=\(contextID) \(detail) failed: \(error)\n".utf8
+ ))
+ }
+
+ private func traceResourceEvent(
+ _ event: String,
+ contextID: UInt32 = 0,
+ resourceID: UInt32? = nil,
+ detail: String = ""
+ ) {
+ guard traceResourceLifecycle else { return }
+ resourceTraceSequence &+= 1
+ let resource = resourceID.map(String.init) ?? "-"
+ let kind: String
+ if let resourceID {
+ if resources2D[resourceID] != nil { kind = "2d" }
+ else if resources3D[resourceID] != nil { kind = "3d" }
+ else if blobResources[resourceID] != nil { kind = "blob" }
+ else { kind = "missing" }
+ } else {
+ kind = "-"
+ }
+ let suffix = detail.isEmpty ? "" : " \(detail)"
+ FileHandle.standardError.write(Data(
+ "dory-gpu-trace: seq=\(resourceTraceSequence) event=\(event) context=\(contextID) resource=\(resource) kind=\(kind)\(suffix)\n".utf8
+ ))
+ }
+
+ private func scanoutRect(from request: [UInt8], at offset: Int) throws -> VirtioGPURect {
+ try requireLength(request, offset + 16)
+ let rect = VirtioGPURect(
+ x: request.leUInt32(at: offset),
+ y: request.leUInt32(at: offset + 4),
+ width: request.leUInt32(at: offset + 8),
+ height: request.leUInt32(at: offset + 12)
+ )
+ guard rect.width > 0, rect.height > 0 else {
+ throw VMError.invalidConfiguration("empty virtio-gpu rectangle")
+ }
+ return rect
+ }
+
+ private static func contains(rect: VirtioGPURect, width: UInt32, height: UInt32) -> Bool {
+ rect.x <= width && rect.width <= width - rect.x
+ && rect.y <= height && rect.height <= height - rect.y
+ }
+
+ private static func intersection(_ lhs: VirtioGPURect, _ rhs: VirtioGPURect) -> VirtioGPURect? {
+ let left = max(UInt64(lhs.x), UInt64(rhs.x))
+ let top = max(UInt64(lhs.y), UInt64(rhs.y))
+ let right = min(UInt64(lhs.x) + UInt64(lhs.width), UInt64(rhs.x) + UInt64(rhs.width))
+ let bottom = min(UInt64(lhs.y) + UInt64(lhs.height), UInt64(rhs.y) + UInt64(rhs.height))
+ guard right > left, bottom > top else { return nil }
+ return VirtioGPURect(
+ x: UInt32(left),
+ y: UInt32(top),
+ width: UInt32(right - left),
+ height: UInt32(bottom - top)
+ )
+ }
+
+ private static func isSupportedScanoutFormat(_ format: UInt32) -> Bool {
+ // All formats below are the 32-bit virtio-gpu formats accepted by Linux's DRM helper.
+ // The host presentation layer retains the format so it can select the matching Metal
+ // swizzle instead of rewriting every pixel in this transport thread.
+ [1, 2, 3, 4, 67, 68, 121, 134].contains(format)
+ }
+
+ private static func scanoutByteRangeIsValid(
+ width: UInt32,
+ height: UInt32,
+ stride: UInt32,
+ offset: UInt32,
+ resourceSize: UInt64
+ ) -> Bool {
+ guard width > 0, height > 0 else { return false }
+ let finalRow = UInt64(height - 1) * UInt64(stride)
+ let finalPixel = UInt64(width) * 4
+ return UInt64(offset) <= resourceSize
+ && finalRow <= resourceSize - UInt64(offset)
+ && finalPixel <= resourceSize - UInt64(offset) - finalRow
+ }
+
+ private func scanoutFrames(
+ resourceID: UInt32,
+ resource: Resource2D,
+ dirtyRect: VirtioGPURect
+ ) throws -> [VirtioGPUScanoutFrame] {
+ let resourceByteCount = Int(UInt64(resource.width) * UInt64(resource.height) * 4)
+ var source = Data(capacity: resourceByteCount)
+ for entry in resource.backing where source.count < resourceByteCount {
+ let count = min(entry.length, resourceByteCount - source.count)
+ source.append(entry.pointer.assumingMemoryBound(to: UInt8.self), count: count)
+ }
+ guard source.count == resourceByteCount else {
+ throw VMError.invalidConfiguration("virtio-gpu scanout backing is incomplete")
+ }
+
+ let sourceStride = Int(resource.width) * 4
+ var frames = [VirtioGPUScanoutFrame]()
+ for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key })
+ where binding.resourceID == resourceID {
+ guard case .resource2D = binding.source else { continue }
+ guard let dirty = Self.intersection(dirtyRect, binding.rect) else { continue }
+ let outputStride = Int(dirty.width) * 4
+ var pixels = Data(capacity: outputStride * Int(dirty.height))
+ for row in 0.. [VirtioGPUScanoutFrame] {
+ let bindings = scanouts.sorted(by: { $0.key < $1.key }).compactMap {
+ (scanoutID, binding) -> (UInt32, ScanoutBinding, UInt32, UInt32, UInt32, UInt32, UInt32)? in
+ guard binding.resourceID == resourceID,
+ case let .blob(format, width, height, stride, offset) = binding.source else {
+ return nil
+ }
+ return (scanoutID, binding, format, width, height, stride, offset)
+ }
+ guard !bindings.isEmpty else { return [] }
+
+ let guestEntries = blob.memory == 1 ? (resourceEntries[resourceID] ?? []) : []
+ let mapping: VirtioGPUBlobMapping?
+ if guestEntries.isEmpty {
+ guard let renderer else {
+ throw VMError.invalidConfiguration("virtio-gpu blob scanout has no accessible backing")
+ }
+ mapping = try ensureBlobMapping(resourceID: resourceID, renderer: renderer)
+ } else {
+ mapping = nil
+ }
+
+ var frames = [VirtioGPUScanoutFrame]()
+ for (scanoutID, binding, format, width, height, stride, offset) in bindings {
+ guard Self.contains(rect: dirtyRect, width: width, height: height) else {
+ throw VMError.invalidConfiguration("virtio-gpu blob damage exceeds framebuffer")
+ }
+ guard let dirty = Self.intersection(dirtyRect, binding.rect) else { continue }
+ let outputStride = Int(dirty.width) * 4
+ var pixels = Data(capacity: outputStride * Int(dirty.height))
+ for row in 0.. [VirtioGPUScanoutFrame] {
+ let publishFullResource = !published3DResources.contains(resourceID)
+ var frames = [VirtioGPUScanoutFrame]()
+ for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key })
+ where binding.resourceID == resourceID {
+ guard case .resource3D = binding.source else { continue }
+ guard let damaged = Self.intersection(dirtyRect, binding.rect) else { continue }
+ let readRect = publishFullResource ? binding.rect : damaged
+ let resourceStride = UInt64(resource.width) * 4
+ let resourceByteCount = resourceStride * UInt64(resource.height)
+ let readbackOffset = UInt64(readRect.y) * resourceStride + UInt64(readRect.x) * 4
+ let outputStride = UInt64(readRect.width) * 4
+ let outputByteCount = outputStride * UInt64(readRect.height)
+ guard resourceStride <= UInt64(UInt32.max),
+ resourceByteCount <= UInt64(Int.max),
+ outputStride <= UInt64(UInt32.max),
+ outputByteCount <= UInt64(Int.max) else {
+ throw VMError.invalidConfiguration("virtio-gpu 3D scanout is too large")
+ }
+ var readback = rendererReadbackBuffers[resourceID]
+ ?? Data(count: Int(resourceByteCount))
+ if readback.count != Int(resourceByteCount) {
+ readback = Data(count: Int(resourceByteCount))
+ }
+ try readback.withUnsafeMutableBytes { bytes in
+ guard let baseAddress = bytes.baseAddress else {
+ throw VMError.invalidConfiguration("virtio-gpu 3D scanout has no storage")
+ }
+ try renderer.transferFromHost3D(
+ VirtioGPUTransfer3D(
+ resourceID: resourceID,
+ contextID: 0,
+ level: 0,
+ stride: UInt32(resourceStride),
+ layerStride: 0,
+ offset: readbackOffset,
+ box: [readRect.x, readRect.y, 0, readRect.width, readRect.height, 1]
+ ),
+ entries: [VirtioGPUMemoryEntry(pointer: baseAddress, length: bytes.count)]
+ )
+ }
+ rendererReadbackBuffers[resourceID] = readback
+
+ var pixels = Data(count: Int(outputByteCount))
+ pixels.withUnsafeMutableBytes { destination in
+ readback.withUnsafeBytes { source in
+ guard let destinationBase = destination.baseAddress,
+ let sourceBase = source.baseAddress else { return }
+ let rowBytes = Int(outputStride)
+ for row in 0.. Data {
+ guard count >= 0 else {
+ throw VMError.invalidConfiguration("invalid virtio-gpu backing range")
+ }
+ var skip = offset
+ var remaining = count
+ var bytes = Data(capacity: count)
+ for entry in entries where remaining > 0 {
+ let entryLength = UInt64(entry.length)
+ if skip >= entryLength {
+ skip -= entryLength
+ continue
+ }
+ let entryOffset = Int(skip)
+ let available = entry.length - entryOffset
+ let copied = min(available, remaining)
+ bytes.append(
+ entry.pointer.advanced(by: entryOffset).assumingMemoryBound(to: UInt8.self),
+ count: copied
+ )
+ remaining -= copied
+ skip = 0
+ }
+ guard remaining == 0 else {
+ throw VMError.invalidConfiguration("virtio-gpu scanout backing is incomplete")
+ }
+ return bytes
+ }
+
+ private func ensureBlobMapping(
+ resourceID: UInt32,
+ renderer: VirtioGPURenderer
+ ) throws -> VirtioGPUBlobMapping {
+ guard var blob = blobResources[resourceID] else {
+ throw VMError.invalidConfiguration("virtio-gpu map of unknown blob")
+ }
+ if let mapping = blob.mapping { return mapping }
+ let mapping = try renderer.mapBlob(resourceID: resourceID)
+ blob.mapping = mapping
+ blobResources[resourceID] = blob
+ return mapping
+ }
+
+ /// Linux creates an implicit default context for primary-node clients before userspace can issue
+ /// VIRTGPU_CONTEXT_INIT. The standard default is VirGL2 when it is advertised; a renderer offering
+ /// one capset has an unambiguous default. Resolving zero here also prevents the kernel from retrying
+ /// an unsupported legacy context on a Venus-only GPU.
+ static func rendererContextFlags(requested: UInt32, capsets: [VirtioGPUCapset]) -> UInt32 {
+ let requestedCapset = requested & 0xff
+ if requestedCapset == 0 {
+ if capsets.contains(where: { $0.id == 2 }) {
+ return 2
+ }
+ if capsets.count == 1 {
+ return capsets[0].id & 0xff
+ }
+ }
+ return requestedCapset
+ }
+
+ private static func makeResourceUUID() -> [UInt8] {
+ var value = UUID().uuid
+ return withUnsafeBytes(of: &value) { Array($0) }
+ }
+
+ private func releaseBlobMappingIfUnused(
+ resourceID: UInt32,
+ renderer: VirtioGPURenderer
+ ) throws {
+ guard var blob = blobResources[resourceID],
+ let mapping = blob.mapping,
+ !blob.guestMapped,
+ !scanouts.values.contains(where: { binding in
+ guard binding.resourceID == resourceID else { return false }
+ if case .blob = binding.source { return true }
+ return false
+ }) else {
+ return
+ }
+ if mapping.requiresRendererUnmap {
+ try renderer.unmapBlob(resourceID: resourceID)
+ }
+ blob.mapping = nil
+ blobResources[resourceID] = blob
+ }
+
private func memoryEntries(
from request: [UInt8],
count: UInt32,
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift
new file mode 100644
index 00000000..4e51408c
--- /dev/null
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift
@@ -0,0 +1,289 @@
+import Foundation
+
+public struct VirtioInputEvent: Sendable, Equatable {
+ public var type: UInt16
+ public var code: UInt16
+ public var value: Int32
+
+ public init(type: UInt16, code: UInt16, value: Int32) {
+ self.type = type
+ self.code = code
+ self.value = value
+ }
+
+ public static let synchronize = VirtioInputEvent(type: 0, code: 0, value: 0)
+
+ fileprivate var bytes: [UInt8] {
+ var result = [UInt8]()
+ result.append(contentsOf: withUnsafeBytes(of: type.littleEndian, Array.init))
+ result.append(contentsOf: withUnsafeBytes(of: code.littleEndian, Array.init))
+ result.append(contentsOf: withUnsafeBytes(of: UInt32(bitPattern: value).littleEndian, Array.init))
+ return result
+ }
+}
+
+/// Converts AppKit's content-direction scroll deltas into Linux evdev wheel events while retaining
+/// sub-tick movement. AppKit and evdev use opposite signs for the same visible scroll direction.
+public struct VirtioInputScrollAccumulator: Sendable {
+ private var verticalRemainder: Double = 0
+ private var horizontalRemainder: Double = 0
+
+ public init() {}
+
+ public mutating func events(
+ horizontalDelta: Double,
+ verticalDelta: Double,
+ hasPreciseDeltas: Bool
+ ) -> [VirtioInputEvent] {
+ let scale: Double = hasPreciseDeltas ? 12 : 120
+ let vertical = -verticalDelta * scale
+ let horizontal = -horizontalDelta * scale
+ verticalRemainder += vertical
+ horizontalRemainder += horizontal
+
+ let verticalTicks = Int32(verticalRemainder / 120)
+ let horizontalTicks = Int32(horizontalRemainder / 120)
+ verticalRemainder -= Double(verticalTicks) * 120
+ horizontalRemainder -= Double(horizontalTicks) * 120
+
+ var result = [VirtioInputEvent]()
+ let verticalHighResolution = Int32(vertical.rounded())
+ let horizontalHighResolution = Int32(horizontal.rounded())
+ if verticalHighResolution != 0 {
+ result.append(VirtioInputEvent(type: 2, code: 11, value: verticalHighResolution))
+ }
+ if verticalTicks != 0 {
+ result.append(VirtioInputEvent(type: 2, code: 8, value: verticalTicks))
+ }
+ if horizontalHighResolution != 0 {
+ result.append(VirtioInputEvent(type: 2, code: 12, value: horizontalHighResolution))
+ }
+ if horizontalTicks != 0 {
+ result.append(VirtioInputEvent(type: 2, code: 6, value: horizontalTicks))
+ }
+ return result
+ }
+}
+
+/// A combined virtio keyboard, absolute pointer, buttons, and high-resolution wheel device.
+/// Host input is submitted as whole evdev frames ending in SYN_REPORT; a frame waits until the
+/// guest has posted enough receive buffers, so Dory never delivers half of a pointer update.
+public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable {
+ public let deviceID: UInt32 = 18
+ public let deviceFeatures: UInt64 = 0
+ public let queueCount = 2
+
+ private enum ConfigSelect {
+ static let name: UInt8 = 0x01
+ static let serial: UInt8 = 0x02
+ static let deviceIDs: UInt8 = 0x03
+ static let propertyBits: UInt8 = 0x10
+ static let eventBits: UInt8 = 0x11
+ static let absoluteInfo: UInt8 = 0x12
+ }
+
+ private enum EventType {
+ static let synchronize: UInt8 = 0
+ static let key: UInt8 = 1
+ static let relative: UInt8 = 2
+ static let absolute: UInt8 = 3
+ static let led: UInt8 = 17
+ }
+
+ private let lock = NSLock()
+ private weak var transport: VirtioMMIOTransport?
+ private var selectedConfig: UInt8 = 0
+ private var selectedSubconfig: UInt8 = 0
+ private var availableEventBuffers = [VirtqueueChain]()
+ private var pendingFrames = [[VirtioInputEvent]]()
+ private let maximumPendingFrames = 256
+ private let statusHandler: (@Sendable (VirtioInputEvent) -> Void)?
+
+ public init(statusHandler: (@Sendable (VirtioInputEvent) -> Void)? = nil) {
+ self.statusHandler = statusHandler
+ }
+
+ public var configSpace: [UInt8] {
+ lock.lock()
+ let select = selectedConfig
+ let subselect = selectedSubconfig
+ lock.unlock()
+
+ var payload = [UInt8]()
+ switch select {
+ case ConfigSelect.name where subselect == 0:
+ payload = Array("Dory keyboard and pointer".utf8)
+ case ConfigSelect.serial where subselect == 0:
+ payload = Array("dory-input-0".utf8)
+ case ConfigSelect.deviceIDs where subselect == 0:
+ payload.appendLE(UInt16(0x06)) // BUS_VIRTUAL
+ payload.appendLE(UInt16(0xD072))
+ payload.appendLE(UInt16(0x0001))
+ payload.appendLE(UInt16(0x0001))
+ case ConfigSelect.propertyBits where subselect == 0:
+ payload = [0]
+ case ConfigSelect.eventBits:
+ payload = Self.eventBitmap(type: subselect)
+ case ConfigSelect.absoluteInfo where subselect == 0 || subselect == 1:
+ payload.appendLE(UInt32(0))
+ payload.appendLE(UInt32(32_767))
+ payload.appendLE(UInt32(0))
+ payload.appendLE(UInt32(0))
+ payload.appendLE(UInt32(100))
+ default:
+ break
+ }
+
+ payload = Array(payload.prefix(128))
+ var config = [select, subselect, UInt8(payload.count), 0, 0, 0, 0, 0]
+ config.append(contentsOf: payload)
+ config.append(contentsOf: repeatElement(0, count: 136 - config.count))
+ return config
+ }
+
+ public func writeConfig(offset: UInt64, value: UInt64, width: Int) {
+ guard offset < 2, width > 0 else { return }
+ lock.lock()
+ for index in 0..> UInt64(index * 8))
+ if position == 0 { selectedConfig = byte }
+ if position == 1 { selectedSubconfig = byte }
+ }
+ lock.unlock()
+ }
+
+ public func deviceReady(transport: VirtioMMIOTransport) {
+ lock.lock()
+ self.transport = transport
+ lock.unlock()
+ }
+
+ public func deviceReset(transport: VirtioMMIOTransport) {
+ lock.lock()
+ self.transport = nil
+ availableEventBuffers.removeAll()
+ pendingFrames.removeAll()
+ lock.unlock()
+ }
+
+ public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) {
+ guard queue == 0 else { return }
+ lock.lock()
+ availableEventBuffers.removeAll()
+ if !ready { pendingFrames.removeAll() }
+ lock.unlock()
+ }
+
+ public func handleKick(queue: Int, transport: VirtioMMIOTransport) {
+ switch queue {
+ case 0:
+ drainEventQueue(transport: transport)
+ case 1:
+ drainStatusQueue(transport: transport)
+ default:
+ break
+ }
+ }
+
+ /// Queues one atomic evdev update. `SYN_REPORT` is appended when the caller omitted it.
+ public func send(frame events: [VirtioInputEvent]) {
+ guard !events.isEmpty else { return }
+ var complete = events
+ if complete.last != .synchronize { complete.append(.synchronize) }
+
+ lock.lock()
+ pendingFrames.append(complete)
+ if pendingFrames.count > maximumPendingFrames {
+ pendingFrames.removeFirst(pendingFrames.count - maximumPendingFrames)
+ }
+ let transport = self.transport
+ lock.unlock()
+
+ if let transport {
+ transport.withQueueLock {
+ drainEventQueue(transport: transport)
+ }
+ }
+ }
+
+ private func drainEventQueue(transport: VirtioMMIOTransport) {
+ let queue = transport.queues[0]
+ var invalidBuffers = [VirtqueueChain]()
+ while let chain = (try? queue.pop()) ?? nil {
+ let writableCount = chain.writableSegments.reduce(0) { $0 + $1.length }
+ if writableCount >= 8 {
+ availableEventBuffers.append(chain)
+ } else {
+ invalidBuffers.append(chain)
+ }
+ }
+
+ lock.lock()
+ var publications = [(VirtqueueChain, VirtioInputEvent)]()
+ while let frame = pendingFrames.first, availableEventBuffers.count >= frame.count {
+ pendingFrames.removeFirst()
+ for event in frame {
+ publications.append((availableEventBuffers.removeFirst(), event))
+ }
+ }
+ lock.unlock()
+
+ var interrupt = false
+ for chain in invalidBuffers {
+ interrupt = ((try? queue.push(chain, written: 0)) ?? false) || interrupt
+ }
+ for (chain, event) in publications {
+ let written = chain.writeBytes(event.bytes)
+ interrupt = ((try? queue.push(chain, written: written)) ?? false) || interrupt
+ }
+ if interrupt { transport.notifyUsed() }
+ }
+
+ private func drainStatusQueue(transport: VirtioMMIOTransport) {
+ let queue = transport.queues[1]
+ var interrupt = false
+ while let chain = (try? queue.pop()) ?? nil {
+ let bytes = chain.readBytes(maximum: 8)
+ if bytes.count == 8 {
+ statusHandler?(VirtioInputEvent(
+ type: bytes.leUInt16(at: 0),
+ code: bytes.leUInt16(at: 2),
+ value: Int32(bitPattern: bytes.leUInt32(at: 4))
+ ))
+ }
+ interrupt = ((try? queue.push(chain, written: 0)) ?? false) || interrupt
+ }
+ if interrupt { transport.notifyUsed() }
+ }
+
+ private static func eventBitmap(type: UInt8) -> [UInt8] {
+ switch type {
+ case EventType.synchronize:
+ return bitmap(codes: [0])
+ case EventType.key:
+ // Standard keyboard range plus primary mouse buttons.
+ return bitmap(codes: Array(1...255) + Array(272...276))
+ case EventType.relative:
+ // Horizontal/vertical wheels and their high-resolution companions.
+ return bitmap(codes: [6, 8, 11, 12])
+ case EventType.absolute:
+ return bitmap(codes: [0, 1])
+ case EventType.led:
+ return bitmap(codes: [0, 1, 2])
+ default:
+ return []
+ }
+ }
+
+ private static func bitmap(codes: [Int]) -> [UInt8] {
+ guard let maximum = codes.max(), maximum >= 0 else { return [] }
+ var bytes = [UInt8](repeating: 0, count: maximum / 8 + 1)
+ for code in codes where code >= 0 {
+ bytes[code / 8] |= UInt8(1 << (code % 8))
+ }
+ while bytes.last == 0 { bytes.removeLast() }
+ return bytes
+ }
+}
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift
index ea526507..a7839ecf 100644
--- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift
@@ -72,6 +72,7 @@ public final class VirtioMMIOTransport: MMIODevice {
private var sharedMemorySelect: UInt32 = 0
private var status: UInt32 = 0
private var interruptStatus: UInt32 = 0
+ private var configGeneration: UInt32 = 0
private let interruptLock = NSLock() // device backends may complete buffers off the vCPU thread
private let registerLock = NSRecursiveLock() // SMP: register access and kicks arrive from any vCPU thread
private var pendingQueueLayout: [(descriptor: UInt64, avail: UInt64, used: UInt64, count: UInt16)]
@@ -102,6 +103,19 @@ public final class VirtioMMIOTransport: MMIODevice {
interrupt()
}
+ /// Signals that device configuration visible at 0x100 changed. Virtio drivers distinguish
+ /// this from a used-ring interrupt through ISR bit 1 and use ConfigGeneration to take a
+ /// coherent snapshot when the host changes multiple fields during one resize.
+ public func notifyConfigChange() {
+ registerLock.lock()
+ configGeneration &+= 1
+ interruptLock.lock()
+ interruptStatus |= 2
+ interruptLock.unlock()
+ registerLock.unlock()
+ interrupt()
+ }
+
public func hostPointer(at guestAddress: UInt64, count: UInt64) throws -> UnsafeMutableRawPointer {
try memory.hostPointer(at: guestAddress, count: count)
}
@@ -139,7 +153,7 @@ public final class VirtioMMIOTransport: MMIODevice {
case 0x0B4: return selectedSharedMemoryRegion?.length.highUInt32 ?? UInt64(UInt32.max)
case 0x0B8: return selectedSharedMemoryRegion?.guestBase.lowUInt32 ?? 0
case 0x0BC: return selectedSharedMemoryRegion?.guestBase.highUInt32 ?? 0
- case 0x0FC: return 0 // ConfigGeneration
+ case 0x0FC: return UInt64(configGeneration)
case 0x100...:
return readConfig(offset: offset - 0x100, width: width)
default:
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift
new file mode 100644
index 00000000..81f34000
--- /dev/null
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift
@@ -0,0 +1,564 @@
+import Foundation
+
+public enum VirtioSoundDirection: UInt8, Sendable {
+ case output = 0
+ case input = 1
+}
+
+public struct VirtioSoundPCMParameters: Equatable, Sendable {
+ public var bufferBytes: Int
+ public var periodBytes: Int
+ public var sampleRate: Double
+ public var channels: Int
+ public var bytesPerSample: Int
+
+ public init(
+ bufferBytes: Int,
+ periodBytes: Int,
+ sampleRate: Double,
+ channels: Int,
+ bytesPerSample: Int = 2
+ ) {
+ self.bufferBytes = bufferBytes
+ self.periodBytes = periodBytes
+ self.sampleRate = sampleRate
+ self.channels = channels
+ self.bytesPerSample = bytesPerSample
+ }
+
+ public var bytesPerFrame: Int { channels * bytesPerSample }
+}
+
+/// Host audio implementation used by the virtio-snd transport. Dory's production implementation
+/// is backed by AVAudioEngine; tests use an in-memory backend so the device state machine and queue
+/// completion rules remain deterministic.
+public protocol VirtioSoundHost: AnyObject, Sendable {
+ func configure(
+ streamID: Int,
+ direction: VirtioSoundDirection,
+ parameters: VirtioSoundPCMParameters
+ ) -> Bool
+ func prepare(streamID: Int, direction: VirtioSoundDirection) -> Bool
+ func start(streamID: Int, direction: VirtioSoundDirection) -> Bool
+ func stop(streamID: Int, direction: VirtioSoundDirection) -> Bool
+ func release(streamID: Int, direction: VirtioSoundDirection)
+ func enqueuePlayback(
+ _ data: Data,
+ parameters: VirtioSoundPCMParameters,
+ completion: @escaping @Sendable (_ success: Bool, _ latencyBytes: UInt32) -> Void
+ ) -> Bool
+ func requestCapture(
+ byteCount: Int,
+ parameters: VirtioSoundPCMParameters,
+ completion: @escaping @Sendable (_ data: Data?, _ latencyBytes: UInt32) -> Void
+ ) -> Bool
+ func reset()
+}
+
+/// VirtIO 1.2 sound device with one stereo-capable output stream and one stereo-capable input
+/// stream. The Linux virtio-snd driver sees standard S16_LE 44.1/48 kHz PCM endpoints; actual Mac
+/// device conversion and permission handling live behind `VirtioSoundHost`.
+public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable {
+ public let deviceID: UInt32 = 25
+ public let deviceFeatures: UInt64 = 0
+ public let queueCount = 4 // control, event, playback TX, capture RX
+
+ private enum Request {
+ static let pcmInfo: UInt32 = 0x0100
+ static let pcmSetParameters: UInt32 = 0x0101
+ static let pcmPrepare: UInt32 = 0x0102
+ static let pcmRelease: UInt32 = 0x0103
+ static let pcmStart: UInt32 = 0x0104
+ static let pcmStop: UInt32 = 0x0105
+ }
+
+ private enum Status: UInt32 {
+ case ok = 0x8000
+ case badMessage = 0x8001
+ case notSupported = 0x8002
+ case ioError = 0x8003
+ }
+
+ private enum Lifecycle {
+ case idle
+ case parameters
+ case prepared
+ case running
+ }
+
+ private struct Stream {
+ var direction: VirtioSoundDirection
+ var lifecycle: Lifecycle = .idle
+ var parameters: VirtioSoundPCMParameters?
+ }
+
+ private struct PendingIO {
+ var streamID: Int
+ var chain: VirtqueueChain
+ var generation: UInt64
+ var payloadBytes: Int
+ }
+
+ private static let streamCount = 2
+ private static let pcmInfoSize = 32
+ private static let s16Format: UInt8 = 5
+ private static let supportedFormats: UInt64 = 1 << s16Format
+ private static let rateValues: [UInt8: Double] = [6: 44_100, 7: 48_000]
+ private static let supportedRates: UInt64 = rateValues.keys.reduce(0) { $0 | (1 << $1) }
+
+ private let host: VirtioSoundHost
+ private let log: @Sendable (String) -> Void
+ private let lock = NSLock()
+ private var streams = [
+ Stream(direction: .output),
+ Stream(direction: .input),
+ ]
+ private var nextRequestID: UInt64 = 1
+ // Each stream advances independently so releasing capture cannot invalidate an in-flight
+ // playback completion (and vice versa).
+ private var streamGenerations = [UInt64](repeating: 1, count: streamCount)
+ private var pendingPlayback = [UInt64: PendingIO]()
+ private var pendingCapture = [UInt64: PendingIO]()
+
+ public init(
+ host: VirtioSoundHost,
+ log: @escaping @Sendable (String) -> Void = { _ in }
+ ) {
+ self.host = host
+ self.log = log
+ }
+
+ public var configSpace: [UInt8] {
+ var bytes = [UInt8]()
+ bytes.appendLE(UInt32(0))
+ bytes.appendLE(UInt32(Self.streamCount))
+ bytes.appendLE(UInt32(0))
+ return bytes
+ }
+
+ public func handleKick(queue: Int, transport: VirtioMMIOTransport) {
+ switch queue {
+ case 0: drainControlQueue(transport)
+ case 1: break // no unsolicited events are advertised
+ case 2: drainPlaybackQueue(transport)
+ case 3: drainCaptureQueue(transport)
+ default: break
+ }
+ }
+
+ public func deviceReset(transport: VirtioMMIOTransport) {
+ lock.lock()
+ for index in streamGenerations.indices { streamGenerations[index] &+= 1 }
+ pendingPlayback.removeAll()
+ pendingCapture.removeAll()
+ streams = [Stream(direction: .output), Stream(direction: .input)]
+ lock.unlock()
+ host.reset()
+ }
+
+ public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) {
+ guard !ready, queue == 2 || queue == 3 else { return }
+ lock.lock()
+ let streamID = queue == 2 ? 0 : 1
+ let playbackCount = pendingPlayback.count
+ let captureCount = pendingCapture.count
+ streamGenerations[streamID] &+= 1
+ if queue == 2 { pendingPlayback.removeAll() }
+ if queue == 3 { pendingCapture.removeAll() }
+ lock.unlock()
+ if playbackCount > 0 || captureCount > 0 {
+ log("virtio sound queue \(queue) disabled with playback=\(playbackCount), capture=\(captureCount)")
+ }
+ }
+
+ private func drainControlQueue(_ transport: VirtioMMIOTransport) {
+ let queue = transport.queues[0]
+ var interrupt = false
+ while let chain = (try? queue.pop()) ?? nil {
+ let capacity = chain.writableSegments.reduce(0) { $0 + $1.length }
+ let response = processControlRequest(
+ chain.readBytes(maximum: 64 * 1024),
+ responseCapacity: capacity,
+ transport: transport
+ )
+ let written = chain.writeBytes(response)
+ interrupt = ((try? queue.push(chain, written: written)) ?? false) || interrupt
+ }
+ if interrupt { transport.notifyUsed() }
+ }
+
+ private func drainPlaybackQueue(_ transport: VirtioMMIOTransport) {
+ let queue = transport.queues[2]
+ while let chain = (try? queue.pop()) ?? nil {
+ let request = chain.readBytes()
+ guard request.count >= 4 else {
+ completeImmediately(chain, queue: queue, status: .badMessage, transport: transport)
+ continue
+ }
+ let streamID = Int(request.leUInt32(at: 0))
+ let audio = Data(request.dropFirst(4))
+ let pending: (UInt64, PendingIO, VirtioSoundPCMParameters)? = lock.withLock {
+ guard streamID == 0,
+ streams[streamID].direction == .output,
+ streams[streamID].lifecycle == .prepared || streams[streamID].lifecycle == .running,
+ let parameters = streams[streamID].parameters,
+ !audio.isEmpty,
+ audio.count % parameters.bytesPerFrame == 0 else { return nil }
+ let id = allocateRequestID()
+ let value = PendingIO(
+ streamID: streamID,
+ chain: chain,
+ generation: streamGenerations[streamID],
+ payloadBytes: audio.count
+ )
+ pendingPlayback[id] = value
+ return (id, value, parameters)
+ }
+ guard let (requestID, _, parameters) = pending else {
+ completeImmediately(chain, queue: queue, status: .ioError, transport: transport)
+ continue
+ }
+ let accepted = host.enqueuePlayback(audio, parameters: parameters) { [weak self, weak transport] success, latency in
+ guard let self, let transport else { return }
+ self.completePlayback(
+ requestID: requestID,
+ success: success,
+ latencyBytes: latency,
+ transport: transport
+ )
+ }
+ if !accepted {
+ completePlayback(
+ requestID: requestID,
+ success: false,
+ latencyBytes: 0,
+ transport: transport
+ )
+ }
+ }
+ }
+
+ private func drainCaptureQueue(_ transport: VirtioMMIOTransport) {
+ let queue = transport.queues[3]
+ while let chain = (try? queue.pop()) ?? nil {
+ let request = chain.readBytes(maximum: 4)
+ let writableBytes = chain.writableSegments.reduce(0) { $0 + $1.length }
+ guard request.count == 4, writableBytes > 8 else {
+ completeCaptureImmediately(
+ chain,
+ payloadBytes: max(0, writableBytes - 8),
+ queue: queue,
+ status: .badMessage,
+ transport: transport
+ )
+ continue
+ }
+ let streamID = Int(request.leUInt32(at: 0))
+ let payloadBytes = writableBytes - 8
+ let pending: (UInt64, VirtioSoundPCMParameters)? = lock.withLock {
+ guard streamID == 1,
+ streams[streamID].direction == .input,
+ streams[streamID].lifecycle == .prepared || streams[streamID].lifecycle == .running,
+ let parameters = streams[streamID].parameters,
+ payloadBytes % parameters.bytesPerFrame == 0 else { return nil }
+ let id = allocateRequestID()
+ pendingCapture[id] = PendingIO(
+ streamID: streamID,
+ chain: chain,
+ generation: streamGenerations[streamID],
+ payloadBytes: payloadBytes
+ )
+ return (id, parameters)
+ }
+ guard let (requestID, parameters) = pending else {
+ completeCaptureImmediately(
+ chain,
+ payloadBytes: payloadBytes,
+ queue: queue,
+ status: .ioError,
+ transport: transport
+ )
+ continue
+ }
+ let accepted = host.requestCapture(byteCount: payloadBytes, parameters: parameters) {
+ [weak self, weak transport] data, latency in
+ guard let self, let transport else { return }
+ self.completeCapture(
+ requestID: requestID,
+ data: data,
+ latencyBytes: latency,
+ transport: transport
+ )
+ }
+ if !accepted {
+ completeCapture(
+ requestID: requestID,
+ data: nil,
+ latencyBytes: 0,
+ transport: transport
+ )
+ }
+ }
+ }
+
+ private func completePlayback(
+ requestID: UInt64,
+ success: Bool,
+ latencyBytes: UInt32,
+ transport: VirtioMMIOTransport
+ ) {
+ var interrupt = false
+ transport.withQueueLock {
+ let pending: PendingIO? = lock.withLock {
+ guard let value = pendingPlayback.removeValue(forKey: requestID),
+ value.generation == streamGenerations[value.streamID] else { return nil }
+ return value
+ }
+ guard let pending else { return }
+ let status = Self.ioStatus(success ? .ok : .ioError, latencyBytes: latencyBytes)
+ let written = pending.chain.writeBytes(status)
+ interrupt = (try? transport.queues[2].push(pending.chain, written: written)) ?? false
+ }
+ if interrupt { transport.notifyUsed() }
+ }
+
+ private func completeCapture(
+ requestID: UInt64,
+ data: Data?,
+ latencyBytes: UInt32,
+ transport: VirtioMMIOTransport
+ ) {
+ var interrupt = false
+ transport.withQueueLock {
+ let pending: PendingIO? = lock.withLock {
+ guard let value = pendingCapture.removeValue(forKey: requestID),
+ value.generation == streamGenerations[value.streamID] else { return nil }
+ return value
+ }
+ guard let pending else { return }
+ let validData = data.flatMap { $0.count == pending.payloadBytes ? $0 : nil }
+ let payload = validData.map(Array.init) ?? []
+ let payloadWritten = pending.chain.writeBytes(payload)
+ let status = Self.ioStatus(
+ validData == nil ? .ioError : .ok,
+ latencyBytes: latencyBytes
+ )
+ let statusWritten = pending.chain.writeBytes(
+ status,
+ atWritableOffset: pending.payloadBytes
+ )
+ let usedLength = validData == nil ? statusWritten : payloadWritten + statusWritten
+ interrupt = (try? transport.queues[3].push(pending.chain, written: usedLength)) ?? false
+ }
+ if interrupt { transport.notifyUsed() }
+ }
+
+ private func completeImmediately(
+ _ chain: VirtqueueChain,
+ queue: Virtqueue,
+ status: Status,
+ transport: VirtioMMIOTransport
+ ) {
+ let written = chain.writeBytes(Self.ioStatus(status, latencyBytes: 0))
+ if (try? queue.push(chain, written: written)) == true { transport.notifyUsed() }
+ }
+
+ private func completeCaptureImmediately(
+ _ chain: VirtqueueChain,
+ payloadBytes: Int,
+ queue: Virtqueue,
+ status: Status,
+ transport: VirtioMMIOTransport
+ ) {
+ let written = chain.writeBytes(
+ Self.ioStatus(status, latencyBytes: 0),
+ atWritableOffset: payloadBytes
+ )
+ if (try? queue.push(chain, written: written)) == true { transport.notifyUsed() }
+ }
+
+ private func processControlRequest(
+ _ request: [UInt8],
+ responseCapacity: Int,
+ transport: VirtioMMIOTransport?
+ ) -> [UInt8] {
+ guard request.count >= 4 else { return Self.header(.badMessage) }
+ let code = request.leUInt32(at: 0)
+ switch code {
+ case Request.pcmInfo:
+ return pcmInfoResponse(request, responseCapacity: responseCapacity)
+ case Request.pcmSetParameters:
+ return setParametersResponse(request)
+ case Request.pcmPrepare, Request.pcmRelease, Request.pcmStart, Request.pcmStop:
+ guard request.count >= 8 else { return Self.header(.badMessage) }
+ let streamID = Int(request.leUInt32(at: 4))
+ return lifecycleResponse(code: code, streamID: streamID, transport: transport)
+ default:
+ return Self.header(.notSupported)
+ }
+ }
+
+ private func pcmInfoResponse(_ request: [UInt8], responseCapacity: Int) -> [UInt8] {
+ guard request.count >= 16 else { return Self.header(.badMessage) }
+ let start = Int(request.leUInt32(at: 4))
+ let count = Int(request.leUInt32(at: 8))
+ let itemSize = Int(request.leUInt32(at: 12))
+ guard start >= 0, count > 0, start <= Self.streamCount,
+ count <= Self.streamCount - start,
+ itemSize >= Self.pcmInfoSize,
+ responseCapacity >= 4 + count * itemSize else {
+ return Self.header(.badMessage)
+ }
+ var response = Self.header(.ok)
+ for streamID in start..<(start + count) {
+ var item = [UInt8]()
+ item.appendLE(UInt32(1))
+ item.appendLE(UInt32(0))
+ item.appendLE(Self.supportedFormats)
+ item.appendLE(Self.supportedRates)
+ item.append(streamID == 0 ? VirtioSoundDirection.output.rawValue : VirtioSoundDirection.input.rawValue)
+ item.append(1)
+ item.append(2)
+ item.append(contentsOf: repeatElement(0, count: 5))
+ item.append(contentsOf: repeatElement(0, count: itemSize - item.count))
+ response.append(contentsOf: item)
+ }
+ return response
+ }
+
+ private func setParametersResponse(_ request: [UInt8]) -> [UInt8] {
+ guard request.count >= 24 else { return Self.header(.badMessage) }
+ let streamID = Int(request.leUInt32(at: 4))
+ let bufferBytes = Int(request.leUInt32(at: 8))
+ let periodBytes = Int(request.leUInt32(at: 12))
+ let features = request.leUInt32(at: 16)
+ let channels = Int(request[20])
+ let format = request[21]
+ let rate = request[22]
+ guard streamID >= 0, streamID < streams.count,
+ bufferBytes > 0, periodBytes > 0,
+ bufferBytes % periodBytes == 0,
+ features == 0,
+ channels >= 1, channels <= 2,
+ format == Self.s16Format,
+ let sampleRate = Self.rateValues[rate] else {
+ return Self.header(.notSupported)
+ }
+ let parameters = VirtioSoundPCMParameters(
+ bufferBytes: bufferBytes,
+ periodBytes: periodBytes,
+ sampleRate: sampleRate,
+ channels: channels
+ )
+ let direction = streams[streamID].direction
+ guard host.configure(streamID: streamID, direction: direction, parameters: parameters) else {
+ return Self.header(.ioError)
+ }
+ lock.withLock {
+ streams[streamID].parameters = parameters
+ streams[streamID].lifecycle = .parameters
+ }
+ return Self.header(.ok)
+ }
+
+ private func lifecycleResponse(
+ code: UInt32,
+ streamID: Int,
+ transport: VirtioMMIOTransport?
+ ) -> [UInt8] {
+ guard streamID >= 0, streamID < streams.count else { return Self.header(.badMessage) }
+ let current = lock.withLock { streams[streamID] }
+ switch code {
+ case Request.pcmPrepare:
+ guard current.parameters != nil,
+ current.lifecycle == .parameters || current.lifecycle == .prepared,
+ host.prepare(streamID: streamID, direction: current.direction) else {
+ return Self.header(.ioError)
+ }
+ lock.withLock { streams[streamID].lifecycle = .prepared }
+ case Request.pcmStart:
+ guard current.lifecycle == .prepared,
+ host.start(streamID: streamID, direction: current.direction) else {
+ return Self.header(.ioError)
+ }
+ lock.withLock { streams[streamID].lifecycle = .running }
+ case Request.pcmStop:
+ guard current.lifecycle == .running,
+ host.stop(streamID: streamID, direction: current.direction) else {
+ return Self.header(.ioError)
+ }
+ lock.withLock { streams[streamID].lifecycle = .prepared }
+ case Request.pcmRelease:
+ guard current.lifecycle == .prepared || current.lifecycle == .parameters else {
+ return Self.header(.ioError)
+ }
+ host.release(streamID: streamID, direction: current.direction)
+ if let transport { flushPending(streamID: streamID, transport: transport) }
+ lock.withLock { streams[streamID].lifecycle = .parameters }
+ default:
+ return Self.header(.notSupported)
+ }
+ return Self.header(.ok)
+ }
+
+ private func flushPending(streamID: Int, transport: VirtioMMIOTransport) {
+ let playback: [PendingIO]
+ let capture: [PendingIO]
+ lock.lock()
+ streamGenerations[streamID] &+= 1
+ playback = pendingPlayback.values.filter { $0.streamID == streamID }
+ capture = pendingCapture.values.filter { $0.streamID == streamID }
+ pendingPlayback = pendingPlayback.filter { $0.value.streamID != streamID }
+ pendingCapture = pendingCapture.filter { $0.value.streamID != streamID }
+ lock.unlock()
+
+ if !playback.isEmpty || !capture.isEmpty {
+ log("virtio sound stream \(streamID) released with playback=\(playback.count), capture=\(capture.count) pending")
+ }
+
+ var interrupt = false
+ for pending in playback {
+ let written = pending.chain.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0))
+ interrupt = ((try? transport.queues[2].push(pending.chain, written: written)) ?? false) || interrupt
+ }
+ for pending in capture {
+ let written = pending.chain.writeBytes(
+ Self.ioStatus(.ioError, latencyBytes: 0),
+ atWritableOffset: pending.payloadBytes
+ )
+ interrupt = ((try? transport.queues[3].push(pending.chain, written: written)) ?? false) || interrupt
+ }
+ if interrupt { transport.notifyUsed() }
+ }
+
+ private func allocateRequestID() -> UInt64 {
+ let value = nextRequestID
+ nextRequestID &+= 1
+ return value
+ }
+
+ private static func header(_ status: Status) -> [UInt8] {
+ var bytes = [UInt8]()
+ bytes.appendLE(status.rawValue)
+ return bytes
+ }
+
+ private static func ioStatus(_ status: Status, latencyBytes: UInt32) -> [UInt8] {
+ var bytes = header(status)
+ bytes.appendLE(latencyBytes)
+ return bytes
+ }
+
+ // Protocol-level test hook; production requests always arrive through controlq.
+ func controlResponseForTesting(_ request: [UInt8], responseCapacity: Int = 4096) -> [UInt8] {
+ processControlRequest(request, responseCapacity: responseCapacity, transport: nil)
+ }
+}
+
+private extension NSLock {
+ func withLock(_ body: () -> T) -> T {
+ lock()
+ defer { unlock() }
+ return body()
+ }
+}
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift
index f0df8f09..a3e17233 100644
--- a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift
@@ -36,16 +36,36 @@ public struct VirtqueueChain {
@discardableResult
public func writeBytes(_ bytes: [UInt8]) -> Int {
- var offset = 0
+ writeBytes(bytes, atWritableOffset: 0)
+ }
+
+ /// Writes into the concatenated device-writable portion of the chain at a byte offset.
+ /// Virtio protocols such as virtio-snd place a writable PCM payload before a writable status
+ /// structure, so the device must address the latter without overwriting the former.
+ @discardableResult
+ public func writeBytes(_ bytes: [UInt8], atWritableOffset requestedOffset: Int) -> Int {
+ guard requestedOffset >= 0 else { return 0 }
+ var sourceOffset = 0
+ var writableOffset = 0
for segment in segments where segment.isDeviceWritable {
- let take = min(segment.length, bytes.count - offset)
+ if writableOffset + segment.length <= requestedOffset {
+ writableOffset += segment.length
+ continue
+ }
+ let destinationOffset = max(0, requestedOffset - writableOffset)
+ let available = segment.length - destinationOffset
+ let take = min(available, bytes.count - sourceOffset)
guard take > 0 else { break }
- bytes[offset..<(offset + take)].withUnsafeBytes { source in
- segment.pointer.copyMemory(from: source.baseAddress!, byteCount: take)
+ bytes[sourceOffset..<(sourceOffset + take)].withUnsafeBytes { source in
+ segment.pointer.advanced(by: destinationOffset).copyMemory(
+ from: source.baseAddress!,
+ byteCount: take
+ )
}
- offset += take
+ sourceOffset += take
+ writableOffset += segment.length
}
- return offset
+ return sourceOffset
}
}
diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift
index 04d9f21a..c9988fef 100644
--- a/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift
+++ b/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift
@@ -37,6 +37,7 @@ public enum X86GuestLayout {
public static let pvhCommandLine: UInt64 = 0x0009_1000
public static let pvhModules: UInt64 = 0x0009_2000
public static let pvhMemoryMap: UInt64 = 0x0009_3000
+ public static let initrd: UInt64 = 0x1000_0000
public static let mpFloatingPointer: UInt64 = 0x000F_0000
public static let mpConfigurationTable: UInt64 = 0x000F_1000
public static let daxWindowBase: UInt64 = 0xC_0000_0000
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift
new file mode 100644
index 00000000..88189162
--- /dev/null
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift
@@ -0,0 +1,498 @@
+@preconcurrency import AVFAudio
+@preconcurrency import AVFoundation
+import DoryHV
+import Foundation
+
+/// Bridges the raw Hypervisor.framework virtio-snd device to Core Audio. Guest PCM remains the
+/// standard signed 16-bit interleaved format while AVAudioEngine performs host sample-rate and
+/// device conversion.
+final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable {
+ private static let deliveryQueue = DispatchQueue(
+ label: "com.dory.desktop.audio.completion",
+ qos: .userInteractive
+ )
+
+ private struct CaptureRequest {
+ var id: UInt64
+ var byteCount: Int
+ var fallbackArmed: Bool
+ var completion: @Sendable (Data?, UInt32) -> Void
+ }
+
+ private final class ConverterInput: @unchecked Sendable {
+ let buffer: AVAudioPCMBuffer
+ var served = false
+
+ init(buffer: AVAudioPCMBuffer) {
+ self.buffer = buffer
+ }
+ }
+
+ private let queue = DispatchQueue(label: "com.dory.desktop.audio", qos: .userInteractive)
+ // Keep playback and capture on independent graphs. PipeWire may prepare and start them in
+ // either order; mutating a shared running graph to add the other direction can leave an
+ // AVAudioPlayerNode permanently scheduled but never rendered.
+ private let outputEngine = AVAudioEngine()
+ private let inputEngine = AVAudioEngine()
+ private let player = AVAudioPlayerNode()
+ private let log: @Sendable (String) -> Void
+
+ private var outputParameters: VirtioSoundPCMParameters?
+ private var inputParameters: VirtioSoundPCMParameters?
+ private var outputRunning = false
+ private var inputRunning = false
+ private var inputTapInstalled = false
+ private var permissionRequestInFlight = false
+ private var captureBytes = Data()
+ private var captureRequests = [CaptureRequest]()
+ private var nextCaptureRequestID: UInt64 = 1
+ private var captureTapCount = 0
+ private var captureFallbackLogged = false
+ private var nextCaptureFallbackUptime: TimeInterval = 0
+ private var queuedPlaybackBytes = 0
+
+ init(log: @escaping @Sendable (String) -> Void) {
+ self.log = log
+ outputEngine.attach(player)
+ }
+
+ func configure(
+ streamID: Int,
+ direction: VirtioSoundDirection,
+ parameters: VirtioSoundPCMParameters
+ ) -> Bool {
+ guard Self.valid(parameters) else { return false }
+ return queue.sync {
+ switch direction {
+ case .output:
+ player.stop()
+ queuedPlaybackBytes = 0
+ outputEngine.disconnectNodeOutput(player)
+ guard let format = Self.floatFormat(parameters) else { return false }
+ outputEngine.connect(player, to: outputEngine.mainMixerNode, format: format)
+ outputParameters = parameters
+ outputRunning = false
+ case .input:
+ removeInputTap()
+ failCaptureRequests()
+ captureBytes.removeAll(keepingCapacity: true)
+ captureTapCount = 0
+ captureFallbackLogged = false
+ nextCaptureFallbackUptime = 0
+ inputParameters = parameters
+ inputRunning = false
+ }
+ return true
+ }
+ }
+
+ func prepare(streamID: Int, direction: VirtioSoundDirection) -> Bool {
+ queue.sync {
+ switch direction {
+ case .output: guard outputParameters != nil else { return false }
+ case .input: guard inputParameters != nil else { return false }
+ }
+ switch direction {
+ case .output:
+ if !outputEngine.isRunning { outputEngine.prepare() }
+ case .input:
+ // An input-only AVAudioEngine has no graph until its tap is installed. Preparing
+ // it here raises an Objective-C exception; installInputTapAndStartEngine() owns
+ // input graph preparation once macOS microphone access is available.
+ break
+ }
+ return true
+ }
+ }
+
+ func start(streamID: Int, direction: VirtioSoundDirection) -> Bool {
+ queue.sync {
+ switch direction {
+ case .output:
+ outputRunning = true
+ guard outputParameters != nil, startOutputEngine() else {
+ outputRunning = false
+ return false
+ }
+ player.play()
+ return true
+ case .input:
+ guard inputParameters != nil else { return false }
+ inputRunning = true
+ guard startInputWhenAuthorized() else { return false }
+ satisfyCaptureRequests()
+ armCaptureFallbacks()
+ return true
+ }
+ }
+ }
+
+ func stop(streamID: Int, direction: VirtioSoundDirection) -> Bool {
+ queue.sync {
+ switch direction {
+ case .output:
+ player.pause()
+ outputRunning = false
+ case .input:
+ inputRunning = false
+ removeInputTap()
+ inputEngine.stop()
+ nextCaptureFallbackUptime = 0
+ }
+ return true
+ }
+ }
+
+ func release(streamID: Int, direction: VirtioSoundDirection) {
+ queue.sync {
+ switch direction {
+ case .output:
+ player.stop()
+ outputEngine.stop()
+ outputRunning = false
+ queuedPlaybackBytes = 0
+ outputParameters = nil
+ case .input:
+ inputRunning = false
+ removeInputTap()
+ inputEngine.stop()
+ inputParameters = nil
+ captureBytes.removeAll(keepingCapacity: false)
+ nextCaptureFallbackUptime = 0
+ failCaptureRequests()
+ }
+ }
+ }
+
+ func enqueuePlayback(
+ _ data: Data,
+ parameters: VirtioSoundPCMParameters,
+ completion: @escaping @Sendable (Bool, UInt32) -> Void
+ ) -> Bool {
+ queue.sync {
+ guard outputParameters == parameters,
+ let buffer = Self.playbackBuffer(data: data, parameters: parameters) else {
+ return false
+ }
+ queuedPlaybackBytes += data.count
+ // The virtio descriptor protects the guest-owned period, not the audible speaker
+ // timeline. We have already copied that period into an AVAudioPCMBuffer, so complete
+ // it when Core Audio consumes the buffer. Waiting for `.dataPlayedBack` couples the
+ // Linux PCM ring to the host device's presentation callback and can deadlock ALSA on
+ // devices that do not publish that callback for an application-owned engine.
+ player.scheduleBuffer(buffer, completionCallbackType: .dataConsumed) {
+ [weak self] _ in
+ guard let self else { return }
+ self.queue.async {
+ self.queuedPlaybackBytes = max(0, self.queuedPlaybackBytes - data.count)
+ let latency = UInt32(clamping: self.queuedPlaybackBytes)
+ Self.deliver { completion(true, latency) }
+ }
+ }
+ // AVAudioPlayerNode stops after an empty queue. Linux commonly starts the PCM stream
+ // before submitting the first period, so the play() issued by start() may have already
+ // gone idle by the time this buffer arrives. Rearm it for every transition from an
+ // empty/stopped player to queued audio, otherwise virtio TX descriptors never complete.
+ if outputRunning { player.play() }
+ return true
+ }
+ }
+
+ func requestCapture(
+ byteCount: Int,
+ parameters: VirtioSoundPCMParameters,
+ completion: @escaping @Sendable (Data?, UInt32) -> Void
+ ) -> Bool {
+ queue.sync {
+ // Linux primes capture descriptors while the PCM is prepared, before PCM_START. Keep
+ // those requests pending just as playback keeps its pre-roll buffers scheduled.
+ guard byteCount > 0, inputParameters == parameters else { return false }
+ let requestID = nextCaptureRequestID
+ nextCaptureRequestID &+= 1
+ captureRequests.append(CaptureRequest(
+ id: requestID,
+ byteCount: byteCount,
+ fallbackArmed: false,
+ completion: completion
+ ))
+ satisfyCaptureRequests()
+ if inputRunning { armCaptureFallback(requestID: requestID) }
+ return true
+ }
+ }
+
+ func reset() {
+ queue.sync {
+ player.stop()
+ removeInputTap()
+ outputEngine.stop()
+ inputEngine.stop()
+ outputParameters = nil
+ inputParameters = nil
+ outputRunning = false
+ inputRunning = false
+ queuedPlaybackBytes = 0
+ captureBytes.removeAll(keepingCapacity: false)
+ nextCaptureFallbackUptime = 0
+ failCaptureRequests()
+ }
+ }
+
+ private func startInputWhenAuthorized() -> Bool {
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
+ case .authorized:
+ return installInputTapAndStartEngine()
+ case .notDetermined:
+ guard !permissionRequestInFlight else { return true }
+ permissionRequestInFlight = true
+ log("requesting Mac microphone access; Linux capture will provide paced silence until permission is resolved")
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
+ guard let self else { return }
+ self.queue.async {
+ self.permissionRequestInFlight = false
+ guard self.inputRunning else { return }
+ if granted, self.installInputTapAndStartEngine() { return }
+ self.log("microphone access is unavailable; Linux capture will continue with paced silence")
+ self.armCaptureFallbacks()
+ }
+ }
+ return true
+ case .denied, .restricted:
+ log("microphone access is denied; enable it for Dory in System Settings > Privacy & Security > Microphone")
+ inputRunning = false
+ return false
+ @unknown default:
+ inputRunning = false
+ return false
+ }
+ }
+
+ private func installInputTapAndStartEngine() -> Bool {
+ guard inputRunning, let parameters = inputParameters else { return false }
+ if !inputTapInstalled {
+ let input = inputEngine.inputNode
+ let nativeFormat = input.outputFormat(forBus: 0)
+ guard nativeFormat.channelCount > 0,
+ nativeFormat.sampleRate > 0,
+ let targetFormat = Self.floatFormat(parameters),
+ let converter = AVAudioConverter(from: nativeFormat, to: targetFormat) else {
+ log("the selected Mac input device does not expose a usable audio format")
+ return false
+ }
+ input.installTap(onBus: 0, bufferSize: 1_024, format: nativeFormat) {
+ [weak self] buffer, _ in
+ let data = Self.convertCapture(
+ buffer: buffer,
+ converter: converter,
+ targetFormat: targetFormat,
+ parameters: parameters
+ )
+ let inputFrames = buffer.frameLength
+ self?.queue.async { [weak self] in
+ guard let self else { return }
+ self.captureTapCount += 1
+ if self.captureTapCount == 1 {
+ self.log("Mac microphone stream active (inputFrames=\(inputFrames), convertedBytes=\(data?.count ?? 0))")
+ }
+ if let data, !data.isEmpty { self.appendCapture(data) }
+ }
+ }
+ inputTapInstalled = true
+ }
+ return startInputEngine()
+ }
+
+ private func startOutputEngine() -> Bool {
+ if outputEngine.isRunning {
+ if outputRunning, !player.isPlaying { player.play() }
+ return true
+ }
+ do {
+ outputEngine.prepare()
+ try outputEngine.start()
+ if outputRunning, !player.isPlaying { player.play() }
+ return true
+ } catch {
+ log("could not start Mac audio output: \(error)")
+ return false
+ }
+ }
+
+ private func startInputEngine() -> Bool {
+ if inputEngine.isRunning { return true }
+ do {
+ inputEngine.prepare()
+ try inputEngine.start()
+ return true
+ } catch {
+ log("could not start Mac audio input: \(error)")
+ return false
+ }
+ }
+
+ private func removeInputTap() {
+ guard inputTapInstalled else { return }
+ inputEngine.inputNode.removeTap(onBus: 0)
+ inputTapInstalled = false
+ }
+
+ private func appendCapture(_ data: Data) {
+ captureBytes.append(data)
+ // Bound stale input if PipeWire temporarily stops submitting receive buffers.
+ if captureBytes.count > 4 * 1_024 * 1_024 {
+ captureBytes.removeFirst(captureBytes.count - 4 * 1_024 * 1_024)
+ }
+ satisfyCaptureRequests()
+ }
+
+ private func satisfyCaptureRequests() {
+ while let request = captureRequests.first, captureBytes.count >= request.byteCount {
+ captureRequests.removeFirst()
+ let data = Data(captureBytes.prefix(request.byteCount))
+ captureBytes.removeFirst(request.byteCount)
+ let latency = UInt32(clamping: captureBytes.count)
+ Self.deliver { request.completion(data, latency) }
+ }
+ if captureRequests.isEmpty { nextCaptureFallbackUptime = 0 }
+ }
+
+ private func armCaptureFallbacks() {
+ for requestID in captureRequests.lazy.filter({ !$0.fallbackArmed }).map(\.id) {
+ armCaptureFallback(requestID: requestID)
+ }
+ }
+
+ private func armCaptureFallback(requestID: UInt64) {
+ guard inputRunning,
+ let parameters = inputParameters,
+ let index = captureRequests.firstIndex(where: { $0.id == requestID }),
+ !captureRequests[index].fallbackArmed else { return }
+ captureRequests[index].fallbackArmed = true
+ let byteCount = captureRequests[index].byteCount
+ let frames = byteCount / parameters.bytesPerFrame
+ let period = Double(frames) / parameters.sampleRate
+ // During a permission prompt, pace from the first period. Once the real input graph is
+ // active, allow two periods (at least 100 ms) before treating a missed callback as silence.
+ let now = ProcessInfo.processInfo.systemUptime
+ let firstDelay = inputTapInstalled ? max(0.1, 2 * period) : period
+ let deadline: TimeInterval
+ if nextCaptureFallbackUptime > now {
+ deadline = nextCaptureFallbackUptime + period
+ } else {
+ deadline = now + firstDelay
+ }
+ nextCaptureFallbackUptime = deadline
+ queue.asyncAfter(deadline: .now() + max(0, deadline - now)) { [weak self] in
+ guard let self,
+ let pendingIndex = self.captureRequests.firstIndex(where: { $0.id == requestID }) else {
+ return
+ }
+ let request = self.captureRequests.remove(at: pendingIndex)
+ if !self.captureFallbackLogged {
+ self.captureFallbackLogged = true
+ self.log("Mac microphone frames are pending; Linux capture is using paced silence")
+ }
+ if self.captureRequests.isEmpty { self.nextCaptureFallbackUptime = 0 }
+ Self.deliver { request.completion(Data(count: request.byteCount), 0) }
+ }
+ }
+
+ private func failCaptureRequests() {
+ let requests = captureRequests
+ captureRequests.removeAll(keepingCapacity: false)
+ for request in requests { Self.deliver { request.completion(nil, 0) } }
+ }
+
+ private static func valid(_ parameters: VirtioSoundPCMParameters) -> Bool {
+ parameters.bytesPerSample == 2
+ && (parameters.channels == 1 || parameters.channels == 2)
+ && (parameters.sampleRate == 44_100 || parameters.sampleRate == 48_000)
+ && parameters.bufferBytes > 0
+ && parameters.periodBytes > 0
+ && parameters.periodBytes <= parameters.bufferBytes
+ && parameters.periodBytes % parameters.bytesPerFrame == 0
+ }
+
+ private static func floatFormat(_ parameters: VirtioSoundPCMParameters) -> AVAudioFormat? {
+ AVAudioFormat(
+ commonFormat: .pcmFormatFloat32,
+ sampleRate: parameters.sampleRate,
+ channels: AVAudioChannelCount(parameters.channels),
+ interleaved: false
+ )
+ }
+
+ private static func playbackBuffer(
+ data: Data,
+ parameters: VirtioSoundPCMParameters
+ ) -> AVAudioPCMBuffer? {
+ guard !data.isEmpty,
+ data.count % parameters.bytesPerFrame == 0,
+ let format = floatFormat(parameters) else { return nil }
+ let frames = data.count / parameters.bytesPerFrame
+ guard let buffer = AVAudioPCMBuffer(
+ pcmFormat: format,
+ frameCapacity: AVAudioFrameCount(frames)
+ ), let channels = buffer.floatChannelData else { return nil }
+ buffer.frameLength = AVAudioFrameCount(frames)
+ data.withUnsafeBytes { raw in
+ guard let bytes = raw.bindMemory(to: UInt8.self).baseAddress else { return }
+ for frame in 0.. Data? {
+ let ratio = targetFormat.sampleRate / buffer.format.sampleRate
+ let capacity = AVAudioFrameCount(max(1, Int(ceil(Double(buffer.frameLength) * ratio)) + 32))
+ guard let converted = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else {
+ return nil
+ }
+ let input = ConverterInput(buffer: buffer)
+ var conversionError: NSError?
+ let status = converter.convert(to: converted, error: &conversionError) { _, outStatus in
+ if input.served {
+ outStatus.pointee = .noDataNow
+ return nil
+ }
+ input.served = true
+ outStatus.pointee = .haveData
+ return input.buffer
+ }
+ guard conversionError == nil,
+ status != .error,
+ converted.frameLength > 0,
+ let channels = converted.floatChannelData else { return nil }
+
+ var bytes = Data(count: Int(converted.frameLength) * parameters.bytesPerFrame)
+ bytes.withUnsafeMutableBytes { raw in
+ guard let output = raw.bindMemory(to: UInt8.self).baseAddress else { return }
+ for frame in 0..> 8)
+ }
+ }
+ }
+ return bytes
+ }
+
+ private static func deliver(_ operation: @escaping @Sendable () -> Void) {
+ deliveryQueue.async(execute: operation)
+ }
+}
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift
new file mode 100644
index 00000000..d832fa10
--- /dev/null
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift
@@ -0,0 +1,440 @@
+import AppKit
+import DoryHV
+import Foundation
+import MetalKit
+
+enum DesktopMetalDisplayError: Error, CustomStringConvertible {
+ case metalUnavailable
+ case shaderCompilation(String)
+
+ var description: String {
+ switch self {
+ case .metalUnavailable:
+ return "this Mac does not expose a Metal display device"
+ case let .shaderCompilation(detail):
+ return "could not build the Dory display shader: \(detail)"
+ }
+ }
+}
+
+/// Coalesces producer-thread flushes to the newest complete frame and performs one main-thread
+/// upload. This keeps a busy compositor from building an unbounded queue of stale 16 MiB frames.
+final class DesktopFrameMailbox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var pending = [VirtioGPUScanoutFrame]()
+ private var deliveryScheduled = false
+ nonisolated(unsafe) weak var view: DesktopMetalView?
+
+ func submit(_ frame: VirtioGPUScanoutFrame) {
+ lock.lock()
+ if frame.dirtyRect.x == 0, frame.dirtyRect.y == 0,
+ frame.dirtyRect.width == frame.width, frame.dirtyRect.height == frame.height {
+ pending = [frame]
+ } else {
+ pending.append(frame)
+ if pending.count > 256 { pending.removeFirst(pending.count - 256) }
+ }
+ let shouldSchedule = !deliveryScheduled
+ deliveryScheduled = true
+ lock.unlock()
+ guard shouldSchedule else { return }
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated {
+ self?.deliver()
+ }
+ }
+ }
+
+ /// Drop presentation storage only when the guest destroys the corresponding virtio-gpu
+ /// resource. Direct scanout temporarily switches between application and compositor buffers;
+ /// evicting an older compositor buffer merely because it was not recently visible makes its
+ /// next partial update appear as a black or torn frame.
+ func release(resourceID: UInt32) {
+ lock.lock()
+ pending.removeAll { $0.resourceID == resourceID }
+ lock.unlock()
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated {
+ self?.view?.release(resourceID: resourceID)
+ }
+ }
+ }
+
+ @MainActor
+ private func deliver() {
+ lock.lock()
+ let frames = pending
+ pending.removeAll(keepingCapacity: true)
+ deliveryScheduled = false
+ lock.unlock()
+ view?.present(frames)
+ }
+}
+
+@MainActor
+final class DesktopMetalView: MTKView, MTKViewDelegate {
+ private let commandQueue: MTLCommandQueue
+ private let pipeline: MTLRenderPipelineState
+ private let input: VirtioInput
+ private var resourceTextures: [UInt32: MTLTexture] = [:]
+ private var scanoutTexture: MTLTexture?
+ private var scanoutSize = CGSize.zero
+ private var tracking: NSTrackingArea?
+ private var scrollAccumulator = VirtioInputScrollAccumulator()
+ private var resizeGeneration: UInt64 = 0
+ var onDrawableSizeChange: ((UInt32, UInt32) -> Void)?
+ var onMacShortcut: ((NSEvent) -> Bool)?
+
+ init(frame: NSRect, input: VirtioInput) throws {
+ guard let device = MTLCreateSystemDefaultDevice(),
+ let commandQueue = device.makeCommandQueue() else {
+ throw DesktopMetalDisplayError.metalUnavailable
+ }
+ self.input = input
+ self.commandQueue = commandQueue
+ do {
+ let library = try device.makeLibrary(source: Self.shaderSource, options: nil)
+ let descriptor = MTLRenderPipelineDescriptor()
+ descriptor.vertexFunction = library.makeFunction(name: "dory_scanout_vertex")
+ descriptor.fragmentFunction = library.makeFunction(name: "dory_scanout_fragment")
+ descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
+ self.pipeline = try device.makeRenderPipelineState(descriptor: descriptor)
+ } catch {
+ throw DesktopMetalDisplayError.shaderCompilation(String(describing: error))
+ }
+ super.init(frame: frame, device: device)
+ colorPixelFormat = .bgra8Unorm
+ clearColor = MTLClearColorMake(0.025, 0.03, 0.04, 1)
+ framebufferOnly = true
+ enableSetNeedsDisplay = true
+ isPaused = true
+ preferredFramesPerSecond = 60
+ delegate = self
+ }
+
+ @available(*, unavailable)
+ required init(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override var acceptsFirstResponder: Bool { true }
+ override var isFlipped: Bool { true }
+
+ override func updateTrackingAreas() {
+ if let tracking { removeTrackingArea(tracking) }
+ let replacement = NSTrackingArea(
+ rect: bounds,
+ options: [.activeInKeyWindow, .inVisibleRect, .mouseMoved, .mouseEnteredAndExited],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(replacement)
+ tracking = replacement
+ super.updateTrackingAreas()
+ }
+
+ func present(_ frames: [VirtioGPUScanoutFrame]) {
+ var updated = false
+ for frame in frames {
+ updated = presentUpdate(frame) || updated
+ }
+ if updated { needsDisplay = true }
+ }
+
+ func release(resourceID: UInt32) {
+ resourceTextures.removeValue(forKey: resourceID)
+ }
+
+ private func presentUpdate(_ frame: VirtioGPUScanoutFrame) -> Bool {
+ guard let pixelFormat = Self.pixelFormat(for: frame.format),
+ let device,
+ frame.width > 0, frame.height > 0,
+ frame.dirtyRect.width > 0, frame.dirtyRect.height > 0,
+ frame.dirtyRect.x <= frame.width,
+ frame.dirtyRect.width <= frame.width - frame.dirtyRect.x,
+ frame.dirtyRect.y <= frame.height,
+ frame.dirtyRect.height <= frame.height - frame.dirtyRect.y,
+ frame.stride >= frame.dirtyRect.width * 4,
+ frame.bytes.count >= Int(frame.stride) * Int(frame.dirtyRect.height) else {
+ return false
+ }
+ var texture = resourceTextures[frame.resourceID]
+ if texture?.width != Int(frame.width)
+ || texture?.height != Int(frame.height)
+ || texture?.pixelFormat != pixelFormat {
+ let descriptor = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: pixelFormat,
+ width: Int(frame.width),
+ height: Int(frame.height),
+ mipmapped: false
+ )
+ descriptor.storageMode = .shared
+ descriptor.usage = [.shaderRead]
+ texture = device.makeTexture(descriptor: descriptor)
+ resourceTextures[frame.resourceID] = texture
+ }
+ guard let texture else { return false }
+ frame.bytes.withUnsafeBytes { bytes in
+ guard let baseAddress = bytes.baseAddress else { return }
+ texture.replace(
+ region: MTLRegionMake2D(
+ Int(frame.dirtyRect.x),
+ Int(frame.dirtyRect.y),
+ Int(frame.dirtyRect.width),
+ Int(frame.dirtyRect.height)
+ ),
+ mipmapLevel: 0,
+ withBytes: baseAddress,
+ bytesPerRow: Int(frame.stride)
+ )
+ }
+ scanoutTexture = texture
+ scanoutSize = CGSize(width: Int(frame.width), height: Int(frame.height))
+ return true
+ }
+
+ func draw(in view: MTKView) {
+ guard let texture = scanoutTexture,
+ let descriptor = currentRenderPassDescriptor,
+ let drawable = currentDrawable,
+ let commandBuffer = commandQueue.makeCommandBuffer(),
+ let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
+ return
+ }
+
+ let contentRect = scanoutContentRect(in: drawableSize)
+ let viewport = MTLViewport(
+ originX: contentRect.minX,
+ originY: contentRect.minY,
+ width: contentRect.width,
+ height: contentRect.height,
+ znear: 0,
+ zfar: 1
+ )
+ encoder.setViewport(viewport)
+ encoder.setRenderPipelineState(pipeline)
+ encoder.setFragmentTexture(texture, index: 0)
+ encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
+ encoder.endEncoding()
+ commandBuffer.present(drawable)
+ commandBuffer.commit()
+ }
+
+ func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
+ let width = UInt32(clamping: max(1, Int(size.width.rounded())))
+ let height = UInt32(clamping: max(1, Int(size.height.rounded())))
+ resizeGeneration &+= 1
+ let generation = resizeGeneration
+ // AppKit reports every intermediate drag size. Debounce the guest modeset so Mutter/Xfce
+ // receives the final Retina pixel size without reallocating scanout resources per mouse
+ // event while the window is still moving.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self, self.resizeGeneration == generation else { return }
+ self.onDrawableSizeChange?(width, height)
+ }
+ }
+ }
+
+ override func keyDown(with event: NSEvent) {
+ if onMacShortcut?(event) == true { return }
+ if event.modifierFlags.contains(.command),
+ let character = event.charactersIgnoringModifiers?.lowercased(),
+ let code = Self.macCommandShortcutMap[character] {
+ sendControlShortcut(keyCode: code)
+ return
+ }
+ guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else {
+ super.keyDown(with: event)
+ return
+ }
+ input.send(frame: [VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1)])
+ }
+
+ override func keyUp(with event: NSEvent) {
+ guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else {
+ super.keyUp(with: event)
+ return
+ }
+ input.send(frame: [VirtioInputEvent(type: 1, code: code, value: 0)])
+ }
+
+ override func flagsChanged(with event: NSEvent) {
+ guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode),
+ let flag = Self.modifierFlag(macKeyCode: event.keyCode) else {
+ super.flagsChanged(with: event)
+ return
+ }
+ input.send(frame: [
+ VirtioInputEvent(type: 1, code: code, value: event.modifierFlags.contains(flag) ? 1 : 0)
+ ])
+ }
+
+ override func mouseMoved(with event: NSEvent) { sendPointer(event: event) }
+ override func mouseDragged(with event: NSEvent) { sendPointer(event: event) }
+ override func rightMouseDragged(with event: NSEvent) { sendPointer(event: event) }
+ override func otherMouseDragged(with event: NSEvent) { sendPointer(event: event) }
+ override func mouseDown(with event: NSEvent) { sendPointer(event: event, button: 272, pressed: true) }
+ override func mouseUp(with event: NSEvent) { sendPointer(event: event, button: 272, pressed: false) }
+ override func rightMouseDown(with event: NSEvent) { sendPointer(event: event, button: 273, pressed: true) }
+ override func rightMouseUp(with event: NSEvent) { sendPointer(event: event, button: 273, pressed: false) }
+ override func otherMouseDown(with event: NSEvent) {
+ sendPointer(event: event, button: linuxOtherMouseButton(for: event.buttonNumber), pressed: true)
+ }
+ override func otherMouseUp(with event: NSEvent) {
+ sendPointer(event: event, button: linuxOtherMouseButton(for: event.buttonNumber), pressed: false)
+ }
+
+ private func linuxOtherMouseButton(for buttonNumber: Int) -> UInt16 {
+ switch buttonNumber {
+ case 2: 274 // BTN_MIDDLE
+ case 3: 275 // BTN_SIDE
+ default: 276 // BTN_EXTRA
+ }
+ }
+
+ override func scrollWheel(with event: NSEvent) {
+ let events = scrollAccumulator.events(
+ horizontalDelta: event.scrollingDeltaX,
+ verticalDelta: event.scrollingDeltaY,
+ hasPreciseDeltas: event.hasPreciseScrollingDeltas
+ )
+ if !events.isEmpty { input.send(frame: events) }
+ }
+
+ private func sendPointer(
+ event: NSEvent,
+ button: UInt16? = nil,
+ pressed: Bool = false
+ ) {
+ window?.makeFirstResponder(self)
+ let point = convert(event.locationInWindow, from: nil)
+ let contentRect = scanoutContentRect(in: bounds.size)
+ let normalizedX = min(1, max(0, (point.x - contentRect.minX) / max(1, contentRect.width)))
+ let normalizedY = min(1, max(0, (point.y - contentRect.minY) / max(1, contentRect.height)))
+ var events = [
+ VirtioInputEvent(type: 3, code: 0, value: Int32((normalizedX * 32_767).rounded())),
+ VirtioInputEvent(type: 3, code: 1, value: Int32((normalizedY * 32_767).rounded())),
+ ]
+ if let button {
+ events.append(VirtioInputEvent(type: 1, code: button, value: pressed ? 1 : 0))
+ }
+ input.send(frame: events)
+ }
+
+ private func scanoutContentRect(in targetSize: CGSize) -> CGRect {
+ guard scanoutSize.width > 0, scanoutSize.height > 0,
+ targetSize.width > 0, targetSize.height > 0 else {
+ return CGRect(origin: .zero, size: targetSize)
+ }
+ let sourceAspect = scanoutSize.width / scanoutSize.height
+ let targetAspect = targetSize.width / targetSize.height
+ if targetAspect > sourceAspect {
+ let width = targetSize.height * sourceAspect
+ return CGRect(
+ x: (targetSize.width - width) / 2,
+ y: 0,
+ width: width,
+ height: targetSize.height
+ )
+ }
+ let height = targetSize.width / sourceAspect
+ return CGRect(
+ x: 0,
+ y: (targetSize.height - height) / 2,
+ width: targetSize.width,
+ height: height
+ )
+ }
+
+ private static func pixelFormat(for virtioFormat: UInt32) -> MTLPixelFormat? {
+ switch virtioFormat {
+ case 1, 2: .bgra8Unorm
+ case 3, 4, 67, 68, 121, 134: .rgba8Unorm
+ default: nil
+ }
+ }
+
+ private static func modifierFlag(macKeyCode: UInt16) -> NSEvent.ModifierFlags? {
+ switch macKeyCode {
+ case 54, 55: .command
+ case 56, 60: .shift
+ case 57: .capsLock
+ case 58, 61: .option
+ case 59, 62: .control
+ default: nil
+ }
+ }
+
+ private static func linuxKeyCode(macKeyCode: UInt16) -> UInt16? {
+ keyMap[macKeyCode]
+ }
+
+ private func sendControlShortcut(keyCode: UInt16) {
+ input.send(frame: [
+ VirtioInputEvent(type: 1, code: 125, value: 0),
+ VirtioInputEvent(type: 1, code: 126, value: 0),
+ VirtioInputEvent(type: 1, code: 29, value: 1),
+ VirtioInputEvent(type: 1, code: keyCode, value: 1),
+ VirtioInputEvent(type: 1, code: keyCode, value: 0),
+ VirtioInputEvent(type: 1, code: 29, value: 0),
+ ])
+ }
+
+ // Match the Mac muscle memory supported by mature desktop hypervisors while leaving Command+Q
+ // to the host application. Shift remains physically pressed for gestures such as Command+Shift+T.
+ private static let macCommandShortcutMap: [String: UInt16] = [
+ "a": 30, "f": 33, "l": 38, "n": 49, "o": 24, "p": 25,
+ "r": 19, "s": 31, "t": 20, "w": 17, "z": 44,
+ ]
+
+ private static let keyMap: [UInt16: UInt16] = [
+ 0: 30, 1: 31, 2: 32, 3: 33, 4: 35, 5: 34, 6: 44, 7: 45,
+ 8: 46, 9: 47, 11: 48, 12: 16, 13: 17, 14: 18, 15: 19, 16: 21,
+ 17: 20, 18: 2, 19: 3, 20: 4, 21: 5, 22: 7, 23: 6, 24: 13,
+ 25: 10, 26: 8, 27: 12, 28: 9, 29: 11, 30: 27, 31: 24, 32: 22,
+ 33: 26, 34: 23, 35: 25, 36: 28, 37: 38, 38: 36, 39: 40, 40: 37,
+ 41: 39, 42: 43, 43: 51, 44: 53, 45: 49, 46: 50, 47: 52, 48: 15,
+ 49: 57, 50: 41, 51: 14, 53: 1, 54: 126, 55: 125, 56: 42, 57: 58,
+ 58: 56, 59: 29, 60: 54, 61: 100, 62: 97,
+ 65: 83, 67: 55, 69: 78, 71: 69, 75: 98, 76: 96, 78: 74, 81: 117,
+ 82: 82, 83: 79, 84: 80, 85: 81, 86: 75, 87: 76, 88: 77, 89: 71,
+ 91: 72, 92: 73,
+ 96: 63, 97: 64, 98: 65, 99: 61, 100: 66, 101: 67, 103: 87,
+ 109: 68, 111: 88, 114: 110, 115: 102, 116: 104, 117: 111, 118: 62,
+ 119: 107, 120: 60, 121: 109, 122: 59, 123: 105, 124: 106, 125: 108,
+ 126: 103,
+ ]
+
+ private static let shaderSource = """
+ #include
+ using namespace metal;
+
+ struct RasterData {
+ float4 position [[position]];
+ float2 textureCoordinate;
+ };
+
+ vertex RasterData dory_scanout_vertex(uint vertexID [[vertex_id]]) {
+ const float2 positions[4] = {
+ float2(-1.0, -1.0), float2(1.0, -1.0),
+ float2(-1.0, 1.0), float2(1.0, 1.0)
+ };
+ const float2 coordinates[4] = {
+ float2(0.0, 1.0), float2(1.0, 1.0),
+ float2(0.0, 0.0), float2(1.0, 0.0)
+ };
+ RasterData output;
+ output.position = float4(positions[vertexID], 0.0, 1.0);
+ output.textureCoordinate = coordinates[vertexID];
+ return output;
+ }
+
+ fragment float4 dory_scanout_fragment(
+ RasterData input [[stage_in]],
+ texture2d scanout [[texture(0)]]) {
+ constexpr sampler textureSampler(coord::normalized, filter::linear);
+ return scanout.sample(textureSampler, input.textureCoordinate);
+ }
+ """
+}
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
new file mode 100644
index 00000000..d3cedac5
--- /dev/null
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
@@ -0,0 +1,674 @@
+import AppKit
+import Darwin
+import DoryCore
+import DoryHV
+import DoryOperations
+import DorydKit
+import DoryVMMKit
+import Foundation
+
+enum DesktopMode {
+ struct Configuration {
+ var machineID: String
+ var stateDirectory: String
+ var kernelPath: String
+ var initrdPath: String?
+ var rootfsPath: String
+ var rootDevice: String
+ var genericGuest: Bool
+ var gvproxyPath: String
+ var handoffSocketPath: String
+ var agentSocketPath: String
+ var shellSocketPath: String
+ var sshAgentSocketPath: String?
+ var memoryMB: UInt64
+ var cpuCount: Int
+ var shares: [DoryMachineShareConfiguration]
+ var environment: [String: String]
+ }
+
+ private struct ResolvedGraphics {
+ var backend: DoryDesktopGraphicsBackend
+ var renderer: VirglRenderer?
+ }
+
+ @MainActor
+ static func run(_ configuration: Configuration) throws {
+ let controller = try Controller(configuration: configuration)
+ try controller.run()
+ }
+
+ @MainActor
+ private final class Controller: NSObject, NSApplicationDelegate, NSWindowDelegate {
+ private let configuration: Configuration
+ private let application = NSApplication.shared
+ private let stateLock: EngineStateDirectoryLock
+ private let serialLog: FileHandle
+ private let machine: Machine
+ private let graphicsBackend: DoryDesktopGraphicsBackend
+ private let input: VirtioInput
+ private let mailbox: DesktopFrameMailbox
+ private let display: DesktopMetalView
+ private let window: NSWindow
+ private let vsock: VirtioVsock
+ private let audio: DoryMacAudioBackend
+ private let gvproxy: Process
+ private let networkSocketPaths: [String]
+ private let agentBridge: GuestVsockSocketBridge
+ private let shellBridge: GuestVsockSocketBridge
+ private let sshAgentBridge: HostSSHAgentBridge?
+ private let clipboard: DoryDesktopClipboardCoordinator?
+ private let firstFrame: FirstFrameGate
+ private var signalSources = [DispatchSourceSignal]()
+ private var stopError: Error?
+ private var stopping = false
+
+ init(configuration: Configuration) throws {
+ self.configuration = configuration
+ try FileManager.default.createDirectory(
+ atPath: configuration.stateDirectory,
+ withIntermediateDirectories: true
+ )
+ self.stateLock = try EngineStateDirectoryLock(stateDirectory: configuration.stateDirectory)
+ self.serialLog = try Self.openAppendLog("\(configuration.stateDirectory)/serial.log")
+ let resolvedGraphics = try Self.resolveGraphics(
+ environment: configuration.environment,
+ requireVulkan: configuration.genericGuest
+ )
+ self.graphicsBackend = resolvedGraphics.backend
+ self.machine = try Machine(configuration: MachineConfiguration(
+ kernelPath: configuration.kernelPath,
+ initrdPath: configuration.initrdPath,
+ commandLine: Self.kernelCommandLine(
+ machineID: configuration.machineID,
+ rootDevice: configuration.rootDevice,
+ graphicsBackend: resolvedGraphics.backend,
+ genericGuest: configuration.genericGuest
+ ),
+ memoryBytes: configuration.memoryMB << 20,
+ cpuCount: configuration.cpuCount
+ ))
+ Self.attachPlatformDevices(to: machine, serialLog: serialLog)
+
+ self.input = VirtioInput()
+ self.mailbox = DesktopFrameMailbox()
+ let firstFrame = FirstFrameGate()
+ self.firstFrame = firstFrame
+ self.display = try DesktopMetalView(
+ frame: NSRect(x: 0, y: 0, width: 1_280, height: 800),
+ input: input
+ )
+ mailbox.view = display
+
+ let renderer = resolvedGraphics.renderer
+ let hostVisibleMemory = try renderer.map { _ in
+ try VirtioGPUHostVisibleMemory(guestBase: GuestLayout.daxWindowBase)
+ }
+ let gpu = VirtioGPU(
+ hostMemoryBase: GuestLayout.daxWindowBase,
+ scanoutCount: 1,
+ scanoutWidth: 2_560,
+ scanoutHeight: 1_600,
+ renderer: renderer,
+ hostVisibleMemory: hostVisibleMemory,
+ traceResourceLifecycle: configuration.environment["DORY_GPU_TRACE_RESOURCES"] == "1",
+ onScanoutFrame: { [mailbox, firstFrame] frame in
+ mailbox.submit(frame)
+ firstFrame.signal()
+ },
+ onScanoutResourceReleased: { [mailbox] resourceID in
+ mailbox.release(resourceID: resourceID)
+ }
+ )
+ self.vsock = VirtioVsock(guestCID: 3)
+ self.audio = DoryMacAudioBackend(log: Self.log)
+ let sound = VirtioSound(host: audio, log: Self.log)
+ let balloon = VirtioBalloon(memory: machine.memory) { message in
+ Self.log(message)
+ }
+
+ let runtimeDirectory = (configuration.agentSocketPath as NSString).deletingLastPathComponent
+ try FileManager.default.createDirectory(atPath: runtimeDirectory, withIntermediateDirectories: true)
+ let token = String(configuration.machineID.prefix(12))
+ let gvproxySocket = "\(runtimeDirectory)/\(token)-gv.sock"
+ let vmNetworkSocket = "\(runtimeDirectory)/\(token)-vm.sock"
+ let apiSocket = "\(runtimeDirectory)/\(token)-api.sock"
+ self.networkSocketPaths = [gvproxySocket, vmNetworkSocket, apiSocket]
+ for path in networkSocketPaths { unlink(path) }
+
+ let gvproxy = Process()
+ gvproxy.executableURL = URL(fileURLWithPath: configuration.gvproxyPath)
+ gvproxy.arguments = GVProxyDesktopLaunchPlan.arguments(
+ mtu: DoryNetworkMTU.resolved(),
+ datapathSocket: gvproxySocket,
+ apiSocket: apiSocket
+ )
+ gvproxy.standardOutput = FileHandle.standardError
+ gvproxy.standardError = FileHandle.standardError
+ try gvproxy.run()
+ self.gvproxy = gvproxy
+ do {
+ try Self.waitForSocket(path: gvproxySocket, process: gvproxy)
+ let network = try VirtioNet(socketPath: vmNetworkSocket, remotePath: gvproxySocket)
+ var backends: [VirtioDeviceBackend] = [
+ try VirtioBlk(path: configuration.rootfsPath, identity: "dory-rootfs"),
+ gpu,
+ input,
+ VirtioRng(),
+ balloon,
+ vsock,
+ sound,
+ ]
+ for share in configuration.shares {
+ let rawShare = try VirtioFSShareConfiguration(
+ tag: share.tag,
+ path: share.hostPath,
+ readOnly: share.readOnly,
+ guestMountPoint: share.guestPath
+ )
+ backends.append(try rawShare.makeBackend(
+ requestQueueCount: min(8, max(1, configuration.cpuCount))
+ ))
+ }
+ backends.append(network)
+ for (slot, backend) in backends.enumerated() {
+ let transport = Self.attachBackend(backend, to: machine, slot: slot)
+ if backend === gpu {
+ display.onDrawableSizeChange = { [weak gpu, weak transport] width, height in
+ guard let gpu, let transport else { return }
+ gpu.updateScanoutSize(width: width, height: height, transport: transport)
+ }
+ }
+ }
+ try machine.loadBootPayload()
+ } catch {
+ ChildProcessTerminator.terminateAndReap(gvproxy)
+ for path in networkSocketPaths { unlink(path) }
+ throw error
+ }
+
+ self.agentBridge = GuestVsockSocketBridge(
+ socketPath: configuration.agentSocketPath,
+ guestPort: VsockPorts.agent,
+ log: Self.log
+ )
+ self.shellBridge = GuestVsockSocketBridge(
+ socketPath: configuration.shellSocketPath,
+ guestPort: 1027,
+ log: Self.log
+ )
+ try agentBridge.attach(to: vsock)
+ try shellBridge.attach(to: vsock)
+ if let sshAgentSocketPath = configuration.sshAgentSocketPath {
+ let bridge = try HostSSHAgentBridge(
+ socketPath: sshAgentSocketPath,
+ log: Self.log
+ )
+ bridge.attach(to: vsock)
+ self.sshAgentBridge = bridge
+ } else {
+ self.sshAgentBridge = nil
+ }
+ if configuration.genericGuest {
+ self.clipboard = nil
+ } else {
+ let clipboardControl = DorydKit.AgentControl(configuration: .init(
+ directSocketPath: configuration.agentSocketPath
+ ))
+ let clipboardInput = self.input
+ self.clipboard = DoryDesktopClipboardCoordinator(
+ policy: DoryDesktopClipboardPolicy(environment: configuration.environment),
+ execute: { argv, stdin, timeoutMs, outputLimitBytes in
+ try clipboardControl.execWithInput(
+ argv: argv,
+ stdin: stdin,
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes
+ )
+ },
+ sendShortcut: { keyCode in
+ clipboardInput.send(frame: [
+ VirtioInputEvent(type: 1, code: 125, value: 0),
+ VirtioInputEvent(type: 1, code: 126, value: 0),
+ VirtioInputEvent(type: 1, code: 29, value: 1),
+ VirtioInputEvent(type: 1, code: keyCode, value: 1),
+ VirtioInputEvent(type: 1, code: keyCode, value: 0),
+ VirtioInputEvent(type: 1, code: 29, value: 0),
+ ])
+ },
+ log: Self.log
+ )
+ }
+
+ self.window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 1_280, height: 800),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.title = "\(configuration.machineID) — Dory Linux"
+ window.contentView = display
+ window.minSize = NSSize(width: 640, height: 400)
+ window.collectionBehavior.insert(.fullScreenPrimary)
+ window.tabbingMode = .disallowed
+ window.center()
+ super.init()
+ window.delegate = self
+ display.onMacShortcut = { [weak clipboard] event in
+ clipboard?.handleMacShortcut(event) ?? false
+ }
+ clipboard?.start()
+ }
+
+ func run() throws {
+ application.setActivationPolicy(.regular)
+ application.delegate = self
+ installSignalHandlers()
+ window.makeKeyAndOrderFront(nil)
+ application.activate()
+ startMachine()
+ application.run()
+ cleanup()
+ if let stopError { throw stopError }
+ }
+
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+ window.makeKeyAndOrderFront(nil)
+ return true
+ }
+
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ requestGuestShutdown()
+ return .terminateCancel
+ }
+
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
+ sender.orderOut(nil)
+ return false
+ }
+
+ private func startMachine() {
+ let machine = self.machine
+ DispatchQueue.global(qos: .userInteractive).async { [weak self] in
+ do {
+ let reason = try machine.run()
+ DispatchQueue.main.async {
+ MainActor.assumeIsolated {
+ self?.finish(error: Self.error(for: reason))
+ }
+ }
+ } catch {
+ DispatchQueue.main.async {
+ MainActor.assumeIsolated { self?.finish(error: error) }
+ }
+ }
+ }
+
+ let configuration = self.configuration
+ let graphicsDisplayName = graphicsBackend.displayName
+ let firstFrame = self.firstFrame
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ do {
+ if configuration.genericGuest {
+ guard firstFrame.wait(timeout: 90) else {
+ throw VMError.bootFailure(
+ "generic Linux guest did not publish a graphics frame within 90s"
+ )
+ }
+ try VmmHandoffClient.send(
+ path: configuration.handoffSocketPath,
+ ready: VmmReadyMessage(
+ machineID: configuration.machineID,
+ agentBuild: "dory-hv/generic-linux",
+ detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics; guest tools are not installed"
+ )
+ )
+ return
+ }
+ let info = try Self.prepareGuest(configuration: configuration)
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated { self?.clipboard?.markGuestReady() }
+ }
+ try VmmHandoffClient.send(
+ path: configuration.handoffSocketPath,
+ ready: VmmReadyMessage(
+ machineID: configuration.machineID,
+ agentBuild: info.agentBuild,
+ agentSocketPath: configuration.agentSocketPath,
+ shellSocketPath: configuration.shellSocketPath,
+ detail: "raw-HV desktop running with \(graphicsDisplayName) graphics; dory-agent answered protocol \(info.protocolVersion)"
+ )
+ )
+ } catch {
+ machine.requestStop(.crash("desktop readiness failed: \(error)"))
+ DispatchQueue.main.async {
+ MainActor.assumeIsolated { self?.finish(error: error) }
+ }
+ }
+ }
+ }
+
+ private func requestGuestShutdown() {
+ guard !stopping else { return }
+ stopping = true
+ window.orderOut(nil)
+ let machine = self.machine
+ if configuration.genericGuest {
+ // Linux maps KEY_POWER to logind's normal power-button action. This provides a
+ // clean integration-free shutdown path until Dory guest tools are installed.
+ input.send(frame: [
+ VirtioInputEvent(type: 1, code: 116, value: 1),
+ VirtioInputEvent(type: 1, code: 116, value: 0),
+ ])
+ } else {
+ do {
+ let control = AgentControl(configuration: .init(
+ directSocketPath: configuration.agentSocketPath
+ ))
+ defer { control.disconnect() }
+ try Self.requireSuccess(control.exec(
+ argv: ["/bin/sh", "-c", GuestShutdownCommand.detachedDesktopRequest()],
+ timeoutMs: 5_000,
+ outputLimitBytes: 64 * 1024
+ ), operation: "desktop guest shutdown request")
+ } catch {
+ Self.log("dory-hv desktop: graceful guest shutdown request failed: \(error)")
+ }
+ }
+ DispatchQueue.global(qos: .userInitiated).asyncAfter(
+ deadline: .now() + DoryEngineShutdownTiming.helperWatchdogSeconds
+ ) {
+ machine.requestStop(.crash("guest shutdown timed out"))
+ }
+ }
+
+ private func finish(error: Error?) {
+ if stopError == nil { stopError = error }
+ application.stop(nil)
+ if let wakeEvent = NSEvent.otherEvent(
+ with: .applicationDefined,
+ location: .zero,
+ modifierFlags: [],
+ timestamp: 0,
+ windowNumber: 0,
+ context: nil,
+ subtype: 0,
+ data1: 0,
+ data2: 0
+ ) {
+ application.postEvent(wakeEvent, atStart: false)
+ }
+ }
+
+ private func cleanup() {
+ clipboard?.stop()
+ signalSources.forEach { $0.cancel() }
+ signalSources.removeAll()
+ ChildProcessTerminator.terminateAndReap(gvproxy)
+ unlink(configuration.agentSocketPath)
+ unlink(configuration.shellSocketPath)
+ for path in networkSocketPaths { unlink(path) }
+ try? serialLog.close()
+ }
+
+ private func installSignalHandlers() {
+ for number in [SIGTERM, SIGINT] {
+ signal(number, SIG_IGN)
+ let source = DispatchSource.makeSignalSource(signal: number, queue: .main)
+ source.setEventHandler { [weak self] in
+ MainActor.assumeIsolated { self?.requestGuestShutdown() }
+ }
+ source.resume()
+ signalSources.append(source)
+ }
+
+ // dory-hv is a command-line helper rather than an app bundle, so LaunchServices cannot
+ // reliably activate it from Dory.app. Give the UI a narrow same-user signal that asks
+ // the helper itself to raise its hidden or covered display window.
+ signal(SIGUSR1, SIG_IGN)
+ let raiseSource = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .main)
+ raiseSource.setEventHandler { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ self.window.makeKeyAndOrderFront(nil)
+ self.application.activate()
+ }
+ }
+ raiseSource.resume()
+ signalSources.append(raiseSource)
+ }
+
+ private nonisolated static func prepareGuest(configuration: Configuration) throws -> DoryAgentInfo {
+ let deadline = Date().addingTimeInterval(90)
+ var lastError: Error?
+ while Date() < deadline {
+ do {
+ let control = AgentControl(configuration: .init(
+ directSocketPath: configuration.agentSocketPath
+ ))
+ let info = try control.info()
+ try requireSuccess(control.exec(
+ argv: ["/usr/lib/dory/configure-machine"],
+ env: configuration.environment.sorted(by: { $0.key < $1.key }).map {
+ DoryExecEnvironment(key: $0.key, value: $0.value)
+ },
+ timeoutMs: 30_000,
+ outputLimitBytes: 64 * 1024
+ ), operation: "guest account configuration")
+ for share in configuration.shares {
+ try requireSuccess(control.exec(
+ argv: ["/bin/mkdir", "-p", share.guestPath],
+ timeoutMs: 10_000,
+ outputLimitBytes: 64 * 1024
+ ), operation: "create share mount point \(share.guestPath)")
+ try requireSuccess(control.exec(
+ argv: [
+ "/bin/mount", "-t", "virtiofs", "-o",
+ share.readOnly ? "ro" : "rw",
+ share.tag, share.guestPath,
+ ],
+ timeoutMs: 30_000,
+ outputLimitBytes: 64 * 1024
+ ), operation: "mount share \(share.tag)")
+ }
+ try requireSuccess(control.exec(
+ argv: ["/usr/bin/touch", "/var/lib/dory/host-configured"],
+ timeoutMs: 10_000,
+ outputLimitBytes: 64 * 1024
+ ), operation: "complete desktop configuration")
+ return info
+ } catch {
+ lastError = error
+ Thread.sleep(forTimeInterval: 0.25)
+ }
+ }
+ throw lastError ?? VMError.bootFailure("desktop agent did not become ready")
+ }
+
+ private nonisolated static func requireSuccess(_ result: DoryExecResult, operation: String) throws {
+ guard !result.timedOut, result.exitCode == 0 else {
+ let stderr = String(decoding: result.stderr.prefix(4_096), as: UTF8.self)
+ throw VMError.bootFailure(
+ "\(operation) failed (exit=\(result.exitCode), timedOut=\(result.timedOut)): \(stderr)"
+ )
+ }
+ }
+
+ private static func waitForSocket(path: String, process: Process) throws {
+ for _ in 0..<100 {
+ if FileManager.default.fileExists(atPath: path) { return }
+ guard process.isRunning else {
+ throw VMError.bootFailure("gvproxy exited before publishing its network socket")
+ }
+ usleep(50_000)
+ }
+ throw VMError.bootFailure("gvproxy did not publish its network socket")
+ }
+
+ @discardableResult
+ private static func attachBackend(
+ _ backend: VirtioDeviceBackend,
+ to machine: Machine,
+ slot: Int
+ ) -> VirtioMMIOTransport {
+ let interrupt = GuestLayout.virtioFirstIRQ + UInt32(slot)
+ let transport = VirtioMMIOTransport(
+ baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize,
+ backend: backend,
+ memory: machine.memory
+ ) { [weak machine] in
+ machine?.raiseGSI(interrupt)
+ }
+ machine.attachVirtioSlot(transport)
+ return transport
+ }
+
+ private static func attachPlatformDevices(to machine: Machine, serialLog: FileHandle) {
+ #if arch(arm64)
+ machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase))
+ machine.attachConsole(PL011(baseAddress: GuestLayout.uartBase) { byte in
+ FileHandle.standardError.write(Data([byte]))
+ try? serialLog.write(contentsOf: Data([byte]))
+ })
+ #endif
+ }
+
+ private static func resolveGraphics(
+ environment: [String: String],
+ requireVulkan: Bool
+ ) throws -> ResolvedGraphics {
+ let preference = try DoryDesktopGraphicsPreference(environment: environment)
+ var rendererEnvironment = ProcessInfo.processInfo.environment
+ for key in [
+ DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey,
+ "DORY_VIRGL_SYNC_MODE",
+ "DORY_VIRGLRENDERER_PATH",
+ "DORY_MOLTENVK_ICD",
+ ] {
+ if let value = environment[key] {
+ rendererEnvironment[key] = value
+ }
+ }
+
+ func accelerated(classicOnly: Bool) throws -> ResolvedGraphics {
+ rendererEnvironment[DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey] =
+ classicOnly ? "1" : "0"
+ let renderer = try VirglRenderer.discover(environment: rendererEnvironment)
+ return ResolvedGraphics(
+ backend: classicOnly ? .virgl : .virglVenus,
+ renderer: renderer
+ )
+ }
+
+ switch preference {
+ case .automatic:
+ if requireVulkan {
+ do {
+ return try accelerated(classicOnly: false)
+ } catch {
+ throw VMError.bootFailure(
+ "generic Linux desktop requires Dory's Vulkan-capable Venus renderer; \(error)"
+ )
+ }
+ }
+ do {
+ return try accelerated(classicOnly: false)
+ } catch let venusError {
+ log("VirGL2 + Venus unavailable; trying classic VirGL2: \(venusError)")
+ do {
+ return try accelerated(classicOnly: true)
+ } catch let virglError {
+ log("accelerated graphics unavailable; using software scanout: \(virglError)")
+ return ResolvedGraphics(backend: .software, renderer: nil)
+ }
+ }
+ case .virgl:
+ return try accelerated(classicOnly: true)
+ case .virglVenus:
+ return try accelerated(classicOnly: false)
+ case .software:
+ return ResolvedGraphics(backend: .software, renderer: nil)
+ }
+ }
+
+ private static func kernelCommandLine(
+ machineID: String,
+ rootDevice: String,
+ graphicsBackend: DoryDesktopGraphicsBackend,
+ genericGuest: Bool
+ ) -> String {
+ var arguments = [
+ "console=ttyAMA0",
+ "earlycon=pl011,mmio32,0x0c000000",
+ "root=\(rootDevice)",
+ "rw",
+ "rootwait",
+ "panic=1",
+ "dory.machine_id=\(machineID)",
+ graphicsBackend.kernelArgument,
+ ]
+ if genericGuest {
+ // Installer initramfs images commonly default to their live-media boot path
+ // (Ubuntu's casper is one example). The same kernel/initramfs can boot the
+ // installed root directly when the local-root path is selected explicitly.
+ arguments.append("boot=local")
+ // Direct-kernel boot does not consume the EFI System Partition. Installer
+ // kernels can omit optional FAT/NLS modules that a distro's installed fstab
+ // expects for /boot/efi, so keep that nonessential mount out of the boot
+ // transaction without modifying the guest filesystem.
+ arguments.append("systemd.mask=boot-efi.mount")
+ }
+ if let legacy = graphicsBackend.legacyKernelArgument {
+ arguments.append(legacy)
+ }
+ return arguments.joined(separator: " ")
+ }
+
+ private static func openAppendLog(_ path: String) throws -> FileHandle {
+ if !FileManager.default.fileExists(atPath: path) {
+ guard FileManager.default.createFile(atPath: path, contents: nil) else {
+ throw VMError.bootFailure("could not create serial log: \(path)")
+ }
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path)
+ }
+ let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
+ try handle.seekToEnd()
+ return handle
+ }
+
+ private static func error(for reason: GuestStopReason) -> Error? {
+ switch reason {
+ case .powerOff: nil
+ case .reset: VMError.unexpectedExit("desktop guest requested reset")
+ case let .crash(detail): VMError.unexpectedExit(detail)
+ }
+ }
+
+ private nonisolated static func log(_ message: String) {
+ FileHandle.standardError.write(Data("dory-hv desktop: \(message)\n".utf8))
+ }
+ }
+}
+
+private final class FirstFrameGate: @unchecked Sendable {
+ private let condition = NSCondition()
+ private var ready = false
+
+ func signal() {
+ condition.lock()
+ ready = true
+ condition.broadcast()
+ condition.unlock()
+ }
+
+ func wait(timeout: TimeInterval) -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ condition.lock()
+ while !ready {
+ if !condition.wait(until: deadline) { break }
+ }
+ let result = ready
+ condition.unlock()
+ return result
+ }
+}
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift
index 08db9c12..9872bcbf 100644
--- a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift
@@ -562,7 +562,8 @@ enum EngineMode {
))
note(
"experimental gpu=venus: attached virtio-gpu with virglrenderer "
- + "\(renderer.libraryPath) and MoltenVK ICD \(renderer.moltenVKICDPath)"
+ + "\(renderer.libraryPath) and MoltenVK ICD "
+ + "\(renderer.moltenVKICDPath ?? "unavailable")"
)
}
let vsock = VirtioVsock(guestCID: 3)
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift
index ab36e25d..3c7a1abe 100644
--- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift
@@ -1,5 +1,6 @@
import DoryHV
import DoryCore
+import DorydKit
import Foundation
signal(SIGPIPE, SIG_IGN)
@@ -188,7 +189,7 @@ func runAgentPing(_ options: Options) {
let arguments = Array(CommandLine.arguments.dropFirst())
guard let command = arguments.first else {
- fail("usage: dory-hv [options]")
+ fail("usage: dory-hv [options]")
}
switch command {
@@ -418,6 +419,93 @@ case "boot":
} catch {
fail("\(error)")
}
+case "desktop":
+ var machineID: String?
+ var stateDirectory: String?
+ var kernel: String?
+ var initrd: String?
+ var rootfs: String?
+ var rootDevice = "/dev/vda"
+ var genericGuest = false
+ var gvproxy: String?
+ var handoffSocket: String?
+ var agentSocket: String?
+ var shellSocket: String?
+ var sshAgentSocket: String?
+ var memoryMB: UInt64 = 6_144
+ var cpus = 6
+ var shares = [DoryMachineShareConfiguration]()
+ var environment = [String: String]()
+ var iterator = arguments.dropFirst().makeIterator()
+ while let argument = iterator.next() {
+ switch argument {
+ case "--machine-id": machineID = iterator.next()
+ case "--state-dir": stateDirectory = iterator.next()
+ case "--kernel": kernel = iterator.next()
+ case "--initrd": initrd = iterator.next()
+ case "--rootfs": rootfs = iterator.next()
+ case "--root-device": rootDevice = iterator.next() ?? rootDevice
+ case "--generic-guest": genericGuest = true
+ case "--gvproxy": gvproxy = iterator.next()
+ case "--handoff-sock": handoffSocket = iterator.next()
+ case "--agent-sock": agentSocket = iterator.next()
+ case "--shell-sock": shellSocket = iterator.next()
+ case "--ssh-agent-socket": sshAgentSocket = iterator.next()
+ case "--memory-mb", "--mem-mb":
+ memoryMB = iterator.next().flatMap(UInt64.init) ?? memoryMB
+ case "--cpus": cpus = iterator.next().flatMap(Int.init) ?? cpus
+ case "--share":
+ guard let value = iterator.next() else { fail("desktop --share requires a value") }
+ do { shares.append(try DoryMachineShareConfiguration(argument: value)) }
+ catch { fail("invalid desktop share: \(error)") }
+ case "--env":
+ guard let value = iterator.next(), let equals = value.firstIndex(of: "=") else {
+ fail("desktop --env requires KEY=VALUE")
+ }
+ environment[String(value[.. [UInt8] {
+ try writeDescriptor(
+ memory,
+ index: 0,
+ addr: requestBuffer,
+ len: UInt32(request.count),
+ flags: 0x1,
+ next: 1
+ )
+ try writeDescriptor(memory, index: 1, addr: responseBuffer, len: 512, flags: 0x2, next: 0)
+ try memory.write(request, at: requestBuffer)
+ let slot = UInt64(availableIndex % 8)
+ try memory.write(UInt16(0), at: availRing + 4 + slot * 2)
+ availableIndex &+= 1
+ try memory.write(availableIndex, at: availRing + 2)
+ gpu.handleKick(queue: 0, transport: transport)
+ let usedSlot = UInt64((availableIndex - 1) % 8)
+ let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8)
+ return try memory.readBytes(at: responseBuffer, count: Int(responseLength))
+ }
+
+ var create = gpuRequest(type: 0x0101, fenceID: 0, contextID: 0, ringIndex: 0)
+ create.appendLE(UInt32(7)) // resource_id
+ create.appendLE(UInt32(1)) // B8G8R8A8_UNORM
+ create.appendLE(UInt32(2))
+ create.appendLE(UInt32(2))
+ #expect(leUInt32(try submit(create), at: 0) == 0x1100)
+ #expect(renderer.createdResources.count == 1)
+ let rendererResource = try #require(renderer.createdResources.first)
+ #expect(rendererResource.resourceID == 7)
+ #expect(rendererResource.target == 2)
+ #expect(rendererResource.bind == 1 << 1)
+ #expect(rendererResource.width == 2)
+ #expect(rendererResource.height == 2)
+ #expect(rendererResource.depth == 1)
+ #expect(rendererResource.arraySize == 1)
+ #expect(rendererResource.flags == 1)
+
+ let pixelBuffer = base + 0x6000
+ let pixels: [UInt8] = [
+ 1, 2, 3, 4, 5, 6, 7, 8,
+ 9, 10, 11, 12, 13, 14, 15, 16,
+ ]
+ try memory.write(pixels, at: pixelBuffer)
+ var attach = gpuRequest(type: 0x0106, fenceID: 0, contextID: 0, ringIndex: 0)
+ attach.appendLE(UInt32(7))
+ attach.appendLE(UInt32(1))
+ attach.appendLE(pixelBuffer)
+ attach.appendLE(UInt32(pixels.count))
+ attach.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(attach), at: 0) == 0x1100)
+ #expect(renderer.attachedBackingResourceIDs == [7])
+
+ var setScanout = gpuRequest(type: 0x0103, fenceID: 0, contextID: 0, ringIndex: 0)
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(7))
+ #expect(leUInt32(try submit(setScanout), at: 0) == 0x1100)
+ let boundFrame = try #require(frameBox.value)
+ #expect(boundFrame.dirtyRect == VirtioGPURect(x: 0, y: 0, width: 2, height: 2))
+ #expect(Array(boundFrame.bytes) == pixels)
+
+ var flush = gpuRequest(type: 0x0104, fenceID: 0, contextID: 0, ringIndex: 0)
+ flush.appendLE(UInt32(1))
+ flush.appendLE(UInt32(0))
+ flush.appendLE(UInt32(1))
+ flush.appendLE(UInt32(2))
+ flush.appendLE(UInt32(7))
+ flush.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(flush), at: 0) == 0x1100)
+
+ let frame = try #require(frameBox.value)
+ #expect(frame.scanoutID == 0)
+ #expect(frame.resourceID == 7)
+ #expect(frame.format == 1)
+ #expect(frame.width == 2)
+ #expect(frame.height == 2)
+ #expect(frame.stride == 4)
+ #expect(frame.dirtyRect == VirtioGPURect(x: 1, y: 0, width: 1, height: 2))
+ #expect(Array(frame.bytes) == [5, 6, 7, 8, 13, 14, 15, 16])
+
+ var detach = gpuRequest(type: 0x0107, fenceID: 0, contextID: 0, ringIndex: 0)
+ detach.appendLE(UInt32(7))
+ detach.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(detach), at: 0) == 0x1100)
+ #expect(renderer.detachedBackingResourceIDs == [7])
+
+ var unref = gpuRequest(type: 0x0102, fenceID: 0, contextID: 0, ringIndex: 0)
+ unref.appendLE(UInt32(7))
+ unref.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(unref), at: 0) == 0x1100)
+ #expect(releasedResources.value == 7)
+ #expect(renderer.unreferencedResourceIDs == [7])
+ }
+
+ @Test func threeDimensionalPartialFlushUsesFullResourceReadbackStride() throws {
+ let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size)
+ let frameBox = ScanoutFrameBox()
+ let renderer = FakeVirtioGPURenderer(capsets: [])
+ renderer.setTransferReadback(
+ width: 4,
+ height: 3,
+ pixels: Array(0..<48).map(UInt8.init)
+ )
+ let gpu = VirtioGPU(
+ hostMemoryBase: 0x1_0000_0000,
+ scanoutCount: 1,
+ scanoutWidth: 4,
+ scanoutHeight: 3,
+ renderer: renderer,
+ onScanoutFrame: { frameBox.store($0) }
+ )
+ let transport = VirtioMMIOTransport(
+ baseAddress: GuestLayout.virtioBase,
+ backend: gpu,
+ memory: memory
+ ) {}
+ transport.queues[0].configure(
+ size: 8,
+ descriptorTable: descTable,
+ availRing: availRing,
+ usedRing: usedRing
+ )
+ transport.queues[0].setReady(true)
+
+ var availableIndex = UInt16(0)
+ func submit(_ request: [UInt8]) throws -> [UInt8] {
+ try writeDescriptor(
+ memory,
+ index: 0,
+ addr: requestBuffer,
+ len: UInt32(request.count),
+ flags: 0x1,
+ next: 1
+ )
+ try writeDescriptor(memory, index: 1, addr: responseBuffer, len: 512, flags: 0x2, next: 0)
+ try memory.write(request, at: requestBuffer)
+ let slot = UInt64(availableIndex % 8)
+ try memory.write(UInt16(0), at: availRing + 4 + slot * 2)
+ availableIndex &+= 1
+ try memory.write(availableIndex, at: availRing + 2)
+ gpu.handleKick(queue: 0, transport: transport)
+ let usedSlot = UInt64((availableIndex - 1) % 8)
+ let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8)
+ return try memory.readBytes(at: responseBuffer, count: Int(responseLength))
+ }
+
+ var create = gpuRequest(type: 0x0204, fenceID: 0, contextID: 0, ringIndex: 0)
+ create.appendLE(UInt32(7)) // resource_id
+ create.appendLE(UInt32(2)) // PIPE_TEXTURE_2D
+ create.appendLE(UInt32(1)) // B8G8R8A8_UNORM
+ create.appendLE(UInt32(1 << 1)) // PIPE_BIND_RENDER_TARGET
+ create.appendLE(UInt32(4))
+ create.appendLE(UInt32(3))
+ create.appendLE(UInt32(1))
+ create.appendLE(UInt32(1))
+ create.appendLE(UInt32(0))
+ create.appendLE(UInt32(0))
+ create.appendLE(UInt32(1))
+ create.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(create), at: 0) == 0x1100)
+
+ var setScanout = gpuRequest(type: 0x0103, fenceID: 0, contextID: 0, ringIndex: 0)
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(4))
+ setScanout.appendLE(UInt32(3))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(7))
+ #expect(leUInt32(try submit(setScanout), at: 0) == 0x1100)
+ #expect(Array(try #require(frameBox.value).bytes) == Array(0..<48).map(UInt8.init))
+
+ renderer.setTransferReadback(
+ width: 4,
+ height: 3,
+ pixels: Array(100..<148).map(UInt8.init)
+ )
+ renderer.transferFromHostCalls.removeAll()
+ var flush = gpuRequest(type: 0x0104, fenceID: 0, contextID: 0, ringIndex: 0)
+ flush.appendLE(UInt32(1))
+ flush.appendLE(UInt32(1))
+ flush.appendLE(UInt32(2))
+ flush.appendLE(UInt32(1))
+ flush.appendLE(UInt32(7))
+ flush.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(flush), at: 0) == 0x1100)
+
+ let transfer = try #require(renderer.transferFromHostCalls.last)
+ #expect(transfer.stride == 16)
+ #expect(transfer.entryLengths == [48])
+ #expect(transfer.offset == 20)
+ #expect(transfer.box == [1, 1, 0, 2, 1, 1])
+ let frame = try #require(frameBox.value)
+ #expect(frame.stride == 8)
+ #expect(frame.dirtyRect == VirtioGPURect(x: 1, y: 1, width: 2, height: 1))
+ #expect(Array(frame.bytes) == Array(120..<128).map(UInt8.init))
+ }
+
+ @Test func guestBlobFlushPublishesStrideAwareScanoutFrame() throws {
+ let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size)
+ let frameBox = ScanoutFrameBox()
+ let renderer = FakeVirtioGPURenderer(capsets: [])
+ let gpu = VirtioGPU(
+ hostMemoryBase: 0x1_0000_0000,
+ scanoutCount: 1,
+ scanoutWidth: 2,
+ scanoutHeight: 2,
+ renderer: renderer,
+ onScanoutFrame: { frameBox.store($0) }
+ )
+ let transport = VirtioMMIOTransport(
+ baseAddress: GuestLayout.virtioBase,
+ backend: gpu,
+ memory: memory
+ ) {}
+ transport.queues[0].configure(
+ size: 8,
+ descriptorTable: descTable,
+ availRing: availRing,
+ usedRing: usedRing
+ )
+ transport.queues[0].setReady(true)
+
+ var availableIndex = UInt16(0)
+ func submit(_ request: [UInt8]) throws -> [UInt8] {
+ try writeDescriptor(
+ memory,
+ index: 0,
+ addr: requestBuffer,
+ len: UInt32(request.count),
+ flags: 0x1,
+ next: 1
+ )
+ try writeDescriptor(memory, index: 1, addr: responseBuffer, len: 512, flags: 0x2, next: 0)
+ try memory.write(request, at: requestBuffer)
+ let slot = UInt64(availableIndex % 8)
+ try memory.write(UInt16(0), at: availRing + 4 + slot * 2)
+ availableIndex &+= 1
+ try memory.write(availableIndex, at: availRing + 2)
+ gpu.handleKick(queue: 0, transport: transport)
+ let usedSlot = UInt64((availableIndex - 1) % 8)
+ let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8)
+ return try memory.readBytes(at: responseBuffer, count: Int(responseLength))
+ }
+
+ let pixelBuffer = base + 0x6000
+ let pixels: [UInt8] = [
+ 1, 2, 3, 4, 5, 6, 7, 8, 90, 91, 92, 93,
+ 9, 10, 11, 12, 13, 14, 15, 16, 94, 95, 96, 97,
+ ]
+ try memory.write(pixels, at: pixelBuffer)
+ var createBlob = gpuRequest(type: 0x010C, fenceID: 0, contextID: 0, ringIndex: 0)
+ createBlob.appendLE(UInt32(7))
+ createBlob.appendLE(UInt32(1)) // VIRTIO_GPU_BLOB_MEM_GUEST
+ createBlob.appendLE(UInt32(0))
+ createBlob.appendLE(UInt32(1))
+ createBlob.appendLE(UInt64(0))
+ createBlob.appendLE(UInt64(pixels.count))
+ createBlob.appendLE(pixelBuffer)
+ createBlob.appendLE(UInt32(pixels.count))
+ createBlob.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(createBlob), at: 0) == 0x1100)
+
+ var setScanout = gpuRequest(type: 0x010D, fenceID: 0, contextID: 0, ringIndex: 0)
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(7))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(1)) // B8G8R8A8_UNORM
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(12))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(setScanout), at: 0) == 0x1100)
+
+ var flush = gpuRequest(type: 0x0104, fenceID: 0, contextID: 0, ringIndex: 0)
+ flush.appendLE(UInt32(0))
+ flush.appendLE(UInt32(1))
+ flush.appendLE(UInt32(2))
+ flush.appendLE(UInt32(1))
+ flush.appendLE(UInt32(7))
+ flush.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(flush), at: 0) == 0x1100)
+
+ let frame = try #require(frameBox.value)
+ #expect(frame.scanoutID == 0)
+ #expect(frame.resourceID == 7)
+ #expect(frame.format == 1)
+ #expect(frame.width == 2)
+ #expect(frame.height == 2)
+ #expect(frame.stride == 8)
+ #expect(frame.dirtyRect == VirtioGPURect(x: 0, y: 1, width: 2, height: 1))
+ #expect(Array(frame.bytes) == [9, 10, 11, 12, 13, 14, 15, 16])
+ }
+
+ @Test func host3DBlobScanoutMapsRendererMemoryUntilDisplayIsReleased() throws {
+ let memory = try GuestMemory(guestBase: base, size: 64 * HostPage.size)
+ let frameBox = ScanoutFrameBox()
+ let rendererPixels: [UInt8] = [
+ 1, 2, 3, 4, 5, 6, 7, 8, 80, 81, 82, 83,
+ 9, 10, 11, 12, 13, 14, 15, 16, 84, 85, 86, 87,
+ ]
+ let renderer = FakeVirtioGPURenderer(capsets: [], blobBytes: rendererPixels)
+ let gpu = VirtioGPU(
+ hostMemoryBase: 0x1_0000_0000,
+ scanoutCount: 1,
+ renderer: renderer,
+ onScanoutFrame: { frameBox.store($0) }
+ )
+ let transport = VirtioMMIOTransport(
+ baseAddress: GuestLayout.virtioBase,
+ backend: gpu,
+ memory: memory
+ ) {}
+ transport.queues[0].configure(
+ size: 8,
+ descriptorTable: descTable,
+ availRing: availRing,
+ usedRing: usedRing
+ )
+ transport.queues[0].setReady(true)
+
+ var availableIndex = UInt16(0)
+ func submit(_ request: [UInt8]) throws -> [UInt8] {
+ try writeDescriptor(memory, index: 0, addr: requestBuffer, len: UInt32(request.count), flags: 0x1, next: 1)
+ try writeDescriptor(memory, index: 1, addr: responseBuffer, len: 512, flags: 0x2, next: 0)
+ try memory.write(request, at: requestBuffer)
+ try memory.write(UInt16(0), at: availRing + 4 + UInt64(availableIndex % 8) * 2)
+ availableIndex &+= 1
+ try memory.write(availableIndex, at: availRing + 2)
+ gpu.handleKick(queue: 0, transport: transport)
+ let usedSlot = UInt64((availableIndex - 1) % 8)
+ let responseLength = try memory.read(UInt32.self, at: usedRing + 8 + usedSlot * 8)
+ return try memory.readBytes(at: responseBuffer, count: Int(responseLength))
+ }
+
+ var createBlob = gpuRequest(type: 0x010C, fenceID: 0, contextID: 3, ringIndex: 0)
+ createBlob.appendLE(UInt32(9))
+ createBlob.appendLE(UInt32(2)) // VIRTIO_GPU_BLOB_MEM_HOST3D
+ createBlob.appendLE(UInt32(1)) // mappable
+ createBlob.appendLE(UInt32(0))
+ createBlob.appendLE(UInt64(44))
+ createBlob.appendLE(UInt64(rendererPixels.count))
+ #expect(leUInt32(try submit(createBlob), at: 0) == 0x1100)
+
+ var setScanout = gpuRequest(type: 0x010D, fenceID: 0, contextID: 0, ringIndex: 0)
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(9))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(2))
+ setScanout.appendLE(UInt32(1))
+ setScanout.appendLE(UInt32(0))
+ setScanout.appendLE(UInt32(12))
+ for _ in 0..<7 { setScanout.appendLE(UInt32(0)) }
+ #expect(leUInt32(try submit(setScanout), at: 0) == 0x1100)
+ let boundFrame = try #require(frameBox.value)
+ #expect(boundFrame.dirtyRect == VirtioGPURect(x: 0, y: 0, width: 2, height: 2))
+ #expect(Array(boundFrame.bytes) == [
+ 1, 2, 3, 4, 5, 6, 7, 8,
+ 9, 10, 11, 12, 13, 14, 15, 16,
+ ])
+
+ var flush = gpuRequest(type: 0x0104, fenceID: 0, contextID: 0, ringIndex: 0)
+ flush.appendLE(UInt32(0))
+ flush.appendLE(UInt32(0))
+ flush.appendLE(UInt32(2))
+ flush.appendLE(UInt32(2))
+ flush.appendLE(UInt32(9))
+ flush.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(flush), at: 0) == 0x1100)
+ #expect(renderer.mapBlobCount == 1)
+ #expect(Array(try #require(frameBox.value).bytes) == [
+ 1, 2, 3, 4, 5, 6, 7, 8,
+ 9, 10, 11, 12, 13, 14, 15, 16,
+ ])
+
+ var disable = gpuRequest(type: 0x0103, fenceID: 0, contextID: 0, ringIndex: 0)
+ disable.appendLE(UInt32(0))
+ disable.appendLE(UInt32(0))
+ disable.appendLE(UInt32(2))
+ disable.appendLE(UInt32(2))
+ disable.appendLE(UInt32(0))
+ disable.appendLE(UInt32(0))
+ #expect(leUInt32(try submit(disable), at: 0) == 0x1100)
+ #expect(renderer.unmapBlobCount == 1)
}
@Test func fencedCommandDefersCompletionUntilRendererSignals() throws {
@@ -994,11 +1549,79 @@ import Testing
}
}
+private final class ScanoutFrameBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var frame: VirtioGPUScanoutFrame?
+
+ func store(_ frame: VirtioGPUScanoutFrame) {
+ lock.lock()
+ self.frame = frame
+ lock.unlock()
+ }
+
+ var value: VirtioGPUScanoutFrame? {
+ lock.lock()
+ defer { lock.unlock() }
+ return frame
+ }
+}
+
+private final class ScanoutResourceBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var resourceID: UInt32?
+
+ func store(_ resourceID: UInt32) {
+ lock.lock()
+ self.resourceID = resourceID
+ lock.unlock()
+ }
+
+ var value: UInt32? {
+ lock.lock()
+ defer { lock.unlock() }
+ return resourceID
+ }
+}
+
private final class FakeVirtioGPURenderer: VirtioGPURenderer {
- let capsets: [VirtioGPUCapset]
+ struct TransferFromHostCall {
+ var stride: UInt32
+ var offset: UInt64
+ var entryLengths: [Int]
+ var box: [UInt32]
+ }
- init(capsets: [VirtioGPUCapset]) {
+ let capsets: [VirtioGPUCapset]
+ private let blobStorage: UnsafeMutableRawPointer?
+ private let blobStorageSize: UInt64
+ private(set) var mapBlobCount = 0
+ private(set) var unmapBlobCount = 0
+ private(set) var createdResources: [VirtioGPUResourceCreate3D] = []
+ private(set) var attachedBackingResourceIDs: [UInt32] = []
+ private(set) var detachedBackingResourceIDs: [UInt32] = []
+ private(set) var unreferencedResourceIDs: [UInt32] = []
+ private var transferReadbackWidth: UInt32 = 0
+ private var transferReadbackHeight: UInt32 = 0
+ private var transferReadbackPixels = [UInt8]()
+ var transferFromHostCalls = [TransferFromHostCall]()
+
+ init(capsets: [VirtioGPUCapset], blobBytes: [UInt8]? = nil) {
self.capsets = capsets
+ if let blobBytes {
+ let storage = UnsafeMutableRawPointer.allocate(byteCount: blobBytes.count, alignment: 16)
+ blobBytes.withUnsafeBytes { bytes in
+ storage.copyMemory(from: bytes.baseAddress!, byteCount: blobBytes.count)
+ }
+ self.blobStorage = storage
+ self.blobStorageSize = UInt64(blobBytes.count)
+ } else {
+ self.blobStorage = nil
+ self.blobStorageSize = 0
+ }
+ }
+
+ deinit {
+ blobStorage?.deallocate()
}
func createContext(id: UInt32, flags: UInt32, name: String) throws {}
@@ -1006,7 +1629,9 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer {
func attachResource(contextID: UInt32, resourceID: UInt32) throws {}
func detachResource(contextID: UInt32, resourceID: UInt32) throws {}
func submit3D(contextID: UInt32, command: [UInt8]) throws {}
- func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws {}
+ func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws {
+ createdResources.append(resource)
+ }
func createBlob(
resourceID: UInt32,
contextID: UInt32,
@@ -1016,15 +1641,67 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer {
size: UInt64,
entries: [VirtioGPUMemoryEntry]
) throws {}
- func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws {}
- func detachBacking(resourceID: UInt32) throws {}
- func unrefResource(resourceID: UInt32) throws {}
+ func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws {
+ attachedBackingResourceIDs.append(resourceID)
+ }
+ func detachBacking(resourceID: UInt32) throws {
+ detachedBackingResourceIDs.append(resourceID)
+ }
+ func unrefResource(resourceID: UInt32) throws {
+ unreferencedResourceIDs.append(resourceID)
+ }
func mapBlob(resourceID: UInt32) throws -> VirtioGPUBlobMapping {
- VirtioGPUBlobMapping(hostPointer: UnsafeMutableRawPointer(bitPattern: 0x1000)!, size: 4096, mapInfo: 2)
+ mapBlobCount += 1
+ return VirtioGPUBlobMapping(
+ hostPointer: blobStorage ?? UnsafeMutableRawPointer(bitPattern: 0x1000)!,
+ size: blobStorage == nil ? 4096 : blobStorageSize,
+ mapInfo: 2
+ )
}
- func unmapBlob(resourceID: UInt32) throws {}
+ func unmapBlob(resourceID: UInt32) throws { unmapBlobCount += 1 }
func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws {}
- func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws {}
+ func setTransferReadback(width: UInt32, height: UInt32, pixels: [UInt8]) {
+ transferReadbackWidth = width
+ transferReadbackHeight = height
+ transferReadbackPixels = pixels
+ }
+
+ func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws {
+ transferFromHostCalls.append(TransferFromHostCall(
+ stride: transfer.stride,
+ offset: transfer.offset,
+ entryLengths: entries.map(\.length),
+ box: transfer.box
+ ))
+ guard transferReadbackWidth > 0, transferReadbackHeight > 0 else { return }
+ guard transfer.box.count == 6,
+ transfer.box[2] == 0, transfer.box[5] == 1,
+ transfer.box[0] <= transferReadbackWidth,
+ transfer.box[3] <= transferReadbackWidth - transfer.box[0],
+ transfer.box[1] <= transferReadbackHeight,
+ transfer.box[4] <= transferReadbackHeight - transfer.box[1],
+ transferReadbackPixels.count == Int(transferReadbackWidth * transferReadbackHeight * 4),
+ let destination = entries.first else {
+ throw VMError.invalidConfiguration("invalid fake renderer readback")
+ }
+ let rowBytes = Int(transfer.box[3] * 4)
+ for row in 0.. Void)?
/// Registered fences accumulate; tests fire them via `signalFence` to model asynchronous
@@ -1048,6 +1725,425 @@ private final class FakeVirtioGPURenderer: VirtioGPURenderer {
}
}
+@Suite struct VirtioSoundTests {
+ @Test func advertisesStandardPlaybackAndCaptureStreams() {
+ let host = TestSoundHost()
+ let sound = VirtioSound(host: host)
+
+ #expect(sound.deviceID == 25)
+ #expect(sound.queueCount == 4)
+ #expect(sound.configSpace.leUInt32(at: 0) == 0)
+ #expect(sound.configSpace.leUInt32(at: 4) == 2)
+ #expect(sound.configSpace.leUInt32(at: 8) == 0)
+
+ var request = [UInt8]()
+ request.appendLE(UInt32(0x0100))
+ request.appendLE(UInt32(0))
+ request.appendLE(UInt32(2))
+ request.appendLE(UInt32(32))
+ let response = sound.controlResponseForTesting(request, responseCapacity: 68)
+
+ #expect(response.count == 68)
+ #expect(response.leUInt32(at: 0) == 0x8000)
+ #expect(response[28] == VirtioSoundDirection.output.rawValue)
+ #expect(response[60] == VirtioSoundDirection.input.rawValue)
+ #expect(response[29] == 1 && response[30] == 2)
+ #expect(response[61] == 1 && response[62] == 2)
+ #expect(response.leUInt64(at: 12) & (1 << 5) != 0) // S16_LE
+ #expect(response.leUInt64(at: 20) & (1 << 6) != 0) // 44.1 kHz
+ #expect(response.leUInt64(at: 20) & (1 << 7) != 0) // 48 kHz
+ }
+
+ @Test func enforcesTheVirtioPCMStreamLifecycle() {
+ let host = TestSoundHost()
+ let sound = VirtioSound(host: host)
+
+ #expect(status(sound, setParameters(streamID: 0)) == 0x8000)
+ #expect(status(sound, lifecycle(0x0102, streamID: 0)) == 0x8000)
+ #expect(status(sound, lifecycle(0x0104, streamID: 0)) == 0x8000)
+ #expect(status(sound, lifecycle(0x0105, streamID: 0)) == 0x8000)
+ #expect(status(sound, lifecycle(0x0103, streamID: 0)) == 0x8000)
+ #expect(host.events == [
+ "configure-0-output-48000-2",
+ "prepare-0-output",
+ "start-0-output",
+ "stop-0-output",
+ "release-0-output",
+ ])
+ }
+
+ @Test func rejectsFormatsThatLinuxWasNotToldAreAvailable() {
+ let host = TestSoundHost()
+ let sound = VirtioSound(host: host)
+
+ #expect(status(sound, setParameters(streamID: 0, format: 1)) == 0x8002)
+ #expect(host.events.isEmpty)
+ }
+
+ @Test func playbackDescriptorCompletesOnlyAfterHostPlaybackCallback() throws {
+ let base = GuestLayout.ramBase
+ let descriptorTable = base + 0x1000
+ let availableRing = base + 0x2000
+ let usedRing = base + 0x3000
+ let requestBuffer = base + 0x4000
+ let responseBuffer = base + 0x5000
+ let host = TestSoundHost()
+ host.acceptPlayback = true
+ let sound = VirtioSound(host: host)
+
+ #expect(status(sound, setParameters(streamID: 0)) == 0x8000)
+ #expect(status(sound, lifecycle(0x0102, streamID: 0)) == 0x8000)
+ #expect(status(sound, lifecycle(0x0104, streamID: 0)) == 0x8000)
+
+ let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size)
+ let transport = VirtioMMIOTransport(
+ baseAddress: GuestLayout.virtioBase,
+ backend: sound,
+ memory: memory
+ ) {}
+ transport.queues[2].configure(
+ size: 8,
+ descriptorTable: descriptorTable,
+ availRing: availableRing,
+ usedRing: usedRing
+ )
+ transport.queues[2].setReady(true)
+
+ let audio = [UInt8](repeating: 0x40, count: 16)
+ var request = [UInt8]()
+ request.appendLE(UInt32(0))
+ request.append(contentsOf: audio)
+ try writeDescriptor(
+ memory,
+ table: descriptorTable,
+ index: 0,
+ address: requestBuffer,
+ length: UInt32(request.count),
+ flags: 0x1,
+ next: 1
+ )
+ try writeDescriptor(
+ memory,
+ table: descriptorTable,
+ index: 1,
+ address: responseBuffer,
+ length: 8,
+ flags: 0x2,
+ next: 0
+ )
+ try memory.write(request, at: requestBuffer)
+ try memory.write(UInt16(0), at: availableRing)
+ try memory.write(UInt16(0), at: availableRing + 4)
+ try memory.write(UInt16(1), at: availableRing + 2)
+
+ sound.handleKick(queue: 2, transport: transport)
+
+ #expect(host.playbackData == Data(audio))
+ #expect(try memory.read(UInt16.self, at: usedRing + 2) == 0)
+ let completion = try #require(host.playbackCompletion)
+ completion(true, 256)
+ #expect(try memory.read(UInt16.self, at: usedRing + 2) == 1)
+ #expect(try memory.read(UInt32.self, at: usedRing + 8) == 8)
+ #expect(try memory.read(UInt32.self, at: responseBuffer) == 0x8000)
+ #expect(try memory.read(UInt32.self, at: responseBuffer + 4) == 256)
+ }
+
+ @Test func captureDescriptorCanPrimeBeforeStartAndReturnsPayloadThenStatus() throws {
+ let base = GuestLayout.ramBase
+ let descriptorTable = base + 0x1000
+ let availableRing = base + 0x2000
+ let usedRing = base + 0x3000
+ let requestBuffer = base + 0x4000
+ let audioBuffer = base + 0x5000
+ let statusBuffer = base + 0x6000
+ let host = TestSoundHost()
+ host.acceptCapture = true
+ let sound = VirtioSound(host: host)
+
+ #expect(status(sound, setParameters(streamID: 1)) == 0x8000)
+ #expect(status(sound, lifecycle(0x0102, streamID: 1)) == 0x8000)
+
+ let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size)
+ let transport = VirtioMMIOTransport(
+ baseAddress: GuestLayout.virtioBase,
+ backend: sound,
+ memory: memory
+ ) {}
+ transport.queues[3].configure(
+ size: 8,
+ descriptorTable: descriptorTable,
+ availRing: availableRing,
+ usedRing: usedRing
+ )
+ transport.queues[3].setReady(true)
+
+ try writeDescriptor(
+ memory,
+ table: descriptorTable,
+ index: 0,
+ address: requestBuffer,
+ length: 4,
+ flags: 0x1,
+ next: 1
+ )
+ try writeDescriptor(
+ memory,
+ table: descriptorTable,
+ index: 1,
+ address: audioBuffer,
+ length: 16,
+ flags: 0x3,
+ next: 2
+ )
+ try writeDescriptor(
+ memory,
+ table: descriptorTable,
+ index: 2,
+ address: statusBuffer,
+ length: 8,
+ flags: 0x2,
+ next: 0
+ )
+ try memory.write([UInt8](repeating: 0, count: 4), at: requestBuffer)
+ try memory.write(UInt32(1), at: requestBuffer)
+ try memory.write(UInt16(0), at: availableRing)
+ try memory.write(UInt16(0), at: availableRing + 4)
+ try memory.write(UInt16(1), at: availableRing + 2)
+
+ sound.handleKick(queue: 3, transport: transport)
+
+ #expect(host.captureByteCount == 16)
+ #expect(try memory.read(UInt16.self, at: usedRing + 2) == 0)
+ let completion = try #require(host.captureCompletion)
+ let audio = Data((0..<16).map(UInt8.init))
+ completion(audio, 512)
+ #expect(try memory.read(UInt16.self, at: usedRing + 2) == 1)
+ #expect(try memory.read(UInt32.self, at: usedRing + 8) == 24)
+ #expect(try memory.readBytes(at: audioBuffer, count: 16) == [UInt8](audio))
+ #expect(try memory.read(UInt32.self, at: statusBuffer) == 0x8000)
+ #expect(try memory.read(UInt32.self, at: statusBuffer + 4) == 512)
+ }
+
+ private func status(_ sound: VirtioSound, _ request: [UInt8]) -> UInt32 {
+ sound.controlResponseForTesting(request).leUInt32(at: 0)
+ }
+
+ private func lifecycle(_ code: UInt32, streamID: UInt32) -> [UInt8] {
+ var request = [UInt8]()
+ request.appendLE(code)
+ request.appendLE(streamID)
+ return request
+ }
+
+ private func setParameters(
+ streamID: UInt32,
+ channels: UInt8 = 2,
+ format: UInt8 = 5,
+ rate: UInt8 = 7
+ ) -> [UInt8] {
+ var request = [UInt8]()
+ request.appendLE(UInt32(0x0101))
+ request.appendLE(streamID)
+ request.appendLE(UInt32(32_768))
+ request.appendLE(UInt32(4_096))
+ request.appendLE(UInt32(0))
+ request.append(channels)
+ request.append(format)
+ request.append(rate)
+ request.append(0)
+ return request
+ }
+
+ private func writeDescriptor(
+ _ memory: GuestMemory,
+ table: UInt64,
+ index: UInt64,
+ address: UInt64,
+ length: UInt32,
+ flags: UInt16,
+ next: UInt16
+ ) throws {
+ let descriptor = table + index * 16
+ try memory.write(address, at: descriptor)
+ try memory.write(length, at: descriptor + 8)
+ try memory.write(flags, at: descriptor + 12)
+ try memory.write(next, at: descriptor + 14)
+ }
+
+ private final class TestSoundHost: VirtioSoundHost, @unchecked Sendable {
+ var events = [String]()
+ var acceptPlayback = false
+ var acceptCapture = false
+ var playbackData: Data?
+ var playbackCompletion: (@Sendable (Bool, UInt32) -> Void)?
+ var captureByteCount: Int?
+ var captureCompletion: (@Sendable (Data?, UInt32) -> Void)?
+
+ func configure(
+ streamID: Int,
+ direction: VirtioSoundDirection,
+ parameters: VirtioSoundPCMParameters
+ ) -> Bool {
+ events.append("configure-\(streamID)-\(direction)-\(Int(parameters.sampleRate))-\(parameters.channels)")
+ return true
+ }
+
+ func prepare(streamID: Int, direction: VirtioSoundDirection) -> Bool {
+ events.append("prepare-\(streamID)-\(direction)")
+ return true
+ }
+
+ func start(streamID: Int, direction: VirtioSoundDirection) -> Bool {
+ events.append("start-\(streamID)-\(direction)")
+ return true
+ }
+
+ func stop(streamID: Int, direction: VirtioSoundDirection) -> Bool {
+ events.append("stop-\(streamID)-\(direction)")
+ return true
+ }
+
+ func release(streamID: Int, direction: VirtioSoundDirection) {
+ events.append("release-\(streamID)-\(direction)")
+ }
+
+ func enqueuePlayback(
+ _ data: Data,
+ parameters: VirtioSoundPCMParameters,
+ completion: @escaping @Sendable (Bool, UInt32) -> Void
+ ) -> Bool {
+ guard acceptPlayback else { return false }
+ playbackData = data
+ playbackCompletion = completion
+ return true
+ }
+
+ func requestCapture(
+ byteCount: Int,
+ parameters: VirtioSoundPCMParameters,
+ completion: @escaping @Sendable (Data?, UInt32) -> Void
+ ) -> Bool {
+ guard acceptCapture else { return false }
+ captureByteCount = byteCount
+ captureCompletion = completion
+ return true
+ }
+
+ func reset() { events.append("reset") }
+ }
+}
+
+@Suite struct VirtioInputTests {
+ private let base = GuestLayout.ramBase
+ private var descriptorTable: UInt64 { base + 0x1000 }
+ private var availableRing: UInt64 { base + 0x2000 }
+ private var usedRing: UInt64 { base + 0x3000 }
+ private var eventBuffers: UInt64 { base + 0x4000 }
+
+ @Test func advertisesKeyboardPointerWheelAndAbsoluteAxes() {
+ let input = VirtioInput()
+
+ input.writeConfig(offset: 0, value: 0x11, width: 1)
+ input.writeConfig(offset: 1, value: 1, width: 1) // EV_KEY
+ var config = input.configSpace
+ #expect(config[2] >= 35)
+ #expect(config[8 + 30] & (1 << 7) != 0) // KEY 247
+ #expect(config[8 + 34] & 1 != 0) // BTN_LEFT 272
+
+ input.writeConfig(offset: 1, value: 2, width: 1) // EV_REL
+ config = input.configSpace
+ #expect(config[8 + 1] & (1 << 3) != 0) // REL_WHEEL_HI_RES 11
+
+ input.writeConfig(offset: 0, value: 0x12, width: 1)
+ input.writeConfig(offset: 1, value: 0, width: 1) // ABS_X
+ config = input.configSpace
+ #expect(config[2] == 20)
+ #expect(config.leUInt32(at: 12) == 32_767)
+ }
+
+ @Test func convertsAppKitScrollDirectionToLinuxWheelDirection() {
+ var scroll = VirtioInputScrollAccumulator()
+ let events = scroll.events(
+ horizontalDelta: -1,
+ verticalDelta: 1,
+ hasPreciseDeltas: false
+ )
+
+ #expect(events == [
+ VirtioInputEvent(type: 2, code: 11, value: -120),
+ VirtioInputEvent(type: 2, code: 8, value: -1),
+ VirtioInputEvent(type: 2, code: 12, value: 120),
+ VirtioInputEvent(type: 2, code: 6, value: 1),
+ ])
+ }
+
+ @Test func accumulatesPreciseScrollIntoDiscreteLinuxTicks() {
+ var scroll = VirtioInputScrollAccumulator()
+ for _ in 0..<9 {
+ let events = scroll.events(
+ horizontalDelta: 0,
+ verticalDelta: 1,
+ hasPreciseDeltas: true
+ )
+ #expect(events == [VirtioInputEvent(type: 2, code: 11, value: -12)])
+ }
+ let finalEvents = scroll.events(
+ horizontalDelta: 0,
+ verticalDelta: 1,
+ hasPreciseDeltas: true
+ )
+ #expect(finalEvents == [
+ VirtioInputEvent(type: 2, code: 11, value: -12),
+ VirtioInputEvent(type: 2, code: 8, value: -1),
+ ])
+ }
+
+ @Test func waitsForEnoughGuestBuffersBeforePublishingWholeInputFrame() throws {
+ let memory = try GuestMemory(guestBase: base, size: 8 * HostPage.size)
+ let input = VirtioInput()
+ let transport = VirtioMMIOTransport(
+ baseAddress: GuestLayout.virtioBase,
+ backend: input,
+ memory: memory
+ ) {}
+ transport.queues[0].configure(
+ size: 8,
+ descriptorTable: descriptorTable,
+ availRing: availableRing,
+ usedRing: usedRing
+ )
+ transport.queues[0].setReady(true)
+ input.deviceReady(transport: transport)
+
+ for index in 0..<3 {
+ let descriptor = descriptorTable + UInt64(index) * 16
+ try memory.write(eventBuffers + UInt64(index) * 8, at: descriptor)
+ try memory.write(UInt32(8), at: descriptor + 8)
+ try memory.write(UInt16(2), at: descriptor + 12) // device-writable
+ try memory.write(UInt16(0), at: descriptor + 14)
+ try memory.write(UInt16(index), at: availableRing + 4 + UInt64(index) * 2)
+ }
+ try memory.write(UInt16(0), at: availableRing)
+ try memory.write(UInt16(2), at: availableRing + 2)
+
+ input.send(frame: [
+ VirtioInputEvent(type: 1, code: 30, value: 1),
+ VirtioInputEvent(type: 3, code: 0, value: 16_000),
+ ])
+ #expect(try memory.read(UInt16.self, at: usedRing + 2) == 0)
+
+ try memory.write(UInt16(3), at: availableRing + 2)
+ input.handleKick(queue: 0, transport: transport)
+ #expect(try memory.read(UInt16.self, at: usedRing + 2) == 3)
+ #expect(try memory.read(UInt16.self, at: eventBuffers) == 1)
+ #expect(try memory.read(UInt16.self, at: eventBuffers + 2) == 30)
+ #expect(try memory.read(UInt32.self, at: eventBuffers + 4) == 1)
+ #expect(try memory.read(UInt16.self, at: eventBuffers + 8) == 3)
+ #expect(try memory.read(UInt32.self, at: eventBuffers + 12) == 16_000)
+ #expect(try memory.read(UInt16.self, at: eventBuffers + 16) == 0)
+ }
+}
+
@Suite struct PL011Tests {
@Test func transmitsToSinkAndReportsReadyFlags() {
var out = [UInt8]()
diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift
new file mode 100644
index 00000000..ebb30c90
--- /dev/null
+++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/GVProxyDesktopLaunchPlanTests.swift
@@ -0,0 +1,20 @@
+import Testing
+@testable import DoryHV
+
+@Suite struct GVProxyDesktopLaunchPlanTests {
+ @Test func desktopNetworkingUsesOnlyPerMachineSockets() {
+ let arguments = GVProxyDesktopLaunchPlan.arguments(
+ mtu: 1_280,
+ datapathSocket: "/private/dory/machine-a/gv.sock",
+ apiSocket: "/private/dory/machine-a/api.sock"
+ )
+
+ #expect(arguments == [
+ "-mtu", "1280",
+ "-listen-vfkit", "unixgram:///private/dory/machine-a/gv.sock",
+ "-listen", "unix:///private/dory/machine-a/api.sock",
+ "-ssh-port", "-1",
+ ])
+ #expect(!arguments.contains("2222"))
+ }
+}
diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestShutdownCommandTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestShutdownCommandTests.swift
index f3f44ea0..4465a373 100644
--- a/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestShutdownCommandTests.swift
+++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestShutdownCommandTests.swift
@@ -143,4 +143,24 @@ struct GuestShutdownCommandTests {
#expect(process.terminationStatus == 0)
}
+
+ @Test func detachedDesktopRequestUsesGracefulSystemShutdown() throws {
+ let command = GuestShutdownCommand.detachedDesktopRequest()
+
+ #expect(command.contains("systemd-run"))
+ #expect(command.contains("--on-active=1s"))
+ #expect(command.contains("systemctl poweroff"))
+ #expect(!command.contains("poweroff -f"))
+ #expect(!command.hasSuffix("&"))
+
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/sh")
+ process.arguments = ["-n", "-c", command]
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ try process.run()
+ process.waitUntilExit()
+
+ #expect(process.terminationStatus == 0)
+ }
}
diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift
index da6d8d5f..89a79352 100644
--- a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift
+++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift
@@ -60,6 +60,26 @@ import Testing
#expect(try queue.pop() == nil) // ring drained
}
+ @Test func writesAcrossWritableSegmentsAtAProtocolOffset() throws {
+ let memory = try makeMemory()
+ let queue = Virtqueue(memory: memory)
+ queue.configure(size: 8, descriptorTable: descTable, availRing: availRing, usedRing: usedRing)
+ queue.setReady(true)
+
+ try writeDescriptor(memory, index: 0, addr: dataIn, len: 5, flags: 0x3, next: 1)
+ try writeDescriptor(memory, index: 1, addr: dataIn + 5, len: 7, flags: 0x2, next: 0)
+ try memory.write([UInt8](repeating: 0xAA, count: 12), at: dataIn)
+ try memory.write(UInt16(0), at: availRing)
+ try memory.write(UInt16(0), at: availRing + 4)
+ try memory.write(UInt16(1), at: availRing + 2)
+
+ let chain = try #require(try queue.pop())
+ #expect(chain.writeBytes([1, 2, 3, 4], atWritableOffset: 4) == 4)
+ #expect(try memory.readBytes(at: dataIn, count: 12) == [
+ 0xAA, 0xAA, 0xAA, 0xAA, 1, 2, 3, 4, 0xAA, 0xAA, 0xAA, 0xAA,
+ ])
+ }
+
@Test func pushIsSafeAfterResetToZeroSize() throws {
let memory = try makeMemory()
let queue = Virtqueue(memory: memory)
diff --git a/Packages/ContainerizationEngine/dory-hv.entitlements b/Packages/ContainerizationEngine/dory-hv.entitlements
index ee0058be..c550707a 100644
--- a/Packages/ContainerizationEngine/dory-hv.entitlements
+++ b/Packages/ContainerizationEngine/dory-hv.entitlements
@@ -2,7 +2,11 @@
- com.apple.security.hypervisor
-
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.hypervisor
+
diff --git a/dory-core-swift/Sources/DoryCore/GuestShutdownCommand.swift b/dory-core-swift/Sources/DoryCore/GuestShutdownCommand.swift
index 96f51591..8d0cb00b 100644
--- a/dory-core-swift/Sources/DoryCore/GuestShutdownCommand.swift
+++ b/dory-core-swift/Sources/DoryCore/GuestShutdownCommand.swift
@@ -37,6 +37,23 @@ public enum GuestShutdownCommand {
+ " ) >/var/log/dory-shutdown.log 2>&1 String {
+ // Let PID 1 own the delayed request. A shell backgrounded by dory-agent remains in the
+ // agent's process group/cgroup and can be reaped before it reaches `systemctl poweroff`.
+ // A transient systemd service survives the RPC process and gives the agent time to return
+ // its success response before the graphical session begins shutting down.
+ "echo shutdown requested >/var/log/dory-shutdown.log; sync; "
+ + "systemd-run --quiet --collect --unit=dory-host-poweroff "
+ + "--on-active=1s /usr/bin/systemctl poweroff "
+ + ">>/var/log/dory-shutdown.log 2>&1"
+ }
+
private static func shutdownSequence() -> String {
let attempts = DoryEngineShutdownTiming.dockerdPollAttempts
let interval = DoryEngineShutdownTiming.pollIntervalSeconds
diff --git a/patches/moltenvk-dory-native-arrays.patch b/patches/moltenvk-dory-native-arrays.patch
new file mode 100644
index 00000000..a39f01a0
--- /dev/null
+++ b/patches/moltenvk-dory-native-arrays.patch
@@ -0,0 +1,37 @@
+diff --git a/MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp b/MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp
+index 406f4f8..56e9bda 100644
+--- a/MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp
++++ b/MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp
+@@ -28,6 +28,11 @@ using namespace std;
+ using namespace spv;
+ using namespace SPIRV_CROSS_NAMESPACE;
+
++extern "C" MVK_PUBLIC_SYMBOL uint32_t dory_moltenvk_spirv_native_array_fix();
++extern "C" MVK_PUBLIC_SYMBOL uint32_t dory_moltenvk_spirv_native_array_fix() {
++ return 1;
++}
++
+
+ #pragma mark -
+ #pragma mark SPIRVToMSLConversionConfiguration
+@@ -89,6 +93,9 @@ MVK_PUBLIC_SYMBOL SPIRVToMSLConversionOptions::SPIRVToMSLConversionOptions() {
+ #endif
+
+ mslOptions.pad_fragment_output_components = true;
++ // Dory: keep fixed-size arrays native so values extracted from nested
++ // Vulkan structs retain the same Metal type when passed to helper functions.
++ mslOptions.force_native_arrays = true;
+ }
+
+ MVK_PUBLIC_SYMBOL bool mvk::MSLShaderInterfaceVariable::matches(const mvk::MSLShaderInterfaceVariable& other) const {
+@@ -295,6 +302,10 @@ MVK_PUBLIC_SYMBOL bool SPIRVToMSLConverter::convert(SPIRVToMSLConversionConfigur
+
+ // Establish the MSL options for the compiler
+ // This needs to be done in two steps...for CompilerMSL and its superclass.
++ // Dory: Metal has no by-value native-array parameters. Force SPIRV-Cross
++ // to keep fixed arrays native at the final conversion boundary as well;
++ // cached/deserialized conversion configurations may predate this default.
++ shaderConfig.options.mslOptions.force_native_arrays = true;
+ pMSLCompiler->set_msl_options(shaderConfig.options.mslOptions);
+
+ auto scOpts = pMSLCompiler->get_common_options();
diff --git a/patches/virglrenderer-macos-fragment-coord.patch b/patches/virglrenderer-macos-fragment-coord.patch
new file mode 100644
index 00000000..325d1b64
--- /dev/null
+++ b/patches/virglrenderer-macos-fragment-coord.patch
@@ -0,0 +1,177 @@
+diff --git a/src/virglrenderer.h b/src/virglrenderer.h
+index 12a9904..5d940b7 100644
+--- a/src/virglrenderer.h
++++ b/src/virglrenderer.h
+@@ -34,6 +34,8 @@ struct virgl_box;
+ struct iovec;
+
+ #define VIRGL_EXPORT __attribute__((visibility("default")))
++VIRGL_EXPORT int dory_virglrenderer_macos_fragment_coord_fix(void);
++VIRGL_EXPORT int dory_virglrenderer_macos_venus_fence_fix(void);
+
+ typedef void *virgl_renderer_gl_context;
+
+diff --git a/src/virglrenderer.c b/src/virglrenderer.c
+index 6c565d2..d0f3b21 100644
+--- a/src/virglrenderer.c
++++ b/src/virglrenderer.c
+@@ -61,6 +61,19 @@ struct global_state {
+
+ static struct global_state state;
+
++/* Release tooling requires this marker before it will bundle a renderer on macOS. */
++VIRGL_EXPORT int
++dory_virglrenderer_macos_fragment_coord_fix(void)
++{
++ return 1;
++}
++
++VIRGL_EXPORT int
++dory_virglrenderer_macos_venus_fence_fix(void)
++{
++ return 1;
++}
++
+ /* new API - just wrap internal API for now */
+
+ static int virgl_renderer_resource_create_internal(struct virgl_renderer_resource_create_args *args,
+diff --git a/src/vrend_shader.c b/src/vrend_shader.c
+index 327523f..50b3c08 100644
+--- a/src/vrend_shader.c
++++ b/src/vrend_shader.c
+@@ -6266,10 +6266,14 @@ static void emit_header(const struct dump_ctx *ctx,
+
+ if (ctx->prog_type == TGSI_PROCESSOR_VERTEX && ctx->cfg->use_explicit_locations)
+ emit_ext(glsl_strbufs, "ARB_explicit_attrib_location", "require");
+- if (ctx->prog_type == TGSI_PROCESSOR_FRAGMENT && fs_emit_layout(ctx))
++ if (ctx->prog_type == TGSI_PROCESSOR_FRAGMENT && fs_emit_layout(ctx) &&
++ ctx->glsl_ver_required < 150)
+ emit_ext(glsl_strbufs, "ARB_fragment_coord_conventions", "require");
+
+- if (ctx->ubo_used_mask)
+- emit_ext(glsl_strbufs, "ARB_uniform_buffer_object", "require");
++ if (ctx->ubo_used_mask) {
++#ifndef __APPLE__
++ emit_ext(glsl_strbufs, "ARB_uniform_buffer_object", "require");
++#endif
++ }
+
+ if (ctx->num_cull_dist_prop || ctx->key->num_in_cull || ctx->key->num_out_cull)
+@@ -8181,6 +8185,13 @@ bool vrend_convert_shader(const struct vrend_context *rctx,
+ ctx.shader_req_bits |= SHADER_REQ_SHADER_NOPERSPECTIVE_INTERPOLATION;
+ }
+
++ /* Fragment coordinate layout qualifiers are core in GLSL 1.50. Some
++ * core-profile implementations (notably macOS) support the core syntax
++ * without exposing GL_ARB_fragment_coord_conventions as an extension. */
++ if (!cfg->use_gles && cfg->glsl_version >= 150 &&
++ ctx.prog_type == TGSI_PROCESSOR_FRAGMENT && fs_emit_layout(&ctx))
++ ctx.glsl_ver_required = require_glsl_ver(&ctx, 150);
++
+ emit_header(&ctx, &ctx.glsl_strbufs);
+ ctx.glsl_ver_required = emit_ios(&ctx, &ctx.glsl_strbufs, &ctx.generic_ios,
+ &ctx.texcoord_ios, &ctx.patches_emitted_mask,
+diff --git a/src/venus_context.c b/src/venus_context.c
+index 45a98af..5302069 100644
+--- a/src/venus_context.c
++++ b/src/venus_context.c
+@@ -231,33 +231,19 @@ venus_context_retire_fences_internal(struct venus_context *ctx, uint32_t ring_i
+ static void
+ venus_context_retire_fences(struct virgl_context *base)
+ {
+- fprintf(stderr, "%s: UNIMPLEMENTED\n", __func__);
+-#if 0
+- struct proxy_context *ctx = (struct proxy_context *)base;
+-
+- uint64_t new_busy_mask = 0;
+- uint64_t old_busy_mask = ctx->timeline_busy_mask;
+- while (old_busy_mask) {
+- const uint32_t ring_idx = u_bit_scan64(&old_busy_mask);
+- const uint32_t cur_seqno = proxy_context_load_timeline_seqno(ctx, ring_idx);
+- if (!proxy_context_retire_timeline_fences_locked(ctx, ring_idx, cur_seqno))
+- new_busy_mask |= 1ull << ring_idx;
+- }
+-
+- ctx->timeline_busy_mask = new_busy_mask;
+-
+- if (proxy_renderer.flags & VIRGL_RENDERER_ASYNC_FENCE_CB)
+- mtx_unlock(&ctx->timeline_mutex);
+-#endif
++ /* vkr_renderer is always initialized with VKR_RENDERER_ASYNC_FENCE_CB and
++ * retires Venus fences through venus_state_cb_retire_fence. The outer
++ * virglrenderer was intentionally initialized without its Linux eventfd
++ * flag on macOS, so virgl_renderer_poll still visits this context. There
++ * is nothing to poll here; the callback is the sole completion path.
++ */
++ (void)base;
+ }
+
+ static int
+ venus_context_get_fencing_fd(struct virgl_context *base)
+ {
+- struct venus_context *ctx = (struct venus_context *)base;
+-
+- //fprintf(stderr, "%s: UNIMPLEMENTED\n", __func__);
+-
+- //assert(!(venus_renderer.flags & VIRGL_RENDERER_ASYNC_FENCE_CB));
+- //return ctx->sync_thread.fence_eventfd;
++ /* The krunkit macOS renderer has no Linux eventfd. */
++ (void)base;
++ return -1;
+ }
+@@ -414,7 +400,8 @@ venus_state_cb_debug_logger(UNUSED enum virgl_log_level_flags log_level,
+ const char *message,
+ UNUSED void* user_data)
+ {
+- //fprintf(stderr, "%s: %s\n", __func__, message);
++ /* Do not discard renderer protocol/device failures on macOS. */
++ fprintf(stderr, "dory-venus: %s\n", message);
+ }
+
+ static void
+diff --git a/src/venus/vkr_ring.c b/src/venus/vkr_ring.c
+index 1ba50e8..4d60065 100644
+--- a/src/venus/vkr_ring.c
++++ b/src/venus/vkr_ring.c
+@@ -280,6 +280,9 @@ vkr_ring_thread(void *arg)
+ const uint32_t cmd_size = vkr_ring_load_tail(ring) - ring->buffer.cur;
+ if (cmd_size) {
+ if (cmd_size > ring->buffer.size) {
++ vkr_log("ring command size %u exceeds buffer size %zu "
++ "(head %u, tail %u)", cmd_size, ring->buffer.size,
++ ring->buffer.cur, vkr_ring_load_tail(ring));
+ ret = -EINVAL;
+ break;
+ }
+diff --git a/src/venus/vkr_pipeline.c b/src/venus/vkr_pipeline.c
+index c142ab1..cfa9b17 100644
+--- a/src/venus/vkr_pipeline.c
++++ b/src/venus/vkr_pipeline.c
+@@ -111,8 +111,12 @@ vkr_dispatch_vkCreateGraphicsPipelines(struct vn_dispatch_context *dispatch,
+ struct vkr_device *dev = vkr_device_from_handle(args->device);
+ struct object_array arr;
+
+- if (vkr_graphics_pipeline_create_array(ctx, args, &arr) < VK_SUCCESS)
++ const VkResult result =
++ vkr_graphics_pipeline_create_array(ctx, args, &arr);
++ if (result < VK_SUCCESS) {
++ vkr_log("vkCreateGraphicsPipelines failed (%d)", result);
+ return;
++ }
+
+ vkr_pipeline_add_array(ctx, dev, &arr, args->pPipelines);
+ }
+@@ -125,8 +129,12 @@ vkr_dispatch_vkCreateComputePipelines(struct vn_dispatch_context *dispatch,
+ struct vkr_device *dev = vkr_device_from_handle(args->device);
+ struct object_array arr;
+
+- if (vkr_compute_pipeline_create_array(ctx, args, &arr) < VK_SUCCESS)
++ const VkResult result =
++ vkr_compute_pipeline_create_array(ctx, args, &arr);
++ if (result < VK_SUCCESS) {
++ vkr_log("vkCreateComputePipelines failed (%d)", result);
+ return;
++ }
+
+ vkr_pipeline_add_array(ctx, dev, &arr, args->pPipelines);
+ }
From 096f3d0210c659cf858e895bdb6840d840f67d9e Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 10:52:43 +0000
Subject: [PATCH 004/338] feat(vm): add durable EFI Linux installation
---
Dory/Features/Components/ComponentsView.swift | 9 +-
Dory/Features/Machines/MachinesView.swift | 95 +-
Dory/Features/Sheets/NewMachineSheet.swift | 269 ++-
Dory/Models/AppStore.swift | 212 ++-
Dory/Models/Models.swift | 2 +
Dory/Runtime/Doryd/DorydClient.swift | 78 +-
.../Machines/DesktopMachineAssets.swift | 9 +-
Dory/Runtime/Machines/MachineService.swift | 7 +
Dory/Runtime/Shared/SharedVMProvisioner.swift | 7 +-
DoryTests/DorydClientTests.swift | 20 +
DoryTests/NewMachineSettingsTests.swift | 52 +
.../DoryInstalledLinuxBootBundle.swift | 301 ++++
.../DoryOperations/DoryInstallerISO.swift | 1100 ++++++++++++
.../DoryLinuxInstalledDisk.swift | 154 ++
.../Sources/DoryVMMKit/DoryVMM.swift | 259 ++-
.../DoryVMMDesktopApplication.swift | 118 +-
.../DoryVMMKit/DoryVMMSerialConsole.swift | 267 +++
.../Sources/DorydKit/DorydConfiguration.swift | 25 +
.../Sources/DorydKit/DorydControl.swift | 1 +
.../Sources/DorydKit/DorydService.swift | 82 +-
.../Sources/DorydKit/HvProcess.swift | 58 +-
.../Sources/DorydKit/MachineManager.swift | 1557 +++++++++++++++--
dory-core-swift/Sources/dory-vmm/Info.plist | 2 +
.../Sources/dory-vmm/dory-vmm.entitlements | 2 +
dory-core-swift/Sources/dorydctl/main.swift | 126 +-
.../DoryInstalledLinuxBootBundleTests.swift | 78 +
.../DoryInstallerISOTests.swift | 235 +++
.../Tests/DorydKitTests/DoryVMMKitTests.swift | 315 +++-
.../DorydConfigurationTests.swift | 41 +
.../Tests/DorydKitTests/HvProcessTests.swift | 49 +
.../DorydKitTests/MachineManagerTests.swift | 785 ++++++++-
31 files changed, 6034 insertions(+), 281 deletions(-)
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryLinuxInstalledDisk.swift
create mode 100644 dory-core-swift/Sources/DoryVMMKit/DoryVMMSerialConsole.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryInstalledLinuxBootBundleTests.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift
diff --git a/Dory/Features/Components/ComponentsView.swift b/Dory/Features/Components/ComponentsView.swift
index 29f45483..c58436b1 100644
--- a/Dory/Features/Components/ComponentsView.swift
+++ b/Dory/Features/Components/ComponentsView.swift
@@ -424,6 +424,7 @@ struct ComponentsView: View {
let store = try DoryComponentStore.selected()
let installer = DoryComponentInstaller(store: store)
let digest = DoryComponentCatalogVerifier.digest(catalogData)
+ var activatedComponents: Set = [id]
for release in try installationOrder(id, catalog: catalog) {
if let current = try store.installedComponent(release.id),
current.version == release.version,
@@ -436,12 +437,18 @@ struct ComponentsView: View {
_ = try await installer.install(release, catalogData: catalogData) { update in
Task { @MainActor in self.progress[release.id] = update }
}
+ activatedComponents.insert(release.id)
busy.remove(release.id)
}
+ let desktopUpdates = try await appStore.updateManagedDesktops(affectedBy: activatedComponents)
statuses = store.list(catalog: catalog, catalogDigest: digest)
HostDockerCLI.reconcileOptionalTools(enabled: appStore.routeDockerCLI)
if showSuccess {
- appStore.showSettingsSuccess("\(displayName(id)) is installed and verified.")
+ let updated = desktopUpdates.isEmpty
+ ? ""
+ : " Updated " + String(desktopUpdates.count) + " existing desktop"
+ + (desktopUpdates.count == 1 ? "." : "s.")
+ appStore.showSettingsSuccess("\(displayName(id)) is installed and verified." + updated)
}
return true
} catch {
diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift
index 37259f01..9bba664b 100644
--- a/Dory/Features/Machines/MachinesView.swift
+++ b/Dory/Features/Machines/MachinesView.swift
@@ -1,4 +1,5 @@
import Darwin
+import DoryOperations
import SwiftUI
struct MachinesView: View {
@@ -158,7 +159,7 @@ private struct MachineCard: View {
}
.padding(.top, 16).padding(.bottom, 14)
- if machine.username != "root" {
+ if machine.username != "root", !machine.loginShell.isEmpty {
Text("\(machine.username) · \(machine.loginShell)")
.font(.system(size: 11)).foregroundStyle(p.text3).lineLimit(1)
.padding(.bottom, 12)
@@ -247,6 +248,17 @@ private struct MachineCard: View {
Button { store.openMachineEdit(machine) } label: {
Label("Edit…", systemImage: "slider.horizontal.3")
}
+ if machine.bootMode == .efi {
+ Divider()
+ Button {
+ store.setMachineInstallerMedia(machine, attached: !machine.installerMediaAttached)
+ } label: {
+ Label(
+ machine.installerMediaAttached ? "Eject Installer ISO" : "Attach Installer ISO",
+ systemImage: machine.installerMediaAttached ? "eject" : "opticaldiscdrive"
+ )
+ }
+ }
} label: {
Image(systemName: "ellipsis.circle").font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.text2)
@@ -350,6 +362,9 @@ private struct MachineEditSheet: View {
@State private var address = ""
@State private var displayMode: MachineDisplayMode = .headless
@State private var guestUsername = "dory"
+ @State private var clipboardPolicy = DoryDesktopClipboardPolicy.bidirectional
+ @State private var runtimePreference = DoryDesktopVMMPreference.automatic
+ @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic
@State private var environment: [String: String] = [:]
private struct MountRow: Identifiable, Hashable {
@@ -369,6 +384,8 @@ private struct MachineEditSheet: View {
VStack(alignment: .leading, spacing: 18) {
warning
machineTypeBlock
+ runtimeBlock
+ clipboardBlock
resourceRow
addressBlock
mountsBlock
@@ -391,6 +408,9 @@ private struct MachineEditSheet: View {
displayMode = settings.displayMode
environment = settings.env
guestUsername = settings.env["DORY_GUEST_USER"] ?? "dory"
+ clipboardPolicy = DoryDesktopClipboardPolicy(environment: settings.env)
+ runtimePreference = (try? DoryDesktopVMMPreference(environment: settings.env)) ?? .automatic
+ graphicsPreference = (try? DoryDesktopGraphicsPreference(environment: settings.env)) ?? .automatic
mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly) }
}
@@ -401,7 +421,10 @@ private struct MachineEditSheet: View {
.background(p.accentSoft, in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 1) {
Text("Edit \(machine.name)").font(.system(size: 15, weight: .bold)).foregroundStyle(p.text)
- Text("Apply user, resources, address and mounted folders").font(.system(size: 11.5)).foregroundStyle(p.text3)
+ Text(machine.bootMode == .efi
+ ? "Apply resources, address and mounted folders"
+ : "Apply user, resources, address and mounted folders")
+ .font(.system(size: 11.5)).foregroundStyle(p.text3)
}
Spacer()
}
@@ -411,7 +434,9 @@ private struct MachineEditSheet: View {
private var warning: some View {
HStack(spacing: 9) {
Image(systemName: "info.circle.fill").font(.system(size: 13)).foregroundStyle(p.accent)
- Text("Resource, user and mount changes restart a running machine automatically. The machine type is fixed to protect its existing disk.")
+ Text(machine.bootMode == .efi
+ ? "Resource and mount changes restart a running machine automatically. The EFI machine type is fixed to protect its installed disk."
+ : "Resource, user and mount changes restart a running machine automatically. The machine type is fixed to protect its existing disk.")
.font(.system(size: 12)).foregroundStyle(p.text2)
Spacer(minLength: 0)
}
@@ -428,13 +453,13 @@ private struct MachineEditSheet: View {
.frame(width: 34, height: 34)
.background(p.accentSoft, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 2) {
- Text(displayMode == .desktop ? "Desktop Linux" : "Headless Linux")
+ Text(machine.bootMode == .efi ? "Custom EFI Linux" : (displayMode == .desktop ? "Desktop Linux" : "Headless Linux"))
.font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text)
Text(displayMode == .desktop ? "\(machine.distro) \(machine.version)" : "Lightweight Dory Linux")
.font(.system(size: 11)).foregroundStyle(p.text3)
}
Spacer(minLength: 0)
- if displayMode == .desktop {
+ if displayMode == .desktop, machine.bootMode != .efi {
TextField("dory", text: $guestUsername)
.textFieldStyle(.plain)
.font(.mono(11.5)).foregroundStyle(p.text)
@@ -478,6 +503,55 @@ private struct MachineEditSheet: View {
}
}
+ @ViewBuilder private var clipboardBlock: some View {
+ if displayMode == .desktop, machine.bootMode != .efi {
+ VStack(alignment: .leading, spacing: 8) {
+ sectionLabel("CLIPBOARD SHARING")
+ Picker("Clipboard sharing", selection: $clipboardPolicy) {
+ ForEach(DoryDesktopClipboardPolicy.allCases, id: \.self) { policy in
+ Text(policy.displayName).tag(policy)
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.segmented)
+ .accessibilityIdentifier("edit-machine-clipboard-policy")
+ Text("Control whether text and images can move between this Linux desktop and your Mac.")
+ .font(.system(size: 11)).foregroundStyle(p.text3)
+ }
+ }
+ }
+
+ @ViewBuilder private var runtimeBlock: some View {
+ if displayMode == .desktop, machine.bootMode != .efi {
+ VStack(alignment: .leading, spacing: 9) {
+ sectionLabel("DISPLAY ENGINE")
+ HStack(spacing: 14) {
+ Picker("Virtual machine", selection: $runtimePreference) {
+ Text("Automatic").tag(DoryDesktopVMMPreference.automatic)
+ Text("Accelerated").tag(DoryDesktopVMMPreference.accelerated)
+ Text("Compatibility").tag(DoryDesktopVMMPreference.compatible)
+ }
+ .frame(maxWidth: .infinity)
+ .accessibilityIdentifier("edit-machine-vmm-preference")
+
+ Picker("Graphics", selection: $graphicsPreference) {
+ Text("Automatic").tag(DoryDesktopGraphicsPreference.automatic)
+ Text("VirGL").tag(DoryDesktopGraphicsPreference.virgl)
+ Text("VirGL + Venus").tag(DoryDesktopGraphicsPreference.virglVenus)
+ Text("Software").tag(DoryDesktopGraphicsPreference.software)
+ }
+ .frame(maxWidth: .infinity)
+ .disabled(runtimePreference == .compatible)
+ .accessibilityIdentifier("edit-machine-graphics-preference")
+ }
+ Text(runtimePreference == .compatible
+ ? "Compatibility uses Apple's Virtualization framework; the raw graphics choice is kept for when you switch back."
+ : "Automatic uses VirGL for the desktop and Venus for Vulkan apps, then falls back safely when acceleration is unavailable.")
+ .font(.system(size: 11)).foregroundStyle(p.text3)
+ }
+ }
+ }
+
private var addressBlock: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("DNS TARGET OVERRIDE")
@@ -613,7 +687,7 @@ private struct MachineEditSheet: View {
guard !host.isEmpty, !guest.isEmpty else { return nil }
return MountPair(host: host, guest: guest, readOnly: row.readOnly)
}
- if displayMode == .desktop {
+ if displayMode == .desktop, machine.bootMode != .efi {
let previousUsername = environment["DORY_GUEST_USER"] ?? "dory"
if previousUsername != normalizedGuestUsername {
let previousHome = "/home/\(previousUsername)"
@@ -631,6 +705,10 @@ private struct MachineEditSheet: View {
}
environment["DORY_GUEST_USER"] = normalizedGuestUsername
environment["DORY_GUEST_UID"] = environment["DORY_GUEST_UID"] ?? String(getuid())
+ environment[DoryDesktopClipboardPolicy.environmentKey] = clipboardPolicy.rawValue
+ environment[DoryDesktopVMMPreference.environmentKey] = runtimePreference.rawValue
+ environment[DoryDesktopGraphicsPreference.environmentKey] = graphicsPreference.rawValue
+ environment.removeValue(forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey)
}
let settings = MachineSettings(
cpus: cpus,
@@ -638,7 +716,8 @@ private struct MachineEditSheet: View {
mounts: mounts,
env: environment,
address: address.trimmingCharacters(in: .whitespacesAndNewlines),
- displayMode: displayMode
+ displayMode: displayMode,
+ bootMode: machine.bootMode
)
let target = machine
store.editMachineTarget = nil
@@ -650,7 +729,7 @@ private struct MachineEditSheet: View {
}
private var guestUsernameInvalid: Bool {
- guard displayMode == .desktop else { return false }
+ guard displayMode == .desktop, machine.bootMode != .efi else { return false }
return normalizedGuestUsername.range(
of: "^[a-z_][a-z0-9_-]{0,31}$",
options: .regularExpression
diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift
index 04edc9ef..2593fab4 100644
--- a/Dory/Features/Sheets/NewMachineSheet.swift
+++ b/Dory/Features/Sheets/NewMachineSheet.swift
@@ -1,6 +1,7 @@
import Darwin
import DoryOperations
import SwiftUI
+import UniformTypeIdentifiers
struct NewMachineSheet: View {
@Environment(AppStore.self) private var store
@@ -12,6 +13,23 @@ struct NewMachineSheet: View {
@State private var displayMode: MachineDisplayMode
@State private var desktopDistro: DesktopMachineDistro = .debian
@State private var guestUsername = NewMachineSheet.defaultGuestUsername()
+ @State private var customISOInstall = false
+ @State private var installerISOPath = ""
+ @State private var installerISOCheck: InstallerISOCheck = .none
+ @State private var diskSizeGB = 64
+
+ private enum InstallerISOCheck: Equatable {
+ case none
+ case checking
+ case compatible(
+ DoryInstallerISOArchitecture,
+ DoryInstallerISORuntimeQualification
+ )
+ case unknown(DoryInstallerISOMediaIdentity)
+ case unstable(String)
+ case incompatible(String)
+ case failed(String)
+ }
enum Stage: Hashable { case useCase, form }
@State private var stage: Stage
@@ -30,9 +48,14 @@ struct NewMachineSheet: View {
}
init(displayMode: MachineDisplayMode) {
+ let resources = Self.recommendedDesktopResources()
_displayMode = State(initialValue: displayMode)
_stage = State(initialValue: displayMode == .desktop ? .form : .useCase)
_name = State(initialValue: NewMachineSheet.defaultName())
+ if displayMode == .desktop {
+ _cpus = State(initialValue: resources.cpus)
+ _memoryGB = State(initialValue: resources.memoryGB)
+ }
if let installedDistro = DesktopMachineDistro.allCases.first(where: {
AppInfo.componentAvailable($0.componentID)
}) {
@@ -206,7 +229,9 @@ struct NewMachineSheet: View {
return "\(useCase.title) — tweak anything below"
}
if displayMode == .desktop {
- return "\(desktopDistro.displayName) \(desktopDistro.version) · \(desktopDistro.desktopName) · Apple Silicon"
+ return customISOInstall
+ ? "Install an arm64 Linux distribution from ISO · Apple EFI"
+ : "\(desktopDistro.displayName) \(desktopDistro.version) · \(desktopDistro.desktopName) · Apple Silicon"
}
return "Headless Linux · native Apple Silicon"
}
@@ -232,17 +257,125 @@ struct NewMachineSheet: View {
private var desktopDistroSection: some View {
VStack(alignment: .leading, spacing: 9) {
- sectionLabel("DESKTOP DISTRIBUTION")
- LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 9), count: 3), spacing: 9) {
- ForEach(installedDesktopDistros) { distro in
- desktopDistroButton(distro)
+ sectionLabel("INSTALLATION SOURCE")
+ Picker("", selection: $customISOInstall) {
+ Text("Dory desktop").tag(false)
+ Text("Custom ISO").tag(true)
+ }
+ .labelsHidden()
+ .pickerStyle(.segmented)
+ .onChange(of: customISOInstall) { _, custom in
+ if custom {
+ selectedRecipe = nil
+ cpus = DoryInstallerMachinePolicy.defaultCPUCount
+ }
+ }
+
+ if customISOInstall {
+ customISOSection
+ } else {
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 9), count: 3), spacing: 9) {
+ ForEach(installedDesktopDistros) { distro in
+ desktopDistroButton(distro)
+ }
}
+ Text("Only installed distributions are shown. Add or remove Debian, Ubuntu, and Kali independently in Components.")
+ .font(.system(size: 11)).foregroundStyle(p.text3)
}
- Text("Only installed distributions are shown. Add or remove Debian, Ubuntu, and Kali independently in Components.")
+ }
+ }
+
+ private var customISOSection: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Button(action: chooseInstallerISO) {
+ HStack(spacing: 8) {
+ Image(systemName: "opticaldiscdrive").foregroundStyle(p.accent)
+ Text(installerISOPath.isEmpty ? "Choose Linux installer ISO…" : installerISOPath)
+ .font(.mono(11.5)).foregroundStyle(installerISOPath.isEmpty ? p.text3 : p.text)
+ .lineLimit(1).truncationMode(.head)
+ Spacer(minLength: 0)
+ Text("Choose").font(.system(size: 11, weight: .semibold)).foregroundStyle(p.accent)
+ }
+ .padding(11)
+ .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 10))
+ .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(p.border))
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("custom-linux-iso-picker")
+
+ installerISOStatus
+
+ HStack(spacing: 16) {
+ sectionLabel("VIRTUAL DISK")
+ boundedResourceControl(
+ value: $diskSizeGB,
+ range: 16...512,
+ display: { "\($0) GB" },
+ valueIdentifier: "custom-linux-disk-size",
+ decrementIdentifier: "custom-linux-disk-decrement",
+ incrementIdentifier: "custom-linux-disk-increment"
+ )
+ }
+ Text("Dory stores a private copy of the ISO, a thin-provisioned disk, a stable VM identity, and persistent EFI NVRAM. Choose an arm64 ISO on Apple Silicon.")
.font(.system(size: 11)).foregroundStyle(p.text3)
+ Label(
+ "Dory starts installers with a balanced 4-vCPU default. EFI architecture and exact-media runtime qualification are checked separately.",
+ systemImage: "cpu"
+ )
+ .font(.system(size: 11)).foregroundStyle(p.text3)
+ }
+ }
+
+ @ViewBuilder private var installerISOStatus: some View {
+ switch installerISOCheck {
+ case .none:
+ EmptyView()
+ case .checking:
+ HStack(spacing: 7) {
+ ProgressView().controlSize(.mini)
+ Text("Checking EFI architecture before import…")
+ }
+ .font(.system(size: 11, weight: .medium)).foregroundStyle(p.text2)
+ case let .compatible(architecture, qualification):
+ switch qualification {
+ case let .qualified(message):
+ isoStatusRow(icon: "checkmark.circle.fill", color: p.green, text: message)
+ case .unqualified:
+ isoStatusRow(
+ icon: "questionmark.circle.fill",
+ color: p.amber,
+ text: architecture == .multiArchitecture
+ ? "Universal EFI architecture confirmed — this exact media is not yet runtime-qualified by Dory."
+ : "ARM64 EFI architecture confirmed — this exact media is not yet runtime-qualified by Dory."
+ )
+ case let .knownUnstable(message):
+ isoStatusRow(icon: "exclamationmark.octagon.fill", color: p.red, text: message)
+ }
+ case .unknown:
+ isoStatusRow(
+ icon: "questionmark.circle.fill",
+ color: p.amber,
+ text: "EFI architecture was not recognizable; Dory can try this custom image."
+ )
+ case let .unstable(message):
+ isoStatusRow(icon: "exclamationmark.octagon.fill", color: p.red, text: message)
+ case let .incompatible(message):
+ isoStatusRow(icon: "xmark.octagon.fill", color: p.red, text: message)
+ case let .failed(message):
+ isoStatusRow(icon: "exclamationmark.triangle.fill", color: p.red, text: message)
}
}
+ private func isoStatusRow(icon: String, color: Color, text: String) -> some View {
+ HStack(alignment: .top, spacing: 7) {
+ Image(systemName: icon).font(.system(size: 11, weight: .semibold)).foregroundStyle(color)
+ Text(text).font(.system(size: 11, weight: .medium)).foregroundStyle(p.text2)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer(minLength: 0)
+ }
+ .accessibilityIdentifier("custom-linux-iso-compatibility")
+ }
+
private var installedDesktopDistros: [DesktopMachineDistro] {
DesktopMachineDistro.allCases.filter { AppInfo.componentAvailable($0.componentID) }
}
@@ -298,6 +431,10 @@ struct NewMachineSheet: View {
private var devEnvironmentSection: some View {
VStack(alignment: .leading, spacing: 9) {
sectionLabel("DEV ENVIRONMENT")
+ if customISOInstall {
+ Text("Choose packages and applications inside the Linux installer.")
+ .font(.system(size: 11.5)).foregroundStyle(p.text2)
+ } else {
Picker("", selection: Binding(
get: { selectedRecipe?.id ?? "" },
set: { selectedRecipe = $0.isEmpty ? nil : DevRecipe.forID($0) }
@@ -310,6 +447,7 @@ struct NewMachineSheet: View {
? "Recipes install verified apt packages after the desktop starts."
: "Recipes install verified Alpine packages after the VM starts.")
.font(.system(size: 11)).foregroundStyle(p.text3)
+ }
}
}
@@ -319,7 +457,7 @@ struct NewMachineSheet: View {
HStack(spacing: 9) {
Image(systemName: "person.crop.circle.badge.checkmark")
.font(.system(size: 14)).foregroundStyle(p.accent)
- if displayMode == .desktop {
+ if displayMode == .desktop, !customISOInstall {
Text("Linux user").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text)
Spacer(minLength: 0)
TextField("dory", text: $guestUsername)
@@ -331,9 +469,11 @@ struct NewMachineSheet: View {
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(guestUsernameInvalid ? p.red : p.border))
.accessibilityIdentifier("new-machine-guest-user")
} else {
- Text("Administrator shell").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text)
+ Text(customISOInstall ? "Linux account" : "Administrator shell")
+ .font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text)
Spacer(minLength: 0)
- Text("root · /bin/sh").font(.mono(11.5)).foregroundStyle(p.text3)
+ Text(customISOInstall ? "Created in the installer" : "root · /bin/sh")
+ .font(.mono(11.5)).foregroundStyle(p.text3)
}
}
if guestUsernameInvalid {
@@ -572,7 +712,9 @@ struct NewMachineSheet: View {
Image(systemName: "terminal")
.font(.system(size: 11)).foregroundStyle(p.text3)
Text(displayMode == .desktop
- ? "\(desktopDistro.displayName) \(desktopDistro.version) · arm64 · \(normalizedGuestUsername)"
+ ? (customISOInstall
+ ? customISOFooterDescription
+ : "\(desktopDistro.displayName) \(desktopDistro.version) · arm64 · \(normalizedGuestUsername)")
: "Dory Linux · arm64 · root shell")
.font(.mono(11.5)).foregroundStyle(p.text3).lineLimit(1)
}
@@ -611,7 +753,9 @@ struct NewMachineSheet: View {
private var createDisabled: Bool {
name.trimmingCharacters(in: .whitespaces).isEmpty
|| !nameValid
- || guestUsernameInvalid
+ || (!customISOInstall && guestUsernameInvalid)
+ || (customISOInstall && installerISOPath.isEmpty)
+ || (customISOInstall && installerISOCheckBlocksCreate)
|| store.machineBusy
|| !engineReady
|| mountsOutsideHome
@@ -641,10 +785,82 @@ struct NewMachineSheet: View {
settings.mounts.append(MountPair(host: NSHomeDirectory(), guest: sharedHomeGuestPath))
}
let machineName = name
- let recipe = selectedRecipe
+ let recipe = customISOInstall ? nil : selectedRecipe
Task { _ = await store.createMachine(name: machineName, recipe: recipe, settings: settings) }
}
+ private func chooseInstallerISO() {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = false
+ panel.canChooseFiles = true
+ panel.allowsMultipleSelection = false
+ if let isoType = UTType(filenameExtension: "iso") {
+ panel.allowedContentTypes = [isoType]
+ }
+ panel.message = "Choose an arm64 Linux installer ISO"
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ installerISOPath = url.path
+ installerISOCheck = .checking
+ let selectedPath = url.path
+ Task {
+ let result: (DoryInstallerISOMediaIdentity?, String?) = await Task.detached(
+ priority: .userInitiated
+ ) {
+ let hasSecurityScope = url.startAccessingSecurityScopedResource()
+ defer {
+ if hasSecurityScope { url.stopAccessingSecurityScopedResource() }
+ }
+ do {
+ return (try DoryInstallerISOInspector.mediaIdentity(atPath: selectedPath), nil)
+ } catch {
+ return (nil, error.localizedDescription)
+ }
+ }.value
+ guard installerISOPath == selectedPath else { return }
+ guard let identity = result.0 else {
+ installerISOCheck = .failed(result.1 ?? "Dory could not inspect this ISO.")
+ return
+ }
+ switch DoryInstallerISOInspector.compatibility(
+ of: identity.architecture,
+ hostArchitecture: DoryInstallerISOInspector.currentHostArchitecture
+ ) {
+ case .compatible:
+ let qualification = DoryInstallerISORuntimeCatalog.qualification(of: identity)
+ if case let .knownUnstable(message) = qualification {
+ installerISOCheck = .unstable(message)
+ } else {
+ installerISOCheck = .compatible(identity.architecture, qualification)
+ }
+ case .unknown:
+ installerISOCheck = .unknown(identity)
+ case let .incompatible(message):
+ installerISOCheck = .incompatible(message)
+ }
+ }
+ }
+
+ private var installerISOCheckBlocksCreate: Bool {
+ switch installerISOCheck {
+ case .compatible, .unknown:
+ false
+ case .none, .checking, .unstable, .incompatible, .failed:
+ true
+ }
+ }
+
+ private var customISOFooterDescription: String {
+ switch installerISOCheck {
+ case .compatible(.multiArchitecture, _): "Custom universal Linux · EFI · \(diskSizeGB) GB"
+ case .compatible(.arm64, _): "Custom arm64 Linux · EFI · \(diskSizeGB) GB"
+ case .compatible(.x86_64, _): "Custom x86_64 Linux · EFI · \(diskSizeGB) GB"
+ case .compatible(.unknown, _), .unknown: "Custom Linux · EFI architecture unknown · \(diskSizeGB) GB"
+ case .incompatible: "Intel x86_64 ISO · incompatible"
+ case .unstable: "Known-unstable installer · choose different media"
+ case .none, .checking, .failed: "Custom Linux · EFI · \(diskSizeGB) GB"
+ }
+ }
+
static func buildSettings(
cpus: Int,
memoryGB: Int,
@@ -663,6 +879,9 @@ struct NewMachineSheet: View {
environment["DORY_DESKTOP_NAME"] = desktopDistro.displayName
environment["DORY_DESKTOP_VERSION"] = desktopDistro.version
environment["DORY_DESKTOP_ENVIRONMENT"] = desktopDistro.desktopName
+ environment[DoryDesktopClipboardPolicy.environmentKey] = DoryDesktopClipboardPolicy.bidirectional.rawValue
+ environment[DoryDesktopVMMPreference.environmentKey] = DoryDesktopVMMPreference.automatic.rawValue
+ environment[DoryDesktopGraphicsPreference.environmentKey] = DoryDesktopGraphicsPreference.automatic.rawValue
}
return MachineSettings(
cpus: cpus,
@@ -674,6 +893,23 @@ struct NewMachineSheet: View {
)
}
+ static func recommendedDesktopResources(
+ activeProcessorCount: Int = ProcessInfo.processInfo.activeProcessorCount,
+ physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory
+ ) -> (cpus: Int, memoryGB: Int) {
+ let cpus = max(4, min(8, activeProcessorCount / 2))
+ let hostMemoryGB = Int(physicalMemory / 1_073_741_824)
+ let memoryGB: Int
+ if hostMemoryGB >= 24 {
+ memoryGB = 8
+ } else if hostMemoryGB >= 16 {
+ memoryGB = 6
+ } else {
+ memoryGB = 4
+ }
+ return (cpus, memoryGB)
+ }
+
private func collectedSettings() -> MachineSettings {
let mounts = mountRows.compactMap { row -> MountPair? in
let host = row.host.trimmingCharacters(in: .whitespaces)
@@ -681,7 +917,7 @@ struct NewMachineSheet: View {
guard !host.isEmpty, !guest.isEmpty else { return nil }
return MountPair(host: host, guest: guest)
}
- return Self.buildSettings(
+ var settings = Self.buildSettings(
cpus: cpus,
memoryGB: memoryGB,
mounts: mounts,
@@ -690,6 +926,13 @@ struct NewMachineSheet: View {
desktopDistro: desktopDistro,
guestUsername: normalizedGuestUsername
)
+ if customISOInstall {
+ settings.bootMode = .efi
+ settings.installerISOPath = installerISOPath
+ settings.diskSizeGB = diskSizeGB
+ settings.env = ["DORY_CUSTOM_LINUX": "1"]
+ }
+ return settings
}
static func defaultName() -> String {
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index 5da45c84..cff78697 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5085,7 +5085,8 @@ final class AppStore {
mounts: status.shares.map(Self.mountPair(fromDoryd:)),
env: status.environment,
address: status.configuredAddress,
- displayMode: status.displayMode
+ displayMode: status.displayMode,
+ bootMode: status.bootMode
)
} catch {
actionError = "Could not load doryd machine settings: \(error)"
@@ -5160,26 +5161,29 @@ final class AppStore {
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty } ?? status.state
let isDesktop = status.displayMode == .desktop
+ let isCustomLinux = status.bootMode == .efi
let desktopDistro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"])
- let guestUsername = status.environment["DORY_GUEST_USER"] ?? (isDesktop ? "dory" : "root")
+ let guestUsername = status.environment["DORY_GUEST_USER"] ?? (isCustomLinux ? "installer" : (isDesktop ? "dory" : "root"))
return Machine(
name: status.id,
- distro: isDesktop ? desktopDistro.displayName : "Dory Linux",
- version: isDesktop ? "\(desktopDistro.version) · \(desktopDistro.desktopName)" : detail,
+ distro: isCustomLinux ? "Custom Linux" : (isDesktop ? desktopDistro.displayName : "Dory Linux"),
+ version: isCustomLinux ? "EFI · arm64" : (isDesktop ? "\(desktopDistro.version) · \(desktopDistro.desktopName)" : detail),
status: runState,
cpuPercent: 0,
memoryDisplay: "—",
ip: status.address ?? Self.machineDNSName(name: status.id, suffix: domainSuffix),
- letter: isDesktop ? String(desktopDistro.displayName.prefix(1)) : "D",
- badgeHex: isDesktop ? desktopDistro.badgeHex : 0x3B82F6,
+ letter: isCustomLinux ? "L" : (isDesktop ? String(desktopDistro.displayName.prefix(1)) : "D"),
+ badgeHex: isCustomLinux ? 0x7C3AED : (isDesktop ? desktopDistro.badgeHex : 0x3B82F6),
containerID: "",
arch: "",
recipe: "doryd",
username: guestUsername,
- loginShell: isDesktop ? "/bin/bash" : "/bin/sh",
+ loginShell: isCustomLinux ? "" : (isDesktop ? "/bin/bash" : "/bin/sh"),
shellSocketPath: status.shellSocketPath ?? "",
processID: status.pid,
displayMode: status.displayMode,
+ bootMode: status.bootMode,
+ installerMediaAttached: status.installerMediaAttached,
mounts: status.shares.map(Self.mountPair(fromDoryd:))
)
}
@@ -5232,7 +5236,10 @@ final class AppStore {
actionError = "The Desktop Linux display is not available yet. Start the machine and try again."
return
}
- guard application.activate(options: [.activateAllWindows]) else {
+ if application.activate(options: [.activateAllWindows]) { return }
+ // Raw-HV desktops run in an unbundled helper, which LaunchServices can discover by PID but
+ // does not always activate. The helper handles SIGUSR1 by raising its own display window.
+ guard Darwin.kill(processID, SIGUSR1) == 0 else {
actionError = "Dory could not bring \(machine.name)'s desktop window forward."
return
}
@@ -5363,6 +5370,24 @@ final class AppStore {
}
}
+ func setMachineInstallerMedia(_ machine: Machine, attached: Bool) {
+ guard requireDorydMachines(), machine.bootMode == .efi else { return }
+ guard !busyMachines.contains(machine.name) else { return }
+ busyMachines.insert(machine.name)
+ Task {
+ defer { busyMachines.remove(machine.name) }
+ do {
+ _ = try await dorydClient.machineUpdate(
+ machine.name,
+ installerMediaAttached: attached
+ )
+ } catch {
+ actionError = "Could not \(attached ? "attach" : "eject") the installer ISO for \(machine.name): \(error)"
+ }
+ await refreshMachines()
+ }
+ }
+
nonisolated static func allocateFreePort() -> Int {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { return 0 }
@@ -5402,6 +5427,83 @@ final class AppStore {
return result
}
+ /// Brings persistent desktop machines forward after a signed desktop component activation.
+ /// The daemon preserves each guest's disk, applications, accounts, and settings; it owns the
+ /// last-good snapshot, reboot qualification, and automatic rollback transaction.
+ func updateManagedDesktops(affectedBy components: Set) async throws -> [DorydDesktopUpdateResult] {
+ let statuses = try await dorydClient.machineList()
+ let desktopStatuses = statuses.filter { $0.displayMode == .desktop }
+ guard !desktopStatuses.isEmpty else { return [] }
+
+ let runtimeChanged = components.contains(.linuxDesktop)
+ let store = try DoryComponentStore.selected()
+ guard let runtimeRelease = try store.installedComponent(.linuxDesktop) else {
+ throw DesktopMachineAssetError.missingAsset("runtime update")
+ }
+ _ = try store.verify(.linuxDesktop)
+ let home = environment["HOME"] ?? NSHomeDirectory()
+ var prepared: [DesktopMachineDistro: DesktopMachineAssets] = [:]
+ var results: [DorydDesktopUpdateResult] = []
+
+ for status in desktopStatuses {
+ guard status.environment["DORY_CUSTOM_LINUX"] != "1" else { continue }
+ let distro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"])
+ guard runtimeChanged || components.contains(distro.componentID) else { continue }
+ guard let distroRelease = try store.installedComponent(distro.componentID) else {
+ if components.contains(distro.componentID) {
+ throw DesktopMachineAssetError.missingAsset(distro.displayName + " update")
+ }
+ // Removing a component intentionally preserves its machine disks. A runtime-only
+ // update cannot update that distro until the user reinstalls its signed payload.
+ continue
+ }
+ _ = try store.verify(distro.componentID)
+ let targetVersion = distroRelease.version + "+runtime." + runtimeRelease.version
+ if status.environment["DORY_DESKTOP_RELEASE_VERSION"] == targetVersion {
+ continue
+ }
+ guard let bundlePath = DoryComponentStore.activeAssetPath(
+ component: distro.componentID,
+ path: "dory-desktop-" + distro.rawValue + "-update-arm64.tar"
+ ) else {
+ throw DesktopMachineAssetError.missingAsset(distro.displayName + " in-place update")
+ }
+ let assets: DesktopMachineAssets
+ if let cached = prepared[distro] {
+ assets = cached
+ } else {
+ assets = try await desktopMachineAssetPreparer(
+ home,
+ Self.desktopAssetEnvironment(processEnvironment: environment, distro: distro),
+ Bundle.main.resourcePath
+ )
+ prepared[distro] = assets
+ }
+
+ busyMachines.insert(status.id)
+ defer { busyMachines.remove(status.id) }
+ do {
+ let result = try await dorydClient.machineDesktopUpdate(
+ status.id,
+ distro: distro.rawValue,
+ version: targetVersion,
+ bundlePath: bundlePath,
+ kernelPath: assets.kernelPath
+ )
+ results.append(result)
+ } catch {
+ throw DesktopMachineAssetError.filesystem(
+ "Could not update " + status.id + ". Dory restored its last-good snapshot. "
+ + String(describing: error)
+ )
+ }
+ }
+ if !results.isEmpty {
+ _ = await refreshMachines()
+ }
+ return results
+ }
+
nonisolated static func preservingHiddenMachineSettings(_ settings: MachineSettings, existing: MachineSettings) -> MachineSettings {
var copy = settings
if copy.env.isEmpty { copy.env = existing.env }
@@ -5445,23 +5547,31 @@ final class AppStore {
) -> DorydMachineConfiguration? {
let useBundledAssets = environment["DORYD_DISABLE_BUNDLED_MACHINE_ASSETS"] != "1"
let arch = hostMachineAssetArch
- let kernel = assets?.kernelPath
- ?? firstMachinePath(["DORYD_MACHINE_KERNEL", "DORYD_GUEST_KERNEL"], environment: environment)
- ?? installedMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"])
- ?? (useBundledAssets ? bundledMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) : nil)
- let rootfs = assets?.rootfsPath
- ?? firstMachinePath(["DORYD_MACHINE_ROOTFS", "DORYD_GUEST_ROOTFS"], environment: environment)
- ?? installedMachinePath([
- "dory-machine-rootfs-\(arch).ext4",
- "dory-machine-rootfs.ext4",
- ])
- ?? (useBundledAssets ? bundledMachinePath([
- "dory-machine-rootfs-\(arch).ext4",
- "dory-machine-rootfs.ext4",
- "initfs-\(arch).ext4",
- ]) : nil)
- guard let kernel, let rootfs else {
- return nil
+ let kernel: String
+ let rootfs: String
+ if settings.bootMode == .efi {
+ kernel = ""
+ rootfs = ""
+ } else {
+ guard let resolvedKernel = assets?.kernelPath
+ ?? firstMachinePath(["DORYD_MACHINE_KERNEL", "DORYD_GUEST_KERNEL"], environment: environment)
+ ?? installedMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"])
+ ?? (useBundledAssets ? bundledMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) : nil),
+ let resolvedRootfs = assets?.rootfsPath
+ ?? firstMachinePath(["DORYD_MACHINE_ROOTFS", "DORYD_GUEST_ROOTFS"], environment: environment)
+ ?? installedMachinePath([
+ "dory-machine-rootfs-\(arch).ext4",
+ "dory-machine-rootfs.ext4",
+ ])
+ ?? (useBundledAssets ? bundledMachinePath([
+ "dory-machine-rootfs-\(arch).ext4",
+ "dory-machine-rootfs.ext4",
+ "initfs-\(arch).ext4",
+ ]) : nil) else {
+ return nil
+ }
+ kernel = resolvedKernel
+ rootfs = resolvedRootfs
}
let memoryMB: UInt64
if let rawMemory = environment["DORYD_MACHINE_MEMORY_MB"] {
@@ -5481,6 +5591,11 @@ final class AppStore {
id: name,
kernelPath: kernel,
rootfsPath: rootfs,
+ bootMode: settings.bootMode,
+ installerISOPath: settings.installerISOPath,
+ diskSizeBytes: settings.diskSizeGB.flatMap { UInt64(exactly: $0) }.map {
+ $0 * 1024 * 1024 * 1024
+ },
memoryMB: memoryMB,
cpuCount: cpuCount,
address: address,
@@ -5501,7 +5616,15 @@ final class AppStore {
actionError = "Invalid machine name: use letters, digits, and _ . - (must start alphanumeric)"
return "Invalid machine name"
}
- if settings.displayMode == .desktop {
+ if settings.bootMode == .efi {
+ guard settings.displayMode == .desktop,
+ let installerISOPath = settings.installerISOPath,
+ FileManager.default.fileExists(atPath: installerISOPath) else {
+ let message = "Choose a readable Linux installer ISO before creating the VM."
+ actionError = message
+ return message
+ }
+ } else if settings.displayMode == .desktop {
let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"])
guard AppInfo.componentAvailable(.linuxDesktop),
AppInfo.componentAvailable(distro.componentID) else {
@@ -5534,6 +5657,7 @@ final class AppStore {
}
private func createDorydMachine(name: String, settings: MachineSettings, recipe: DevRecipe?) async -> String? {
+ var settings = settings
let address = Self.trimmedNonEmpty(settings.address)
let provisioningRecipe: String?
if let recipe {
@@ -5555,10 +5679,41 @@ final class AppStore {
activeSheet = .creatingMachine
defer { busyMachines.remove(name) }
+ var stagedInstallerISOPath: String?
+ defer {
+ if let stagedInstallerISOPath {
+ try? FileManager.default.removeItem(atPath: stagedInstallerISOPath)
+ }
+ }
+
var createdDefinition = false
do {
+ if settings.bootMode == .efi, let installerISOPath = settings.installerISOPath {
+ let sourceURL = URL(fileURLWithPath: installerISOPath)
+ let hasSecurityScope = sourceURL.startAccessingSecurityScopedResource()
+ defer {
+ if hasSecurityScope { sourceURL.stopAccessingSecurityScopedResource() }
+ }
+ appendMachineCreationLog("Checking the selected installer's EFI architecture…")
+ let staged = try DoryInstallerISOStager.stage(atPath: installerISOPath)
+ if staged.architecture == .unknown {
+ appendMachineCreationLog("The EFI architecture is non-standard; continuing as a custom image.")
+ } else {
+ appendMachineCreationLog("\(staged.architecture.rawValue) EFI architecture confirmed.")
+ }
+ appendMachineCreationLog("Media SHA-256: \(staged.sha256)")
+ if case .unqualified = staged.runtimeQualification {
+ appendMachineCreationLog("This exact installer/host combination is not yet runtime-qualified by Dory.")
+ }
+ appendMachineCreationLog("Installer media is staged for the VM service.")
+ stagedInstallerISOPath = staged.path
+ settings.installerISOPath = staged.path
+ }
let desktopAssets: DesktopMachineAssets?
- if settings.displayMode == .desktop {
+ if settings.bootMode == .efi {
+ appendMachineCreationLog("Importing the installer ISO and creating a thin-provisioned virtual disk…")
+ desktopAssets = nil
+ } else if settings.displayMode == .desktop {
let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"])
appendMachineCreationLog("Preparing \(distro.displayName) \(distro.version) Desktop in the selected Dory data drive…")
let home = environment["HOME"] ?? NSHomeDirectory()
@@ -5651,7 +5806,8 @@ final class AppStore {
mounts: current?.shares.map(Self.mountPair(fromDoryd:)) ?? [],
env: current?.environment ?? [:],
address: current?.configuredAddress,
- displayMode: current?.displayMode ?? machine.displayMode
+ displayMode: current?.displayMode ?? machine.displayMode,
+ bootMode: current?.bootMode ?? machine.bootMode
)
let effectiveSettings = Self.preservingHiddenMachineSettings(settings, existing: currentSettings)
let memory = effectiveSettings.memoryMB.flatMap { UInt64(exactly: $0) } ?? current?.memoryMB
diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift
index 50c25759..0ea45800 100644
--- a/Dory/Models/Models.swift
+++ b/Dory/Models/Models.swift
@@ -299,6 +299,8 @@ struct Machine: Identifiable, Hashable, Sendable {
var shellSocketPath: String = ""
var processID: Int32? = nil
var displayMode: MachineDisplayMode = .headless
+ var bootMode: MachineBootMode = .linuxKernel
+ var installerMediaAttached: Bool = false
var mounts: [MountPair] = []
var id: String { name }
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index e01b8886..0c7d4f3c 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -22,6 +22,7 @@ nonisolated protocol DorydControlXPC {
func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
+ func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineSnapshots(_ machineID: String, reply: @escaping (NSArray, String) -> Void)
func machineCloneSnapshot(_ machineID: String, snapshotID: String, newID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
@@ -86,6 +87,9 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable {
var id: String
var kernelPath: String
var rootfsPath: String
+ var bootMode: MachineBootMode = .linuxKernel
+ var installerISOPath: String? = nil
+ var diskSizeBytes: UInt64? = nil
var memoryMB: UInt64
var cpuCount: Int
var address: String? = nil
@@ -98,6 +102,7 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable {
"id": id,
"kernelPath": kernelPath,
"rootfsPath": rootfsPath,
+ "bootMode": bootMode.rawValue,
"memoryMB": memoryMB,
"cpuCount": cpuCount,
"displayMode": displayMode.rawValue,
@@ -105,6 +110,12 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable {
if let address {
dictionary["address"] = address
}
+ if let installerISOPath {
+ dictionary["installerISOPath"] = installerISOPath
+ }
+ if let diskSizeBytes {
+ dictionary["diskSizeBytes"] = diskSizeBytes
+ }
if !shares.isEmpty {
dictionary["shares"] = shares.map(\.xpcDictionary)
}
@@ -139,6 +150,8 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable {
var currentBalloonTargetMB: UInt64? = nil
var cpuCount: Int?
var displayMode: MachineDisplayMode = .headless
+ var bootMode: MachineBootMode = .linuxKernel
+ var installerMediaAttached: Bool = false
var shares: [DorydMachineShareConfiguration] = []
var environment: [String: String] = [:]
}
@@ -170,6 +183,17 @@ nonisolated struct DorydMachineProvisionResult: Sendable, Equatable {
var verify: DorydMachineExecResult
}
+nonisolated struct DorydDesktopUpdateResult: Sendable, Equatable {
+ var machineID: String
+ var distro: String
+ var version: String
+ var inputSHA256: String
+ var bundleSHA256: String
+ var snapshotID: String
+ var status: DorydMachineStatus
+ var restoredRunningState: Bool
+}
+
nonisolated struct DorydMachineSnapshot: Sendable, Equatable {
var id: String
var machineID: String
@@ -634,7 +658,8 @@ nonisolated final class DorydClient: @unchecked Sendable {
address: String? = nil,
updatesAddress: Bool = false,
shares: [DorydMachineShareConfiguration]? = nil,
- environment: [String: String]? = nil
+ environment: [String: String]? = nil,
+ installerMediaAttached: Bool? = nil
) async throws -> DorydMachineStatus {
var config: [String: Any] = [:]
if let memoryMB {
@@ -659,6 +684,9 @@ nonisolated final class DorydClient: @unchecked Sendable {
] as NSDictionary
}
}
+ if let installerMediaAttached {
+ config["installerMediaAttached"] = installerMediaAttached
+ }
return try await withTimeout(atLeast: 120).statusCommand { proxy, reply in
proxy.machineUpdate(machineID, config: config as NSDictionary, reply: reply)
} decode: {
@@ -710,6 +738,26 @@ nonisolated final class DorydClient: @unchecked Sendable {
}
}
+ func machineDesktopUpdate(
+ _ machineID: String,
+ distro: String,
+ version: String,
+ bundlePath: String,
+ kernelPath: String
+ ) async throws -> DorydDesktopUpdateResult {
+ let request: NSDictionary = [
+ "distro": distro,
+ "version": version,
+ "bundlePath": bundlePath,
+ "kernelPath": kernelPath,
+ ]
+ return try await withTimeout(atLeast: 3_900).statusCommand { proxy, reply in
+ proxy.machineDesktopUpdate(machineID, request: request, reply: reply)
+ } decode: {
+ Self.desktopUpdateResult(from: $0)
+ }
+ }
+
func machineSnapshot(
_ machineID: String,
note: String = "",
@@ -1160,6 +1208,10 @@ nonisolated final class DorydClient: @unchecked Sendable {
currentBalloonTargetMB: uint64(dictionary["currentBalloonTargetMB"]),
cpuCount: int(dictionary["cpuCount"]),
displayMode: (dictionary["displayMode"] as? String).flatMap(MachineDisplayMode.init(rawValue:)) ?? .headless,
+ bootMode: (dictionary["bootMode"] as? String).flatMap(MachineBootMode.init(rawValue:)) ?? .linuxKernel,
+ installerMediaAttached: (dictionary["installerMediaAttached"] as? Bool)
+ ?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue
+ ?? false,
shares: machineShares(from: dictionary["shares"]),
environment: machineEnvironment(from: dictionary["env"])
)
@@ -1276,6 +1328,30 @@ nonisolated final class DorydClient: @unchecked Sendable {
return DorydMachineProvisionResult(recipeID: recipeID, install: install, verify: verify)
}
+ nonisolated private static func desktopUpdateResult(from dictionary: NSDictionary) -> DorydDesktopUpdateResult? {
+ guard let machineID = dictionary["machineID"] as? String,
+ let distro = dictionary["distro"] as? String,
+ let version = dictionary["version"] as? String,
+ let inputSHA256 = dictionary["inputSHA256"] as? String,
+ let bundleSHA256 = dictionary["bundleSHA256"] as? String,
+ let snapshotID = dictionary["snapshotID"] as? String,
+ let statusDictionary = dictionary["status"] as? NSDictionary,
+ let status = machineStatus(from: statusDictionary),
+ let restoredRunningState = dictionary["restoredRunningState"] as? Bool else {
+ return nil
+ }
+ return DorydDesktopUpdateResult(
+ machineID: machineID,
+ distro: distro,
+ version: version,
+ inputSHA256: inputSHA256,
+ bundleSHA256: bundleSHA256,
+ snapshotID: snapshotID,
+ status: status,
+ restoredRunningState: restoredRunningState
+ )
+ }
+
nonisolated private static func machineSnapshot(from dictionary: NSDictionary) -> DorydMachineSnapshot? {
guard let id = dictionary["id"] as? String,
let machineID = dictionary["machineID"] as? String,
diff --git a/Dory/Runtime/Machines/DesktopMachineAssets.swift b/Dory/Runtime/Machines/DesktopMachineAssets.swift
index c6435087..a849760c 100644
--- a/Dory/Runtime/Machines/DesktopMachineAssets.swift
+++ b/Dory/Runtime/Machines/DesktopMachineAssets.swift
@@ -25,12 +25,17 @@ nonisolated enum DesktopMachineDistro: String, CaseIterable, Identifiable, Senda
}
}
- var desktopName: String { "Xfce" }
+ var desktopName: String {
+ switch self {
+ case .ubuntu: "GNOME"
+ case .debian, .kali: "Xfce"
+ }
+ }
var summary: String {
switch self {
case .debian: "Stable, clean desktop for everyday Linux and development"
- case .ubuntu: "Familiar Ubuntu base with long-term support packages"
+ case .ubuntu: "Canonical's Ubuntu GNOME desktop with long-term support"
case .kali: "Security lab desktop with Kali's official rolling repository"
}
}
diff --git a/Dory/Runtime/Machines/MachineService.swift b/Dory/Runtime/Machines/MachineService.swift
index 20b5068c..c09cb253 100644
--- a/Dory/Runtime/Machines/MachineService.swift
+++ b/Dory/Runtime/Machines/MachineService.swift
@@ -6,6 +6,10 @@ nonisolated enum MachineDisplayMode: String, Sendable, Hashable, CaseIterable {
case headless
case desktop
}
+nonisolated enum MachineBootMode: String, Sendable, Hashable, CaseIterable {
+ case linuxKernel = "linux-kernel"
+ case efi
+}
nonisolated struct MachineSettings: Sendable, Hashable {
var cpus: Int?
var memoryMB: Int?
@@ -15,6 +19,9 @@ nonisolated struct MachineSettings: Sendable, Hashable {
var env: [String: String] = [:]
var address: String? = nil
var displayMode: MachineDisplayMode = .headless
+ var bootMode: MachineBootMode = .linuxKernel
+ var installerISOPath: String? = nil
+ var diskSizeGB: Int? = nil
nonisolated static let `default` = MachineSettings(cpus: nil, memoryMB: nil)
}
diff --git a/Dory/Runtime/Shared/SharedVMProvisioner.swift b/Dory/Runtime/Shared/SharedVMProvisioner.swift
index 7585da69..add0516f 100644
--- a/Dory/Runtime/Shared/SharedVMProvisioner.swift
+++ b/Dory/Runtime/Shared/SharedVMProvisioner.swift
@@ -283,13 +283,16 @@ nonisolated enum SharedVMProvisioner {
guard icdCandidates.contains(where: { fileManager.fileExists(atPath: $0) }) else { return false }
// The Venus path exposes host-visible blobs by hv_vm_mapping the pointer virglrenderer returns
// from virgl_renderer_resource_map (the libkrun/krunkit model), so probe the renderer actually
- // exports a blob-map entrypoint before enabling the toggle.
+ // exports a blob-map entrypoint and Dory's async macOS fence fix before enabling the
+ // toggle. A stock or older renderer can appear usable but stall Vulkan applications.
for path in rendererCandidates where fileManager.fileExists(atPath: path) {
guard let handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL) else { continue }
let hasBlobMap = dlsym(handle, "virgl_renderer_resource_map") != nil
|| dlsym(handle, "virgl_renderer_resource_get_map_ptr") != nil
+ let hasDoryFenceFix = dlsym(handle, "dory_virglrenderer_macos_venus_fence_fix") != nil
+ let hasDoryMoltenVKFix = dlsym(handle, "dory_moltenvk_spirv_native_array_fix") != nil
dlclose(handle)
- if hasBlobMap { return true }
+ if hasBlobMap && hasDoryFenceFix && hasDoryMoltenVKFix { return true }
}
return false
}
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 0e2b5b18..cdb4c8ed 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -2160,6 +2160,26 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
] as NSDictionary, "")
}
+ func machineDesktopUpdate(
+ _ machineID: String,
+ request: NSDictionary,
+ reply: @escaping (Bool, NSDictionary, String) -> Void
+ ) {
+ lock.lock()
+ let current = machines[machineID] ?? Self.machineRow(id: machineID, state: "stopped")
+ lock.unlock()
+ reply(true, [
+ "machineID": machineID,
+ "distro": request["distro"] as? String ?? "ubuntu",
+ "version": request["version"] as? String ?? "test",
+ "inputSHA256": String(repeating: "1", count: 64),
+ "bundleSHA256": String(repeating: "2", count: 64),
+ "snapshotID": "du-test",
+ "restoredRunningState": false,
+ "status": current,
+ ] as NSDictionary, "")
+ }
+
func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) {
let id = request["snapshotID"] as? String ?? "s\(UUID().uuidString.prefix(8).lowercased())"
let row = Self.snapshotRow(
diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift
index 4905a442..6ca32090 100644
--- a/DoryTests/NewMachineSettingsTests.swift
+++ b/DoryTests/NewMachineSettingsTests.swift
@@ -1,8 +1,37 @@
import Darwin
+import DoryOperations
import Testing
@testable import Dory
struct NewMachineSettingsTests {
+ @Test func desktopDefaultsScaleForBrowserWorkloadsWithoutConsumingTheHost() {
+ let eightGB = NewMachineSheet.recommendedDesktopResources(
+ activeProcessorCount: 8,
+ physicalMemory: 8 * 1_073_741_824
+ )
+ #expect(eightGB.cpus == 4)
+ #expect(eightGB.memoryGB == 4)
+
+ let sixteenGB = NewMachineSheet.recommendedDesktopResources(
+ activeProcessorCount: 12,
+ physicalMemory: 16 * 1_073_741_824
+ )
+ #expect(sixteenGB.cpus == 6)
+ #expect(sixteenGB.memoryGB == 6)
+
+ let largerHost = NewMachineSheet.recommendedDesktopResources(
+ activeProcessorCount: 32,
+ physicalMemory: 64 * 1_073_741_824
+ )
+ #expect(largerHost.cpus == 8)
+ #expect(largerHost.memoryGB == 8)
+ }
+
+ @Test func customISOInstallationUsesABalancedResourceDefault() {
+ #expect(DoryInstallerMachinePolicy.defaultCPUCount == 4)
+ #expect(DoryInstallerMachinePolicy.defaultMemoryMB == 4_096)
+ }
+
@Test func collectsResourcesRegardlessOfDisclosure() {
let s = NewMachineSheet.buildSettings(cpus: 4, memoryGB: 8,
mounts: [MountPair(host: "/Users/u/p", guest: "/Users/u/p")],
@@ -16,6 +45,9 @@ struct NewMachineSettingsTests {
#expect(s.env["DORY_GUEST_UID"] == String(getuid()))
#expect(s.env["DORY_DESKTOP_DISTRO"] == "debian")
#expect(s.env["DORY_DESKTOP_VERSION"] == "13")
+ #expect(s.env[DoryDesktopClipboardPolicy.environmentKey] == "bidirectional")
+ #expect(s.env[DoryDesktopVMMPreference.environmentKey] == "auto")
+ #expect(s.env[DoryDesktopGraphicsPreference.environmentKey] == "auto")
#expect(s.ports.isEmpty)
}
@@ -38,6 +70,23 @@ struct NewMachineSettingsTests {
#expect(settings.env["DORY_GUEST_UID"] == "1001")
}
+ @Test func recordsUbuntuAsTheCanonicalGnomeDesktop() {
+ let settings = NewMachineSheet.buildSettings(
+ cpus: 4,
+ memoryGB: 8,
+ mounts: [],
+ displayMode: .desktop,
+ desktopDistro: .ubuntu,
+ guestUsername: "developer",
+ guestUID: 1_002
+ )
+
+ #expect(settings.env["DORY_DESKTOP_DISTRO"] == "ubuntu")
+ #expect(settings.env["DORY_DESKTOP_NAME"] == "Ubuntu")
+ #expect(settings.env["DORY_DESKTOP_VERSION"] == "24.04 LTS")
+ #expect(settings.env["DORY_DESKTOP_ENVIRONMENT"] == "GNOME")
+ }
+
@Test func headlessServersDoNotCarryDesktopMetadata() {
let settings = NewMachineSheet.buildSettings(
cpus: 2,
@@ -48,5 +97,8 @@ struct NewMachineSettingsTests {
#expect(settings.env["DORY_DESKTOP_DISTRO"] == nil)
#expect(settings.env["DORY_GUEST_USER"] == nil)
+ #expect(settings.env[DoryDesktopClipboardPolicy.environmentKey] == nil)
+ #expect(settings.env[DoryDesktopVMMPreference.environmentKey] == nil)
+ #expect(settings.env[DoryDesktopGraphicsPreference.environmentKey] == nil)
}
}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift b/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift
new file mode 100644
index 00000000..8cbdfdda
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift
@@ -0,0 +1,301 @@
+import CryptoKit
+import Darwin
+import Foundation
+
+public struct DoryInstalledLinuxBootDescriptor: Sendable, Equatable {
+ public let rootDevice: String
+ public let kernelLength: UInt64
+ public let initrdLength: UInt64
+
+ public init(rootDevice: String, kernelLength: UInt64, initrdLength: UInt64) {
+ self.rootDevice = rootDevice
+ self.kernelLength = kernelLength
+ self.initrdLength = initrdLength
+ }
+}
+
+/// A single portable artifact containing the direct-boot kernel, initrd, and root-device contract
+/// for an EFI-installed Linux disk. EFI snapshots already preserve the machine's opaque `kernel`
+/// artifact, so using one verified bundle also makes the accelerated runtime survive clone,
+/// snapshot, export, import, and restore without inventing a parallel set of lifecycle rules.
+public enum DoryInstalledLinuxBootBundle {
+ private static let magic = Data("DORYLINUXBOOT1\n".utf8)
+ private static let fixedHeaderBytes = 4 + 8 + 8 + 32 + 32
+ private static let maximumRootDeviceBytes = 128
+ private static let maximumKernelBytes: UInt64 = 256 * 1024 * 1024
+ private static let maximumInitrdBytes: UInt64 = 512 * 1024 * 1024
+ private static let copyChunkBytes = 4 * 1024 * 1024
+
+ public static func isBundle(atPath path: String) -> Bool {
+ guard let handle = try? openForReading(path),
+ let prefix = try? handle.read(upToCount: magic.count) else {
+ return false
+ }
+ try? handle.close()
+ return prefix == magic
+ }
+
+ public static func write(
+ assets: DoryLinuxInstallerBootAssets,
+ rootDevice: String,
+ toPath path: String
+ ) throws {
+ let rootBytes = Data(rootDevice.utf8)
+ guard isValidRootDevice(rootDevice),
+ !rootBytes.isEmpty,
+ rootBytes.count <= maximumRootDeviceBytes else {
+ throw DoryInstalledLinuxBootBundleError.invalidRootDevice(rootDevice)
+ }
+ guard !assets.kernel.isEmpty,
+ UInt64(assets.kernel.count) <= maximumKernelBytes else {
+ throw DoryInstalledLinuxBootBundleError.invalidKernelSize(UInt64(assets.kernel.count))
+ }
+ guard !assets.initrd.isEmpty,
+ UInt64(assets.initrd.count) <= maximumInitrdBytes else {
+ throw DoryInstalledLinuxBootBundleError.invalidInitrdSize(UInt64(assets.initrd.count))
+ }
+
+ let destination = URL(fileURLWithPath: path)
+ let parent = destination.deletingLastPathComponent()
+ let temporary = parent.appendingPathComponent(".\(destination.lastPathComponent).boot-\(UUID().uuidString)")
+ guard FileManager.default.createFile(
+ atPath: temporary.path,
+ contents: nil,
+ attributes: [.posixPermissions: 0o600]
+ ) else {
+ throw DoryInstalledLinuxBootBundleError.write(path, errno)
+ }
+ do {
+ let output = try FileHandle(forWritingTo: temporary)
+ try output.write(contentsOf: magic)
+ try output.write(contentsOf: bigEndian(UInt32(rootBytes.count)))
+ try output.write(contentsOf: bigEndian(UInt64(assets.kernel.count)))
+ try output.write(contentsOf: bigEndian(UInt64(assets.initrd.count)))
+ try output.write(contentsOf: Data(SHA256.hash(data: assets.kernel)))
+ try output.write(contentsOf: Data(SHA256.hash(data: assets.initrd)))
+ try output.write(contentsOf: rootBytes)
+ try output.write(contentsOf: assets.kernel)
+ try output.write(contentsOf: assets.initrd)
+ try output.synchronize()
+ try output.close()
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path)
+ guard rename(temporary.path, destination.path) == 0 else {
+ throw DoryInstalledLinuxBootBundleError.write(path, errno)
+ }
+ } catch {
+ try? FileManager.default.removeItem(at: temporary)
+ throw error
+ }
+ }
+
+ public static func descriptor(atPath path: String) throws -> DoryInstalledLinuxBootDescriptor {
+ let input = try openForReading(path)
+ defer { try? input.close() }
+ return try readHeader(from: input).descriptor
+ }
+
+ @discardableResult
+ public static func materialize(
+ fromPath path: String,
+ kernelPath: String,
+ initrdPath: String
+ ) throws -> DoryInstalledLinuxBootDescriptor {
+ let input = try openForReading(path)
+ defer { try? input.close() }
+ let header = try readHeader(from: input)
+ let destinations = [URL(fileURLWithPath: kernelPath), URL(fileURLWithPath: initrdPath)]
+ let parent = destinations[0].deletingLastPathComponent()
+ guard destinations[1].deletingLastPathComponent() == parent else {
+ throw DoryInstalledLinuxBootBundleError.invalidDestination
+ }
+ let token = UUID().uuidString
+ let temporaries = destinations.map {
+ parent.appendingPathComponent(".\($0.lastPathComponent).boot-\(token)")
+ }
+ for temporary in temporaries {
+ guard FileManager.default.createFile(
+ atPath: temporary.path,
+ contents: nil,
+ attributes: [.posixPermissions: 0o600]
+ ) else {
+ temporaries.forEach { try? FileManager.default.removeItem(at: $0) }
+ throw DoryInstalledLinuxBootBundleError.write(temporary.path, errno)
+ }
+ }
+ do {
+ let kernelOutput = try FileHandle(forWritingTo: temporaries[0])
+ let initrdOutput = try FileHandle(forWritingTo: temporaries[1])
+ defer {
+ try? kernelOutput.close()
+ try? initrdOutput.close()
+ }
+ try input.seek(toOffset: header.kernelOffset)
+ let kernelDigest = try copyExactly(
+ from: input,
+ to: kernelOutput,
+ byteCount: header.descriptor.kernelLength
+ )
+ let initrdDigest = try copyExactly(
+ from: input,
+ to: initrdOutput,
+ byteCount: header.descriptor.initrdLength
+ )
+ guard kernelDigest == header.kernelDigest,
+ initrdDigest == header.initrdDigest else {
+ throw DoryInstalledLinuxBootBundleError.digestMismatch
+ }
+ try kernelOutput.synchronize()
+ try initrdOutput.synchronize()
+ try kernelOutput.close()
+ try initrdOutput.close()
+ for temporary in temporaries {
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path)
+ }
+ for (temporary, destination) in zip(temporaries, destinations) {
+ guard rename(temporary.path, destination.path) == 0 else {
+ throw DoryInstalledLinuxBootBundleError.write(destination.path, errno)
+ }
+ }
+ return header.descriptor
+ } catch {
+ temporaries.forEach { try? FileManager.default.removeItem(at: $0) }
+ throw error
+ }
+ }
+
+ private struct Header {
+ var descriptor: DoryInstalledLinuxBootDescriptor
+ var kernelOffset: UInt64
+ var kernelDigest: Data
+ var initrdDigest: Data
+ }
+
+ private static func readHeader(from input: FileHandle) throws -> Header {
+ try input.seek(toOffset: 0)
+ guard try readExactly(from: input, count: magic.count) == magic else {
+ throw DoryInstalledLinuxBootBundleError.invalidMagic
+ }
+ let rootLength = Int(decodeUInt32(try readExactly(from: input, count: 4)))
+ let kernelLength = decodeUInt64(try readExactly(from: input, count: 8))
+ let initrdLength = decodeUInt64(try readExactly(from: input, count: 8))
+ let kernelDigest = try readExactly(from: input, count: 32)
+ let initrdDigest = try readExactly(from: input, count: 32)
+ guard rootLength > 0, rootLength <= maximumRootDeviceBytes,
+ kernelLength > 0, kernelLength <= maximumKernelBytes,
+ initrdLength > 0, initrdLength <= maximumInitrdBytes else {
+ throw DoryInstalledLinuxBootBundleError.invalidHeader
+ }
+ let rootData = try readExactly(from: input, count: rootLength)
+ guard let rootDevice = String(data: rootData, encoding: .utf8),
+ isValidRootDevice(rootDevice) else {
+ throw DoryInstalledLinuxBootBundleError.invalidHeader
+ }
+ let kernelOffset = UInt64(magic.count + fixedHeaderBytes + rootLength)
+ let (afterKernel, kernelOverflow) = kernelOffset.addingReportingOverflow(kernelLength)
+ let (expectedLength, initrdOverflow) = afterKernel.addingReportingOverflow(initrdLength)
+ guard !kernelOverflow, !initrdOverflow,
+ try input.seekToEnd() == expectedLength else {
+ throw DoryInstalledLinuxBootBundleError.invalidHeader
+ }
+ return Header(
+ descriptor: DoryInstalledLinuxBootDescriptor(
+ rootDevice: rootDevice,
+ kernelLength: kernelLength,
+ initrdLength: initrdLength
+ ),
+ kernelOffset: kernelOffset,
+ kernelDigest: kernelDigest,
+ initrdDigest: initrdDigest
+ )
+ }
+
+ private static func isValidRootDevice(_ value: String) -> Bool {
+ value.wholeMatch(of: /\/dev\/vd[a-z][1-9][0-9]*/) != nil
+ }
+
+ private static func openForReading(_ path: String) throws -> FileHandle {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw DoryInstalledLinuxBootBundleError.open(path, errno)
+ }
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ info.st_mode & S_IFMT == S_IFREG,
+ info.st_size > 0 else {
+ close(descriptor)
+ throw DoryInstalledLinuxBootBundleError.invalidHeader
+ }
+ return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
+ }
+
+ private static func copyExactly(
+ from input: FileHandle,
+ to output: FileHandle,
+ byteCount: UInt64
+ ) throws -> Data {
+ var remaining = byteCount
+ var hasher = SHA256()
+ while remaining > 0 {
+ let count = Int(min(UInt64(copyChunkBytes), remaining))
+ let data = try readExactly(from: input, count: count)
+ try output.write(contentsOf: data)
+ hasher.update(data: data)
+ remaining -= UInt64(data.count)
+ }
+ return Data(hasher.finalize())
+ }
+
+ private static func readExactly(from input: FileHandle, count: Int) throws -> Data {
+ var result = Data()
+ while result.count < count {
+ let chunk = try input.read(upToCount: count - result.count) ?? Data()
+ guard !chunk.isEmpty else {
+ throw DoryInstalledLinuxBootBundleError.invalidHeader
+ }
+ result.append(chunk)
+ }
+ return result
+ }
+
+ private static func bigEndian(_ value: UInt32) -> Data {
+ withUnsafeBytes(of: value.bigEndian) { Data($0) }
+ }
+
+ private static func bigEndian(_ value: UInt64) -> Data {
+ withUnsafeBytes(of: value.bigEndian) { Data($0) }
+ }
+
+ private static func decodeUInt32(_ data: Data) -> UInt32 {
+ data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
+ }
+
+ private static func decodeUInt64(_ data: Data) -> UInt64 {
+ data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
+ }
+}
+
+public enum DoryInstalledLinuxBootBundleError: Error, LocalizedError, Sendable, Equatable {
+ case invalidRootDevice(String)
+ case invalidKernelSize(UInt64)
+ case invalidInitrdSize(UInt64)
+ case invalidMagic
+ case invalidHeader
+ case digestMismatch
+ case invalidDestination
+ case open(String, Int32)
+ case write(String, Int32)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .invalidRootDevice(device): "Invalid installed Linux root device: \(device)"
+ case let .invalidKernelSize(size): "Invalid installed Linux kernel size: \(size) bytes"
+ case let .invalidInitrdSize(size): "Invalid installed Linux initrd size: \(size) bytes"
+ case .invalidMagic: "Not a Dory installed-Linux boot bundle"
+ case .invalidHeader: "Installed-Linux boot bundle has an invalid or truncated header"
+ case .digestMismatch: "Installed-Linux boot bundle failed kernel/initrd verification"
+ case .invalidDestination: "Installed-Linux boot destinations must share one directory"
+ case let .open(path, code): "Could not open installed-Linux boot bundle \(path): \(String(cString: strerror(code)))"
+ case let .write(path, code): "Could not write installed-Linux boot artifact \(path): \(String(cString: strerror(code)))"
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift b/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift
new file mode 100644
index 00000000..1ae95072
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift
@@ -0,0 +1,1100 @@
+import Darwin
+import CryptoKit
+import Foundation
+import zlib
+
+public enum DoryInstallerISOArchitecture: String, Sendable, Equatable {
+ case arm64
+ case x86_64
+ case multiArchitecture = "multi-architecture"
+ case unknown
+}
+
+public enum DoryInstallerISOCompatibility: Sendable, Equatable {
+ case compatible
+ case unknown
+ case incompatible(String)
+}
+
+public struct DoryInstallerISOMediaIdentity: Sendable, Equatable {
+ public let architecture: DoryInstallerISOArchitecture
+ public let sha256: String
+ public let byteCount: UInt64
+
+ public init(
+ architecture: DoryInstallerISOArchitecture,
+ sha256: String,
+ byteCount: UInt64
+ ) {
+ self.architecture = architecture
+ self.sha256 = sha256.lowercased()
+ self.byteCount = byteCount
+ }
+}
+
+public enum DoryInstallerISORuntimeQualification: Sendable, Equatable {
+ case qualified(String)
+ case unqualified
+ case knownUnstable(String)
+}
+
+/// A qualification key for the parts of Dory's EFI device model that materially affect a guest.
+/// Changing this value deliberately invalidates evidence gathered with an older virtual storage
+/// controller instead of carrying a failure (or a pass) across different virtual hardware.
+public enum DoryInstallerRuntimeProfile: String, Sendable, Equatable {
+ case legacyVirtioBlockV1 = "vz-efi-virtio-blk-v1"
+ case nativeNVMeFsyncV1 = "vz-efi-nvme-fsync-v1"
+
+ public static let current = DoryInstallerRuntimeProfile.nativeNVMeFsyncV1
+}
+
+public struct DoryInstallerHostRuntime: Sendable {
+ public let architecture: String
+ public let hardwareModel: String
+ public let operatingSystemVersion: OperatingSystemVersion
+ public let operatingSystemBuild: String
+ public let runtimeProfile: String
+
+ public init(
+ architecture: String,
+ hardwareModel: String,
+ operatingSystemVersion: OperatingSystemVersion,
+ operatingSystemBuild: String,
+ runtimeProfile: String = DoryInstallerRuntimeProfile.current.rawValue
+ ) {
+ self.architecture = architecture
+ self.hardwareModel = hardwareModel
+ self.operatingSystemVersion = operatingSystemVersion
+ self.operatingSystemBuild = operatingSystemBuild
+ self.runtimeProfile = runtimeProfile
+ }
+
+ public static var current: DoryInstallerHostRuntime {
+ DoryInstallerHostRuntime(
+ architecture: DoryInstallerISOInspector.currentHostArchitecture,
+ hardwareModel: systemString("hw.model"),
+ operatingSystemVersion: ProcessInfo.processInfo.operatingSystemVersion,
+ operatingSystemBuild: systemString("kern.osversion"),
+ runtimeProfile: DoryInstallerRuntimeProfile.current.rawValue
+ )
+ }
+
+ private static func systemString(_ name: String) -> String {
+ var byteCount = 0
+ guard sysctlbyname(name, nil, &byteCount, nil, 0) == 0, byteCount > 1 else {
+ return "unknown"
+ }
+ var bytes = [CChar](repeating: 0, count: byteCount)
+ guard sysctlbyname(name, &bytes, &byteCount, nil, 0) == 0 else {
+ return "unknown"
+ }
+ return String(
+ decoding: bytes.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) },
+ as: UTF8.self
+ )
+ }
+}
+
+/// Runtime evidence is deliberately separate from EFI architecture compatibility.
+///
+/// A matching loader proves that the host can enter the installer; it does not prove that the
+/// distribution kernel is stable on a particular Virtualization.framework and Mac combination.
+public enum DoryInstallerISORuntimeCatalog {
+ public static let ubuntu24044DesktopARM64SHA256 =
+ "c2610520bf582976839a1724c669e1cfed0547427be5a0ad12d457b92b46ffbe"
+ public static let ubuntu24043DesktopARM64SHA256 =
+ "cdbf0f83ab4f7d46be767e73c59b5cbca9743dd5fb887142c96f4b2df38fa5ad"
+
+ public static func qualification(
+ of identity: DoryInstallerISOMediaIdentity,
+ on host: DoryInstallerHostRuntime = .current
+ ) -> DoryInstallerISORuntimeQualification {
+ if [ubuntu24044DesktopARM64SHA256, ubuntu24043DesktopARM64SHA256]
+ .contains(identity.sha256),
+ host.architecture.lowercased() == "arm64",
+ host.hardwareModel == "Mac14,10",
+ host.operatingSystemBuild == "26A5406e",
+ host.runtimeProfile == DoryInstallerRuntimeProfile.legacyVirtioBlockV1.rawValue {
+ return .knownUnstable(
+ "This Ubuntu 24.04 Desktop ARM64 media is known to panic or freeze with Dory's retired VirtIO-block EFI profile on this Mac and macOS build. Recreate the machine with Dory's current native-NVMe EFI profile."
+ )
+ }
+ return .unqualified
+ }
+}
+
+/// Balanced defaults for distribution-owned EFI installers. These are resource choices, not a
+/// compatibility workaround; runtime qualification is based on the exact media and host evidence.
+public enum DoryInstallerMachinePolicy {
+ public static let defaultCPUCount = 4
+ public static let defaultMemoryMB: UInt64 = 4_096
+}
+
+/// Fast, mount-free architecture preflight for user-selected Linux installer media.
+///
+/// Dory reads ISO9660 directory records plus bounded EFI boot regions. It never mounts the image,
+/// does not execute anything from it, and does not copy a multi-gigabyte image before deciding
+/// whether its EFI loader can run on this Mac.
+public enum DoryInstallerISOInspector {
+ private static let logicalBlockSize: Int64 = 2_048
+ private static let scanChunkSize = 1024 * 1024
+ private static let maximumDirectoryBytes = 64 * 1024 * 1024
+ private static let maximumSingleDirectoryBytes = 16 * 1024 * 1024
+ private static let maximumBootRegionBytes: Int64 = 128 * 1024 * 1024
+ private static let edgeScanBytes: Int64 = 8 * 1024 * 1024
+ private static let trailingScanBytes: Int64 = 64 * 1024 * 1024
+
+ private static let arm64Markers = markerVariants(["BOOTAA64.EFI", "GRUBAA64.EFI"])
+ private static let x86Markers = markerVariants(["BOOTX64.EFI", "GRUBX64.EFI"])
+ private static let maximumMarkerBytes =
+ (arm64Markers + x86Markers).map(\.count).max() ?? 1
+
+ public static func architecture(atPath path: String) throws -> DoryInstallerISOArchitecture {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw DoryInstallerISOInspectionError.open(path, errno)
+ }
+ defer { close(descriptor) }
+
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ (info.st_mode & S_IFMT) == S_IFREG,
+ info.st_size > 0 else {
+ throw DoryInstallerISOInspectionError.notRegularFile(path)
+ }
+
+ return try architecture(descriptor: descriptor, size: Int64(info.st_size), path: path)
+ }
+
+ public static func mediaIdentity(atPath path: String) throws -> DoryInstallerISOMediaIdentity {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw DoryInstallerISOInspectionError.open(path, errno)
+ }
+ defer { close(descriptor) }
+
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ (info.st_mode & S_IFMT) == S_IFREG,
+ info.st_size > 0 else {
+ throw DoryInstallerISOInspectionError.notRegularFile(path)
+ }
+
+ return try mediaIdentity(
+ descriptor: descriptor,
+ size: Int64(info.st_size),
+ path: path
+ )
+ }
+
+ /// Reads one bounded regular file from the ISO9660 namespace without mounting or executing
+ /// the image. Paths are matched case-insensitively and ISO9660 version suffixes (`;1`) are
+ /// ignored, which covers the layout used by mainstream Linux installer media.
+ public static func fileData(
+ atISOPath isoPath: String,
+ fromPath path: String,
+ maximumBytes: Int
+ ) throws -> Data {
+ guard maximumBytes > 0 else {
+ throw DoryInstallerISOInspectionError.fileTooLarge(path, 0)
+ }
+ let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
+ guard !components.isEmpty,
+ components.allSatisfy({ $0 != "." && $0 != ".." && !$0.contains("\0") }) else {
+ throw DoryInstallerISOInspectionError.invalidISOPath(path)
+ }
+
+ let descriptor = open(isoPath, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw DoryInstallerISOInspectionError.open(isoPath, errno)
+ }
+ defer { close(descriptor) }
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ (info.st_mode & S_IFMT) == S_IFREG,
+ info.st_size > 0 else {
+ throw DoryInstallerISOInspectionError.notRegularFile(isoPath)
+ }
+
+ let imageSize = Int64(info.st_size)
+ let metadata = try inspectISO9660(descriptor: descriptor, size: imageSize, path: isoPath)
+ guard var current = metadata.rootDirectory else {
+ throw DoryInstallerISOInspectionError.fileNotFound(path)
+ }
+ for (index, component) in components.enumerated() {
+ let entry = try directoryEntry(
+ named: component,
+ in: current,
+ descriptor: descriptor,
+ imageSize: imageSize,
+ path: isoPath
+ )
+ guard let entry else {
+ throw DoryInstallerISOInspectionError.fileNotFound(path)
+ }
+ let isLast = index == components.count - 1
+ if isLast {
+ guard !entry.isDirectory else {
+ throw DoryInstallerISOInspectionError.fileNotFound(path)
+ }
+ guard entry.range.length <= Int64(maximumBytes) else {
+ throw DoryInstallerISOInspectionError.fileTooLarge(path, entry.range.length)
+ }
+ return try read(
+ descriptor: descriptor,
+ offset: entry.range.offset,
+ count: Int(entry.range.length),
+ path: isoPath
+ )
+ }
+ guard entry.isDirectory else {
+ throw DoryInstallerISOInspectionError.fileNotFound(path)
+ }
+ current = entry.range
+ }
+ throw DoryInstallerISOInspectionError.fileNotFound(path)
+ }
+
+ fileprivate static func mediaIdentity(
+ descriptor: Int32,
+ size: Int64,
+ path: String
+ ) throws -> DoryInstallerISOMediaIdentity {
+ let architecture = try architecture(descriptor: descriptor, size: size, path: path)
+ var hasher = SHA256()
+ var offset: Int64 = 0
+ while offset < size {
+ let byteCount = Int(min(Int64(scanChunkSize), size - offset))
+ let data = try read(
+ descriptor: descriptor,
+ offset: offset,
+ count: byteCount,
+ path: path
+ )
+ guard !data.isEmpty else {
+ throw DoryInstallerISOInspectionError.read(path, EIO)
+ }
+ hasher.update(data: data)
+ offset += Int64(data.count)
+ }
+ let sha256 = hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ return DoryInstallerISOMediaIdentity(
+ architecture: architecture,
+ sha256: sha256,
+ byteCount: UInt64(size)
+ )
+ }
+
+ fileprivate static func architecture(
+ descriptor: Int32,
+ size: Int64,
+ path: String
+ ) throws -> DoryInstallerISOArchitecture {
+ var markers = MarkerState()
+ let metadata = try inspectISO9660(descriptor: descriptor, size: size, path: path)
+ markers.merge(metadata.markers)
+
+ var scannedRanges = Set()
+ let partitionRanges = try mbrEFIPartitionRanges(
+ descriptor: descriptor,
+ size: size,
+ path: path
+ )
+ for range in metadata.bootImageRanges + partitionRanges {
+ try scan(
+ descriptor: descriptor,
+ size: size,
+ range: range,
+ path: path,
+ markers: &markers,
+ scannedRanges: &scannedRanges
+ )
+ if markers.architecture == .multiArchitecture { return .multiArchitecture }
+ }
+
+ try scan(
+ descriptor: descriptor,
+ size: size,
+ range: ByteRange(offset: 0, length: min(size, edgeScanBytes)),
+ path: path,
+ markers: &markers,
+ scannedRanges: &scannedRanges
+ )
+ if markers.architecture == .multiArchitecture { return .multiArchitecture }
+
+ let trailingLength = min(size, trailingScanBytes)
+ try scan(
+ descriptor: descriptor,
+ size: size,
+ range: ByteRange(offset: size - trailingLength, length: trailingLength),
+ path: path,
+ markers: &markers,
+ scannedRanges: &scannedRanges
+ )
+ return markers.architecture
+ }
+
+ public static func compatibility(
+ of architecture: DoryInstallerISOArchitecture,
+ hostArchitecture: String
+ ) -> DoryInstallerISOCompatibility {
+ switch (normalizedHostArchitecture(hostArchitecture), architecture) {
+ case ("arm64", .x86_64):
+ .incompatible("This ISO is Intel x86_64-only. Apple Silicon requires an arm64 EFI ISO.")
+ case ("x86_64", .arm64):
+ .incompatible("This ISO is arm64-only. This Intel Mac requires an x86_64 EFI ISO.")
+ case (_, .unknown):
+ .unknown
+ default:
+ .compatible
+ }
+ }
+
+ public static var currentHostArchitecture: String {
+ #if arch(arm64)
+ "arm64"
+ #elseif arch(x86_64)
+ "x86_64"
+ #else
+ "unknown"
+ #endif
+ }
+
+ private static func normalizedHostArchitecture(_ architecture: String) -> String {
+ switch architecture.lowercased() {
+ case "aarch64", "arm64": "arm64"
+ case "amd64", "x86_64": "x86_64"
+ default: architecture.lowercased()
+ }
+ }
+
+ private struct ISO9660Inspection {
+ var markers = MarkerState()
+ var bootImageRanges: [ByteRange] = []
+ var rootDirectory: ByteRange?
+ }
+
+ private static func inspectISO9660(
+ descriptor: Int32,
+ size: Int64,
+ path: String
+ ) throws -> ISO9660Inspection {
+ var result = ISO9660Inspection()
+ var rootDirectory: ByteRange?
+ var bootCatalogLBAs: [UInt32] = []
+
+ for index in 16..<272 {
+ let offset = Int64(index) * logicalBlockSize
+ guard offset + logicalBlockSize <= size else { break }
+ let block = try read(
+ descriptor: descriptor,
+ offset: offset,
+ count: Int(logicalBlockSize),
+ path: path
+ )
+ guard block.count == Int(logicalBlockSize),
+ block[1..<6].elementsEqual(Data("CD001".utf8)) else {
+ if index == 16 { return result }
+ break
+ }
+
+ switch block[0] {
+ case 0:
+ if block.count >= 75,
+ String(decoding: block[7..<39], as: UTF8.self)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .hasPrefix("EL TORITO SPECIFICATION") {
+ bootCatalogLBAs.append(littleEndianUInt32(block, at: 71))
+ }
+ case 1:
+ if let directory = directoryRange(from: block, recordOffset: 156, imageSize: size) {
+ rootDirectory = directory
+ result.rootDirectory = directory
+ }
+ case 255:
+ break
+ default:
+ continue
+ }
+ if block[0] == 255 { break }
+ }
+
+ if let rootDirectory {
+ try inspectDirectories(
+ descriptor: descriptor,
+ imageSize: size,
+ root: rootDirectory,
+ path: path,
+ markers: &result.markers
+ )
+ }
+
+ for catalogLBA in bootCatalogLBAs {
+ result.bootImageRanges.append(contentsOf: try elToritoBootImageRanges(
+ descriptor: descriptor,
+ imageSize: size,
+ catalogLBA: catalogLBA,
+ path: path
+ ))
+ }
+ return result
+ }
+
+ private struct ISO9660DirectoryEntry {
+ var range: ByteRange
+ var isDirectory: Bool
+ }
+
+ private static func directoryEntry(
+ named requestedName: String,
+ in directory: ByteRange,
+ descriptor: Int32,
+ imageSize: Int64,
+ path: String
+ ) throws -> ISO9660DirectoryEntry? {
+ guard directory.length > 0,
+ directory.length <= Int64(maximumSingleDirectoryBytes),
+ directory.offset >= 0,
+ directory.offset + directory.length <= imageSize else {
+ return nil
+ }
+ let data = try read(
+ descriptor: descriptor,
+ offset: directory.offset,
+ count: Int(directory.length),
+ path: path
+ )
+ var position = 0
+ while position < data.count {
+ let recordLength = Int(data[position])
+ if recordLength == 0 {
+ position = ((position / Int(logicalBlockSize)) + 1) * Int(logicalBlockSize)
+ continue
+ }
+ guard recordLength >= 34, position + recordLength <= data.count else { break }
+ let identifierLength = Int(data[position + 32])
+ guard position + 33 + identifierLength <= position + recordLength else {
+ position += recordLength
+ continue
+ }
+ let identifier = data[(position + 33)..<(position + 33 + identifierLength)]
+ let isDotEntry = identifierLength == 1 && (identifier.first == 0 || identifier.first == 1)
+ if !isDotEntry {
+ let rawName = String(decoding: identifier, as: UTF8.self)
+ var isoName = rawName.split(separator: ";", maxSplits: 1).first.map(String.init) ?? rawName
+ // ISO9660 encodes a filename with an empty extension as `NAME.;1`.
+ if isoName.last == "." { isoName.removeLast() }
+ if isoName.caseInsensitiveCompare(requestedName) == .orderedSame {
+ guard data[position + 25] & 0x80 == 0,
+ let range = directoryRange(
+ from: data,
+ recordOffset: position,
+ imageSize: imageSize
+ ) else {
+ return nil
+ }
+ return ISO9660DirectoryEntry(
+ range: range,
+ isDirectory: data[position + 25] & 0x02 != 0
+ )
+ }
+ }
+ position += recordLength
+ }
+ return nil
+ }
+
+ private static func inspectDirectories(
+ descriptor: Int32,
+ imageSize: Int64,
+ root: ByteRange,
+ path: String,
+ markers: inout MarkerState
+ ) throws {
+ var queue = [root]
+ var visited = Set()
+ var totalBytes = 0
+
+ while !queue.isEmpty, visited.count < 4_096, totalBytes < maximumDirectoryBytes {
+ let directory = queue.removeFirst()
+ guard visited.insert(directory).inserted,
+ directory.length > 0,
+ directory.length <= Int64(maximumSingleDirectoryBytes),
+ directory.offset >= 0,
+ directory.offset + directory.length <= imageSize else {
+ continue
+ }
+ totalBytes += Int(directory.length)
+ let data = try read(
+ descriptor: descriptor,
+ offset: directory.offset,
+ count: Int(directory.length),
+ path: path
+ )
+ markers.observe(data)
+
+ var position = 0
+ while position < data.count {
+ let recordLength = Int(data[position])
+ if recordLength == 0 {
+ position = ((position / Int(logicalBlockSize)) + 1) * Int(logicalBlockSize)
+ continue
+ }
+ guard recordLength >= 34, position + recordLength <= data.count else { break }
+ let identifierLength = Int(data[position + 32])
+ guard position + 33 + identifierLength <= position + recordLength else {
+ position += recordLength
+ continue
+ }
+ let identifier = data[(position + 33)..<(position + 33 + identifierLength)]
+ let isDotEntry = identifierLength == 1 && (identifier.first == 0 || identifier.first == 1)
+ if !isDotEntry {
+ markers.observe(Data(identifier))
+ let flags = data[position + 25]
+ if flags & 0x02 != 0,
+ let child = directoryRange(
+ from: data,
+ recordOffset: position,
+ imageSize: imageSize
+ ) {
+ queue.append(child)
+ }
+ }
+ position += recordLength
+ }
+ }
+ }
+
+ private static func directoryRange(
+ from data: Data,
+ recordOffset: Int,
+ imageSize: Int64
+ ) -> ByteRange? {
+ guard recordOffset >= 0, recordOffset + 18 <= data.count else { return nil }
+ let extent = Int64(littleEndianUInt32(data, at: recordOffset + 2)) * logicalBlockSize
+ let length = Int64(littleEndianUInt32(data, at: recordOffset + 10))
+ guard extent >= 0, length > 0, extent <= imageSize, length <= imageSize - extent else {
+ return nil
+ }
+ return ByteRange(offset: extent, length: length)
+ }
+
+ private static func elToritoBootImageRanges(
+ descriptor: Int32,
+ imageSize: Int64,
+ catalogLBA: UInt32,
+ path: String
+ ) throws -> [ByteRange] {
+ let catalogOffset = Int64(catalogLBA) * logicalBlockSize
+ guard catalogOffset >= 0, catalogOffset < imageSize else { return [] }
+ let catalog = try read(
+ descriptor: descriptor,
+ offset: catalogOffset,
+ count: Int(min(Int64(64 * 1024), imageSize - catalogOffset)),
+ path: path
+ )
+ var ranges: [ByteRange] = []
+ var platform: UInt8 = catalog.count >= 2 ? catalog[1] : 0
+ var offset = 32
+ while offset + 32 <= catalog.count {
+ let indicator = catalog[offset]
+ if indicator == 0x90 || indicator == 0x91 {
+ platform = catalog[offset + 1]
+ } else if indicator == 0x88, platform == 0xEF {
+ let imageLBA = littleEndianUInt32(catalog, at: offset + 8)
+ let imageOffset = Int64(imageLBA) * logicalBlockSize
+ if imageOffset >= 0, imageOffset < imageSize {
+ ranges.append(ByteRange(
+ offset: imageOffset,
+ length: min(maximumBootRegionBytes, imageSize - imageOffset)
+ ))
+ }
+ }
+ if indicator == 0x91 { break }
+ offset += 32
+ }
+ return ranges
+ }
+
+ private static func mbrEFIPartitionRanges(
+ descriptor: Int32,
+ size: Int64,
+ path: String
+ ) throws -> [ByteRange] {
+ guard size >= 512 else { return [] }
+ let mbr = try read(descriptor: descriptor, offset: 0, count: 512, path: path)
+ guard mbr.count == 512, mbr[510] == 0x55, mbr[511] == 0xAA else { return [] }
+ return (0..<4).compactMap { index in
+ let entry = 446 + index * 16
+ guard mbr[entry + 4] == 0xEF else { return nil }
+ let offset = Int64(littleEndianUInt32(mbr, at: entry + 8)) * 512
+ let declaredLength = Int64(littleEndianUInt32(mbr, at: entry + 12)) * 512
+ guard offset >= 0, offset < size, declaredLength > 0 else { return nil }
+ return ByteRange(
+ offset: offset,
+ length: min(maximumBootRegionBytes, min(declaredLength, size - offset))
+ )
+ }
+ }
+
+ private static func scan(
+ descriptor: Int32,
+ size: Int64,
+ range: ByteRange,
+ path: String,
+ markers: inout MarkerState,
+ scannedRanges: inout Set
+ ) throws {
+ guard range.offset >= 0, range.length > 0, range.offset < size else { return }
+ let bounded = ByteRange(
+ offset: range.offset,
+ length: min(range.length, size - range.offset)
+ )
+ guard scannedRanges.insert(bounded).inserted else { return }
+
+ var offset = bounded.offset
+ let end = bounded.offset + bounded.length
+ var carry = Data()
+ while offset < end {
+ let count = Int(min(Int64(scanChunkSize), end - offset))
+ let chunk = try read(descriptor: descriptor, offset: offset, count: count, path: path)
+ if chunk.isEmpty { break }
+ var searchable = carry
+ searchable.append(chunk)
+ markers.observe(searchable)
+ if markers.architecture == .multiArchitecture { return }
+ carry = Data(searchable.suffix(maximumMarkerBytes - 1))
+ offset += Int64(chunk.count)
+ }
+ }
+
+ private static func read(
+ descriptor: Int32,
+ offset: Int64,
+ count: Int,
+ path: String
+ ) throws -> Data {
+ guard count > 0 else { return Data() }
+ var data = Data(count: count)
+ while true {
+ let bytesRead = data.withUnsafeMutableBytes { buffer in
+ pread(descriptor, buffer.baseAddress, buffer.count, off_t(offset))
+ }
+ if bytesRead < 0 {
+ if errno == EINTR { continue }
+ throw DoryInstallerISOInspectionError.read(path, errno)
+ }
+ data.count = bytesRead
+ return data
+ }
+ }
+
+ private static func markerVariants(_ values: [String]) -> [Data] {
+ values.flatMap { value in
+ [Data(value.utf8), Data(value.lowercased().utf8)]
+ }
+ }
+
+ private static func littleEndianUInt32(_ data: Data, at offset: Int) -> UInt32 {
+ UInt32(data[offset])
+ | UInt32(data[offset + 1]) << 8
+ | UInt32(data[offset + 2]) << 16
+ | UInt32(data[offset + 3]) << 24
+ }
+
+ private struct ByteRange: Hashable {
+ let offset: Int64
+ let length: Int64
+ }
+
+ private struct MarkerState {
+ var foundArm64 = false
+ var foundX86 = false
+
+ mutating func observe(_ data: Data) {
+ if !foundArm64 {
+ foundArm64 = arm64Markers.contains { data.range(of: $0) != nil }
+ }
+ if !foundX86 {
+ foundX86 = x86Markers.contains { data.range(of: $0) != nil }
+ }
+ }
+
+ mutating func merge(_ other: MarkerState) {
+ foundArm64 = foundArm64 || other.foundArm64
+ foundX86 = foundX86 || other.foundX86
+ }
+
+ var architecture: DoryInstallerISOArchitecture {
+ switch (foundArm64, foundX86) {
+ case (true, true): .multiArchitecture
+ case (true, false): .arm64
+ case (false, true): .x86_64
+ case (false, false): .unknown
+ }
+ }
+ }
+}
+
+public struct DoryLinuxInstallerBootAssets: Sendable, Equatable {
+ public let kernel: Data
+ public let initrd: Data
+ public let kernelISOPath: String
+ public let initrdISOPath: String
+
+ public init(kernel: Data, initrd: Data, kernelISOPath: String, initrdISOPath: String) {
+ self.kernel = kernel
+ self.initrd = initrd
+ self.kernelISOPath = kernelISOPath
+ self.initrdISOPath = initrdISOPath
+ }
+}
+
+/// Derives the direct-boot payload for an installed Linux disk from its installer media. The EFI
+/// VM remains the installation backend; these immutable files let the installed disk move to
+/// Dory's accelerated VirtIO GPU runtime without attempting to emulate EFI in raw Hypervisor.
+public enum DoryLinuxInstallerBootAssetExtractor {
+ private static let maximumCompressedKernelBytes = 128 * 1024 * 1024
+ private static let maximumKernelBytes = 256 * 1024 * 1024
+ private static let maximumInitrdBytes = 512 * 1024 * 1024
+ private static let arm64ImageMagic: UInt32 = 0x644d_5241
+
+ private static let candidates: [(kernel: String, initrd: String)] = [
+ ("casper/vmlinuz", "casper/initrd"),
+ ("live/vmlinuz", "live/initrd.img"),
+ ("install.a64/vmlinuz", "install.a64/initrd.gz"),
+ ("install.arm64/vmlinuz", "install.arm64/initrd.gz"),
+ ("arch/boot/aarch64/vmlinuz-linux", "arch/boot/aarch64/initramfs-linux.img"),
+ ]
+
+ public static func extract(atPath isoPath: String) throws -> DoryLinuxInstallerBootAssets {
+ var attempted = [String]()
+ for candidate in candidates {
+ attempted.append("\(candidate.kernel) + \(candidate.initrd)")
+ guard let compressedKernel = try? DoryInstallerISOInspector.fileData(
+ atISOPath: isoPath,
+ fromPath: candidate.kernel,
+ maximumBytes: maximumCompressedKernelBytes
+ ), let initrd = try? DoryInstallerISOInspector.fileData(
+ atISOPath: isoPath,
+ fromPath: candidate.initrd,
+ maximumBytes: maximumInitrdBytes
+ ) else {
+ continue
+ }
+ let kernel = try materializeARM64Kernel(compressedKernel, source: candidate.kernel)
+ return DoryLinuxInstallerBootAssets(
+ kernel: kernel,
+ initrd: initrd,
+ kernelISOPath: candidate.kernel,
+ initrdISOPath: candidate.initrd
+ )
+ }
+ throw DoryLinuxInstallerBootAssetError.notFound(attempted)
+ }
+
+ private static func materializeARM64Kernel(_ data: Data, source: String) throws -> Data {
+ let kernel: Data
+ if data.count >= 2, data[0] == 0x1f, data[1] == 0x8b {
+ kernel = try gunzip(data, source: source)
+ } else {
+ kernel = data
+ }
+ guard kernel.count >= 0x3c,
+ littleEndianUInt32(kernel, at: 0x38) == arm64ImageMagic else {
+ throw DoryLinuxInstallerBootAssetError.unsupportedKernel(source)
+ }
+ return kernel
+ }
+
+ private static func gunzip(_ input: Data, source: String) throws -> Data {
+ guard input.count >= 18 else {
+ throw DoryLinuxInstallerBootAssetError.invalidGzip(source)
+ }
+ let expectedSize = Int(littleEndianUInt32(input, at: input.count - 4))
+ guard expectedSize > 0, expectedSize <= maximumKernelBytes else {
+ throw DoryLinuxInstallerBootAssetError.kernelTooLarge(source, expectedSize)
+ }
+ var output = Data(count: expectedSize)
+ var stream = z_stream()
+ let initialized = inflateInit2_(
+ &stream,
+ MAX_WBITS + 16,
+ ZLIB_VERSION,
+ Int32(MemoryLayout.size)
+ )
+ guard initialized == Z_OK else {
+ throw DoryLinuxInstallerBootAssetError.invalidGzip(source)
+ }
+ defer { inflateEnd(&stream) }
+
+ let result = input.withUnsafeBytes { inputBytes in
+ output.withUnsafeMutableBytes { outputBytes in
+ stream.next_in = UnsafeMutablePointer(
+ mutating: inputBytes.bindMemory(to: Bytef.self).baseAddress!
+ )
+ stream.avail_in = uInt(inputBytes.count)
+ stream.next_out = outputBytes.bindMemory(to: Bytef.self).baseAddress!
+ stream.avail_out = uInt(outputBytes.count)
+ return inflate(&stream, Z_FINISH)
+ }
+ }
+ guard result == Z_STREAM_END,
+ stream.total_out == uLong(expectedSize),
+ stream.avail_out == 0 else {
+ throw DoryLinuxInstallerBootAssetError.invalidGzip(source)
+ }
+ return output
+ }
+
+ private static func littleEndianUInt32(_ data: Data, at offset: Int) -> UInt32 {
+ UInt32(data[offset])
+ | UInt32(data[offset + 1]) << 8
+ | UInt32(data[offset + 2]) << 16
+ | UInt32(data[offset + 3]) << 24
+ }
+}
+
+public enum DoryLinuxInstallerBootAssetError: Error, LocalizedError, Sendable, Equatable {
+ case notFound([String])
+ case unsupportedKernel(String)
+ case invalidGzip(String)
+ case kernelTooLarge(String, Int)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .notFound(candidates):
+ "Installer ISO does not expose a supported arm64 Linux kernel/initrd pair (tried \(candidates.joined(separator: ", ")))."
+ case let .unsupportedKernel(path):
+ "Installer kernel \(path) is not a raw arm64 Linux Image that Dory can direct-boot."
+ case let .invalidGzip(path):
+ "Installer kernel \(path) has an invalid or unsupported gzip payload."
+ case let .kernelTooLarge(path, size):
+ "Installer kernel \(path) expands to an unsupported size (\(size) bytes)."
+ }
+ }
+}
+
+public struct DoryStagedInstallerISO: Sendable, Equatable {
+ public let path: String
+ public let identity: DoryInstallerISOMediaIdentity
+ public let runtimeQualification: DoryInstallerISORuntimeQualification
+
+ public var architecture: DoryInstallerISOArchitecture { identity.architecture }
+ public var sha256: String { identity.sha256 }
+
+ public init(
+ path: String,
+ identity: DoryInstallerISOMediaIdentity,
+ runtimeQualification: DoryInstallerISORuntimeQualification
+ ) {
+ self.path = path
+ self.identity = identity
+ self.runtimeQualification = runtimeQualification
+ }
+}
+
+/// Creates a private, daemon-readable handoff for user-selected installer media.
+///
+/// Sandboxed apps and interactive command-line tools can read files selected from protected macOS
+/// locations such as Downloads, while a launch agent intentionally cannot. This staging boundary
+/// keeps those permissions in the selecting process and gives doryd only a short-lived private copy.
+public enum DoryInstallerISOStager {
+ public static func stage(
+ atPath sourcePath: String,
+ stagingDirectory requestedDirectory: URL? = nil,
+ hostArchitecture: String = DoryInstallerISOInspector.currentHostArchitecture,
+ hostRuntime requestedHostRuntime: DoryInstallerHostRuntime? = nil
+ ) throws -> DoryStagedInstallerISO {
+ let sourceDescriptor = open(
+ sourcePath,
+ O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK
+ )
+ guard sourceDescriptor >= 0 else {
+ throw DoryInstallerISOStagingError.openSource(sourcePath, errno)
+ }
+ defer { close(sourceDescriptor) }
+
+ var sourceInfo = stat()
+ guard fstat(sourceDescriptor, &sourceInfo) == 0,
+ (sourceInfo.st_mode & S_IFMT) == S_IFREG,
+ sourceInfo.st_size > 0 else {
+ throw DoryInstallerISOStagingError.invalidSource(sourcePath)
+ }
+
+ let identity = try DoryInstallerISOInspector.mediaIdentity(
+ descriptor: sourceDescriptor,
+ size: Int64(sourceInfo.st_size),
+ path: sourcePath
+ )
+ if case let .incompatible(message) = DoryInstallerISOInspector.compatibility(
+ of: identity.architecture,
+ hostArchitecture: hostArchitecture
+ ) {
+ throw DoryInstallerISOStagingError.incompatible(message)
+ }
+ let currentHost = requestedHostRuntime ?? .current
+ let hostRuntime = DoryInstallerHostRuntime(
+ architecture: hostArchitecture,
+ hardwareModel: currentHost.hardwareModel,
+ operatingSystemVersion: currentHost.operatingSystemVersion,
+ operatingSystemBuild: currentHost.operatingSystemBuild
+ )
+ let runtimeQualification = DoryInstallerISORuntimeCatalog.qualification(
+ of: identity,
+ on: hostRuntime
+ )
+ if case let .knownUnstable(message) = runtimeQualification {
+ throw DoryInstallerISOStagingError.knownUnstable(message)
+ }
+
+ let stagingDirectory = requestedDirectory
+ ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ .appendingPathComponent("dory-installer-imports", isDirectory: true)
+ if mkdir(stagingDirectory.path, mode_t(0o700)) != 0, errno != EEXIST {
+ throw DoryInstallerISOStagingError.createStagingDirectory(
+ stagingDirectory.path,
+ errno
+ )
+ }
+ let directoryDescriptor = open(
+ stagingDirectory.path,
+ O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW
+ )
+ guard directoryDescriptor >= 0 else {
+ throw DoryInstallerISOStagingError.openStagingDirectory(
+ stagingDirectory.path,
+ errno
+ )
+ }
+ defer { close(directoryDescriptor) }
+
+ var directoryInfo = stat()
+ guard fstat(directoryDescriptor, &directoryInfo) == 0,
+ (directoryInfo.st_mode & S_IFMT) == S_IFDIR,
+ directoryInfo.st_uid == geteuid(),
+ fchmod(directoryDescriptor, mode_t(0o700)) == 0 else {
+ throw DoryInstallerISOStagingError.insecureStagingDirectory(stagingDirectory.path)
+ }
+
+ let destinationName = "\(UUID().uuidString.lowercased()).iso"
+ do {
+ if fclonefileat(sourceDescriptor, directoryDescriptor, destinationName, 0) != 0 {
+ let destinationDescriptor = openat(
+ directoryDescriptor,
+ destinationName,
+ O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
+ mode_t(0o600)
+ )
+ guard destinationDescriptor >= 0 else {
+ throw DoryInstallerISOStagingError.copy(sourcePath, errno)
+ }
+ defer { close(destinationDescriptor) }
+ guard lseek(sourceDescriptor, 0, SEEK_SET) == 0,
+ fcopyfile(
+ sourceDescriptor,
+ destinationDescriptor,
+ nil,
+ copyfile_flags_t(COPYFILE_DATA)
+ ) == 0,
+ fchmod(destinationDescriptor, mode_t(0o600)) == 0,
+ fsync(destinationDescriptor) == 0 else {
+ throw DoryInstallerISOStagingError.copy(sourcePath, errno)
+ }
+ }
+
+ let stagedDescriptor = openat(
+ directoryDescriptor,
+ destinationName,
+ O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK
+ )
+ guard stagedDescriptor >= 0 else {
+ throw DoryInstallerISOStagingError.verify(sourcePath, errno)
+ }
+ defer { close(stagedDescriptor) }
+ var stagedInfo = stat()
+ guard fchmod(stagedDescriptor, mode_t(0o600)) == 0,
+ fstat(stagedDescriptor, &stagedInfo) == 0,
+ (stagedInfo.st_mode & S_IFMT) == S_IFREG,
+ stagedInfo.st_uid == geteuid(),
+ stagedInfo.st_size == sourceInfo.st_size,
+ stagedInfo.st_mode & 0o077 == 0,
+ fsync(stagedDescriptor) == 0,
+ fsync(directoryDescriptor) == 0 else {
+ throw DoryInstallerISOStagingError.verify(sourcePath, errno)
+ }
+ } catch {
+ _ = unlinkat(directoryDescriptor, destinationName, 0)
+ throw error
+ }
+
+ return DoryStagedInstallerISO(
+ path: stagingDirectory.appendingPathComponent(destinationName).path,
+ identity: identity,
+ runtimeQualification: runtimeQualification
+ )
+ }
+}
+
+public enum DoryInstallerISOStagingError: Error, LocalizedError, CustomStringConvertible {
+ case openSource(String, Int32)
+ case invalidSource(String)
+ case incompatible(String)
+ case knownUnstable(String)
+ case createStagingDirectory(String, Int32)
+ case openStagingDirectory(String, Int32)
+ case insecureStagingDirectory(String)
+ case copy(String, Int32)
+ case verify(String, Int32)
+
+ public var description: String { errorDescription ?? "Installer ISO staging failed." }
+
+ public var errorDescription: String? {
+ switch self {
+ case let .openSource(path, code):
+ "Could not open installer ISO \(path): \(String(cString: strerror(code)))"
+ case let .invalidSource(path):
+ "Installer ISO is not a nonempty regular file: \(path)"
+ case let .incompatible(message):
+ message
+ case let .knownUnstable(message):
+ message
+ case let .createStagingDirectory(path, code):
+ "Could not create private installer staging directory \(path): \(String(cString: strerror(code)))"
+ case let .openStagingDirectory(path, code):
+ "Could not open private installer staging directory \(path): \(String(cString: strerror(code)))"
+ case let .insecureStagingDirectory(path):
+ "Installer staging directory is not private and user-owned: \(path)"
+ case let .copy(path, code):
+ "Could not stage installer ISO \(path): \(String(cString: strerror(code)))"
+ case let .verify(path, code):
+ "Could not verify staged installer ISO \(path): \(String(cString: strerror(code)))"
+ }
+ }
+}
+
+public enum DoryInstallerISOInspectionError: Error, LocalizedError {
+ case open(String, Int32)
+ case read(String, Int32)
+ case notRegularFile(String)
+ case invalidISOPath(String)
+ case fileNotFound(String)
+ case fileTooLarge(String, Int64)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .open(path, code):
+ "Could not inspect installer ISO \(path): \(String(cString: strerror(code)))"
+ case let .read(path, code):
+ "Could not read installer ISO \(path): \(String(cString: strerror(code)))"
+ case let .notRegularFile(path):
+ "Installer ISO is not a nonempty regular file: \(path)"
+ case let .invalidISOPath(path):
+ "Invalid ISO9660 file path: \(path)"
+ case let .fileNotFound(path):
+ "File was not found in the installer ISO: \(path)"
+ case let .fileTooLarge(path, size):
+ "Installer ISO file exceeds its extraction limit: \(path) (\(size) bytes)"
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryLinuxInstalledDisk.swift b/dory-core-swift/Sources/DoryOperations/DoryLinuxInstalledDisk.swift
new file mode 100644
index 00000000..a2b99727
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryLinuxInstalledDisk.swift
@@ -0,0 +1,154 @@
+import Darwin
+import Foundation
+
+/// Discovers the installed Linux root candidate from a whole-disk GPT image. Apple
+/// Virtualization presents this image as NVMe during installation; Dory's accelerated runtime
+/// presents the same bytes as VirtIO block, so partition 2 becomes `/dev/vda2` without rewriting
+/// the guest disk.
+public enum DoryLinuxInstalledDiskInspector {
+ private static let sectorSize: UInt64 = 512
+ private static let maximumPartitionTableBytes = 8 * 1024 * 1024
+ // Linux filesystem data partition GUID 0FC63DAF-8483-4772-8E79-3D69D8477DE4 in GPT byte order.
+ private static let linuxFilesystemType = Data([
+ 0xaf, 0x3d, 0xc6, 0x0f, 0x83, 0x84, 0x72, 0x47,
+ 0x8e, 0x79, 0x3d, 0x69, 0xd8, 0x47, 0x7d, 0xe4,
+ ])
+
+ public static func rootDevice(
+ atPath diskPath: String,
+ devicePrefix: String = "/dev/vda"
+ ) throws -> String {
+ guard devicePrefix.hasPrefix("/dev/"),
+ !devicePrefix.contains("\0"),
+ devicePrefix.last?.isLetter == true else {
+ throw DoryLinuxInstalledDiskInspectionError.invalidDevicePrefix(devicePrefix)
+ }
+ let descriptor = open(diskPath, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw DoryLinuxInstalledDiskInspectionError.open(diskPath, errno)
+ }
+ defer { close(descriptor) }
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ info.st_mode & S_IFMT == S_IFREG,
+ info.st_size >= off_t(sectorSize * 34) else {
+ throw DoryLinuxInstalledDiskInspectionError.invalidDisk(diskPath)
+ }
+
+ let header = try readExactly(
+ descriptor: descriptor,
+ offset: Int64(sectorSize),
+ count: Int(sectorSize),
+ path: diskPath
+ )
+ guard header.prefix(8) == Data("EFI PART".utf8) else {
+ throw DoryLinuxInstalledDiskInspectionError.missingGPT(diskPath)
+ }
+ let entryLBA = littleEndianUInt64(header, at: 72)
+ let entryCount = UInt64(littleEndianUInt32(header, at: 80))
+ let entrySize = UInt64(littleEndianUInt32(header, at: 84))
+ let (tableByteCount, tableOverflow) = entryCount.multipliedReportingOverflow(by: entrySize)
+ let (tableOffset, offsetOverflow) = entryLBA.multipliedReportingOverflow(by: sectorSize)
+ guard !tableOverflow,
+ !offsetOverflow,
+ entryCount > 0,
+ entrySize >= 128,
+ entrySize <= 4_096,
+ tableByteCount > 0,
+ tableByteCount <= UInt64(maximumPartitionTableBytes),
+ tableOffset < UInt64(info.st_size),
+ tableByteCount <= UInt64(info.st_size) - tableOffset else {
+ throw DoryLinuxInstalledDiskInspectionError.invalidGPT(diskPath)
+ }
+ let entries = try readExactly(
+ descriptor: descriptor,
+ offset: Int64(tableOffset),
+ count: Int(tableByteCount),
+ path: diskPath
+ )
+
+ var candidates = [(index: Int, sectors: UInt64)]()
+ for index in 0.. 0, lastLBA >= firstLBA else { continue }
+ candidates.append((index + 1, lastLBA - firstLBA + 1))
+ }
+ guard let root = candidates.max(by: { $0.sectors < $1.sectors }) else {
+ throw DoryLinuxInstalledDiskInspectionError.rootPartitionNotFound(diskPath)
+ }
+ return "\(devicePrefix)\(root.index)"
+ }
+
+ private static func readExactly(
+ descriptor: Int32,
+ offset: Int64,
+ count: Int,
+ path: String
+ ) throws -> Data {
+ var data = Data(count: count)
+ var completed = 0
+ while completed < count {
+ let bytesRead = data.withUnsafeMutableBytes { bytes in
+ pread(
+ descriptor,
+ bytes.baseAddress!.advanced(by: completed),
+ count - completed,
+ off_t(offset + Int64(completed))
+ )
+ }
+ if bytesRead < 0 {
+ if errno == EINTR { continue }
+ throw DoryLinuxInstalledDiskInspectionError.read(path, errno)
+ }
+ guard bytesRead > 0 else {
+ throw DoryLinuxInstalledDiskInspectionError.read(path, EIO)
+ }
+ completed += bytesRead
+ }
+ return data
+ }
+
+ private static func littleEndianUInt32(_ data: Data, at offset: Int) -> UInt32 {
+ UInt32(data[offset])
+ | UInt32(data[offset + 1]) << 8
+ | UInt32(data[offset + 2]) << 16
+ | UInt32(data[offset + 3]) << 24
+ }
+
+ private static func littleEndianUInt64(_ data: Data, at offset: Int) -> UInt64 {
+ UInt64(littleEndianUInt32(data, at: offset))
+ | UInt64(littleEndianUInt32(data, at: offset + 4)) << 32
+ }
+}
+
+public enum DoryLinuxInstalledDiskInspectionError: Error, LocalizedError, Sendable, Equatable {
+ case invalidDevicePrefix(String)
+ case open(String, Int32)
+ case read(String, Int32)
+ case invalidDisk(String)
+ case missingGPT(String)
+ case invalidGPT(String)
+ case rootPartitionNotFound(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .invalidDevicePrefix(prefix):
+ "Invalid Linux block-device prefix: \(prefix)"
+ case let .open(path, code):
+ "Could not open installed Linux disk \(path): \(String(cString: strerror(code)))"
+ case let .read(path, code):
+ "Could not read installed Linux disk \(path): \(String(cString: strerror(code)))"
+ case let .invalidDisk(path):
+ "Installed Linux disk is not a valid nonempty regular image: \(path)"
+ case let .missingGPT(path):
+ "Installed Linux disk has no GPT partition table: \(path)"
+ case let .invalidGPT(path):
+ "Installed Linux disk has an invalid GPT partition table: \(path)"
+ case let .rootPartitionNotFound(path):
+ "Installed Linux disk has no Linux filesystem partition to direct-boot: \(path)"
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
index 6c4e2685..d091c62e 100644
--- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
@@ -1,5 +1,6 @@
import Darwin
import DoryCore
+import DoryOperations
import DorydKit
import Foundation
@preconcurrency import Virtualization
@@ -21,6 +22,8 @@ public struct DoryVMMArguments: Sendable, Equatable {
public var dataDriveRoot: String?
public var kernelPath: String?
public var rootfsPath: String?
+ public var machineBootMode: DoryMachineBootMode = .linuxKernel
+ public var installerISOPath: String?
public var gvproxyPath: String?
public var sshAgentSocketPath: String?
public var publishHost = "127.0.0.1"
@@ -64,6 +67,7 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver
case missingGVProxy
case invalidPublishHost(String)
case invalidDisplayMode(String)
+ case invalidMachineBootMode(String)
case invalidEnvironment(String)
public var description: String {
@@ -88,6 +92,8 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver
return "invalid --publish-host (expected 127.0.0.1 or 0.0.0.0): \(host)"
case let .invalidDisplayMode(mode):
return "invalid --display-mode (expected headless or desktop): \(mode)"
+ case let .invalidMachineBootMode(mode):
+ return "invalid --boot-mode (expected linux-kernel or efi): \(mode)"
case let .invalidEnvironment(value):
return "invalid --env value: \(value)"
}
@@ -111,6 +117,14 @@ public func parseDoryVMMArguments(_ raw: [String]) throws -> DoryVMMArguments {
parsed.kernelPath = try value(after: argument, from: raw, index: &index)
case "--rootfs":
parsed.rootfsPath = try value(after: argument, from: raw, index: &index)
+ case "--boot-mode":
+ let value = try value(after: argument, from: raw, index: &index)
+ guard let mode = DoryMachineBootMode(rawValue: value) else {
+ throw DoryVMMArgumentError.invalidMachineBootMode(value)
+ }
+ parsed.machineBootMode = mode
+ case "--installer-iso":
+ parsed.installerISOPath = try value(after: argument, from: raw, index: &index)
case "--gvproxy":
parsed.gvproxyPath = try value(after: argument, from: raw, index: &index)
case "--ssh-agent-socket":
@@ -202,6 +216,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable {
public var stateDirectory: String
public var kernelPath: String
public var rootfsPath: String
+ public var bootMode: DoryMachineBootMode
+ public var installerISOPath: String?
public var memoryMB: UInt64
public var cpuCount: Int
public var displayMode: DoryMachineDisplayMode
@@ -218,6 +234,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable {
stateDirectory: String,
kernelPath: String,
rootfsPath: String,
+ bootMode: DoryMachineBootMode = .linuxKernel,
+ installerISOPath: String? = nil,
memoryMB: UInt64,
cpuCount: Int,
displayMode: DoryMachineDisplayMode = .headless,
@@ -233,6 +251,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable {
self.stateDirectory = stateDirectory
self.kernelPath = kernelPath
self.rootfsPath = rootfsPath
+ self.bootMode = bootMode
+ self.installerISOPath = installerISOPath
self.memoryMB = memoryMB
self.cpuCount = cpuCount
self.displayMode = displayMode
@@ -288,6 +308,7 @@ public enum DoryVZConfigurationBuilder {
public static func makeConfiguration(
spec: DoryVZMachineSpec,
serialOutput: FileHandle?,
+ serialInput: FileHandle? = nil,
networkAttachment: VZNetworkDeviceAttachment? = nil
) throws -> VZVirtualMachineConfiguration {
let fileManager = FileManager.default
@@ -308,18 +329,40 @@ public enum DoryVZConfigurationBuilder {
"native IPv6 requires the gvproxy file-handle network attachment"
)
}
- guard fileManager.fileExists(atPath: spec.kernelPath) else {
- throw DoryVZMachineError.missingFile(spec.kernelPath)
- }
guard fileManager.fileExists(atPath: spec.rootfsPath) else {
throw DoryVZMachineError.missingFile(spec.rootfsPath)
}
- let bootLoader = VZLinuxBootLoader(kernelURL: URL(fileURLWithPath: spec.kernelPath))
- bootLoader.commandLine = spec.kernelCommandLine ?? defaultKernelCommandLine(machineID: spec.machineID)
-
let configuration = VZVirtualMachineConfiguration()
- configuration.bootLoader = bootLoader
+ switch spec.bootMode {
+ case .linuxKernel:
+ guard fileManager.fileExists(atPath: spec.kernelPath) else {
+ throw DoryVZMachineError.missingFile(spec.kernelPath)
+ }
+ let bootLoader = VZLinuxBootLoader(kernelURL: URL(fileURLWithPath: spec.kernelPath))
+ bootLoader.commandLine = spec.kernelCommandLine ?? defaultKernelCommandLine(machineID: spec.machineID)
+ configuration.bootLoader = bootLoader
+ case .efi:
+ guard spec.displayMode == .desktop else {
+ throw DoryVZMachineError.validation("EFI boot requires desktop display mode")
+ }
+ let platform = VZGenericPlatformConfiguration()
+ platform.machineIdentifier = try persistentMachineIdentifier(
+ at: spec.stateDirectory + "/MachineIdentifier"
+ )
+ let bootLoader = VZEFIBootLoader()
+ // Keep installation/recovery firmware state separate from the installed system.
+ // UEFI remembers the disk as its first boot target after an OS installation, and
+ // merely reconnecting a USB ISO does not override that persisted BootOrder. A
+ // dedicated installer variable store gives attached media a stable CD-first boot
+ // profile while preserving the installed disk's firmware entries for normal boots.
+ let variableStoreName = spec.installerISOPath == nil ? "NVRAM" : "NVRAM.installer"
+ bootLoader.variableStore = try persistentEFIVariableStore(
+ at: spec.stateDirectory + "/\(variableStoreName)"
+ )
+ configuration.platform = platform
+ configuration.bootLoader = bootLoader
+ }
configuration.cpuCount = spec.cpuCount
configuration.memorySize = memorySize
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
@@ -344,14 +387,25 @@ public enum DoryVZConfigurationBuilder {
let output = VZVirtioSoundDeviceOutputStreamConfiguration()
output.sink = VZHostAudioOutputStreamSink()
- let sound = VZVirtioSoundDeviceConfiguration()
- sound.streams = [output]
- configuration.audioDevices = [sound]
+ let outputSound = VZVirtioSoundDeviceConfiguration()
+ outputSound.streams = [output]
+
+ let input = VZVirtioSoundDeviceInputStreamConfiguration()
+ input.source = VZHostAudioInputStreamSource()
+ let inputSound = VZVirtioSoundDeviceConfiguration()
+ inputSound.streams = [input]
+ configuration.audioDevices = [outputSound, inputSound]
let console = VZVirtioConsoleDeviceConfiguration()
let spiceAgent = VZVirtioConsolePortConfiguration()
spiceAgent.name = VZSpiceAgentPortAttachment.spiceAgentPortName
- spiceAgent.attachment = VZSpiceAgentPortAttachment()
+ let spiceAttachment = VZSpiceAgentPortAttachment()
+ // The native SPICE bridge has only an all-or-nothing switch. Keep it for the efficient
+ // bidirectional default; directional policies use Dory's agent-backed bridge instead.
+ spiceAttachment.sharesClipboard = DoryDesktopClipboardPolicy(
+ environment: spec.environment
+ ) == .bidirectional
+ spiceAgent.attachment = spiceAttachment
console.ports[0] = spiceAgent
configuration.consoleDevices = [console]
}
@@ -411,14 +465,48 @@ public enum DoryVZConfigurationBuilder {
}
do {
- let attachment = try VZDiskImageStorageDeviceAttachment(
- url: URL(fileURLWithPath: spec.rootfsPath),
- readOnly: false
- )
- let block = VZVirtioBlockDeviceConfiguration(attachment: attachment)
- block.blockDeviceIdentifier = "dory-rootfs"
- configuration.storageDevices = [block]
+ let rootURL = URL(fileURLWithPath: spec.rootfsPath)
+ if spec.bootMode == .efi {
+ // EFI installers exercise long, flush-heavy writes before Dory guest tools exist.
+ // Apple's native NVMe model avoids tying that workload to the VirtIO-block path
+ // used by Dory-owned direct-kernel guests. Cached host I/O with fsync semantics
+ // matches a normal virtual disk while retaining crash-consistent guest flushes.
+ let attachment = try VZDiskImageStorageDeviceAttachment(
+ url: rootURL,
+ readOnly: false,
+ cachingMode: .cached,
+ synchronizationMode: .fsync
+ )
+ configuration.storageDevices = [
+ VZNVMExpressControllerDeviceConfiguration(attachment: attachment),
+ ]
+ } else {
+ let attachment = try VZDiskImageStorageDeviceAttachment(
+ url: rootURL,
+ readOnly: false
+ )
+ let block = VZVirtioBlockDeviceConfiguration(attachment: attachment)
+ block.blockDeviceIdentifier = "dory-rootfs"
+ configuration.storageDevices = [block]
+ }
+ if let installerISOPath = spec.installerISOPath {
+ guard spec.bootMode == .efi else {
+ throw DoryVZMachineError.validation("installer ISO requires EFI boot mode")
+ }
+ guard fileManager.fileExists(atPath: installerISOPath) else {
+ throw DoryVZMachineError.missingFile(installerISOPath)
+ }
+ let installerAttachment = try VZDiskImageStorageDeviceAttachment(
+ url: URL(fileURLWithPath: installerISOPath),
+ readOnly: true
+ )
+ configuration.storageDevices.insert(
+ VZUSBMassStorageDeviceConfiguration(attachment: installerAttachment),
+ at: 0
+ )
+ }
} catch {
+ if let error = error as? DoryVZMachineError { throw error }
throw DoryVZMachineError.storageAttachment("\(error)")
}
@@ -439,7 +527,7 @@ public enum DoryVZConfigurationBuilder {
if let serialOutput {
let serial = VZVirtioConsoleDeviceSerialPortConfiguration()
serial.attachment = VZFileHandleSerialPortAttachment(
- fileHandleForReading: nil,
+ fileHandleForReading: serialInput,
fileHandleForWriting: serialOutput
)
configuration.serialPorts = [serial]
@@ -448,6 +536,35 @@ public enum DoryVZConfigurationBuilder {
return configuration
}
+ private static func persistentMachineIdentifier(at path: String) throws -> VZGenericMachineIdentifier {
+ let url = URL(fileURLWithPath: path)
+ if FileManager.default.fileExists(atPath: path) {
+ let data = try Data(contentsOf: url)
+ guard let identifier = VZGenericMachineIdentifier(dataRepresentation: data) else {
+ throw DoryVZMachineError.validation("stored EFI machine identifier is invalid")
+ }
+ return identifier
+ }
+ let identifier = VZGenericMachineIdentifier()
+ try identifier.dataRepresentation.write(to: url, options: [.atomic])
+ guard chmod(path, mode_t(0o600)) == 0 else {
+ throw DoryVZMachineError.syscall("chmod MachineIdentifier", errno)
+ }
+ return identifier
+ }
+
+ private static func persistentEFIVariableStore(at path: String) throws -> VZEFIVariableStore {
+ let url = URL(fileURLWithPath: path)
+ if FileManager.default.fileExists(atPath: path) {
+ return VZEFIVariableStore(url: url)
+ }
+ let store = try VZEFIVariableStore(creatingVariableStoreAt: url)
+ guard chmod(path, mode_t(0o600)) == 0 else {
+ throw DoryVZMachineError.syscall("chmod NVRAM", errno)
+ }
+ return store
+ }
+
public static func defaultKernelCommandLine(machineID: String) -> String {
"console=hvc0 root=/dev/vda rw rootwait panic=1 dory.machine_id=\(machineID)"
}
@@ -705,6 +822,8 @@ public enum DoryVMMMain {
stateDirectory: stateDirectory,
kernelPath: kernelPath,
rootfsPath: rootfsPath,
+ bootMode: arguments.machineBootMode,
+ installerISOPath: arguments.installerISOPath,
handoffSocketPath: handoffSocketPath,
dockerdSocketPath: arguments.dockerdSocketPath ?? "\(stateDirectory)/dockerd.sock",
agentSocketPath: arguments.agentSocketPath ?? "\(stateDirectory)/agent.sock",
@@ -746,7 +865,8 @@ public enum DoryVMMMain {
try MainActor.assumeIsolated {
try DoryVMMDesktopApplication.run(
runtime: runtime,
- machineID: machineID
+ machineID: machineID,
+ environment: arguments.environment
)
}
} else {
@@ -789,6 +909,8 @@ public enum DoryVMMMain {
stateDirectory: String,
kernelPath: String,
rootfsPath: String,
+ bootMode: DoryMachineBootMode,
+ installerISOPath: String?,
handoffSocketPath: String,
dockerdSocketPath: String,
agentSocketPath: String,
@@ -816,6 +938,21 @@ public enum DoryVMMMain {
)
}
let serialLog = try openAppendLog("\(stateDirectory)/serial.log")
+ try appendBootSessionMarker(
+ to: serialLog,
+ machineID: machineID,
+ bootMode: bootMode,
+ cpuCount: cpuCount,
+ memoryMB: memoryMB,
+ runtimeProfile: bootMode == .efi
+ ? DoryInstallerRuntimeProfile.current.rawValue
+ : "linux-kernel-virtio-blk"
+ )
+ let runtimeSocketDirectory = (controlSocketPath as NSString).deletingLastPathComponent
+ let serialConsole = try DoryVMMSerialConsole(
+ socketPath: "\(runtimeSocketDirectory)/console.sock",
+ log: serialLog
+ )
let dataDrive: DoryDataDrive?
if let dataDriveRoot, machineID == "docker" {
dataDrive = try DoryDataDrive(overrideRoot: dataDriveRoot)
@@ -869,6 +1006,8 @@ public enum DoryVMMMain {
stateDirectory: stateDirectory,
kernelPath: kernelPath,
rootfsPath: rootfsPath,
+ bootMode: bootMode,
+ installerISOPath: installerISOPath,
memoryMB: memoryMB,
cpuCount: cpuCount,
displayMode: displayMode,
@@ -882,7 +1021,8 @@ public enum DoryVMMMain {
)
let configuration = try DoryVZConfigurationBuilder.makeConfiguration(
spec: spec,
- serialOutput: serialLog,
+ serialOutput: serialConsole.guestOutput,
+ serialInput: serialConsole.guestInput,
networkAttachment: gvproxyNetwork?.attachment
)
try validate(configuration: configuration)
@@ -937,6 +1077,7 @@ public enum DoryVMMMain {
controlServer: controlServer,
proxies: [dockerdProxy, agentProxy, shellProxy],
serialLog: serialLog,
+ serialConsole: serialConsole,
gvproxyNetwork: gvproxyNetwork,
sourcePreservingLANClient: sourcePreservingLANClient,
sourcePreservingLANSessionID: sourcePreservingLANSessionID,
@@ -957,6 +1098,22 @@ public enum DoryVMMMain {
runtime.portForwarder?.start()
onRuntimeCreated(runtime)
+ if bootMode == .efi {
+ try sendHandoff(
+ machineID: machineID,
+ handoffSocketPath: handoffSocketPath,
+ agentBuild: "dory-vmm/efi",
+ agentSocketPath: nil,
+ dockerdSocketPath: nil,
+ shellSocketPath: nil,
+ controlSocketPath: controlSocketPath,
+ detail: installerISOPath == nil
+ ? "EFI machine running from its installed disk"
+ : "EFI machine running with read-only installer media attached"
+ )
+ return runtime
+ }
+
let agentConnection = try machine.waitForConnection(toPort: DoryGuestPorts.control, timeout: readyTimeoutSeconds)
defer { agentConnection.close() }
let agentInfo = try prepareAgent(
@@ -1058,6 +1215,20 @@ public enum DoryVMMMain {
return FileHandle(fileDescriptor: fd, closeOnDealloc: true)
}
+ private static func appendBootSessionMarker(
+ to log: FileHandle,
+ machineID: String,
+ bootMode: DoryMachineBootMode,
+ cpuCount: Int,
+ memoryMB: UInt64,
+ runtimeProfile: String
+ ) throws {
+ let timestamp = Date().formatted(.iso8601)
+ let marker = "\n--- DORY BOOT \(timestamp) machine=\(machineID) mode=\(bootMode.rawValue) cpus=\(cpuCount) memoryMB=\(memoryMB) runtime=\(runtimeProfile) ---\n"
+ try log.write(contentsOf: Data(marker.utf8))
+ try log.synchronize()
+ }
+
private static func validate(configuration: VZVirtualMachineConfiguration) throws {
do {
try configuration.validate()
@@ -1199,6 +1370,7 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable {
private let controlServer: DoryVMMControlServer
private let proxies: [DoryVZPortUnixProxy]
private let serialLog: FileHandle
+ private let serialConsole: DoryVMMSerialConsole
private let gvproxyNetwork: DoryVMMGVProxyNetwork?
private let sourcePreservingLANClient: SourcePreservingLANPrivilegedClient?
private let sourcePreservingLANSessionID: String?
@@ -1210,6 +1382,7 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable {
controlServer: DoryVMMControlServer,
proxies: [DoryVZPortUnixProxy],
serialLog: FileHandle,
+ serialConsole: DoryVMMSerialConsole,
gvproxyNetwork: DoryVMMGVProxyNetwork?,
sourcePreservingLANClient: SourcePreservingLANPrivilegedClient?,
sourcePreservingLANSessionID: String?,
@@ -1220,6 +1393,7 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable {
self.controlServer = controlServer
self.proxies = proxies
self.serialLog = serialLog
+ self.serialConsole = serialConsole
self.gvproxyNetwork = gvproxyNetwork
self.sourcePreservingLANClient = sourcePreservingLANClient
self.sourcePreservingLANSessionID = sourcePreservingLANSessionID
@@ -1231,6 +1405,26 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable {
machine.isStopped
}
+ func executeDesktopIntegration(
+ argv: [String],
+ stdin: Data,
+ timeoutMs: UInt64,
+ outputLimitBytes: UInt64
+ ) throws -> DoryExecResult {
+ let connection = try machine.connect(toPort: DoryGuestPorts.control)
+ defer { connection.close() }
+ let fd = dup(connection.fileDescriptor)
+ guard fd >= 0 else { throw DoryVZMachineError.syscall("dup", errno) }
+ let control = try DoryCore.connectAgentControlOverFD(fd)
+ defer { control.close() }
+ return try control.execWithInput(
+ argv: argv,
+ stdin: stdin,
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes
+ )
+ }
+
func requestGuestShutdown() throws {
do {
try requestGuestShutdownThroughAgent()
@@ -1244,6 +1438,14 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable {
try gvproxyNetwork.requestGuestShutdown()
return
}
+ do {
+ try machine.requestGuestStop()
+ return
+ } catch {
+ FileHandle.standardError.write(Data(
+ "dory-vmm: virtual power-button shutdown request failed, trying transport fallback: \(error)\n".utf8
+ ))
+ }
let deadline = Date().addingTimeInterval(5)
var lastError: Error?
repeat {
@@ -1301,6 +1503,7 @@ final class DoryVMMRuntime: DoryVMMGuestShutdownHandling, @unchecked Sendable {
))
}
gvproxyNetwork?.stop()
+ serialConsole.stop()
try? serialLog.close()
}
}
@@ -1410,6 +1613,20 @@ public final class DoryVZMachine: @unchecked Sendable {
try stopObserver.waitUntilStopped()
}
+ /// Ask the guest firmware/OS to shut down as if its virtual power button were pressed.
+ /// This is the integration-free shutdown path for arbitrary EFI guests that do not run
+ /// Dory's agent yet; it gives Linux a chance to flush filesystems before the VMM exits.
+ public func requestGuestStop() throws {
+ try queue.sync {
+ guard virtualMachine.canRequestStop else {
+ throw DoryVZMachineError.validation(
+ "virtual machine is not in a state that accepts a guest stop request"
+ )
+ }
+ try virtualMachine.requestStop()
+ }
+ }
+
public func connect(toPort port: UInt32) throws -> VZVirtioSocketConnection {
let box = BlockingResultBox()
queue.async { [self] in
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift
index 5df9de7f..d4b53c5d 100644
--- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift
@@ -1,4 +1,5 @@
import AppKit
+import DoryOperations
import Foundation
@preconcurrency import Virtualization
@@ -8,24 +9,48 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
private let runtime: DoryVMMRuntime
private let machineView: VZVirtualMachineView
private let window: NSWindow
+ private let clipboard: DoryDesktopClipboardCoordinator
private var pendingDisplayResize: DispatchWorkItem?
private var requestedPixelSize: CGSize?
private var stopError: String?
- private init(runtime: DoryVMMRuntime, machineID: String) {
+ private init(runtime: DoryVMMRuntime, machineID: String, environment: [String: String]) {
self.application = NSApplication.shared
self.runtime = runtime
let windowSize = NSSize(width: 1_280, height: 800)
- let machineView = VZVirtualMachineView(frame: NSRect(origin: .zero, size: windowSize))
+ let machineView = DoryVirtualMachineView(frame: NSRect(origin: .zero, size: windowSize))
machineView.virtualMachine = runtime.machine.virtualMachineForDisplay
// Apple's automatic path currently requests the view's point size on Retina displays.
// Dory drives the scanout with backing pixels so a 1280x800-point window renders a true
// 2560x1600 guest framebuffer instead of stretching a low-resolution desktop.
machineView.automaticallyReconfiguresDisplay = false
- machineView.capturesSystemKeys = false
+ machineView.capturesSystemKeys = true
self.machineView = machineView
+ let requestedPolicy = DoryDesktopClipboardPolicy(environment: environment)
+ // Bidirectional sharing is already handled by Apple's efficient SPICE transport. The
+ // shared coordinator still translates Mac shortcuts, while directional modes use its
+ // agent-backed data path to enforce the selected boundary.
+ let coordinatorPolicy: DoryDesktopClipboardPolicy = requestedPolicy == .bidirectional
+ ? .off
+ : requestedPolicy
+ self.clipboard = DoryDesktopClipboardCoordinator(
+ policy: coordinatorPolicy,
+ execute: { argv, stdin, timeoutMs, outputLimitBytes in
+ try runtime.executeDesktopIntegration(
+ argv: argv,
+ stdin: stdin,
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes
+ )
+ },
+ sendShortcut: { keyCode in machineView.sendControlShortcut(linuxKeyCode: keyCode) },
+ log: { message in
+ FileHandle.standardError.write(Data("dory-vmm clipboard: \(message)\n".utf8))
+ }
+ )
+
self.window = NSWindow(
contentRect: NSRect(origin: .zero, size: windowSize),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
@@ -35,19 +60,34 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
self.window.title = "\(machineID) — Dory Linux"
self.window.contentView = machineView
self.window.minSize = NSSize(width: 640, height: 400)
+ self.window.collectionBehavior.insert(.fullScreenPrimary)
+ self.window.tabbingMode = .disallowed
self.window.center()
super.init()
self.window.delegate = self
+ machineView.onMacShortcut = { [weak clipboard] event in
+ clipboard?.handleMacShortcut(event) ?? false
+ }
}
- static func run(runtime: DoryVMMRuntime, machineID: String) throws {
- let controller = DoryVMMDesktopApplication(runtime: runtime, machineID: machineID)
+ static func run(
+ runtime: DoryVMMRuntime,
+ machineID: String,
+ environment: [String: String]
+ ) throws {
+ let controller = DoryVMMDesktopApplication(
+ runtime: runtime,
+ machineID: machineID,
+ environment: environment
+ )
try controller.runUntilStopped()
}
private func runUntilStopped() throws {
application.setActivationPolicy(.regular)
application.delegate = self
+ clipboard.start()
+ clipboard.markGuestReady()
window.makeKeyAndOrderFront(nil)
application.activate()
reconfigureDisplayNow()
@@ -67,6 +107,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
}
application.run()
+ clipboard.stop()
if let stopError {
throw DoryVZMachineError.stoppedWithError(stopError)
}
@@ -162,3 +203,70 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
}
}
}
+
+/// `VZVirtualMachineView` forwards the device-oriented Core Graphics wheel deltas to Linux.
+/// AppKit normally applies the user's macOS natural-scrolling preference before an `NSView`
+/// consumes those deltas, but the VM view bypasses that normalization. Preserve all of the event's
+/// phase and momentum metadata while correcting the delta fields only when AppKit says the host
+/// preference inverted them from the physical device.
+@MainActor
+private final class DoryVirtualMachineView: VZVirtualMachineView {
+ var onMacShortcut: ((NSEvent) -> Bool)?
+
+ override func keyDown(with event: NSEvent) {
+ if onMacShortcut?(event) == true { return }
+ super.keyDown(with: event)
+ }
+
+ func sendControlShortcut(linuxKeyCode: UInt16) {
+ guard let macKeyCode = Self.macKeyCode(forLinuxKeyCode: linuxKeyCode) else { return }
+ for keyDown in [true, false] {
+ guard let cgEvent = CGEvent(
+ keyboardEventSource: nil,
+ virtualKey: CGKeyCode(macKeyCode),
+ keyDown: keyDown
+ ) else { continue }
+ cgEvent.flags = .maskControl
+ if let event = NSEvent(cgEvent: cgEvent) {
+ if keyDown { super.keyDown(with: event) } else { super.keyUp(with: event) }
+ }
+ }
+ }
+
+ private static func macKeyCode(forLinuxKeyCode code: UInt16) -> UInt16? {
+ switch code {
+ case 46: 8 // C
+ case 45: 7 // X
+ case 47: 9 // V
+ default: nil
+ }
+ }
+
+ override func scrollWheel(with event: NSEvent) {
+ guard event.isDirectionInvertedFromDevice,
+ let correctedCGEvent = event.cgEvent?.copy() else {
+ super.scrollWheel(with: event)
+ return
+ }
+
+ Self.invertIntegerDelta(.scrollWheelEventDeltaAxis1, in: correctedCGEvent)
+ Self.invertIntegerDelta(.scrollWheelEventDeltaAxis2, in: correctedCGEvent)
+ Self.invertIntegerDelta(.scrollWheelEventPointDeltaAxis1, in: correctedCGEvent)
+ Self.invertIntegerDelta(.scrollWheelEventPointDeltaAxis2, in: correctedCGEvent)
+ Self.invertFixedPointDelta(.scrollWheelEventFixedPtDeltaAxis1, in: correctedCGEvent)
+ Self.invertFixedPointDelta(.scrollWheelEventFixedPtDeltaAxis2, in: correctedCGEvent)
+ guard let correctedEvent = NSEvent(cgEvent: correctedCGEvent) else {
+ super.scrollWheel(with: event)
+ return
+ }
+ super.scrollWheel(with: correctedEvent)
+ }
+
+ private static func invertIntegerDelta(_ field: CGEventField, in event: CGEvent) {
+ event.setIntegerValueField(field, value: -event.getIntegerValueField(field))
+ }
+
+ private static func invertFixedPointDelta(_ field: CGEventField, in event: CGEvent) {
+ event.setDoubleValueField(field, value: -event.getDoubleValueField(field))
+ }
+}
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMSerialConsole.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMSerialConsole.swift
new file mode 100644
index 00000000..ba1818d1
--- /dev/null
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMSerialConsole.swift
@@ -0,0 +1,267 @@
+import Darwin
+import Foundation
+
+/// Bidirectional, private serial console for EFI installers and recovery boots.
+///
+/// Virtualization.framework receives one end of a socket pair. The host end is multiplexed to the
+/// durable serial log and one local Unix-socket client, so diagnostics remain available even when
+/// no terminal is attached. The socket is user-only and lives inside Dory's short runtime directory
+/// because Darwin Unix-domain socket paths have a small fixed limit.
+final class DoryVMMSerialConsole: @unchecked Sendable {
+ let guestInput: FileHandle
+ let guestOutput: FileHandle
+
+ private let hostInputDescriptor: Int32
+ private let hostOutputDescriptor: Int32
+ private let listenerDescriptor: Int32
+ private let log: FileHandle
+ private let socketPath: String
+ private let queue = DispatchQueue(label: "dev.dory.dory-vmm.serial-console")
+ private let lock = NSLock()
+ private var running = true
+ private var clientDescriptor: Int32 = -1
+
+ init(socketPath: String, log: FileHandle) throws {
+ self.socketPath = socketPath
+ self.log = log
+
+ var inputPair = [Int32](repeating: -1, count: 2)
+ guard socketpair(AF_UNIX, SOCK_STREAM, 0, &inputPair) == 0 else {
+ throw DoryVZMachineError.syscall("socketpair serial console input", errno)
+ }
+ var outputPair = [Int32](repeating: -1, count: 2)
+ guard socketpair(AF_UNIX, SOCK_STREAM, 0, &outputPair) == 0 else {
+ close(inputPair[0])
+ close(inputPair[1])
+ throw DoryVZMachineError.syscall("socketpair serial console output", errno)
+ }
+ let hostInput = inputPair[0]
+ let input = inputPair[1]
+ let hostOutput = outputPair[0]
+ let output = outputPair[1]
+
+ let listener = socket(AF_UNIX, SOCK_STREAM, 0)
+ guard listener >= 0 else {
+ close(input)
+ close(output)
+ close(hostInput)
+ close(hostOutput)
+ throw DoryVZMachineError.syscall("socket serial console", errno)
+ }
+
+ do {
+ try Self.configureDescriptor(hostInput, nonblocking: true)
+ try Self.configureDescriptor(hostOutput, nonblocking: true)
+ try Self.configureDescriptor(input, nonblocking: false)
+ try Self.configureDescriptor(output, nonblocking: false)
+ try Self.configureDescriptor(listener, nonblocking: true)
+ try Self.bind(listener: listener, path: socketPath)
+ } catch {
+ close(listener)
+ close(input)
+ close(output)
+ close(hostInput)
+ close(hostOutput)
+ throw error
+ }
+
+ hostInputDescriptor = hostInput
+ hostOutputDescriptor = hostOutput
+ listenerDescriptor = listener
+ guestInput = FileHandle(fileDescriptor: input, closeOnDealloc: true)
+ guestOutput = FileHandle(fileDescriptor: output, closeOnDealloc: true)
+
+ queue.async { [weak self] in self?.run() }
+ }
+
+ func stop() {
+ lock.lock()
+ guard running else {
+ lock.unlock()
+ return
+ }
+ running = false
+ let client = clientDescriptor
+ clientDescriptor = -1
+ lock.unlock()
+
+ if client >= 0 {
+ shutdown(client, SHUT_RDWR)
+ close(client)
+ }
+ shutdown(listenerDescriptor, SHUT_RDWR)
+ close(listenerDescriptor)
+ shutdown(hostInputDescriptor, SHUT_RDWR)
+ close(hostInputDescriptor)
+ shutdown(hostOutputDescriptor, SHUT_RDWR)
+ close(hostOutputDescriptor)
+ try? guestInput.close()
+ try? guestOutput.close()
+ unlink(socketPath)
+ }
+
+ deinit {
+ stop()
+ }
+
+ private func run() {
+ var buffer = [UInt8](repeating: 0, count: 32 * 1024)
+ while isRunning {
+ let client = currentClient
+ var descriptors = [
+ pollfd(fd: hostOutputDescriptor, events: Int16(POLLIN), revents: 0),
+ pollfd(fd: listenerDescriptor, events: Int16(POLLIN), revents: 0),
+ ]
+ if client >= 0 {
+ descriptors.append(pollfd(fd: client, events: Int16(POLLIN), revents: 0))
+ }
+
+ let result = poll(&descriptors, nfds_t(descriptors.count), 250)
+ if result < 0 {
+ if errno == EINTR { continue }
+ break
+ }
+ if result == 0 { continue }
+
+ if descriptors[1].revents & Int16(POLLIN) != 0 {
+ acceptClient()
+ }
+ if descriptors[0].revents & Int16(POLLIN) != 0 {
+ let count = Darwin.read(hostOutputDescriptor, &buffer, buffer.count)
+ if count > 0 {
+ let data = Data(buffer.prefix(count))
+ try? log.write(contentsOf: data)
+ forwardToClient(buffer: &buffer, count: count)
+ } else if count == 0 {
+ break
+ } else if errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR {
+ break
+ }
+ }
+ if descriptors.count == 3, descriptors[2].revents & Int16(POLLIN) != 0 {
+ let count = Darwin.read(client, &buffer, buffer.count)
+ if count > 0 {
+ sendAll(descriptor: hostInputDescriptor, buffer: &buffer, count: count)
+ } else if count == 0 || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
+ disconnectClient(client)
+ }
+ }
+ }
+ }
+
+ private var isRunning: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return running
+ }
+
+ private var currentClient: Int32 {
+ lock.lock()
+ defer { lock.unlock() }
+ return clientDescriptor
+ }
+
+ private func acceptClient() {
+ let accepted = accept(listenerDescriptor, nil, nil)
+ guard accepted >= 0 else { return }
+ do {
+ try Self.configureDescriptor(accepted, nonblocking: true)
+ } catch {
+ close(accepted)
+ return
+ }
+
+ lock.lock()
+ let previous = clientDescriptor
+ clientDescriptor = accepted
+ lock.unlock()
+ if previous >= 0 {
+ shutdown(previous, SHUT_RDWR)
+ close(previous)
+ }
+ }
+
+ private func disconnectClient(_ descriptor: Int32) {
+ lock.lock()
+ if clientDescriptor == descriptor { clientDescriptor = -1 }
+ lock.unlock()
+ shutdown(descriptor, SHUT_RDWR)
+ close(descriptor)
+ }
+
+ private func forwardToClient(buffer: inout [UInt8], count: Int) {
+ let client = currentClient
+ guard client >= 0 else { return }
+ if !sendAll(descriptor: client, buffer: &buffer, count: count) {
+ disconnectClient(client)
+ }
+ }
+
+ @discardableResult
+ private func sendAll(descriptor: Int32, buffer: inout [UInt8], count: Int) -> Bool {
+ var offset = 0
+ while offset < count {
+ let sent = buffer.withUnsafeBytes { rawBuffer -> Int in
+ guard let base = rawBuffer.baseAddress else { return -1 }
+ return Darwin.send(
+ descriptor,
+ base.advanced(by: offset),
+ count - offset,
+ MSG_NOSIGNAL
+ )
+ }
+ if sent > 0 {
+ offset += sent
+ continue
+ }
+ if sent < 0, errno == EINTR { continue }
+ if sent < 0, errno == EAGAIN || errno == EWOULDBLOCK {
+ return true
+ }
+ return false
+ }
+ return true
+ }
+
+ private static func configureDescriptor(_ descriptor: Int32, nonblocking: Bool) throws {
+ guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else {
+ throw DoryVZMachineError.syscall("fcntl serial console cloexec", errno)
+ }
+ let flags = fcntl(descriptor, F_GETFL)
+ guard flags >= 0,
+ fcntl(
+ descriptor,
+ F_SETFL,
+ nonblocking ? flags | O_NONBLOCK : flags & ~O_NONBLOCK
+ ) == 0 else {
+ throw DoryVZMachineError.syscall("fcntl serial console flags", errno)
+ }
+ }
+
+ private static func bind(listener: Int32, path: String) throws {
+ let pathBytes = Array(path.utf8CString)
+ guard pathBytes.count <= MemoryLayout.size(ofValue: sockaddr_un().sun_path) else {
+ throw DoryVZMachineError.validation("serial console socket path is too long")
+ }
+ unlink(path)
+ var address = sockaddr_un()
+ address.sun_family = sa_family_t(AF_UNIX)
+ withUnsafeMutableBytes(of: &address.sun_path) { destination in
+ pathBytes.withUnsafeBytes { source in destination.copyBytes(from: source) }
+ }
+ let bindResult = withUnsafePointer(to: &address) { pointer in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in
+ Darwin.bind(listener, raw, socklen_t(MemoryLayout.size))
+ }
+ }
+ guard bindResult == 0 else {
+ throw DoryVZMachineError.syscall("bind serial console", errno)
+ }
+ guard chmod(path, mode_t(0o600)) == 0 else {
+ throw DoryVZMachineError.syscall("chmod serial console", errno)
+ }
+ guard listen(listener, 1) == 0 else {
+ throw DoryVZMachineError.syscall("listen serial console", errno)
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift b/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift
index fa8bbf06..031875f7 100644
--- a/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift
@@ -267,6 +267,7 @@ public struct DorydEnvironment: Sendable {
guard let helper = executablePath(firstOf: ["DORYD_VMM_HELPER", "DORY_VMM_HELPER"], fallbackCandidates: helperCandidates(named: "dory-vmm")) else {
return nil
}
+ let acceleratedDesktop = acceleratedDesktopHelperConfiguration()
let stateDirectory: String
if let explicit = string("DORYD_MACHINE_STATE_DIR") {
stateDirectory = explicit
@@ -276,9 +277,11 @@ public struct DorydEnvironment: Sendable {
}
return MachineManagerConfiguration(
vmmExecutablePath: helper,
+ acceleratedDesktopExecutablePath: acceleratedDesktop?.executablePath,
stateDirectory: stateDirectory,
runtimeDirectory: string("DORYD_MACHINE_RUNTIME_DIR") ?? "\(home)/.dory/machines",
baseArguments: splitArguments(string("DORYD_VMM_ARGS") ?? ""),
+ acceleratedDesktopBaseArguments: acceleratedDesktop?.arguments ?? [],
passMachineArguments: bool("DORYD_VMM_PASS_MACHINE_ARGS", default: true),
logDirectory: string("DORYD_MACHINE_LOG_DIR") ?? "\(stateDirectory)/logs",
requiresReadyHandoff: bool("DORYD_VMM_READY_HANDOFF", default: true),
@@ -287,6 +290,28 @@ public struct DorydEnvironment: Sendable {
)
}
+ private func acceleratedDesktopHelperConfiguration() -> (
+ executablePath: String,
+ arguments: [String]
+ )? {
+ guard rawHVSupported,
+ hostGuestArch == "arm64",
+ bool("DORYD_ACCELERATED_DESKTOP", default: true),
+ let helper = executablePath(
+ firstOf: ["DORYD_DESKTOP_HV_HELPER", "DORYD_HV_HELPER", "DORY_HV_HELPER"],
+ fallbackCandidates: helperCandidates(named: "dory-hv")
+ ),
+ let gvproxy = executablePath(
+ firstOf: ["DORYD_GVPROXY", "DORY_GVPROXY"],
+ fallbackCandidates: gvproxyCandidates()
+ ) else {
+ return nil
+ }
+ let arguments = ["desktop", "--gvproxy", gvproxy]
+ + splitArguments(string("DORYD_DESKTOP_HV_ARGS") ?? "")
+ return (helper, arguments)
+ }
+
public func dataDriveConfiguration() throws -> DoryDataDrive {
let explicit = string("DORYD_DATA_DRIVE") ?? string("DORY_DATA_DRIVE")
let selected = explicit == nil
diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift
index eccfad14..ff247df2 100644
--- a/dory-core-swift/Sources/DorydKit/DorydControl.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift
@@ -23,6 +23,7 @@ import Foundation
func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
+ func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineSnapshots(_ machineID: String, reply: @escaping (NSArray, String) -> Void)
func machineCloneSnapshot(_ machineID: String, snapshotID: String, newID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index d1d59aff..ab12e3b9 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -280,7 +280,8 @@ public final class DorydService: NSObject, DorydControl {
shares: update.shares,
updatesShares: update.updatesShares,
environment: update.environment,
- updatesEnvironment: update.updatesEnvironment
+ updatesEnvironment: update.updatesEnvironment,
+ installerMediaAttached: update.installerMediaAttached
)
incidentWriter?.record(type: "machine.update", detail: machineID)
reply(true, status.xpcDictionary, "")
@@ -380,6 +381,41 @@ public final class DorydService: NSObject, DorydControl {
}
}
+ public func machineDesktopUpdate(
+ _ machineID: String,
+ request: NSDictionary,
+ reply: @escaping (Bool, NSDictionary, String) -> Void
+ ) {
+ guard let machineManager else {
+ reply(false, [:], "machine manager is not configured")
+ return
+ }
+ let parsedRequest: DoryDesktopUpdateRequest
+ do {
+ parsedRequest = try DoryDesktopUpdateRequest(xpcDictionary: request)
+ } catch {
+ reply(false, [:], String(describing: error))
+ return
+ }
+ let reply = StatusReply(reply)
+ DispatchQueue.global(qos: .utility).async { [incidentWriter] in
+ do {
+ let result = try machineManager.updateDesktop(id: machineID, request: parsedRequest)
+ incidentWriter?.record(
+ type: "machine.desktop_update",
+ detail: machineID + " " + result.distro + " " + result.version + " snapshot=" + result.snapshotID
+ )
+ reply.reply(true, result.xpcDictionary, "")
+ } catch {
+ incidentWriter?.record(
+ type: "machine.desktop_update_failed",
+ detail: machineID + ": " + String(describing: error)
+ )
+ reply.reply(false, [:], String(describing: error))
+ }
+ }
+ }
+
public func machineSnapshot(
_ machineID: String,
request: NSDictionary,
@@ -1146,6 +1182,17 @@ private struct MachineProvisionRequest {
}
}
+private extension DoryDesktopUpdateRequest {
+ init(xpcDictionary dictionary: NSDictionary) throws {
+ self.init(
+ distro: try dictionary.requiredString("distro"),
+ version: try dictionary.requiredString("version"),
+ bundlePath: try dictionary.requiredString("bundlePath"),
+ kernelPath: try dictionary.requiredString("kernelPath")
+ )
+ }
+}
+
private struct MachineUpdateRequest {
var memoryMB: UInt64?
var cpuCount: Int?
@@ -1155,6 +1202,7 @@ private struct MachineUpdateRequest {
var updatesShares: Bool
var environment: [String: String]?
var updatesEnvironment: Bool
+ var installerMediaAttached: Bool?
init(xpcDictionary dictionary: NSDictionary) throws {
self.memoryMB = try dictionary.optionalUInt64("memoryMB")
@@ -1170,7 +1218,9 @@ private struct MachineUpdateRequest {
self.updatesShares = dictionary["shares"] != nil
self.environment = dictionary["env"] == nil ? nil : try dictionary.optionalEnvironmentDictionary("env")
self.updatesEnvironment = dictionary["env"] != nil
- if memoryMB == nil, cpuCount == nil, !updatesAddress, !updatesShares, !updatesEnvironment {
+ self.installerMediaAttached = dictionary.optionalBool("installerMediaAttached")
+ if memoryMB == nil, cpuCount == nil, !updatesAddress, !updatesShares, !updatesEnvironment,
+ installerMediaAttached == nil {
throw XPCRemoteConfigError.invalid("config")
}
}
@@ -1222,10 +1272,17 @@ private extension DoryMachineConfiguration {
guard let displayMode = DoryMachineDisplayMode(rawValue: rawDisplayMode) else {
throw MachineManagerError.persistence("unsupported machine display mode: \(rawDisplayMode)")
}
+ let rawBootMode = dictionary.optionalString("bootMode") ?? DoryMachineBootMode.linuxKernel.rawValue
+ guard let bootMode = DoryMachineBootMode(rawValue: rawBootMode) else {
+ throw MachineManagerError.persistence("unsupported machine boot mode: \(rawBootMode)")
+ }
self.init(
id: try dictionary.requiredString("id"),
- kernelPath: try dictionary.requiredString("kernelPath"),
- rootfsPath: try dictionary.requiredString("rootfsPath"),
+ kernelPath: dictionary.optionalString("kernelPath") ?? "",
+ rootfsPath: dictionary.optionalString("rootfsPath") ?? "",
+ bootMode: bootMode,
+ installerISOPath: dictionary.optionalString("installerISOPath"),
+ diskSizeBytes: try dictionary.optionalUInt64("diskSizeBytes"),
memoryMB: try dictionary.optionalUInt64("memoryMB") ?? 2048,
cpuCount: try dictionary.optionalInt("cpuCount") ?? 2,
address: dictionary.optionalString("address"),
@@ -1542,6 +1599,8 @@ private extension DoryMachineStatus {
dictionary["currentBalloonTargetMB"] = currentBalloonTargetMB
dictionary["cpuCount"] = cpuCount
dictionary["displayMode"] = displayMode.rawValue
+ dictionary["bootMode"] = bootMode.rawValue
+ dictionary["installerMediaAttached"] = installerMediaAttached
return dictionary as NSDictionary
}
}
@@ -1640,6 +1699,21 @@ private extension DoryMachineSnapshot {
}
}
+private extension DoryDesktopUpdateResult {
+ var xpcDictionary: NSDictionary {
+ [
+ "machineID": machineID,
+ "distro": distro,
+ "version": version,
+ "inputSHA256": inputSHA256,
+ "bundleSHA256": bundleSHA256,
+ "snapshotID": snapshotID,
+ "status": status.xpcDictionary,
+ "restoredRunningState": restoredRunningState,
+ ]
+ }
+}
+
private extension DoryExecResult {
var provisionDictionary: NSDictionary {
[
diff --git a/dory-core-swift/Sources/DorydKit/HvProcess.swift b/dory-core-swift/Sources/DorydKit/HvProcess.swift
index d6b2b244..521fe8d6 100644
--- a/dory-core-swift/Sources/DorydKit/HvProcess.swift
+++ b/dory-core-swift/Sources/DorydKit/HvProcess.swift
@@ -94,6 +94,8 @@ public final class HvProcess: @unchecked Sendable {
private var hasStarted = false
private var suspended = false
private var restartCount = 0
+ private var restartPending = false
+ private var restartsEnabled = true
private var lastTerminationStatus: Int32?
private var lastLaunchError: String?
@@ -118,6 +120,16 @@ public final class HvProcess: @unchecked Sendable {
return process?.isRunning == true
}
+ /// True while a helper is running or a bounded restart has already been scheduled.
+ /// Callers use this to distinguish a transient startup handoff from a terminal exit.
+ public var isRunningOrRestarting: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ // Keep the launch active while Foundation is between observing child exit and invoking
+ // its termination callback; that callback decides whether a retry is permitted.
+ return (!stopping && process != nil) || (!stopping && restartsEnabled && restartPending)
+ }
+
public var terminationStatus: Int32? {
lock.lock()
defer { lock.unlock() }
@@ -146,6 +158,8 @@ public final class HvProcess: @unchecked Sendable {
stopping = false
suspended = false
restartCount = 0
+ restartPending = false
+ restartsEnabled = true
lastTerminationStatus = nil
lastLaunchError = nil
try launchLocked()
@@ -214,10 +228,13 @@ public final class HvProcess: @unchecked Sendable {
oldLog = logHandle
logHandle = nil
wasUnexpected = !stopping
- shouldRestart = wasUnexpected && restartCount < configuration.restartPolicy.maxRestarts
+ shouldRestart = wasUnexpected
+ && restartsEnabled
+ && restartCount < configuration.restartPolicy.maxRestarts
if shouldRestart {
restartCount += 1
}
+ restartPending = shouldRestart
delay = configuration.restartPolicy.delay(forAttempt: restartCount)
lock.unlock()
try? oldLog?.close()
@@ -234,15 +251,43 @@ public final class HvProcess: @unchecked Sendable {
private func restartAfterUnexpectedExit() {
lock.lock()
- defer { lock.unlock() }
- guard !stopping, process == nil else { return }
+ guard !stopping, restartsEnabled, restartPending, process == nil else {
+ restartPending = false
+ lock.unlock()
+ return
+ }
+ restartPending = false
do {
try launchLocked()
+ lock.unlock()
} catch {
lastLaunchError = "\(error)"
+ let shouldRetry = !stopping
+ && restartsEnabled
+ && restartCount < configuration.restartPolicy.maxRestarts
+ if shouldRetry {
+ restartCount += 1
+ restartPending = true
+ }
+ let delay = configuration.restartPolicy.delay(forAttempt: restartCount)
+ lock.unlock()
+ if shouldRetry {
+ DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in
+ self?.restartAfterUnexpectedExit()
+ }
+ }
}
}
+ /// Startup retries are useful until the supervisor accepts the ready handoff. After that
+ /// point an unexpected VMM exit is a real machine failure and must remain visible.
+ public func disableRestarts() {
+ lock.lock()
+ restartsEnabled = false
+ restartPending = false
+ lock.unlock()
+ }
+
public var isSuspended: Bool {
lock.lock()
defer { lock.unlock() }
@@ -278,6 +323,8 @@ public final class HvProcess: @unchecked Sendable {
let wasSuspended: Bool
lock.lock()
stopping = true
+ restartsEnabled = false
+ restartPending = false
task = process
terminationWaiter = self.terminationWaiter
// Take-and-null the handle so exactly one of stop()/handleTermination closes it; a
@@ -288,13 +335,16 @@ public final class HvProcess: @unchecked Sendable {
suspended = false
lock.unlock()
- guard let task, task.isRunning else {
+ guard let task else {
try? oldLog?.close()
return
}
if wasSuspended {
kill(task.processIdentifier, SIGCONT)
}
+ // Process.isRunning may briefly read false before its termination callback fires. Send
+ // the signal unconditionally; ESRCH is harmless and avoids waiting for a live child to
+ // reach the timeout solely because Foundation exposed that transient state.
kill(task.processIdentifier, signal)
// Process.isRunning can flip to false when SIGTERM is delivered while the child is still
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 109103e8..c1972d9f 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -1,15 +1,30 @@
import CryptoKit
import DoryCore
+import DoryOperations
import Foundation
public struct MachineManagerConfiguration: Sendable, Equatable {
public var vmmExecutablePath: String
+ /// Raw-Hypervisor helper used only for accelerated Linux desktops. EFI installers and
+ /// headless machines retain the established Virtualization.framework helper.
+ public var acceleratedDesktopExecutablePath: String?
public var stateDirectory: String
public var runtimeDirectory: String
public var baseArguments: [String]
+ public var acceleratedDesktopBaseArguments: [String]
public var passMachineArguments: Bool
public var logDirectory: String
public var requiresReadyHandoff: Bool
+ /// Headless Linux guests should publish the agent handoff promptly. Keep this bounded so a
+ /// helper that is alive but unable to boot does not remain in the starting state.
+ public var handoffReadyTimeoutSeconds: TimeInterval
+ /// Full desktop first boots perform account, display-manager, graphics, and share setup before
+ /// the helper can publish readiness. This must exceed the desktop helper's own preparation
+ /// budget, while still remaining bounded.
+ public var desktopHandoffReadyTimeoutSeconds: TimeInterval
+ /// Bounded helper retries are active only until the ready handoff. This absorbs transient
+ /// Virtualization.framework resource release races without masking a later VM crash.
+ public var startupRestartPolicy: HvRestartPolicy
public var guestArchitecture: String
/// Host SSH agent made available to ordinary machines. Sandboxes only receive it when their
/// persisted policy contains the explicit DORY_SANDBOX_SSH_AGENT=1 grant.
@@ -17,22 +32,37 @@ public struct MachineManagerConfiguration: Sendable, Equatable {
public init(
vmmExecutablePath: String,
+ acceleratedDesktopExecutablePath: String? = nil,
stateDirectory: String,
runtimeDirectory: String? = nil,
baseArguments: [String] = [],
+ acceleratedDesktopBaseArguments: [String] = [],
passMachineArguments: Bool = true,
logDirectory: String? = nil,
requiresReadyHandoff: Bool = true,
+ handoffReadyTimeoutSeconds: TimeInterval = 60,
+ desktopHandoffReadyTimeoutSeconds: TimeInterval = 180,
+ startupRestartPolicy: HvRestartPolicy = HvRestartPolicy(
+ maxRestarts: 4,
+ delaySeconds: 0.25,
+ maximumDelaySeconds: 2,
+ stableRunSeconds: 0
+ ),
guestArchitecture: String? = nil,
sshAgentSocketPath: String? = nil
) {
self.vmmExecutablePath = vmmExecutablePath
+ self.acceleratedDesktopExecutablePath = acceleratedDesktopExecutablePath
self.stateDirectory = stateDirectory
self.runtimeDirectory = runtimeDirectory ?? stateDirectory
self.baseArguments = baseArguments
+ self.acceleratedDesktopBaseArguments = acceleratedDesktopBaseArguments
self.passMachineArguments = passMachineArguments
self.logDirectory = logDirectory ?? "\(stateDirectory)/logs"
self.requiresReadyHandoff = requiresReadyHandoff
+ self.handoffReadyTimeoutSeconds = handoffReadyTimeoutSeconds
+ self.desktopHandoffReadyTimeoutSeconds = desktopHandoffReadyTimeoutSeconds
+ self.startupRestartPolicy = startupRestartPolicy
self.guestArchitecture = guestArchitecture ?? Self.currentGuestArchitecture
self.sshAgentSocketPath = sshAgentSocketPath
}
@@ -164,10 +194,18 @@ public enum DoryMachineDisplayMode: String, Sendable, Equatable, Hashable, Codab
case desktop
}
+public enum DoryMachineBootMode: String, Sendable, Equatable, Hashable, Codable, CaseIterable {
+ case linuxKernel = "linux-kernel"
+ case efi
+}
+
public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
public var id: String
public var kernelPath: String
public var rootfsPath: String
+ public var bootMode: DoryMachineBootMode
+ public var installerISOPath: String?
+ public var diskSizeBytes: UInt64?
public var memoryMB: UInt64
public var cpuCount: Int
public var address: String?
@@ -179,6 +217,9 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
id: String,
kernelPath: String,
rootfsPath: String,
+ bootMode: DoryMachineBootMode = .linuxKernel,
+ installerISOPath: String? = nil,
+ diskSizeBytes: UInt64? = nil,
memoryMB: UInt64 = 2048,
cpuCount: Int = 2,
address: String? = nil,
@@ -189,6 +230,9 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
self.id = id
self.kernelPath = kernelPath
self.rootfsPath = rootfsPath
+ self.bootMode = bootMode
+ self.installerISOPath = installerISOPath
+ self.diskSizeBytes = diskSizeBytes
self.memoryMB = memoryMB
self.cpuCount = cpuCount
self.address = address
@@ -201,6 +245,9 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
case id
case kernelPath
case rootfsPath
+ case bootMode
+ case installerISOPath
+ case diskSizeBytes
case memoryMB
case cpuCount
case address
@@ -215,6 +262,9 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
id: try container.decode(String.self, forKey: .id),
kernelPath: try container.decode(String.self, forKey: .kernelPath),
rootfsPath: try container.decode(String.self, forKey: .rootfsPath),
+ bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel,
+ installerISOPath: try container.decodeIfPresent(String.self, forKey: .installerISOPath),
+ diskSizeBytes: try container.decodeIfPresent(UInt64.self, forKey: .diskSizeBytes),
memoryMB: try container.decodeIfPresent(UInt64.self, forKey: .memoryMB) ?? 2048,
cpuCount: try container.decodeIfPresent(Int.self, forKey: .cpuCount) ?? 2,
address: try container.decodeIfPresent(String.self, forKey: .address),
@@ -254,6 +304,8 @@ public struct DoryMachineStatus: Sendable, Equatable {
public var currentBalloonTargetMB: UInt64
public var cpuCount: Int
public var displayMode: DoryMachineDisplayMode
+ public var bootMode: DoryMachineBootMode
+ public var installerMediaAttached: Bool
public var shares: [DoryMachineShareConfiguration]
public var environment: [String: String]
@@ -276,6 +328,8 @@ public struct DoryMachineStatus: Sendable, Equatable {
currentBalloonTargetMB: UInt64? = nil,
cpuCount: Int = 0,
displayMode: DoryMachineDisplayMode = .headless,
+ bootMode: DoryMachineBootMode = .linuxKernel,
+ installerMediaAttached: Bool = false,
shares: [DoryMachineShareConfiguration] = [],
environment: [String: String] = [:]
) {
@@ -297,6 +351,8 @@ public struct DoryMachineStatus: Sendable, Equatable {
self.currentBalloonTargetMB = currentBalloonTargetMB ?? memoryMB
self.cpuCount = cpuCount
self.displayMode = displayMode
+ self.bootMode = bootMode
+ self.installerMediaAttached = installerMediaAttached
self.shares = shares
self.environment = environment
}
@@ -317,6 +373,9 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
public var address: String?
public var shares: [DoryMachineShareConfiguration]
public var environment: [String: String]
+ public var bootMode: DoryMachineBootMode
+ public var machineIdentifierPath: String?
+ public var nvramPath: String?
public init(
id: String,
@@ -332,7 +391,10 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
displayMode: DoryMachineDisplayMode = .headless,
address: String? = nil,
shares: [DoryMachineShareConfiguration] = [],
- environment: [String: String] = [:]
+ environment: [String: String] = [:],
+ bootMode: DoryMachineBootMode = .linuxKernel,
+ machineIdentifierPath: String? = nil,
+ nvramPath: String? = nil
) {
self.id = id
self.machineID = machineID
@@ -348,6 +410,9 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
self.address = address
self.shares = shares
self.environment = environment
+ self.bootMode = bootMode
+ self.machineIdentifierPath = machineIdentifierPath
+ self.nvramPath = nvramPath
}
private enum CodingKeys: String, CodingKey {
@@ -365,6 +430,9 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
case address
case shares
case environment
+ case bootMode
+ case machineIdentifierPath
+ case nvramPath
}
public init(from decoder: Decoder) throws {
@@ -383,11 +451,69 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
displayMode: try container.decodeIfPresent(DoryMachineDisplayMode.self, forKey: .displayMode) ?? .headless,
address: try container.decodeIfPresent(String.self, forKey: .address),
shares: try container.decodeIfPresent([DoryMachineShareConfiguration].self, forKey: .shares) ?? [],
- environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:]
+ environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:],
+ bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel,
+ machineIdentifierPath: try container.decodeIfPresent(String.self, forKey: .machineIdentifierPath),
+ nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath)
)
}
}
+public struct DoryDesktopUpdateRequest: Sendable, Equatable {
+ public var distro: String
+ public var version: String
+ public var bundlePath: String
+ public var kernelPath: String
+
+ public init(distro: String, version: String, bundlePath: String, kernelPath: String) {
+ self.distro = distro
+ self.version = version
+ self.bundlePath = bundlePath
+ self.kernelPath = kernelPath
+ }
+}
+
+public struct DoryDesktopUpdateResult: Sendable, Equatable {
+ public var machineID: String
+ public var distro: String
+ public var version: String
+ public var inputSHA256: String
+ public var bundleSHA256: String
+ public var snapshotID: String
+ public var status: DoryMachineStatus
+ public var restoredRunningState: Bool
+
+ public init(
+ machineID: String,
+ distro: String,
+ version: String,
+ inputSHA256: String,
+ bundleSHA256: String,
+ snapshotID: String,
+ status: DoryMachineStatus,
+ restoredRunningState: Bool
+ ) {
+ self.machineID = machineID
+ self.distro = distro
+ self.version = version
+ self.inputSHA256 = inputSHA256
+ self.bundleSHA256 = bundleSHA256
+ self.snapshotID = snapshotID
+ self.status = status
+ self.restoredRunningState = restoredRunningState
+ }
+}
+
+private struct DesktopUpdateJournal: Codable, Sendable, Equatable {
+ var schema: Int
+ var machineID: String
+ var distro: String
+ var version: String
+ var snapshotID: String
+ var originalWasRunning: Bool
+ var stage: String
+}
+
public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvertible {
case duplicateMachine(String)
case invalidID(String)
@@ -439,16 +565,20 @@ public final class MachineManager: @unchecked Sendable {
public typealias AgentConnector = @Sendable (String) throws -> any AgentControlClient
public typealias ProcessStarter = @Sendable (HvProcess) throws -> Void
- private static let handoffReadyTimeoutSeconds: TimeInterval = 60
private static let deletionQuarantinePrefix = ".dory-machine-delete-"
private static let machineDiskTemporaryPrefix = ".rootfs.ext4.tmp-"
private static let machineKernelTemporaryPrefix = ".kernel.tmp-"
+ private static let installedLinuxKernelName = "direct-kernel"
+ private static let installedLinuxInitrdName = "direct-initrd"
private static let machineMetadataTemporaryPrefix = ".dory-machine-metadata-"
private static let machineRestoreBackupMarker = ".restore-"
private static let snapshotDeletionQuarantinePrefix = ".dory-snapshot-delete-"
private static let snapshotDiskTemporaryMarker = ".ext4.tmp-"
private static let snapshotKernelTemporaryMarker = ".kernel.tmp-"
+ private static let snapshotMachineIdentifierTemporaryMarker = ".machine-identifier.tmp-"
+ private static let snapshotNVRAMTemporaryMarker = ".nvram.tmp-"
private static let snapshotMetadataTemporaryPrefix = ".dory-snapshot-metadata-"
+ private static let desktopUpdateJournalName = "desktop-update.json"
private static let maximumPersistedMetadataBytes: Int64 = 16 * 1024 * 1024
/// Public Apple-Silicon machine resource contract. These match the app's steppers; enforcing
/// them again in doryd prevents CLI/XPC callers from persisting values that the VMM would later
@@ -457,6 +587,8 @@ public final class MachineManager: @unchecked Sendable {
public static let maximumMachineMemoryMB: UInt64 = 16 * 1024
public static let minimumMachineCPUCount = 1
public static let maximumMachineCPUCount = 8
+ public static let minimumEFIDiskSizeBytes: UInt64 = 8 * 1024 * 1024 * 1024
+ public static let maximumEFIDiskSizeBytes: UInt64 = 2 * 1024 * 1024 * 1024 * 1024
private let configuration: MachineManagerConfiguration
private let agentConnector: AgentConnector
@@ -484,10 +616,19 @@ public final class MachineManager: @unchecked Sendable {
stateDirectory: configuration.stateDirectory,
includeDescendants: true
)
+ if let acceleratedDesktopExecutablePath = configuration.acceleratedDesktopExecutablePath,
+ acceleratedDesktopExecutablePath != configuration.vmmExecutablePath {
+ _ = HelperProcessJanitor.terminateStaleHelpers(
+ executablePath: acceleratedDesktopExecutablePath,
+ stateDirectory: configuration.stateDirectory,
+ includeDescendants: true
+ )
+ }
Self.removeStaleDeletionQuarantines(stateDirectory: configuration.stateDirectory)
Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory)
Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory)
self.machines = Self.loadPersistedMachines(configuration: configuration)
+ recoverInterruptedDesktopUpdates()
}
@discardableResult
@@ -506,9 +647,42 @@ public final class MachineManager: @unchecked Sendable {
guard !exists else {
throw MachineManagerError.duplicateMachine(machine.id)
}
- guard Self.isRegularNonemptyFile(path: machine.kernelPath) else {
- throw MachineManagerError.persistence("machine kernel is missing or invalid: \(machine.kernelPath)")
+ switch machine.bootMode {
+ case .linuxKernel:
+ guard Self.isRegularNonemptyFile(path: machine.kernelPath) else {
+ throw MachineManagerError.persistence("machine kernel is missing or invalid: \(machine.kernelPath)")
+ }
+ guard Self.isRegularNonemptyFile(path: machine.rootfsPath) else {
+ throw MachineManagerError.persistence("machine rootfs is missing or invalid: \(machine.rootfsPath)")
+ }
+ case .efi:
+ guard machine.displayMode == .desktop else {
+ throw MachineManagerError.persistence("EFI installer machines require desktop display mode")
+ }
+ if let installerISOPath = machine.installerISOPath {
+ guard Self.isRegularNonemptyFile(path: installerISOPath) else {
+ throw MachineManagerError.persistence("installer ISO is missing or invalid: \(installerISOPath)")
+ }
+ }
+ if Self.isRegularNonemptyFile(path: machine.rootfsPath) {
+ guard machine.diskSizeBytes == nil else {
+ throw MachineManagerError.persistence("EFI disk imports cannot also specify diskSizeBytes")
+ }
+ } else {
+ guard machine.installerISOPath != nil else {
+ throw MachineManagerError.persistence("a new EFI machine requires an installer ISO")
+ }
+ guard let diskSizeBytes = machine.diskSizeBytes,
+ (Self.minimumEFIDiskSizeBytes...Self.maximumEFIDiskSizeBytes).contains(diskSizeBytes) else {
+ throw MachineManagerError.persistence(
+ "EFI disk size must be between \(Self.minimumEFIDiskSizeBytes) and \(Self.maximumEFIDiskSizeBytes) bytes"
+ )
+ }
+ }
}
+ // Reject incompatible media before allocating a virtual disk or importing a potentially
+ // multi-gigabyte ISO. The managed copy is created only after this preflight succeeds.
+ try validateInstallerArchitecture(machine)
let fileManager = FileManager.default
do {
try fileManager.createDirectory(atPath: configuration.stateDirectory, withIntermediateDirectories: true)
@@ -545,6 +719,9 @@ public final class MachineManager: @unchecked Sendable {
configuredAddress: preparedMachine.address,
memoryMB: preparedMachine.memoryMB,
cpuCount: preparedMachine.cpuCount,
+ displayMode: preparedMachine.displayMode,
+ bootMode: preparedMachine.bootMode,
+ installerMediaAttached: preparedMachine.installerISOPath != nil,
shares: preparedMachine.shares,
environment: preparedMachine.environment
)
@@ -559,13 +736,26 @@ public final class MachineManager: @unchecked Sendable {
lock.unlock()
throw MachineManagerError.unknownMachine(id)
}
+ if entry.process?.isRunning == true {
+ lock.unlock()
+ throw MachineManagerError.alreadyRunning(id)
+ }
+ lock.unlock()
do {
+ try ensureInstalledLinuxBootBundleIfNeeded(entry.configuration)
+ try materializeInstalledLinuxBootRuntimeIfNeeded(entry.configuration)
try Self.validateLaunchConfiguration(entry.configuration)
try validateManagedMachineArtifacts(entry.configuration)
+ try validateRuntimeAvailability(entry.configuration)
} catch {
- lock.unlock()
throw error
}
+ lock.lock()
+ guard let currentEntry = machines[id] else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ entry = currentEntry
if entry.process?.isRunning == true {
lock.unlock()
throw MachineManagerError.alreadyRunning(id)
@@ -585,7 +775,19 @@ public final class MachineManager: @unchecked Sendable {
lock.unlock()
throw error
}
- let process = HvProcess(configuration: processConfiguration(for: entry.configuration, handoffPath: handoffPath))
+ let processConfiguration: HvProcessConfiguration
+ do {
+ processConfiguration = try self.processConfiguration(
+ for: entry.configuration,
+ handoffPath: handoffPath
+ )
+ } catch {
+ handoffServer?.stop()
+ lock.unlock()
+ throw error
+ }
+ let process = HvProcess(configuration: processConfiguration)
+ let handoffReadyTimeout = handoffReadyTimeout(for: entry.configuration)
entry.process = process
entry.handoffServer = handoffServer
entry.handoff = nil
@@ -611,7 +813,7 @@ public final class MachineManager: @unchecked Sendable {
throw error
}
if configuration.requiresReadyHandoff {
- scheduleHandoffTimeout(id: id, process: process)
+ scheduleHandoffTimeout(id: id, process: process, timeout: handoffReadyTimeout)
}
return status(id: id) ?? DoryMachineStatus(id: id, state: .running)
}
@@ -620,7 +822,11 @@ public final class MachineManager: @unchecked Sendable {
let started = try start(id: id)
guard started.state == .starting else { return started }
- let deadline = Date().addingTimeInterval(Self.handoffReadyTimeoutSeconds + 1)
+ let handoffReadyTimeout = handoffReadyTimeout(
+ displayMode: started.displayMode,
+ bootMode: started.bootMode
+ )
+ let deadline = Date().addingTimeInterval(handoffReadyTimeout + 1)
while Date() < deadline {
guard let current = status(id: id) else {
throw MachineManagerError.unknownMachine(id)
@@ -643,11 +849,25 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.persistence("vmm ready handoff timed out for \(id)")
}
- private func scheduleHandoffTimeout(id: String, process: HvProcess) {
+ private func handoffReadyTimeout(for machine: DoryMachineConfiguration) -> TimeInterval {
+ handoffReadyTimeout(displayMode: machine.displayMode, bootMode: machine.bootMode)
+ }
+
+ private func handoffReadyTimeout(
+ displayMode: DoryMachineDisplayMode,
+ bootMode: DoryMachineBootMode
+ ) -> TimeInterval {
+ if displayMode == .desktop {
+ return configuration.desktopHandoffReadyTimeoutSeconds
+ }
+ return configuration.handoffReadyTimeoutSeconds
+ }
+
+ private func scheduleHandoffTimeout(id: String, process: HvProcess, timeout: TimeInterval) {
// A VMM that boots but never completes the ready handoff would otherwise leave the
// machine `.starting` forever. Bound the wait: if this exact launch is still starting
// when the deadline passes, mark it failed and tear the helper down.
- DispatchQueue.global().asyncAfter(deadline: .now() + Self.handoffReadyTimeoutSeconds) { [weak self] in
+ DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in
guard let self else { return }
self.lock.lock()
guard var entry = self.machines[id],
@@ -663,7 +883,7 @@ public final class MachineManager: @unchecked Sendable {
entry.runtimeAddress = nil
entry.currentBalloonTargetMB = nil
entry.state = .failed
- entry.lastError = "vmm ready handoff timed out after \(Int(Self.handoffReadyTimeoutSeconds))s"
+ entry.lastError = "vmm ready handoff timed out after \(Int(timeout))s"
self.machines[id] = entry
self.lock.unlock()
process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
@@ -783,7 +1003,8 @@ public final class MachineManager: @unchecked Sendable {
shares: [DoryMachineShareConfiguration]? = nil,
updatesShares: Bool = false,
environment: [String: String]? = nil,
- updatesEnvironment: Bool = false
+ updatesEnvironment: Bool = false,
+ installerMediaAttached: Bool? = nil
) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
@@ -804,6 +1025,20 @@ public final class MachineManager: @unchecked Sendable {
if updatesEnvironment {
updated.environment = environment ?? [:]
}
+ if let installerMediaAttached {
+ guard updated.bootMode == .efi else {
+ throw MachineManagerError.persistence("installer media is only available for EFI machines")
+ }
+ if installerMediaAttached {
+ let managedInstaller = machineInstallerISOPath(id: id)
+ guard Self.isPrivateRegularFile(path: managedInstaller) else {
+ throw MachineManagerError.persistence("managed installer ISO is unavailable")
+ }
+ updated.installerISOPath = managedInstaller
+ } else {
+ updated.installerISOPath = nil
+ }
+ }
try Self.validateLaunchConfiguration(updated)
guard updated != current else {
return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
@@ -812,6 +1047,7 @@ public final class MachineManager: @unchecked Sendable {
|| updated.cpuCount != current.cpuCount
|| updated.shares != current.shares
|| updated.environment != current.environment
+ || updated.installerISOPath != current.installerISOPath
if !requiresRestart {
do {
try persist(updated)
@@ -826,6 +1062,11 @@ public final class MachineManager: @unchecked Sendable {
_ = try stop(id: id)
}
do {
+ if current.bootMode == .efi,
+ current.installerISOPath != nil,
+ updated.installerISOPath == nil {
+ try ensureInstalledLinuxBootBundleIfNeeded(updated)
+ }
try persist(updated)
try publishConfiguration(updated)
} catch {
@@ -887,9 +1128,13 @@ public final class MachineManager: @unchecked Sendable {
try ensurePrivateSnapshotDirectory(machineID: id)
let rootfsPath = snapshotRootfsPath(machineID: id, snapshotID: snapshotID)
let kernelPath = snapshotKernelPath(machineID: id, snapshotID: snapshotID)
+ let machineIdentifierPath = snapshotMachineIdentifierPath(machineID: id, snapshotID: snapshotID)
+ let nvramPath = snapshotNVRAMPath(machineID: id, snapshotID: snapshotID)
guard !Self.pathEntryExists(snapshotMetadataPath(machineID: id, snapshotID: snapshotID)),
!Self.pathEntryExists(rootfsPath),
- !Self.pathEntryExists(kernelPath) else {
+ !Self.pathEntryExists(kernelPath),
+ !Self.pathEntryExists(machineIdentifierPath),
+ !Self.pathEntryExists(nvramPath) else {
throw MachineManagerError.duplicateSnapshot(snapshotID)
}
@@ -899,11 +1144,30 @@ public final class MachineManager: @unchecked Sendable {
let snapshot: DoryMachineSnapshot
var publishedRootfs = false
var publishedKernel = false
+ var publishedMachineIdentifier = false
+ var publishedNVRAM = false
do {
try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsPath)
publishedRootfs = true
try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelPath)
publishedKernel = true
+ if machine.bootMode == .efi {
+ let liveMachineIdentifierPath = machineFirmwareIdentifierPath(id: id)
+ let liveNVRAMPath = machineFirmwareNVRAMPath(id: id)
+ guard Self.isPrivateRegularFile(path: liveMachineIdentifierPath),
+ Self.isPrivateRegularFile(path: liveNVRAMPath) else {
+ throw MachineManagerError.persistence(
+ "EFI firmware state is unavailable; start the machine once before taking a snapshot"
+ )
+ }
+ try Self.cloneOrCopyFile(
+ source: liveMachineIdentifierPath,
+ destination: machineIdentifierPath
+ )
+ publishedMachineIdentifier = true
+ try Self.cloneOrCopyFile(source: liveNVRAMPath, destination: nvramPath)
+ publishedNVRAM = true
+ }
snapshot = DoryMachineSnapshot(
id: snapshotID,
machineID: id,
@@ -918,7 +1182,10 @@ public final class MachineManager: @unchecked Sendable {
displayMode: machine.displayMode,
address: machine.address,
shares: machine.shares,
- environment: machine.environment
+ environment: machine.environment,
+ bootMode: machine.bootMode,
+ machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil,
+ nvramPath: machine.bootMode == .efi ? nvramPath : nil
)
try persistSnapshot(snapshot)
} catch {
@@ -928,6 +1195,12 @@ public final class MachineManager: @unchecked Sendable {
if publishedKernel {
try? FileManager.default.removeItem(atPath: kernelPath)
}
+ if publishedMachineIdentifier {
+ try? FileManager.default.removeItem(atPath: machineIdentifierPath)
+ }
+ if publishedNVRAM {
+ try? FileManager.default.removeItem(atPath: nvramPath)
+ }
if wasRunning {
_ = try? start(id: id)
}
@@ -953,6 +1226,197 @@ public final class MachineManager: @unchecked Sendable {
return snapshot
}
+ /// Applies a signed desktop component to an existing persistent guest. The caller provides
+ /// component-store paths, but doryd owns the entire transaction: a last-good disk/kernel
+ /// snapshot is durable before the guest is mutated, and any failed install or post-reboot
+ /// qualification restores that snapshot and the machine's original running state.
+ public func updateDesktop(
+ id: String,
+ request: DoryDesktopUpdateRequest
+ ) throws -> DoryDesktopUpdateResult {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+
+ let (original, originallyRunning) = try configurationAndRunningState(id: id)
+ guard original.bootMode == .linuxKernel else {
+ throw MachineManagerError.persistence("managed desktop updates do not apply to custom EFI machines")
+ }
+ guard original.displayMode == .desktop else {
+ throw MachineManagerError.persistence("desktop updates require a graphical machine")
+ }
+ guard ["debian", "ubuntu", "kali"].contains(request.distro),
+ original.environment["DORY_DESKTOP_DISTRO"] == request.distro else {
+ throw MachineManagerError.persistence("desktop update distribution does not match " + id)
+ }
+ guard request.version.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil else {
+ throw MachineManagerError.persistence("desktop update version is invalid")
+ }
+ guard Self.isRegularNonemptyFile(path: request.bundlePath),
+ Self.isRegularNonemptyFile(path: request.kernelPath) else {
+ throw MachineManagerError.persistence("desktop update assets are missing or invalid")
+ }
+ let bundleURL = URL(fileURLWithPath: request.bundlePath).standardizedFileURL
+ let bundleDirectory = bundleURL.deletingLastPathComponent().path
+ guard Self.isDirectory(path: bundleDirectory), bundleURL.lastPathComponent != "." else {
+ throw MachineManagerError.persistence("desktop update bundle directory is invalid")
+ }
+ let bundleSHA256 = try Self.sha256(path: request.bundlePath)
+ let snapshotID = Self.generatedSnapshotID(prefix: "du")
+ let snapshot = try snapshot(
+ id: id,
+ note: "Automatic last-good snapshot before " + request.distro + " " + request.version + " desktop update",
+ snapshotID: snapshotID
+ )
+ var journal = DesktopUpdateJournal(
+ schema: 1,
+ machineID: id,
+ distro: request.distro,
+ version: request.version,
+ snapshotID: snapshot.id,
+ originalWasRunning: originallyRunning,
+ stage: "snapshot-ready"
+ )
+ try persistDesktopUpdateJournal(journal)
+
+ let token = String(bundleSHA256.prefix(12))
+ let mountPath = "/mnt/dory-update-" + token
+ let guestStage = "/var/lib/dory/update-" + token
+ let updateShare = DoryMachineShareConfiguration(
+ tag: "dory-update-" + token,
+ hostPath: bundleDirectory,
+ guestPath: mountPath,
+ readOnly: true
+ )
+ let bundleGuestPath = mountPath + "/" + bundleURL.lastPathComponent
+
+ do {
+ var transientShares = original.shares.filter { $0.tag != updateShare.tag }
+ transientShares.append(updateShare)
+ _ = try update(id: id, shares: transientShares, updatesShares: true)
+ if status(id: id)?.state != .running {
+ _ = try startAndWaitUntilReady(id: id)
+ }
+ journal.stage = "installing"
+ try persistDesktopUpdateJournal(journal)
+
+ try requireSuccessfulDesktopUpdateExec(
+ id: id,
+ argv: ["/bin/rm", "-rf", guestStage],
+ stage: "clear guest staging"
+ )
+ try requireSuccessfulDesktopUpdateExec(
+ id: id,
+ argv: ["/bin/mkdir", "-p", guestStage],
+ stage: "create guest staging"
+ )
+ try requireSuccessfulDesktopUpdateExec(
+ id: id,
+ argv: ["/bin/tar", "-xf", bundleGuestPath, "-C", guestStage],
+ timeoutMs: 120_000,
+ stage: "extract signed payload"
+ )
+ let install = try requireSuccessfulDesktopUpdateExec(
+ id: id,
+ argv: [guestStage + "/apply.sh", request.distro, request.version],
+ timeoutMs: 3_600_000,
+ outputLimitBytes: 16 * 1024 * 1024,
+ stage: "install guest update"
+ )
+ var inputSHA256 = Self.desktopUpdateInputSHA256(from: install)
+ if inputSHA256 == nil {
+ // Package managers can emit more than the bounded exec output and displace the
+ // final success line. The update writes this receipt atomically before exiting,
+ // so read it directly instead of rolling back a successfully applied payload.
+ let receipt = try requireSuccessfulDesktopUpdateExec(
+ id: id,
+ argv: ["/bin/cat", "/var/lib/dory/desktop-update.env"],
+ outputLimitBytes: 16 * 1024,
+ stage: "read the installed desktop update receipt"
+ )
+ inputSHA256 = Self.desktopUpdateReceiptInputSHA256(
+ from: receipt,
+ distro: request.distro,
+ version: request.version
+ )
+ }
+ guard let inputSHA256 else {
+ throw MachineManagerError.persistence("desktop update did not return its input fingerprint")
+ }
+
+ _ = try? requireSuccessfulDesktopUpdateExec(
+ id: id,
+ argv: ["/bin/rm", "-rf", guestStage],
+ stage: "clear applied payload"
+ )
+ _ = try stop(id: id)
+ var updatedEnvironment = original.environment
+ updatedEnvironment["DORY_DESKTOP_RELEASE_VERSION"] = request.version
+ updatedEnvironment["DORY_DESKTOP_INPUT_SHA256"] = inputSHA256
+ _ = try update(
+ id: id,
+ shares: original.shares,
+ updatesShares: true,
+ environment: updatedEnvironment,
+ updatesEnvironment: true
+ )
+ try Self.cloneOrCopyFile(
+ source: request.kernelPath,
+ destination: original.kernelPath,
+ replaceExisting: true
+ )
+ journal.stage = "qualifying"
+ try persistDesktopUpdateJournal(journal)
+ _ = try startAndWaitUntilReady(id: id)
+ try qualifyUpdatedDesktop(
+ id: id,
+ distro: request.distro,
+ version: request.version,
+ inputSHA256: inputSHA256
+ )
+
+ let finalStatus: DoryMachineStatus
+ if originallyRunning {
+ finalStatus = status(id: id) ?? DoryMachineStatus(id: id, state: .running)
+ } else {
+ finalStatus = try stop(id: id)
+ }
+ journal.stage = "committed"
+ try persistDesktopUpdateJournal(journal)
+ try removeDesktopUpdateJournal(machineID: id)
+ return DoryDesktopUpdateResult(
+ machineID: id,
+ distro: request.distro,
+ version: request.version,
+ inputSHA256: inputSHA256,
+ bundleSHA256: bundleSHA256,
+ snapshotID: snapshot.id,
+ status: finalStatus,
+ restoredRunningState: originallyRunning
+ )
+ } catch {
+ let updateError = error
+ do {
+ _ = try? stop(id: id)
+ _ = try restoreSnapshot(machineID: id, snapshotID: snapshot.id)
+ if originallyRunning, status(id: id)?.state != .running {
+ _ = try startAndWaitUntilReady(id: id)
+ } else if !originallyRunning, status(id: id)?.state == .running {
+ _ = try stop(id: id)
+ }
+ try removeDesktopUpdateJournal(machineID: id)
+ } catch {
+ throw MachineManagerError.persistence(
+ "desktop update " + request.version + " failed: " + String(describing: updateError)
+ + "; automatic rollback failed: " + String(describing: error)
+ )
+ }
+ throw MachineManagerError.persistence(
+ "desktop update " + request.version + " failed: " + String(describing: updateError)
+ + "; last-good snapshot " + snapshot.id + " was restored"
+ )
+ }
+ }
+
public func listSnapshots(machineID: String? = nil) throws -> [DoryMachineSnapshot] {
operationLock.lock()
defer { operationLock.unlock() }
@@ -997,6 +1461,7 @@ public final class MachineManager: @unchecked Sendable {
id: newID,
kernelPath: snapshot.kernelPath,
rootfsPath: snapshot.rootfsPath,
+ bootMode: snapshot.bootMode,
memoryMB: snapshot.memoryMB,
cpuCount: snapshot.cpuCount,
address: nil,
@@ -1006,6 +1471,17 @@ public final class MachineManager: @unchecked Sendable {
)
_ = try create(machine)
do {
+ if snapshot.bootMode == .efi {
+ guard let snapshotNVRAMPath = snapshot.nvramPath else {
+ throw MachineManagerError.persistence("EFI snapshot is missing NVRAM state")
+ }
+ // A clone gets a new Virtualization.framework machine identifier, but retains
+ // the guest's EFI variables (including its installed boot entries).
+ try Self.cloneOrCopyFile(
+ source: snapshotNVRAMPath,
+ destination: machineFirmwareNVRAMPath(id: newID)
+ )
+ }
return try start(id: newID)
} catch {
do {
@@ -1024,6 +1500,9 @@ public final class MachineManager: @unchecked Sendable {
defer { operationLock.unlock() }
let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID)
let (machine, wasRunning) = try configurationAndRunningState(id: machineID)
+ guard machine.bootMode == snapshot.bootMode else {
+ throw MachineManagerError.persistence("snapshot boot mode does not match the machine")
+ }
let address = try Self.normalizedAddress(snapshot.address)
try Self.validateShares(snapshot.shares)
try Self.validateEnvironment(snapshot.environment)
@@ -1031,6 +1510,7 @@ public final class MachineManager: @unchecked Sendable {
restoredMachine.memoryMB = snapshot.memoryMB
restoredMachine.cpuCount = snapshot.cpuCount
restoredMachine.displayMode = snapshot.displayMode
+ restoredMachine.bootMode = snapshot.bootMode
restoredMachine.address = address
restoredMachine.shares = snapshot.shares
restoredMachine.environment = snapshot.environment
@@ -1068,56 +1548,51 @@ public final class MachineManager: @unchecked Sendable {
public func deleteSnapshot(machineID: String, snapshotID: String) throws {
operationLock.lock()
defer { operationLock.unlock() }
- _ = try loadSnapshot(machineID: machineID, snapshotID: snapshotID)
+ let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID)
let metadataPath = snapshotMetadataPath(machineID: machineID, snapshotID: snapshotID)
let rootfsPath = snapshotRootfsPath(machineID: machineID, snapshotID: snapshotID)
let kernelPath = snapshotKernelPath(machineID: machineID, snapshotID: snapshotID)
let token = "\(Self.snapshotDeletionQuarantinePrefix)\(snapshotID)-\(UUID().uuidString)"
let directory = snapshotDirectory(machineID: machineID)
- let quarantinedMetadataPath = "\(directory)/\(token).json"
- let quarantinedRootfsPath = "\(directory)/\(token).ext4"
- let quarantinedKernelPath = "\(directory)/\(token).kernel"
- do {
- try FileManager.default.moveItem(atPath: rootfsPath, toPath: quarantinedRootfsPath)
- } catch {
- throw MachineManagerError.persistence("could not delete snapshot \(snapshotID): \(error)")
- }
+ var artifacts: [(label: String, source: String, quarantine: String)] = [
+ ("rootfs", rootfsPath, "\(directory)/\(token).ext4"),
+ ("kernel", kernelPath, "\(directory)/\(token).kernel"),
+ ]
+ if snapshot.bootMode == .efi {
+ artifacts.append((
+ "machine identifier",
+ snapshotMachineIdentifierPath(machineID: machineID, snapshotID: snapshotID),
+ "\(directory)/\(token).machine-identifier"
+ ))
+ artifacts.append((
+ "NVRAM",
+ snapshotNVRAMPath(machineID: machineID, snapshotID: snapshotID),
+ "\(directory)/\(token).nvram"
+ ))
+ }
+ artifacts.append(("metadata", metadataPath, "\(directory)/\(token).json"))
+
+ var quarantined: [(label: String, source: String, quarantine: String)] = []
do {
- try FileManager.default.moveItem(atPath: kernelPath, toPath: quarantinedKernelPath)
- } catch {
- do {
- try FileManager.default.moveItem(atPath: quarantinedRootfsPath, toPath: rootfsPath)
- } catch let rollbackError {
- throw MachineManagerError.persistence(
- "could not delete snapshot \(snapshotID): \(error); rootfs rollback failed: \(rollbackError)"
- )
+ for artifact in artifacts {
+ try FileManager.default.moveItem(atPath: artifact.source, toPath: artifact.quarantine)
+ quarantined.append(artifact)
}
- throw MachineManagerError.persistence("could not delete snapshot \(snapshotID): \(error)")
- }
- do {
- try FileManager.default.moveItem(atPath: metadataPath, toPath: quarantinedMetadataPath)
} catch {
var rollbackFailures: [String] = []
- do {
- try FileManager.default.moveItem(atPath: quarantinedKernelPath, toPath: kernelPath)
- } catch {
- rollbackFailures.append("kernel rollback failed: \(error)")
- }
- do {
- try FileManager.default.moveItem(atPath: quarantinedRootfsPath, toPath: rootfsPath)
- } catch {
- rollbackFailures.append("rootfs rollback failed: \(error)")
- }
- if !rollbackFailures.isEmpty {
- throw MachineManagerError.persistence(
- "could not delete snapshot \(snapshotID): \(error); \(rollbackFailures.joined(separator: "; "))"
- )
+ for artifact in quarantined.reversed() {
+ do {
+ try FileManager.default.moveItem(atPath: artifact.quarantine, toPath: artifact.source)
+ } catch {
+ rollbackFailures.append("\(artifact.label) rollback failed: \(error)")
+ }
}
- throw MachineManagerError.persistence("could not delete snapshot \(snapshotID): \(error)")
+ let suffix = rollbackFailures.isEmpty ? "" : "; \(rollbackFailures.joined(separator: "; "))"
+ throw MachineManagerError.persistence("could not delete snapshot \(snapshotID): \(error)\(suffix)")
+ }
+ for artifact in quarantined {
+ try? FileManager.default.removeItem(atPath: artifact.quarantine)
}
- try? FileManager.default.removeItem(atPath: quarantinedMetadataPath)
- try? FileManager.default.removeItem(atPath: quarantinedRootfsPath)
- try? FileManager.default.removeItem(atPath: quarantinedKernelPath)
removeEmptyImportedSnapshotNamespace(machineID: machineID)
}
@@ -1139,6 +1614,8 @@ public final class MachineManager: @unchecked Sendable {
defer { operationLock.unlock() }
var extractedRootfsPath: String?
var extractedKernelPath: String?
+ var extractedMachineIdentifierPath: String?
+ var extractedNVRAMPath: String?
var importedMachineID: String?
do {
let bundle = try MachineSnapshotBundle.readDescriptor(fromPath: path)
@@ -1163,14 +1640,28 @@ public final class MachineManager: @unchecked Sendable {
)
snapshot.rootfsPath = snapshotRootfsPath(machineID: snapshot.machineID, snapshotID: snapshot.id)
snapshot.kernelPath = snapshotKernelPath(machineID: snapshot.machineID, snapshotID: snapshot.id)
+ if snapshot.bootMode == .efi {
+ snapshot.machineIdentifierPath = snapshotMachineIdentifierPath(
+ machineID: snapshot.machineID,
+ snapshotID: snapshot.id
+ )
+ snapshot.nvramPath = snapshotNVRAMPath(machineID: snapshot.machineID, snapshotID: snapshot.id)
+ } else {
+ snapshot.machineIdentifierPath = nil
+ snapshot.nvramPath = nil
+ }
try MachineSnapshotBundle.extractArtifacts(
fromPath: path,
expectedContentID: bundle.contentID,
rootfsPath: snapshot.rootfsPath,
- kernelPath: snapshot.kernelPath
+ kernelPath: snapshot.kernelPath,
+ machineIdentifierPath: snapshot.machineIdentifierPath,
+ nvramPath: snapshot.nvramPath
)
extractedRootfsPath = snapshot.rootfsPath
extractedKernelPath = snapshot.kernelPath
+ extractedMachineIdentifierPath = snapshot.machineIdentifierPath
+ extractedNVRAMPath = snapshot.nvramPath
snapshot.sizeBytes = Self.fileSize(path: snapshot.rootfsPath)
try persistSnapshot(snapshot)
return snapshot
@@ -1181,6 +1672,12 @@ public final class MachineManager: @unchecked Sendable {
if let extractedKernelPath {
try? FileManager.default.removeItem(atPath: extractedKernelPath)
}
+ if let extractedMachineIdentifierPath {
+ try? FileManager.default.removeItem(atPath: extractedMachineIdentifierPath)
+ }
+ if let extractedNVRAMPath {
+ try? FileManager.default.removeItem(atPath: extractedNVRAMPath)
+ }
if let importedMachineID {
removeEmptyImportedSnapshotNamespace(machineID: importedMachineID)
}
@@ -1192,6 +1689,12 @@ public final class MachineManager: @unchecked Sendable {
if let extractedKernelPath {
try? FileManager.default.removeItem(atPath: extractedKernelPath)
}
+ if let extractedMachineIdentifierPath {
+ try? FileManager.default.removeItem(atPath: extractedMachineIdentifierPath)
+ }
+ if let extractedNVRAMPath {
+ try? FileManager.default.removeItem(atPath: extractedNVRAMPath)
+ }
if let importedMachineID {
removeEmptyImportedSnapshotNamespace(machineID: importedMachineID)
}
@@ -1216,7 +1719,7 @@ public final class MachineManager: @unchecked Sendable {
}
private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus {
- if [.starting, .running].contains(entry.state), entry.process?.isRunning != true {
+ if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true {
return DoryMachineStatus(
id: id,
state: .failed,
@@ -1226,6 +1729,8 @@ public final class MachineManager: @unchecked Sendable {
memoryMB: entry.configuration.memoryMB,
cpuCount: entry.configuration.cpuCount,
displayMode: entry.configuration.displayMode,
+ bootMode: entry.configuration.bootMode,
+ installerMediaAttached: entry.configuration.installerISOPath != nil,
shares: entry.configuration.shares,
environment: entry.configuration.environment
)
@@ -1249,6 +1754,8 @@ public final class MachineManager: @unchecked Sendable {
currentBalloonTargetMB: entry.currentBalloonTargetMB ?? entry.configuration.memoryMB,
cpuCount: entry.configuration.cpuCount,
displayMode: entry.configuration.displayMode,
+ bootMode: entry.configuration.bootMode,
+ installerMediaAttached: entry.configuration.installerISOPath != nil,
shares: entry.configuration.shares,
environment: entry.configuration.environment
)
@@ -1257,31 +1764,132 @@ public final class MachineManager: @unchecked Sendable {
private func processConfiguration(
for machine: DoryMachineConfiguration,
handoffPath: String?
- ) -> HvProcessConfiguration {
- HvProcessConfiguration(
- executablePath: configuration.vmmExecutablePath,
- arguments: processArguments(for: machine, handoffPath: handoffPath),
- logPath: "\(configuration.logDirectory)/\(machine.id).log"
+ ) throws -> HvProcessConfiguration {
+ let target = processTarget(for: machine)
+ return HvProcessConfiguration(
+ executablePath: target.executablePath,
+ arguments: try processArguments(
+ for: machine,
+ handoffPath: handoffPath,
+ baseArguments: target.baseArguments,
+ acceleratedDesktop: target.acceleratedDesktop
+ ),
+ logPath: "\(configuration.logDirectory)/\(machine.id).log",
+ restartPolicy: configuration.requiresReadyHandoff
+ ? configuration.startupRestartPolicy
+ : .none
)
}
- private func processArguments(for machine: DoryMachineConfiguration, handoffPath: String?) -> [String] {
- guard configuration.passMachineArguments else {
- return configuration.baseArguments
+ private func processTarget(for machine: DoryMachineConfiguration) -> (
+ executablePath: String,
+ baseArguments: [String],
+ acceleratedDesktop: Bool
+ ) {
+ let desktopPreference = try? DoryDesktopVMMPreference(environment: machine.environment)
+ let supportsAcceleratedBoot = machine.bootMode == .linuxKernel
+ || (machine.bootMode == .efi
+ && machine.installerISOPath == nil
+ && DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath))
+ if configuration.passMachineArguments,
+ machine.displayMode == .desktop,
+ supportsAcceleratedBoot,
+ machine.installerISOPath == nil,
+ desktopPreference != .compatible,
+ let executablePath = configuration.acceleratedDesktopExecutablePath {
+ return (executablePath, configuration.acceleratedDesktopBaseArguments, true)
+ }
+ return (configuration.vmmExecutablePath, configuration.baseArguments, false)
+ }
+
+ private func validateRuntimeAvailability(_ machine: DoryMachineConfiguration) throws {
+ guard machine.displayMode == .desktop,
+ machine.installerISOPath == nil else {
+ return
+ }
+ let preference = try DoryDesktopVMMPreference(environment: machine.environment)
+ let installedEFIDesktop = machine.bootMode == .efi
+ && DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath)
+ if machine.bootMode == .efi,
+ preference == .accelerated,
+ !installedEFIDesktop {
+ throw MachineManagerError.persistence(
+ "accelerated installed-Linux runtime is unavailable; reattach and eject the installer media to derive its boot assets"
+ )
+ }
+ guard preference == .accelerated || (installedEFIDesktop && preference != .compatible) else {
+ return
}
- var arguments = configuration.baseArguments + [
+ guard let executablePath = configuration.acceleratedDesktopExecutablePath,
+ FileManager.default.isExecutableFile(atPath: executablePath) else {
+ throw MachineManagerError.persistence(
+ "accelerated desktop runtime was required but is unavailable"
+ )
+ }
+ }
+
+ private func validateInstallerArchitecture(_ machine: DoryMachineConfiguration) throws {
+ guard machine.bootMode == .efi, let installerISOPath = machine.installerISOPath else {
+ return
+ }
+ let detected: DoryInstallerISOArchitecture
+ do {
+ detected = try DoryInstallerISOInspector.architecture(atPath: installerISOPath)
+ } catch {
+ throw MachineManagerError.persistence("could not inspect installer ISO: \(error)")
+ }
+ switch DoryInstallerISOInspector.compatibility(
+ of: detected,
+ hostArchitecture: configuration.guestArchitecture
+ ) {
+ case let .incompatible(message):
+ throw MachineManagerError.persistence(message)
+ case .compatible, .unknown:
+ return
+ }
+ }
+
+ private func processArguments(
+ for machine: DoryMachineConfiguration,
+ handoffPath: String?,
+ baseArguments: [String],
+ acceleratedDesktop: Bool
+ ) throws -> [String] {
+ guard configuration.passMachineArguments else {
+ return baseArguments
+ }
+ let acceleratedInstalledLinux = acceleratedDesktop
+ && machine.bootMode == .efi
+ && machine.installerISOPath == nil
+ let bootDescriptor = acceleratedInstalledLinux
+ ? try DoryInstalledLinuxBootBundle.descriptor(atPath: machine.kernelPath)
+ : nil
+ var arguments = baseArguments + [
"--machine-id", machine.id,
"--state-dir", machineStateDirectory(id: machine.id),
"--dockerd-sock", "\(machineRuntimeDirectory(id: machine.id))/d.sock",
"--agent-sock", "\(machineRuntimeDirectory(id: machine.id))/a.sock",
"--shell-sock", "\(machineRuntimeDirectory(id: machine.id))/s.sock",
"--control-sock", "\(machineRuntimeDirectory(id: machine.id))/c.sock",
- "--kernel", machine.kernelPath,
+ "--kernel", acceleratedInstalledLinux
+ ? machineInstalledLinuxKernelPath(id: machine.id)
+ : machine.kernelPath,
"--rootfs", machine.rootfsPath,
+ "--boot-mode", acceleratedInstalledLinux ? "efi-installed" : machine.bootMode.rawValue,
"--memory-mb", String(machine.memoryMB),
"--cpus", String(machine.cpuCount),
"--display-mode", machine.displayMode.rawValue,
]
+ if let bootDescriptor {
+ arguments.append(contentsOf: [
+ "--initrd", machineInstalledLinuxInitrdPath(id: machine.id),
+ "--root-device", bootDescriptor.rootDevice,
+ "--generic-guest",
+ ])
+ }
+ if let installerISOPath = machine.installerISOPath {
+ arguments.append(contentsOf: ["--installer-iso", installerISOPath])
+ }
if let handoffPath {
arguments.append(contentsOf: ["--handoff-sock", handoffPath])
}
@@ -1317,6 +1925,208 @@ public final class MachineManager: @unchecked Sendable {
"\(machineRuntimeDirectory(id: id))/h.sock"
}
+ private func desktopUpdateJournalPath(machineID: String) -> String {
+ "\(machineStateDirectory(id: machineID))/\(Self.desktopUpdateJournalName)"
+ }
+
+ private func persistDesktopUpdateJournal(_ journal: DesktopUpdateJournal) throws {
+ let directory = machineStateDirectory(id: journal.machineID)
+ guard Self.isPrivateDirectory(path: directory) else {
+ throw MachineManagerError.persistence("desktop update journal owner is not private")
+ }
+ let temporaryPath = "\(directory)/.desktop-update.tmp-\(UUID().uuidString)"
+ do {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(journal)
+ try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic)
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath)
+ guard rename(temporaryPath, desktopUpdateJournalPath(machineID: journal.machineID)) == 0 else {
+ throw MachineManagerError.persistence(
+ "could not publish desktop update journal: \(String(cString: strerror(errno)))"
+ )
+ }
+ } catch {
+ try? FileManager.default.removeItem(atPath: temporaryPath)
+ if let error = error as? MachineManagerError { throw error }
+ throw MachineManagerError.persistence("could not persist desktop update journal: \(error)")
+ }
+ }
+
+ private func removeDesktopUpdateJournal(machineID: String) throws {
+ let path = desktopUpdateJournalPath(machineID: machineID)
+ guard Self.pathEntryExists(path) else { return }
+ do {
+ try FileManager.default.removeItem(atPath: path)
+ } catch {
+ throw MachineManagerError.persistence("could not remove desktop update journal: \(error)")
+ }
+ }
+
+ private func recoverInterruptedDesktopUpdates() {
+ lock.lock()
+ let machineIDs = Array(machines.keys)
+ lock.unlock()
+ for machineID in machineIDs {
+ let path = desktopUpdateJournalPath(machineID: machineID)
+ guard let data = Self.readPrivateMetadata(path: path),
+ let journal = try? JSONDecoder().decode(DesktopUpdateJournal.self, from: data),
+ journal.schema == 1,
+ journal.machineID == machineID,
+ Self.isValidID(journal.snapshotID) else {
+ continue
+ }
+ if journal.stage == "committed" {
+ try? removeDesktopUpdateJournal(machineID: machineID)
+ continue
+ }
+ do {
+ _ = try restoreSnapshot(machineID: machineID, snapshotID: journal.snapshotID)
+ try removeDesktopUpdateJournal(machineID: machineID)
+ lock.lock()
+ if var entry = machines[machineID] {
+ entry.lastError = "An interrupted desktop update was rolled back to \(journal.snapshotID). Start the machine when ready."
+ machines[machineID] = entry
+ }
+ lock.unlock()
+ } catch {
+ lock.lock()
+ if var entry = machines[machineID] {
+ entry.state = .failed
+ entry.lastError = "Interrupted desktop update recovery failed: \(error)"
+ machines[machineID] = entry
+ }
+ lock.unlock()
+ }
+ }
+ }
+
+ @discardableResult
+ private func requireSuccessfulDesktopUpdateExec(
+ id: String,
+ argv: [String],
+ env: [DoryExecEnvironment] = [],
+ timeoutMs: UInt64 = 30_000,
+ outputLimitBytes: UInt64 = 1024 * 1024,
+ stage: String
+ ) throws -> DoryExecResult {
+ let result = try exec(
+ id: id,
+ argv: argv,
+ env: env,
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes
+ )
+ guard result.exitCode == 0, !result.timedOut else {
+ let stderr = String(decoding: result.stderr.suffix(4096), as: UTF8.self)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ throw MachineManagerError.persistence(
+ "desktop update could not \(stage) (exit \(result.timedOut ? 124 : result.exitCode))\(stderr.isEmpty ? "" : ": \(stderr)")"
+ )
+ }
+ return result
+ }
+
+ private func qualifyUpdatedDesktop(
+ id: String,
+ distro: String,
+ version: String,
+ inputSHA256: String
+ ) throws {
+ let script = """
+ set -euo pipefail
+ . /etc/os-release
+ [ "$ID" = "$DORY_EXPECTED_DISTRO" ]
+ grep -Fqx "version=$DORY_EXPECTED_VERSION" /var/lib/dory/desktop-update.env
+ grep -Fqx "input_sha256=$DORY_EXPECTED_INPUT" /var/lib/dory/desktop-update.env
+ [ "$(systemctl get-default)" = graphical.target ]
+ for _ in $(seq 1 180); do
+ if systemctl is-active --quiet NetworkManager.service \
+ && systemctl is-active --quiet display-manager.service \
+ && systemctl is-active --quiet dory-boot.service \
+ && systemctl is-active --quiet dory-zram.service \
+ && grep -q '^/dev/zram0 ' /proc/swaps; then
+ break
+ fi
+ sleep 1
+ done
+ systemctl is-active --quiet NetworkManager.service
+ systemctl is-active --quiet display-manager.service
+ systemctl is-active --quiet dory-boot.service
+ systemctl is-active --quiet dory-zram.service
+ grep -q '^/dev/zram0 ' /proc/swaps
+ user="$(cat /var/lib/dory/username 2>/dev/null || printf 'dory')"
+ id "$user" >/dev/null
+ for _ in $(seq 1 120); do
+ if pgrep -u "$user" -f 'gnome-shell|xfce4-session' >/dev/null; then break; fi
+ sleep 1
+ done
+ pgrep -u "$user" -f 'gnome-shell|xfce4-session' >/dev/null
+ case "$DORY_EXPECTED_DISTRO" in
+ debian) command -v firefox-esr >/dev/null ;;
+ ubuntu) command -v firefox >/dev/null ;;
+ kali) command -v firefox-esr >/dev/null || command -v firefox >/dev/null ;;
+ esac
+ for _ in $(seq 1 120); do
+ if getent ahosts example.com >/dev/null; then break; fi
+ sleep 1
+ done
+ getent ahosts example.com >/dev/null
+ """
+ _ = try requireSuccessfulDesktopUpdateExec(
+ id: id,
+ argv: ["/bin/bash", "-lc", script],
+ env: [
+ DoryExecEnvironment(key: "DORY_EXPECTED_DISTRO", value: distro),
+ DoryExecEnvironment(key: "DORY_EXPECTED_VERSION", value: version),
+ DoryExecEnvironment(key: "DORY_EXPECTED_INPUT", value: inputSHA256),
+ ],
+ timeoutMs: 480_000,
+ outputLimitBytes: 4 * 1024 * 1024,
+ stage: "qualify the updated desktop"
+ )
+ }
+
+ private static func desktopUpdateInputSHA256(from result: DoryExecResult) -> String? {
+ let output = String(decoding: result.stdout, as: UTF8.self)
+ for line in output.split(separator: "\n").reversed() {
+ let fields = line.split(separator: " ")
+ guard fields.count == 7,
+ fields[0] == "Dory",
+ fields[1] == "desktop",
+ fields[2] == "update",
+ fields[3] == "applied:" else { continue }
+ let digest = String(fields[6])
+ if digest.count == 64, digest.allSatisfy({ $0.isHexDigit && !$0.isUppercase }) {
+ return digest
+ }
+ }
+ return nil
+ }
+
+ private static func desktopUpdateReceiptInputSHA256(
+ from result: DoryExecResult,
+ distro: String,
+ version: String
+ ) -> String? {
+ guard !result.stdoutTruncated else { return nil }
+ let fields = Dictionary(uniqueKeysWithValues: String(decoding: result.stdout, as: UTF8.self)
+ .split(separator: "\n")
+ .compactMap { line -> (String, String)? in
+ guard let separator = line.firstIndex(of: "=") else { return nil }
+ return (String(line[.. DoryAgentInfo {
try withAgentClient(id: id) { client in
try client.info()
@@ -1437,6 +2247,80 @@ public final class MachineManager: @unchecked Sendable {
"\(machineStateDirectory(id: id))/kernel"
}
+ private func machineInstalledLinuxKernelPath(id: String) -> String {
+ "\(machineStateDirectory(id: id))/\(Self.installedLinuxKernelName)"
+ }
+
+ private func machineInstalledLinuxInitrdPath(id: String) -> String {
+ "\(machineStateDirectory(id: id))/\(Self.installedLinuxInitrdName)"
+ }
+
+ private func machineInstallerISOPath(id: String) -> String {
+ "\(machineStateDirectory(id: id))/installer.iso"
+ }
+
+ private func ensureInstalledLinuxBootBundleIfNeeded(
+ _ machine: DoryMachineConfiguration
+ ) throws {
+ guard machine.bootMode == .efi,
+ machine.displayMode == .desktop,
+ machine.installerISOPath == nil,
+ try DoryDesktopVMMPreference(environment: machine.environment) != .compatible,
+ configuration.acceleratedDesktopExecutablePath != nil,
+ !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else {
+ return
+ }
+ let installerPath = machineInstallerISOPath(id: machine.id)
+ guard Self.isPrivateRegularFile(path: installerPath) else {
+ throw MachineManagerError.persistence(
+ "installed Linux acceleration requires the managed installer ISO to derive a kernel and initrd"
+ )
+ }
+ do {
+ let assets = try DoryLinuxInstallerBootAssetExtractor.extract(atPath: installerPath)
+ let rootDevice = try DoryLinuxInstalledDiskInspector.rootDevice(atPath: machine.rootfsPath)
+ try DoryInstalledLinuxBootBundle.write(
+ assets: assets,
+ rootDevice: rootDevice,
+ toPath: machine.kernelPath
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "could not prepare the installed Linux accelerated runtime: \(error.localizedDescription)"
+ )
+ }
+ }
+
+ private func materializeInstalledLinuxBootRuntimeIfNeeded(
+ _ machine: DoryMachineConfiguration
+ ) throws {
+ guard machine.bootMode == .efi,
+ machine.installerISOPath == nil,
+ try DoryDesktopVMMPreference(environment: machine.environment) != .compatible,
+ DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else {
+ return
+ }
+ do {
+ try DoryInstalledLinuxBootBundle.materialize(
+ fromPath: machine.kernelPath,
+ kernelPath: machineInstalledLinuxKernelPath(id: machine.id),
+ initrdPath: machineInstalledLinuxInitrdPath(id: machine.id)
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "could not verify the installed Linux accelerated runtime: \(error.localizedDescription)"
+ )
+ }
+ }
+
+ private func machineFirmwareIdentifierPath(id: String) -> String {
+ "\(machineStateDirectory(id: id))/MachineIdentifier"
+ }
+
+ private func machineFirmwareNVRAMPath(id: String) -> String {
+ "\(machineStateDirectory(id: id))/NVRAM"
+ }
+
private func snapshotDirectory(machineID: String) -> String {
"\(machineStateDirectory(id: machineID))/snapshots"
}
@@ -1453,6 +2337,14 @@ public final class MachineManager: @unchecked Sendable {
"\(snapshotDirectory(machineID: machineID))/\(snapshotID).kernel"
}
+ private func snapshotMachineIdentifierPath(machineID: String, snapshotID: String) -> String {
+ "\(snapshotDirectory(machineID: machineID))/\(snapshotID).machine-identifier"
+ }
+
+ private func snapshotNVRAMPath(machineID: String, snapshotID: String) -> String {
+ "\(snapshotDirectory(machineID: machineID))/\(snapshotID).nvram"
+ }
+
private func availableImportedSnapshotID(machineID: String, preferredID: String) throws -> String {
if !snapshotArtifactsExist(machineID: machineID, snapshotID: preferredID) {
return preferredID
@@ -1470,6 +2362,8 @@ public final class MachineManager: @unchecked Sendable {
Self.pathEntryExists(snapshotMetadataPath(machineID: machineID, snapshotID: snapshotID))
|| Self.pathEntryExists(snapshotRootfsPath(machineID: machineID, snapshotID: snapshotID))
|| Self.pathEntryExists(snapshotKernelPath(machineID: machineID, snapshotID: snapshotID))
+ || Self.pathEntryExists(snapshotMachineIdentifierPath(machineID: machineID, snapshotID: snapshotID))
+ || Self.pathEntryExists(snapshotNVRAMPath(machineID: machineID, snapshotID: snapshotID))
}
private func configurationAndRunningState(id: String) throws -> (DoryMachineConfiguration, Bool) {
@@ -1479,7 +2373,11 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.unknownMachine(id)
}
try validateManagedMachineArtifacts(entry.configuration)
- return (entry.configuration, entry.process?.isRunning == true)
+ // Process.isRunning can briefly read false at the spawn/termination callback boundary.
+ // The supervisor lifecycle is authoritative: a running/starting entry with an active or
+ // scheduled helper must be stopped and restarted for a configuration transaction.
+ let active = [.starting, .running].contains(entry.state) && entry.process != nil
+ return (entry.configuration, active)
}
private func publishConfiguration(_ configuration: DoryMachineConfiguration) throws {
@@ -1496,15 +2394,40 @@ public final class MachineManager: @unchecked Sendable {
private func prepareMachineArtifacts(_ machine: DoryMachineConfiguration) throws -> DoryMachineConfiguration {
let rootfsDestination = machineRootfsPath(id: machine.id)
let kernelDestination = machineKernelPath(id: machine.id)
- guard machine.rootfsPath != rootfsDestination, machine.kernelPath != kernelDestination else {
+ let installerDestination = machineInstallerISOPath(id: machine.id)
+ guard machine.rootfsPath != rootfsDestination, machine.kernelPath != kernelDestination,
+ machine.installerISOPath != installerDestination else {
throw MachineManagerError.persistence("machine artifacts must be imported into managed storage")
}
do {
- try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsDestination)
- try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelDestination)
var copy = machine
+ switch machine.bootMode {
+ case .linuxKernel:
+ try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsDestination)
+ try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelDestination)
+ case .efi:
+ if Self.isRegularNonemptyFile(path: machine.rootfsPath) {
+ try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsDestination)
+ } else if let diskSizeBytes = machine.diskSizeBytes {
+ try Self.createPrivateSparseFile(path: rootfsDestination, sizeBytes: diskSizeBytes)
+ } else {
+ throw MachineManagerError.persistence("EFI machine is missing its disk source or size")
+ }
+ if DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) {
+ // EFI snapshot clones carry this opaque, verified bundle in the existing
+ // kernel artifact, so acceleration survives the full machine lifecycle.
+ try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelDestination)
+ } else {
+ try Self.createPrivateFile(path: kernelDestination, contents: Data("DORY-EFI\n".utf8))
+ }
+ if let installerISOPath = machine.installerISOPath {
+ try Self.cloneOrCopyFile(source: installerISOPath, destination: installerDestination)
+ copy.installerISOPath = installerDestination
+ }
+ }
copy.rootfsPath = rootfsDestination
copy.kernelPath = kernelDestination
+ copy.diskSizeBytes = nil
return copy
} catch {
throw MachineManagerError.persistence("could not prepare artifacts for \(machine.id): \(error)")
@@ -1525,6 +2448,14 @@ public final class MachineManager: @unchecked Sendable {
Self.isPrivateRegularFile(path: expectedKernelPath) else {
throw MachineManagerError.persistence("machine kernel failed managed-storage validation")
}
+ if let installerISOPath = machine.installerISOPath {
+ let expectedInstallerISOPath = machineInstallerISOPath(id: machine.id)
+ guard machine.bootMode == .efi,
+ installerISOPath == expectedInstallerISOPath,
+ Self.isPrivateRegularFile(path: expectedInstallerISOPath) else {
+ throw MachineManagerError.persistence("machine installer ISO failed managed-storage validation")
+ }
+ }
}
private func restoreManagedArtifacts(
@@ -1536,9 +2467,13 @@ public final class MachineManager: @unchecked Sendable {
let token = UUID().uuidString
let rootfsBackup = "\(directory)/.restore-rootfs-\(token)"
let kernelBackup = "\(directory)/.restore-kernel-\(token)"
+ let machineIdentifierBackup = "\(directory)/.restore-machine-identifier-\(token)"
+ let nvramBackup = "\(directory)/.restore-nvram-\(token)"
defer {
try? FileManager.default.removeItem(atPath: rootfsBackup)
try? FileManager.default.removeItem(atPath: kernelBackup)
+ try? FileManager.default.removeItem(atPath: machineIdentifierBackup)
+ try? FileManager.default.removeItem(atPath: nvramBackup)
}
try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsBackup)
do {
@@ -1546,6 +2481,26 @@ public final class MachineManager: @unchecked Sendable {
} catch {
throw MachineManagerError.persistence("could not preserve live kernel before restore: \(error)")
}
+ let firmwareRestore: (identifier: String, nvram: String, snapshotIdentifier: String, snapshotNVRAM: String)?
+ if snapshot.bootMode == .efi {
+ let identifier = machineFirmwareIdentifierPath(id: machine.id)
+ let nvram = machineFirmwareNVRAMPath(id: machine.id)
+ guard let snapshotIdentifier = snapshot.machineIdentifierPath,
+ let snapshotNVRAM = snapshot.nvramPath,
+ Self.isPrivateRegularFile(path: identifier),
+ Self.isPrivateRegularFile(path: nvram) else {
+ throw MachineManagerError.persistence("could not preserve live EFI firmware before restore")
+ }
+ do {
+ try Self.cloneOrCopyFile(source: identifier, destination: machineIdentifierBackup)
+ try Self.cloneOrCopyFile(source: nvram, destination: nvramBackup)
+ } catch {
+ throw MachineManagerError.persistence("could not preserve live EFI firmware before restore: \(error)")
+ }
+ firmwareRestore = (identifier, nvram, snapshotIdentifier, snapshotNVRAM)
+ } else {
+ firmwareRestore = nil
+ }
do {
try Self.cloneOrCopyFile(
source: snapshot.rootfsPath,
@@ -1557,6 +2512,18 @@ public final class MachineManager: @unchecked Sendable {
destination: machine.kernelPath,
replaceExisting: true
)
+ if let firmwareRestore {
+ try Self.cloneOrCopyFile(
+ source: firmwareRestore.snapshotIdentifier,
+ destination: firmwareRestore.identifier,
+ replaceExisting: true
+ )
+ try Self.cloneOrCopyFile(
+ source: firmwareRestore.snapshotNVRAM,
+ destination: firmwareRestore.nvram,
+ replaceExisting: true
+ )
+ }
try commit()
} catch {
var rollbackFailures: [String] = []
@@ -1578,6 +2545,26 @@ public final class MachineManager: @unchecked Sendable {
} catch {
rollbackFailures.append("kernel rollback failed: \(error)")
}
+ if let firmwareRestore {
+ do {
+ try Self.cloneOrCopyFile(
+ source: machineIdentifierBackup,
+ destination: firmwareRestore.identifier,
+ replaceExisting: true
+ )
+ } catch {
+ rollbackFailures.append("machine identifier rollback failed: \(error)")
+ }
+ do {
+ try Self.cloneOrCopyFile(
+ source: nvramBackup,
+ destination: firmwareRestore.nvram,
+ replaceExisting: true
+ )
+ } catch {
+ rollbackFailures.append("NVRAM rollback failed: \(error)")
+ }
+ }
guard rollbackFailures.isEmpty else {
throw MachineManagerError.persistence(
"could not restore machine artifacts: \(error); \(rollbackFailures.joined(separator: "; "))"
@@ -1706,6 +2693,8 @@ public final class MachineManager: @unchecked Sendable {
let path = snapshotMetadataPath(machineID: machineID, snapshotID: snapshotID)
let expectedRootfsPath = snapshotRootfsPath(machineID: machineID, snapshotID: snapshotID)
let expectedKernelPath = snapshotKernelPath(machineID: machineID, snapshotID: snapshotID)
+ let expectedMachineIdentifierPath = snapshotMachineIdentifierPath(machineID: machineID, snapshotID: snapshotID)
+ let expectedNVRAMPath = snapshotNVRAMPath(machineID: machineID, snapshotID: snapshotID)
guard Self.isPrivateDirectory(path: snapshotDirectory(machineID: machineID)),
let data = Self.readPrivateMetadata(path: path),
let snapshot = try? JSONDecoder().decode(DoryMachineSnapshot.self, from: data),
@@ -1719,6 +2708,19 @@ public final class MachineManager: @unchecked Sendable {
Self.isPrivateRegularFile(path: expectedKernelPath) else {
throw MachineManagerError.unknownSnapshot(snapshotID)
}
+ switch snapshot.bootMode {
+ case .linuxKernel:
+ guard snapshot.machineIdentifierPath == nil, snapshot.nvramPath == nil else {
+ throw MachineManagerError.unknownSnapshot(snapshotID)
+ }
+ case .efi:
+ guard snapshot.machineIdentifierPath == expectedMachineIdentifierPath,
+ snapshot.nvramPath == expectedNVRAMPath,
+ Self.isPrivateRegularFile(path: expectedMachineIdentifierPath),
+ Self.isPrivateRegularFile(path: expectedNVRAMPath) else {
+ throw MachineManagerError.unknownSnapshot(snapshotID)
+ }
+ }
var validated = snapshot
validated.sizeBytes = Self.fileSize(path: expectedRootfsPath)
return validated
@@ -1752,6 +2754,7 @@ public final class MachineManager: @unchecked Sendable {
entry.handoff = handoff
entry.state = .running
entry.lastError = nil
+ entry.process?.disableRestarts()
agentSocketPath = handoff.ready.agentSocketPath
case let .failure(error):
entry.state = .failed
@@ -1873,10 +2876,26 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.invalidEnvironment(key)
}
}
+ do {
+ _ = try DoryDesktopVMMPreference(environment: environment)
+ } catch {
+ throw MachineManagerError.invalidEnvironment(DoryDesktopVMMPreference.environmentKey)
+ }
+ do {
+ _ = try DoryDesktopGraphicsPreference(environment: environment)
+ } catch {
+ throw MachineManagerError.invalidEnvironment(DoryDesktopGraphicsPreference.environmentKey)
+ }
}
private static func validateLaunchConfiguration(_ machine: DoryMachineConfiguration) throws {
try validateResources(memoryMB: machine.memoryMB, cpuCount: machine.cpuCount)
+ if machine.bootMode == .efi, machine.displayMode != .desktop {
+ throw MachineManagerError.persistence("EFI machines require desktop display mode")
+ }
+ if machine.bootMode == .linuxKernel, machine.installerISOPath != nil {
+ throw MachineManagerError.persistence("installer ISO requires EFI boot mode")
+ }
let normalizedAddress = try normalizedAddress(machine.address)
guard normalizedAddress == machine.address else {
throw MachineManagerError.invalidAddress(machine.address ?? "")
@@ -2010,6 +3029,60 @@ public final class MachineManager: @unchecked Sendable {
}
}
+ private static func createPrivateSparseFile(path: String, sizeBytes: UInt64) throws {
+ guard sizeBytes <= UInt64(Int64.max) else {
+ throw MachineManagerError.persistence("managed disk size is too large")
+ }
+ let descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode_t(0o600))
+ guard descriptor >= 0 else {
+ throw MachineManagerError.persistence(
+ "could not create managed disk: \(String(cString: strerror(errno)))"
+ )
+ }
+ defer { close(descriptor) }
+ guard ftruncate(descriptor, off_t(sizeBytes)) == 0,
+ fsync(descriptor) == 0,
+ isPrivateRegularFile(descriptor: descriptor) else {
+ let code = errno
+ _ = unlink(path)
+ throw MachineManagerError.persistence(
+ "could not size managed disk: \(String(cString: strerror(code)))"
+ )
+ }
+ }
+
+ private static func createPrivateFile(path: String, contents: Data) throws {
+ let descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode_t(0o600))
+ guard descriptor >= 0 else {
+ throw MachineManagerError.persistence(
+ "could not create managed file: \(String(cString: strerror(errno)))"
+ )
+ }
+ defer { close(descriptor) }
+ let wrote = contents.withUnsafeBytes { buffer -> Bool in
+ guard let baseAddress = buffer.baseAddress else { return contents.isEmpty }
+ var offset = 0
+ while offset < buffer.count {
+ let result = write(descriptor, baseAddress.advanced(by: offset), buffer.count - offset)
+ if result > 0 {
+ offset += result
+ } else if result < 0, errno == EINTR {
+ continue
+ } else {
+ return false
+ }
+ }
+ return true
+ }
+ guard wrote, fsync(descriptor) == 0, isPrivateRegularFile(descriptor: descriptor) else {
+ let code = errno
+ _ = unlink(path)
+ throw MachineManagerError.persistence(
+ "could not write managed file: \(String(cString: strerror(code)))"
+ )
+ }
+ }
+
private static func fileSize(path: String) -> Int64 {
let attrs = try? FileManager.default.attributesOfItem(atPath: path)
if let number = attrs?[.size] as? NSNumber {
@@ -2112,6 +3185,26 @@ public final class MachineManager: @unchecked Sendable {
return lstat(path, &info) == 0 && isPrivateDirectory(info: info)
}
+ private static func isDirectory(path: String) -> Bool {
+ var info = stat()
+ return lstat(path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFDIR
+ }
+
+ private static func sha256(path: String) throws -> String {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw MachineManagerError.persistence("could not open desktop update bundle")
+ }
+ let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
+ var hasher = SHA256()
+ while true {
+ let chunk = try handle.read(upToCount: 1024 * 1024) ?? Data()
+ if chunk.isEmpty { break }
+ hasher.update(data: chunk)
+ }
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ }
+
private static func isPrivateDirectory(info: stat) -> Bool {
(info.st_mode & S_IFMT) == S_IFDIR
&& info.st_uid == getuid()
@@ -2136,6 +3229,7 @@ public final class MachineManager: @unchecked Sendable {
let path = "\(root)/\(id)/machine.json"
let rootfsPath = "\(root)/\(id)/rootfs.ext4"
let kernelPath = "\(root)/\(id)/kernel"
+ let installerISOPath = "\(root)/\(id)/installer.iso"
guard let data = readPrivateMetadata(path: path),
let machine = try? decoder.decode(DoryMachineConfiguration.self, from: data),
machine.id == id,
@@ -2144,7 +3238,12 @@ public final class MachineManager: @unchecked Sendable {
machine.rootfsPath == rootfsPath,
machine.kernelPath == kernelPath,
isPrivateRegularFile(path: rootfsPath),
- isPrivateRegularFile(path: kernelPath) else {
+ isPrivateRegularFile(path: kernelPath),
+ machine.installerISOPath == nil || (
+ machine.bootMode == .efi
+ && machine.installerISOPath == installerISOPath
+ && isPrivateRegularFile(path: installerISOPath)
+ ) else {
continue
}
loaded[id] = MachineEntry(configuration: machine, state: .stopped)
@@ -2199,6 +3298,8 @@ public final class MachineManager: @unchecked Sendable {
|| (entry.hasPrefix(".") && (
entry.contains(snapshotDiskTemporaryMarker)
|| entry.contains(snapshotKernelTemporaryMarker)
+ || entry.contains(snapshotMachineIdentifierTemporaryMarker)
+ || entry.contains(snapshotNVRAMTemporaryMarker)
)) {
try? fileManager.removeItem(atPath: "\(directory)/\(entry)")
}
@@ -2219,10 +3320,17 @@ private enum MachineSnapshotBundle {
var kernelOffset: UInt64
var kernelLength: UInt64
var kernelDigest: Data
+ var machineIdentifierOffset: UInt64?
+ var machineIdentifierLength: UInt64?
+ var machineIdentifierDigest: Data?
+ var nvramOffset: UInt64?
+ var nvramLength: UInt64?
+ var nvramDigest: Data?
var contentID: Data
}
- private static let magic = Data("DORYMACHINE3\n".utf8)
+ private static let v3Magic = Data("DORYMACHINE3\n".utf8)
+ private static let v4Magic = Data("DORYMACHINE4\n".utf8)
private static let lengthByteCount = 8
private static let digestByteCount = 32
private static let maximumMetadataLength: UInt64 = 16 * 1024 * 1024
@@ -2233,20 +3341,52 @@ private enum MachineSnapshotBundle {
defer { try? rootfs.close() }
let kernel = try openRegularFileForReading(path: snapshot.kernelPath, requirePrivateOwnership: true)
defer { try? kernel.close() }
+ let machineIdentifier: FileHandle?
+ let nvram: FileHandle?
+ if snapshot.bootMode == .efi {
+ guard let machineIdentifierPath = snapshot.machineIdentifierPath,
+ let nvramPath = snapshot.nvramPath else {
+ throw MachineManagerError.persistence("EFI snapshot is missing firmware state")
+ }
+ machineIdentifier = try openRegularFileForReading(
+ path: machineIdentifierPath,
+ requirePrivateOwnership: true
+ )
+ nvram = try openRegularFileForReading(path: nvramPath, requirePrivateOwnership: true)
+ } else {
+ machineIdentifier = nil
+ nvram = nil
+ }
+ defer {
+ try? machineIdentifier?.close()
+ try? nvram?.close()
+ }
let rootfsLength = try rootfs.seekToEnd()
let kernelLength = try kernel.seekToEnd()
+ let machineIdentifierLength = try machineIdentifier?.seekToEnd()
+ let nvramLength = try nvram?.seekToEnd()
guard rootfsLength > 0, rootfsLength <= UInt64(Int64.max) else {
throw MachineManagerError.persistence("invalid machine snapshot rootfs size")
}
guard kernelLength > 0, kernelLength <= UInt64(Int64.max) else {
throw MachineManagerError.persistence("invalid machine snapshot kernel size")
}
+ if snapshot.bootMode == .efi {
+ guard let machineIdentifierLength, machineIdentifierLength > 0,
+ let nvramLength, nvramLength > 0 else {
+ throw MachineManagerError.persistence("invalid EFI snapshot firmware size")
+ }
+ }
try rootfs.seek(toOffset: 0)
try kernel.seek(toOffset: 0)
+ try machineIdentifier?.seek(toOffset: 0)
+ try nvram?.seek(toOffset: 0)
var exportedSnapshot = snapshot
exportedSnapshot.rootfsPath = ""
exportedSnapshot.kernelPath = ""
+ exportedSnapshot.machineIdentifierPath = nil
+ exportedSnapshot.nvramPath = nil
exportedSnapshot.address = nil
exportedSnapshot.shares = []
exportedSnapshot.environment = [:]
@@ -2276,15 +3416,31 @@ private enum MachineSnapshotBundle {
}
}
do {
- try output.write(contentsOf: magic)
+ let includesFirmware = snapshot.bootMode == .efi
+ try output.write(contentsOf: includesFirmware ? v4Magic : v3Magic)
try output.write(contentsOf: bigEndianBytes(UInt64(metadata.count)))
try output.write(contentsOf: bigEndianBytes(rootfsLength))
try output.write(contentsOf: bigEndianBytes(kernelLength))
+ if includesFirmware {
+ try output.write(contentsOf: bigEndianBytes(machineIdentifierLength!))
+ try output.write(contentsOf: bigEndianBytes(nvramLength!))
+ }
try output.write(contentsOf: metadataDigest)
let rootfsDigestOffset = try output.offset()
try output.write(contentsOf: Data(repeating: 0, count: digestByteCount))
let kernelDigestOffset = try output.offset()
try output.write(contentsOf: Data(repeating: 0, count: digestByteCount))
+ let machineIdentifierDigestOffset: UInt64?
+ let nvramDigestOffset: UInt64?
+ if includesFirmware {
+ machineIdentifierDigestOffset = try output.offset()
+ try output.write(contentsOf: Data(repeating: 0, count: digestByteCount))
+ nvramDigestOffset = try output.offset()
+ try output.write(contentsOf: Data(repeating: 0, count: digestByteCount))
+ } else {
+ machineIdentifierDigestOffset = nil
+ nvramDigestOffset = nil
+ }
try output.write(contentsOf: metadata)
let rootfsDigest = try copyExactly(
from: rootfs,
@@ -2298,10 +3454,36 @@ private enum MachineSnapshotBundle {
byteCount: kernelLength,
rejectTrailingInput: true
)
+ let machineIdentifierDigest: Data?
+ let nvramDigest: Data?
+ if let machineIdentifier, let machineIdentifierLength, let nvram, let nvramLength {
+ machineIdentifierDigest = try copyExactly(
+ from: machineIdentifier,
+ to: output,
+ byteCount: machineIdentifierLength,
+ rejectTrailingInput: true
+ )
+ nvramDigest = try copyExactly(
+ from: nvram,
+ to: output,
+ byteCount: nvramLength,
+ rejectTrailingInput: true
+ )
+ } else {
+ machineIdentifierDigest = nil
+ nvramDigest = nil
+ }
try output.seek(toOffset: rootfsDigestOffset)
try output.write(contentsOf: rootfsDigest)
try output.seek(toOffset: kernelDigestOffset)
try output.write(contentsOf: kernelDigest)
+ if let machineIdentifierDigestOffset, let machineIdentifierDigest,
+ let nvramDigestOffset, let nvramDigest {
+ try output.seek(toOffset: machineIdentifierDigestOffset)
+ try output.write(contentsOf: machineIdentifierDigest)
+ try output.seek(toOffset: nvramDigestOffset)
+ try output.write(contentsOf: nvramDigest)
+ }
try output.synchronize()
try output.close()
outputIsOpen = false
@@ -2331,7 +3513,9 @@ private enum MachineSnapshotBundle {
fromPath path: String,
expectedContentID: Data,
rootfsPath: String,
- kernelPath: String
+ kernelPath: String,
+ machineIdentifierPath: String?,
+ nvramPath: String?
) throws {
let input = try openRegularFileForReading(path: path)
var inputIsOpen = true
@@ -2344,125 +3528,128 @@ private enum MachineSnapshotBundle {
guard header.contentID == expectedContentID else {
throw MachineManagerError.persistence("machine bundle changed during import")
}
- let rootfsURL = URL(fileURLWithPath: rootfsPath)
- let kernelURL = URL(fileURLWithPath: kernelPath)
- let parent = rootfsURL.deletingLastPathComponent()
- guard kernelURL.deletingLastPathComponent() == parent,
+ var artifacts: [(offset: UInt64, length: UInt64, digest: Data, destination: URL)] = [
+ (header.rootfsOffset, header.rootfsLength, header.rootfsDigest, URL(fileURLWithPath: rootfsPath)),
+ (header.kernelOffset, header.kernelLength, header.kernelDigest, URL(fileURLWithPath: kernelPath)),
+ ]
+ if let offset = header.machineIdentifierOffset,
+ let length = header.machineIdentifierLength,
+ let digest = header.machineIdentifierDigest,
+ let nvramOffset = header.nvramOffset,
+ let nvramLength = header.nvramLength,
+ let nvramDigest = header.nvramDigest,
+ let machineIdentifierPath,
+ let nvramPath {
+ artifacts.append((offset, length, digest, URL(fileURLWithPath: machineIdentifierPath)))
+ artifacts.append((nvramOffset, nvramLength, nvramDigest, URL(fileURLWithPath: nvramPath)))
+ } else if header.machineIdentifierOffset != nil
+ || machineIdentifierPath != nil
+ || nvramPath != nil {
+ throw MachineManagerError.persistence("EFI machine bundle firmware destinations are incomplete")
+ }
+
+ let parent = artifacts[0].destination.deletingLastPathComponent()
+ guard artifacts.allSatisfy({ $0.destination.deletingLastPathComponent() == parent }),
isPrivateDirectory(path: parent.path) else {
throw MachineManagerError.persistence("machine snapshot destination is not private")
}
let token = UUID().uuidString
- let temporaryRootfsURL = parent.appendingPathComponent(".\(rootfsURL.lastPathComponent).tmp-\(token)")
- let temporaryKernelURL = parent.appendingPathComponent(".\(kernelURL.lastPathComponent).tmp-\(token)")
- guard FileManager.default.createFile(
- atPath: temporaryRootfsURL.path,
- contents: nil,
- attributes: [.posixPermissions: 0o600]
- ) else {
- try? input.close()
- throw MachineManagerError.persistence("could not create temporary machine snapshot rootfs")
- }
- guard FileManager.default.createFile(
- atPath: temporaryKernelURL.path,
- contents: nil,
- attributes: [.posixPermissions: 0o600]
- ) else {
- try? FileManager.default.removeItem(at: temporaryRootfsURL)
- throw MachineManagerError.persistence("could not create temporary machine snapshot kernel")
+ let temporaryURLs = artifacts.map {
+ parent.appendingPathComponent(".\($0.destination.lastPathComponent).tmp-\(token)")
+ }
+ var createdTemporaryURLs: [URL] = []
+ for temporaryURL in temporaryURLs {
+ guard FileManager.default.createFile(
+ atPath: temporaryURL.path,
+ contents: nil,
+ attributes: [.posixPermissions: 0o600]
+ ) else {
+ for created in createdTemporaryURLs {
+ try? FileManager.default.removeItem(at: created)
+ }
+ throw MachineManagerError.persistence("could not create temporary machine snapshot artifact")
+ }
+ createdTemporaryURLs.append(temporaryURL)
}
- let rootfsOutput: FileHandle
- let kernelOutput: FileHandle
+ var outputs: [FileHandle] = []
do {
- rootfsOutput = try FileHandle(forWritingTo: temporaryRootfsURL)
- kernelOutput = try FileHandle(forWritingTo: temporaryKernelURL)
+ for temporaryURL in temporaryURLs {
+ outputs.append(try FileHandle(forWritingTo: temporaryURL))
+ }
} catch {
- try? FileManager.default.removeItem(at: temporaryRootfsURL)
- try? FileManager.default.removeItem(at: temporaryKernelURL)
+ for output in outputs { try? output.close() }
+ for temporaryURL in temporaryURLs { try? FileManager.default.removeItem(at: temporaryURL) }
throw error
}
var outputsAreOpen = true
defer {
if outputsAreOpen {
- try? rootfsOutput.close()
- try? kernelOutput.close()
+ for output in outputs { try? output.close() }
}
}
+ var publishedDestinations: [URL] = []
do {
- try input.seek(toOffset: header.rootfsOffset)
- let rootfsDigest = try copyExactly(
- from: input,
- to: rootfsOutput,
- byteCount: header.rootfsLength
- )
- guard rootfsDigest == header.rootfsDigest else {
- throw MachineManagerError.persistence("corrupt dory machine bundle rootfs")
- }
- try input.seek(toOffset: header.kernelOffset)
- let kernelDigest = try copyExactly(
- from: input,
- to: kernelOutput,
- byteCount: header.kernelLength
- )
- guard kernelDigest == header.kernelDigest else {
- throw MachineManagerError.persistence("corrupt dory machine bundle kernel")
+ for (index, artifact) in artifacts.enumerated() {
+ try input.seek(toOffset: artifact.offset)
+ let digest = try copyExactly(
+ from: input,
+ to: outputs[index],
+ byteCount: artifact.length
+ )
+ guard digest == artifact.digest else {
+ throw MachineManagerError.persistence("corrupt dory machine bundle artifact")
+ }
}
- try rootfsOutput.synchronize()
- try kernelOutput.synchronize()
+ for output in outputs { try output.synchronize() }
try input.close()
inputIsOpen = false
- try rootfsOutput.close()
- try kernelOutput.close()
+ for output in outputs { try output.close() }
outputsAreOpen = false
- try FileManager.default.setAttributes(
- [.posixPermissions: 0o600],
- ofItemAtPath: temporaryRootfsURL.path
- )
- try FileManager.default.setAttributes(
- [.posixPermissions: 0o600],
- ofItemAtPath: temporaryKernelURL.path
- )
- var publishedRootfs = false
- var publishedKernel = false
- defer {
- if publishedRootfs {
- try? FileManager.default.removeItem(at: rootfsURL)
- }
- if publishedKernel {
- try? FileManager.default.removeItem(at: kernelURL)
- }
- }
- guard link(temporaryRootfsURL.path, rootfsURL.path) == 0 else {
- throw MachineManagerError.persistence(
- "could not publish machine snapshot rootfs: \(String(cString: strerror(errno)))"
+ for temporaryURL in temporaryURLs {
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: temporaryURL.path
)
}
- publishedRootfs = true
- guard link(temporaryKernelURL.path, kernelURL.path) == 0 else {
- throw MachineManagerError.persistence(
- "could not publish machine snapshot kernel: \(String(cString: strerror(errno)))"
- )
+ for (temporaryURL, artifact) in zip(temporaryURLs, artifacts) {
+ guard link(temporaryURL.path, artifact.destination.path) == 0 else {
+ throw MachineManagerError.persistence(
+ "could not publish machine snapshot artifact: \(String(cString: strerror(errno)))"
+ )
+ }
+ publishedDestinations.append(artifact.destination)
}
- publishedKernel = true
- try FileManager.default.removeItem(at: temporaryRootfsURL)
- try FileManager.default.removeItem(at: temporaryKernelURL)
- publishedRootfs = false
- publishedKernel = false
+ for temporaryURL in temporaryURLs { try FileManager.default.removeItem(at: temporaryURL) }
+ publishedDestinations.removeAll()
} catch {
- try? FileManager.default.removeItem(at: temporaryRootfsURL)
- try? FileManager.default.removeItem(at: temporaryKernelURL)
+ for destination in publishedDestinations {
+ try? FileManager.default.removeItem(at: destination)
+ }
+ for temporaryURL in temporaryURLs { try? FileManager.default.removeItem(at: temporaryURL) }
throw error
}
}
private static func readHeader(from input: FileHandle) throws -> Header {
try input.seek(toOffset: 0)
- let gotMagic = try readExactly(from: input, count: magic.count)
- guard gotMagic == magic else {
+ let gotMagic = try readExactly(from: input, count: v3Magic.count)
+ let includesFirmware: Bool
+ if gotMagic == v3Magic {
+ includesFirmware = false
+ } else if gotMagic == v4Magic {
+ includesFirmware = true
+ } else {
throw MachineManagerError.persistence("not a dory machine bundle")
}
let metadataLength = decodeUInt64(try readExactly(from: input, count: lengthByteCount))
let rootfsLength = decodeUInt64(try readExactly(from: input, count: lengthByteCount))
let kernelLength = decodeUInt64(try readExactly(from: input, count: lengthByteCount))
+ let machineIdentifierLength = includesFirmware
+ ? decodeUInt64(try readExactly(from: input, count: lengthByteCount))
+ : nil
+ let nvramLength = includesFirmware
+ ? decodeUInt64(try readExactly(from: input, count: lengthByteCount))
+ : nil
guard metadataLength > 0, metadataLength <= maximumMetadataLength else {
throw MachineManagerError.persistence("invalid dory machine bundle metadata")
}
@@ -2472,9 +3659,21 @@ private enum MachineSnapshotBundle {
guard kernelLength > 0, kernelLength <= UInt64(Int64.max) else {
throw MachineManagerError.persistence("invalid dory machine bundle kernel size")
}
+ if includesFirmware {
+ guard let machineIdentifierLength, machineIdentifierLength > 0,
+ let nvramLength, nvramLength > 0 else {
+ throw MachineManagerError.persistence("invalid dory machine bundle firmware size")
+ }
+ }
let metadataDigest = try readExactly(from: input, count: digestByteCount)
let rootfsDigest = try readExactly(from: input, count: digestByteCount)
let kernelDigest = try readExactly(from: input, count: digestByteCount)
+ let machineIdentifierDigest = includesFirmware
+ ? try readExactly(from: input, count: digestByteCount)
+ : nil
+ let nvramDigest = includesFirmware
+ ? try readExactly(from: input, count: digestByteCount)
+ : nil
let metadata = try readExactly(from: input, count: Int(metadataLength))
guard Data(SHA256.hash(data: metadata)) == metadataDigest else {
throw MachineManagerError.persistence("corrupt dory machine bundle metadata")
@@ -2483,11 +3682,31 @@ private enum MachineSnapshotBundle {
guard snapshot.sizeBytes == Int64(rootfsLength) else {
throw MachineManagerError.persistence("machine bundle rootfs size does not match metadata")
}
- let fixedHeaderLength = UInt64(magic.count + (lengthByteCount * 3) + (digestByteCount * 3))
+ guard (snapshot.bootMode == .efi) == includesFirmware else {
+ throw MachineManagerError.persistence("machine bundle boot mode does not match its artifacts")
+ }
+ let artifactCount = includesFirmware ? 5 : 3
+ let fixedHeaderLength = UInt64(
+ v3Magic.count + (lengthByteCount * artifactCount) + (digestByteCount * artifactCount)
+ )
let (rootfsOffset, metadataOverflow) = fixedHeaderLength.addingReportingOverflow(metadataLength)
let (kernelOffset, rootfsOverflow) = rootfsOffset.addingReportingOverflow(rootfsLength)
- let (expectedFileLength, kernelOverflow) = kernelOffset.addingReportingOverflow(kernelLength)
- guard !metadataOverflow, !rootfsOverflow, !kernelOverflow,
+ let (afterKernelOffset, kernelOverflow) = kernelOffset.addingReportingOverflow(kernelLength)
+ var machineIdentifierOffset: UInt64?
+ var nvramOffset: UInt64?
+ let expectedFileLength: UInt64
+ var firmwareOverflow = false
+ if let machineIdentifierLength, let nvramLength {
+ machineIdentifierOffset = afterKernelOffset
+ let (computedNVRAMOffset, identifierOverflow) = afterKernelOffset.addingReportingOverflow(machineIdentifierLength)
+ nvramOffset = computedNVRAMOffset
+ let (computedFileLength, nvramOverflow) = computedNVRAMOffset.addingReportingOverflow(nvramLength)
+ expectedFileLength = computedFileLength
+ firmwareOverflow = identifierOverflow || nvramOverflow
+ } else {
+ expectedFileLength = afterKernelOffset
+ }
+ guard !metadataOverflow, !rootfsOverflow, !kernelOverflow, !firmwareOverflow,
try input.seekToEnd() == expectedFileLength else {
throw MachineManagerError.persistence("truncated or trailing dory machine bundle artifacts")
}
@@ -2495,9 +3714,17 @@ private enum MachineSnapshotBundle {
identity.append(bigEndianBytes(metadataLength))
identity.append(bigEndianBytes(rootfsLength))
identity.append(bigEndianBytes(kernelLength))
+ if let machineIdentifierLength, let nvramLength {
+ identity.append(bigEndianBytes(machineIdentifierLength))
+ identity.append(bigEndianBytes(nvramLength))
+ }
identity.append(metadataDigest)
identity.append(rootfsDigest)
identity.append(kernelDigest)
+ if let machineIdentifierDigest, let nvramDigest {
+ identity.append(machineIdentifierDigest)
+ identity.append(nvramDigest)
+ }
return Header(
snapshot: snapshot,
rootfsOffset: rootfsOffset,
@@ -2506,6 +3733,12 @@ private enum MachineSnapshotBundle {
kernelOffset: kernelOffset,
kernelLength: kernelLength,
kernelDigest: kernelDigest,
+ machineIdentifierOffset: machineIdentifierOffset,
+ machineIdentifierLength: machineIdentifierLength,
+ machineIdentifierDigest: machineIdentifierDigest,
+ nvramOffset: nvramOffset,
+ nvramLength: nvramLength,
+ nvramDigest: nvramDigest,
contentID: Data(SHA256.hash(data: identity))
)
}
diff --git a/dory-core-swift/Sources/dory-vmm/Info.plist b/dory-core-swift/Sources/dory-vmm/Info.plist
index 0fd461ad..21735b74 100644
--- a/dory-core-swift/Sources/dory-vmm/Info.plist
+++ b/dory-core-swift/Sources/dory-vmm/Info.plist
@@ -14,6 +14,8 @@
APPL
NSHighResolutionCapable
+ NSMicrophoneUsageDescription
+ Dory shares your Mac microphone with Linux desktop machines when you use audio input.
NSPrincipalClass
NSApplication
diff --git a/dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements b/dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements
index 9b3e03a0..07c35f00 100644
--- a/dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements
+++ b/dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements
@@ -2,6 +2,8 @@
+ com.apple.security.device.audio-input
+
com.apple.security.virtualization
diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift
index 77782a69..15443acb 100644
--- a/dory-core-swift/Sources/dorydctl/main.swift
+++ b/dory-core-swift/Sources/dorydctl/main.swift
@@ -5,6 +5,7 @@ import Foundation
private let engineColdStartTimeout: TimeInterval = 240
private let engineShutdownTimeout = DoryEngineShutdownTiming.hostTerminationSeconds + 5
+private let machineFileMutationTimeout: TimeInterval = 900
enum DorydCtlError: Error, CustomStringConvertible {
case daemon(String)
@@ -163,12 +164,13 @@ func usage(exitCode: Int32 = 2) -> Never {
dorydctl [global] machine list
dorydctl [global] machine status NAME
dorydctl [global] machine stats NAME
- dorydctl [global] machine create NAME --kernel PATH --rootfs PATH [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--env KEY=VALUE]
- dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env]
+ dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--env KEY=VALUE]
+ dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] [--attach-installer | --eject-installer]
dorydctl [global] machine start|stop|delete NAME
dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...]
dorydctl [global] machine shell NAME
dorydctl [global] machine provision NAME --recipe RECIPE
+ dorydctl [global] machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --bundle PATH --kernel PATH
dorydctl [global] machine snapshots [NAME]
dorydctl [global] machine snapshot NAME [--note NOTE] [--id ID]
dorydctl [global] machine clone-snapshot NAME SNAPSHOT_ID NEW_NAME
@@ -1038,7 +1040,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
}
func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
- let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|start|stop|delete|exec|shell|provision|snapshots|snapshot|backup")
+ let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|delete|exec|shell|provision|snapshots|snapshot|backup")
switch subcommand {
case "list":
let rows: NSArray = try client.call { proxy, finish in
@@ -1060,27 +1062,60 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
}
try emitJSON(stats)
case "create":
- let name = try cursor.take("usage: dorydctl machine create NAME --kernel PATH --rootfs PATH")
- guard let kernel = try cursor.optionValue("--kernel"),
- let rootfs = try cursor.optionValue("--rootfs") else {
- throw DorydCtlError.usage("usage: dorydctl machine create NAME --kernel PATH --rootfs PATH")
- }
- let memoryMB = try cursor.optionValue("--memory-mb").map { try positiveUInt64($0, option: "--memory-mb") } ?? 2048
- let cpuCount = try cursor.optionValue("--cpus").map { try positiveInt($0, option: "--cpus") } ?? 2
- let displayMode = try cursor.optionValue("--display-mode") ?? DoryMachineDisplayMode.headless.rawValue
+ let name = try cursor.take("usage: dorydctl machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N])")
+ let installerISO = try cursor.optionValue("--installer-iso")
+ let kernel = try cursor.optionValue("--kernel")
+ let rootfs = try cursor.optionValue("--rootfs")
+ let diskSizeGB = try cursor.optionValue("--disk-size-gb").map {
+ try positiveUInt64($0, option: "--disk-size-gb")
+ } ?? 64
+ guard installerISO != nil || (kernel != nil && rootfs != nil) else {
+ throw DorydCtlError.usage("provide --kernel and --rootfs, or --installer-iso")
+ }
+ let defaultMemoryMB: UInt64 = installerISO == nil
+ ? 2_048
+ : DoryInstallerMachinePolicy.defaultMemoryMB
+ let memoryMB = try cursor.optionValue("--memory-mb").map {
+ try positiveUInt64($0, option: "--memory-mb")
+ } ?? defaultMemoryMB
+ let cpuCount = try cursor.optionValue("--cpus").map {
+ try positiveInt($0, option: "--cpus")
+ } ?? DoryInstallerMachinePolicy.defaultCPUCount
+ let defaultDisplayMode = installerISO == nil
+ ? DoryMachineDisplayMode.headless.rawValue
+ : DoryMachineDisplayMode.desktop.rawValue
+ let displayMode = try cursor.optionValue("--display-mode") ?? defaultDisplayMode
guard DoryMachineDisplayMode(rawValue: displayMode) != nil else {
throw DorydCtlError.usage("--display-mode must be headless or desktop")
}
let shares = try cursor.optionValues("--share").map { try DoryMachineShareConfiguration(argument: $0) }
let env = try cursor.optionValues("--env").map(parseEnvironmentRow)
+ var stagedInstallerISOPath: String?
+ defer {
+ if let stagedInstallerISOPath {
+ try? FileManager.default.removeItem(atPath: stagedInstallerISOPath)
+ }
+ }
+ if let installerISO {
+ let staged = try DoryInstallerISOStager.stage(atPath: installerISO)
+ stagedInstallerISOPath = staged.path
+ }
var config: [String: Any] = [
"id": name,
- "kernelPath": kernel,
- "rootfsPath": rootfs,
+ "kernelPath": kernel ?? "",
+ "rootfsPath": rootfs ?? "",
+ "bootMode": installerISO == nil ? DoryMachineBootMode.linuxKernel.rawValue : DoryMachineBootMode.efi.rawValue,
"memoryMB": memoryMB,
"cpuCount": cpuCount,
"displayMode": displayMode,
]
+ if let stagedInstallerISOPath {
+ guard diskSizeGB <= UInt64.max / (1024 * 1024 * 1024) else {
+ throw DorydCtlError.usage("--disk-size-gb is too large")
+ }
+ config["installerISOPath"] = stagedInstallerISOPath
+ config["diskSizeBytes"] = diskSizeGB * 1024 * 1024 * 1024
+ }
if let address = try cursor.optionValue("--dns-target") {
config["address"] = address
}
@@ -1097,7 +1132,10 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
if !env.isEmpty {
config["env"] = env
}
- let status = try client.statusCommand { proxy, reply in
+ // Creating a machine can copy a multi-gigabyte root disk or installer ISO.
+ // Keep the request alive long enough for that transactional mutation to
+ // finish so the CLI cannot report a timeout after the daemon has succeeded.
+ let status = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in
proxy.machineCreate(config as NSDictionary, reply: reply)
}
try emitJSON(status)
@@ -1106,18 +1144,24 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
try emitJSON(try client.statusCommand { $0.machineStart(name, reply: $1) })
case "stop":
let name = try cursor.take("usage: dorydctl machine stop NAME")
- try emitJSON(try client.statusCommand { $0.machineStop(name, reply: $1) })
+ try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand {
+ $0.machineStop(name, reply: $1)
+ })
case "update":
try runMachineUpdate(cursor: &cursor, client: client)
case "delete", "rm":
let name = try cursor.take("usage: dorydctl machine delete NAME")
- try emitCommandResult(try client.command { $0.machineDelete(name, reply: $1) })
+ try emitCommandResult(try client.withTimeout(atLeast: machineFileMutationTimeout).command {
+ $0.machineDelete(name, reply: $1)
+ })
case "exec":
try runMachineExec(cursor: &cursor, client: client)
case "shell":
try runMachineShell(cursor: &cursor, client: client)
case "provision":
try runMachineProvision(cursor: &cursor, client: client)
+ case "desktop-update":
+ try runMachineDesktopUpdate(cursor: &cursor, client: client)
case "snapshots":
let name = cursor.values.isEmpty ? "" : try cursor.take("usage: dorydctl machine snapshots [NAME]")
guard cursor.values.isEmpty else {
@@ -1138,7 +1182,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
guard cursor.values.isEmpty else {
throw DorydCtlError.usage("unexpected clone-snapshot argument: \(cursor.values[0])")
}
- try emitJSON(try client.statusCommand { proxy, reply in
+ try emitJSON(try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in
proxy.machineCloneSnapshot(name, snapshotID: snapshotID, newID: newName, reply: reply)
})
case "restore-snapshot":
@@ -1147,7 +1191,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
guard cursor.values.isEmpty else {
throw DorydCtlError.usage("unexpected restore-snapshot argument: \(cursor.values[0])")
}
- try emitJSON(try client.statusCommand { proxy, reply in
+ try emitJSON(try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in
proxy.machineRestoreSnapshot(name, snapshotID: snapshotID, reply: reply)
})
case "delete-snapshot":
@@ -1156,7 +1200,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
guard cursor.values.isEmpty else {
throw DorydCtlError.usage("unexpected delete-snapshot argument: \(cursor.values[0])")
}
- try emitCommandResult(try client.command { proxy, reply in
+ try emitCommandResult(try client.withTimeout(atLeast: machineFileMutationTimeout).command { proxy, reply in
proxy.machineDeleteSnapshot(name, snapshotID: snapshotID, reply: reply)
})
case "export-snapshot":
@@ -1166,7 +1210,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
guard cursor.values.isEmpty else {
throw DorydCtlError.usage("unexpected export-snapshot argument: \(cursor.values[0])")
}
- try emitCommandResult(try client.command { proxy, reply in
+ try emitCommandResult(try client.withTimeout(atLeast: machineFileMutationTimeout).command { proxy, reply in
proxy.machineExportSnapshot(name, snapshotID: snapshotID, path: path, reply: reply)
})
case "import-snapshot":
@@ -1174,7 +1218,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
guard cursor.values.isEmpty else {
throw DorydCtlError.usage("unexpected import-snapshot argument: \(cursor.values[0])")
}
- let imported = try client.statusCommand { proxy, reply in
+ let imported = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in
proxy.machineImportSnapshot(path, reply: reply)
}
try emitJSON(imported)
@@ -1240,7 +1284,7 @@ func runMachineBackup(cursor: inout ArgumentCursor, client: DorydCtlClient) thro
case "run":
let name = try cursor.take(usage)
guard cursor.values.isEmpty else { throw DorydCtlError.usage(usage) }
- let status = try client.withTimeout(atLeast: 900).statusCommand { proxy, reply in
+ let status = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in
proxy.machineBackupRun(name, reply: reply)
}
try emitJSON(status)
@@ -1256,7 +1300,7 @@ func runMachineBackup(cursor: inout ArgumentCursor, client: DorydCtlClient) thro
}
func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
- let usage = "usage: dorydctl machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env]"
+ let usage = "usage: dorydctl machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] [--attach-installer | --eject-installer]"
let name = try cursor.take(usage)
var config: [String: Any] = [:]
if let memory = try cursor.optionValue("--memory-mb") {
@@ -1308,6 +1352,15 @@ func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) thro
cursor.values.removeAll { $0 == "--clear-env" }
config["env"] = [] as [NSDictionary]
}
+ let attachInstaller = cursor.values.contains("--attach-installer")
+ let ejectInstaller = cursor.values.contains("--eject-installer")
+ guard !attachInstaller || !ejectInstaller else {
+ throw DorydCtlError.usage("use either --attach-installer or --eject-installer, not both")
+ }
+ if attachInstaller || ejectInstaller {
+ cursor.values.removeAll { $0 == "--attach-installer" || $0 == "--eject-installer" }
+ config["installerMediaAttached"] = attachInstaller
+ }
guard !config.isEmpty else {
throw DorydCtlError.usage(usage)
}
@@ -1334,7 +1387,7 @@ func runMachineSnapshot(cursor: inout ArgumentCursor, client: DorydCtlClient) th
guard cursor.values.isEmpty else {
throw DorydCtlError.usage("unexpected machine snapshot argument: \(cursor.values[0])")
}
- let snapshot = try client.statusCommand { proxy, reply in
+ let snapshot = try client.withTimeout(atLeast: machineFileMutationTimeout).statusCommand { proxy, reply in
proxy.machineSnapshot(name, request: request as NSDictionary, reply: reply)
}
try emitJSON(snapshot)
@@ -1354,6 +1407,31 @@ func runMachineProvision(cursor: inout ArgumentCursor, client: DorydCtlClient) t
try emitJSON(result)
}
+func runMachineDesktopUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
+ let usage = "usage: dorydctl machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --bundle PATH --kernel PATH"
+ let name = try cursor.take(usage)
+ let distro = try requiredOption("--distro", cursor: &cursor, usage: usage)
+ guard ["debian", "ubuntu", "kali"].contains(distro) else {
+ throw DorydCtlError.usage("--distro must be debian, ubuntu, or kali")
+ }
+ let version = try requiredOption("--version", cursor: &cursor, usage: usage)
+ let bundlePath = try requiredOption("--bundle", cursor: &cursor, usage: usage)
+ let kernelPath = try requiredOption("--kernel", cursor: &cursor, usage: usage)
+ guard cursor.values.isEmpty else {
+ throw DorydCtlError.usage("unexpected machine desktop-update argument: " + cursor.values[0])
+ }
+ let request: NSDictionary = [
+ "distro": distro,
+ "version": version,
+ "bundlePath": bundlePath,
+ "kernelPath": kernelPath,
+ ]
+ let result = try client.withTimeout(atLeast: 3_900).statusCommand { proxy, reply in
+ proxy.machineDesktopUpdate(name, request: request, reply: reply)
+ }
+ try emitJSON(result)
+}
+
func runMachineShell(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
let name = try cursor.take("usage: dorydctl machine shell NAME")
guard cursor.values.isEmpty else {
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryInstalledLinuxBootBundleTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryInstalledLinuxBootBundleTests.swift
new file mode 100644
index 00000000..cb74c39b
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryInstalledLinuxBootBundleTests.swift
@@ -0,0 +1,78 @@
+import DoryOperations
+import Foundation
+import XCTest
+
+final class DoryInstalledLinuxBootBundleTests: XCTestCase {
+ func testBundleRoundTripsAndMaterializesVerifiedArtifacts() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-installed-boot-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let bundle = directory.appendingPathComponent("kernel")
+ let kernelOutput = directory.appendingPathComponent("direct-kernel")
+ let initrdOutput = directory.appendingPathComponent("direct-initrd")
+ let kernel = Data((0..<65_537).map { UInt8(truncatingIfNeeded: $0) })
+ let initrd = Data((0..<131_101).map { UInt8(truncatingIfNeeded: $0 * 3) })
+ let assets = DoryLinuxInstallerBootAssets(
+ kernel: kernel,
+ initrd: initrd,
+ kernelISOPath: "casper/vmlinuz",
+ initrdISOPath: "casper/initrd"
+ )
+
+ try DoryInstalledLinuxBootBundle.write(
+ assets: assets,
+ rootDevice: "/dev/vda2",
+ toPath: bundle.path
+ )
+
+ XCTAssertTrue(DoryInstalledLinuxBootBundle.isBundle(atPath: bundle.path))
+ XCTAssertEqual(
+ try DoryInstalledLinuxBootBundle.descriptor(atPath: bundle.path),
+ DoryInstalledLinuxBootDescriptor(
+ rootDevice: "/dev/vda2",
+ kernelLength: UInt64(kernel.count),
+ initrdLength: UInt64(initrd.count)
+ )
+ )
+ let descriptor = try DoryInstalledLinuxBootBundle.materialize(
+ fromPath: bundle.path,
+ kernelPath: kernelOutput.path,
+ initrdPath: initrdOutput.path
+ )
+ XCTAssertEqual(descriptor.rootDevice, "/dev/vda2")
+ XCTAssertEqual(try Data(contentsOf: kernelOutput), kernel)
+ XCTAssertEqual(try Data(contentsOf: initrdOutput), initrd)
+ }
+
+ func testMaterializationRejectsCorruptPayload() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-installed-boot-corrupt-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let bundle = directory.appendingPathComponent("kernel")
+ try DoryInstalledLinuxBootBundle.write(
+ assets: DoryLinuxInstallerBootAssets(
+ kernel: Data(repeating: 0x41, count: 4_096),
+ initrd: Data(repeating: 0x42, count: 8_192),
+ kernelISOPath: "kernel",
+ initrdISOPath: "initrd"
+ ),
+ rootDevice: "/dev/vda7",
+ toPath: bundle.path
+ )
+ let handle = try FileHandle(forUpdating: bundle)
+ try handle.seekToEnd()
+ try handle.seek(toOffset: try handle.offset() - 1)
+ try handle.write(contentsOf: Data([0xff]))
+ try handle.close()
+
+ XCTAssertThrowsError(try DoryInstalledLinuxBootBundle.materialize(
+ fromPath: bundle.path,
+ kernelPath: directory.appendingPathComponent("kernel.out").path,
+ initrdPath: directory.appendingPathComponent("initrd.out").path
+ )) { error in
+ XCTAssertEqual(error as? DoryInstalledLinuxBootBundleError, .digestMismatch)
+ }
+ }
+}
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift
new file mode 100644
index 00000000..cbd1c13c
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryInstallerISOTests.swift
@@ -0,0 +1,235 @@
+import DoryOperations
+import XCTest
+
+final class DoryInstallerISOTests: XCTestCase {
+ func testDiscoversRootPartitionFromOptInInstalledDisk() throws {
+ guard let path = ProcessInfo.processInfo.environment["DORY_TEST_INSTALLED_DISK"],
+ !path.isEmpty else {
+ throw XCTSkip("set DORY_TEST_INSTALLED_DISK for the real-disk GPT smoke test")
+ }
+ XCTAssertEqual(
+ try DoryLinuxInstalledDiskInspector.rootDevice(atPath: path),
+ "/dev/vda2"
+ )
+ }
+
+ func testExtractsBootAssetsFromOptInRealInstallerISO() throws {
+ guard let path = ProcessInfo.processInfo.environment["DORY_TEST_INSTALLER_ISO"],
+ !path.isEmpty else {
+ throw XCTSkip("set DORY_TEST_INSTALLER_ISO for the bounded real-media smoke test")
+ }
+
+ let compressedKernel = try DoryInstallerISOInspector.fileData(
+ atISOPath: path,
+ fromPath: "casper/vmlinuz",
+ maximumBytes: 128 * 1024 * 1024
+ )
+ XCTAssertGreaterThan(compressedKernel.count, 1024 * 1024)
+ let assets = try DoryLinuxInstallerBootAssetExtractor.extract(atPath: path)
+
+ XCTAssertGreaterThan(assets.kernel.count, 1024 * 1024)
+ XCTAssertGreaterThan(assets.initrd.count, 1024 * 1024)
+ XCTAssertEqual(assets.kernel[0x38], 0x41)
+ XCTAssertEqual(assets.kernel[0x39], 0x52)
+ XCTAssertEqual(assets.kernel[0x3a], 0x4d)
+ XCTAssertEqual(assets.kernel[0x3b], 0x64)
+ }
+
+ func testInstallerResourcePolicyUsesBalancedDefaultsWithoutClaimingCompatibility() {
+ XCTAssertEqual(DoryInstallerMachinePolicy.defaultCPUCount, 4)
+ XCTAssertEqual(DoryInstallerMachinePolicy.defaultMemoryMB, 4_096)
+ }
+
+ func testDetectsEFILoaderArchitectureAcrossChunkBoundary() throws {
+ let base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-iso-inspector-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: base) }
+
+ let arm = base.appendingPathComponent("arm.iso")
+ var armBytes = Data(repeating: 0, count: 1024 * 1024 - 4)
+ armBytes.append(Data("bootaa64.efi".utf8))
+ try armBytes.write(to: arm)
+ XCTAssertEqual(
+ try DoryInstallerISOInspector.architecture(atPath: arm.path),
+ .arm64
+ )
+
+ let x86 = base.appendingPathComponent("x86.iso")
+ try Data("EFI/BOOT/BOOTX64.EFI".utf8).write(to: x86)
+ XCTAssertEqual(
+ try DoryInstallerISOInspector.architecture(atPath: x86.path),
+ .x86_64
+ )
+ }
+
+ func testReportsMultiArchitectureAndUnknownMedia() throws {
+ let base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-iso-inspector-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: base) }
+
+ let mixed = base.appendingPathComponent("mixed.iso")
+ try Data("BOOTAA64.EFI---BOOTX64.EFI".utf8).write(to: mixed)
+ XCTAssertEqual(
+ try DoryInstallerISOInspector.architecture(atPath: mixed.path),
+ .multiArchitecture
+ )
+
+ let unknown = base.appendingPathComponent("unknown.iso")
+ try Data("custom loader".utf8).write(to: unknown)
+ XCTAssertEqual(
+ try DoryInstallerISOInspector.architecture(atPath: unknown.path),
+ .unknown
+ )
+ }
+
+ func testCompatibilityMessagesMatchTheHostArchitecture() {
+ XCTAssertEqual(
+ DoryInstallerISOInspector.compatibility(of: .arm64, hostArchitecture: "arm64"),
+ .compatible
+ )
+ XCTAssertEqual(
+ DoryInstallerISOInspector.compatibility(of: .multiArchitecture, hostArchitecture: "arm64"),
+ .compatible
+ )
+ XCTAssertEqual(
+ DoryInstallerISOInspector.compatibility(of: .unknown, hostArchitecture: "arm64"),
+ .unknown
+ )
+ guard case let .incompatible(message) = DoryInstallerISOInspector.compatibility(
+ of: .x86_64,
+ hostArchitecture: "aarch64"
+ ) else {
+ return XCTFail("x86_64 media should be incompatible with Apple Silicon")
+ }
+ XCTAssertTrue(message.contains("Intel x86_64-only"))
+ XCTAssertTrue(message.contains("Apple Silicon"))
+ }
+
+ func testMediaIdentityIncludesArchitectureSizeAndSHA256() throws {
+ let base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-iso-identity-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: base) }
+ let media = base.appendingPathComponent("installer.iso")
+ let contents = Data("EFI/BOOT/BOOTAA64.EFI\nqualified-by-digest".utf8)
+ try contents.write(to: media)
+
+ let identity = try DoryInstallerISOInspector.mediaIdentity(atPath: media.path)
+
+ XCTAssertEqual(identity.architecture, .arm64)
+ XCTAssertEqual(identity.byteCount, UInt64(contents.count))
+ XCTAssertEqual(
+ identity.sha256,
+ "8f0bf0de7de4e656b9f29d4252461df93c6cb0d075d9cb993accd5722f48c27e"
+ )
+ }
+
+ func testRuntimeCatalogSeparatesArchitectureFromExactHostMediaEvidence() {
+ let ubuntu24044 = DoryInstallerISOMediaIdentity(
+ architecture: .arm64,
+ sha256: DoryInstallerISORuntimeCatalog.ubuntu24044DesktopARM64SHA256,
+ byteCount: 3_556_515_840
+ )
+ let failingHost = DoryInstallerHostRuntime(
+ architecture: "arm64",
+ hardwareModel: "Mac14,10",
+ operatingSystemVersion: OperatingSystemVersion(majorVersion: 27, minorVersion: 0, patchVersion: 0),
+ operatingSystemBuild: "26A5406e",
+ runtimeProfile: DoryInstallerRuntimeProfile.legacyVirtioBlockV1.rawValue
+ )
+ guard case let .knownUnstable(message) = DoryInstallerISORuntimeCatalog.qualification(
+ of: ubuntu24044,
+ on: failingHost
+ ) else {
+ return XCTFail("the observed host/media tuple must be kept out of the installer path")
+ }
+ XCTAssertTrue(message.contains("retired VirtIO-block EFI profile"))
+
+ let ubuntu24043 = DoryInstallerISOMediaIdentity(
+ architecture: .arm64,
+ sha256: DoryInstallerISORuntimeCatalog.ubuntu24043DesktopARM64SHA256,
+ byteCount: 3_473_190_912
+ )
+ guard case .knownUnstable = DoryInstallerISORuntimeCatalog.qualification(
+ of: ubuntu24043,
+ on: failingHost
+ ) else {
+ return XCTFail("24.04.3 also froze under the retired VirtIO-block profile")
+ }
+
+ let currentStorageProfile = DoryInstallerHostRuntime(
+ architecture: "arm64",
+ hardwareModel: "Mac14,10",
+ operatingSystemVersion: OperatingSystemVersion(majorVersion: 27, minorVersion: 0, patchVersion: 0),
+ operatingSystemBuild: "26A5406e",
+ runtimeProfile: DoryInstallerRuntimeProfile.nativeNVMeFsyncV1.rawValue
+ )
+ XCTAssertEqual(
+ DoryInstallerISORuntimeCatalog.qualification(of: ubuntu24043, on: currentStorageProfile),
+ .unqualified
+ )
+
+ let differentBuild = DoryInstallerHostRuntime(
+ architecture: "arm64",
+ hardwareModel: "Mac14,10",
+ operatingSystemVersion: OperatingSystemVersion(majorVersion: 27, minorVersion: 1, patchVersion: 0),
+ operatingSystemBuild: "26B100",
+ runtimeProfile: DoryInstallerRuntimeProfile.legacyVirtioBlockV1.rawValue
+ )
+ XCTAssertEqual(
+ DoryInstallerISORuntimeCatalog.qualification(of: ubuntu24044, on: differentBuild),
+ .unqualified
+ )
+ }
+
+ func testStagesCompatibleMediaAsAPrivateRegularFile() throws {
+ let base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-iso-stager-\(UUID().uuidString)")
+ let sourceDirectory = base.appendingPathComponent("source", isDirectory: true)
+ let stagingDirectory = base.appendingPathComponent("staging", isDirectory: true)
+ try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: base) }
+
+ let source = sourceDirectory.appendingPathComponent("ubuntu.iso")
+ let contents = Data("EFI/BOOT/BOOTAA64.EFI\nubuntu desktop".utf8)
+ try contents.write(to: source)
+
+ let staged = try DoryInstallerISOStager.stage(
+ atPath: source.path,
+ stagingDirectory: stagingDirectory,
+ hostArchitecture: "arm64"
+ )
+
+ XCTAssertEqual(staged.architecture, .arm64)
+ XCTAssertEqual(staged.identity.byteCount, UInt64(contents.count))
+ XCTAssertFalse(staged.sha256.isEmpty)
+ XCTAssertEqual(staged.runtimeQualification, .unqualified)
+ XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: staged.path)), contents)
+ let attributes = try FileManager.default.attributesOfItem(atPath: staged.path)
+ XCTAssertEqual(attributes[.type] as? FileAttributeType, .typeRegular)
+ XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600)
+ let directoryAttributes = try FileManager.default.attributesOfItem(atPath: stagingDirectory.path)
+ XCTAssertEqual((directoryAttributes[.posixPermissions] as? NSNumber)?.intValue, 0o700)
+ }
+
+ func testRejectsIncompatibleMediaBeforeCreatingAStagingDirectory() throws {
+ let base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-iso-stager-rejection-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: base) }
+ let source = base.appendingPathComponent("omarchy.iso")
+ let stagingDirectory = base.appendingPathComponent("staging", isDirectory: true)
+ try Data("EFI/BOOT/BOOTX64.EFI".utf8).write(to: source)
+
+ XCTAssertThrowsError(try DoryInstallerISOStager.stage(
+ atPath: source.path,
+ stagingDirectory: stagingDirectory,
+ hostArchitecture: "arm64"
+ )) { error in
+ XCTAssertTrue(String(describing: error).contains("Intel x86_64-only"))
+ }
+ XCTAssertFalse(FileManager.default.fileExists(atPath: stagingDirectory.path))
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
index cad66f90..d2ba6144 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
@@ -1,3 +1,4 @@
+import AppKit
import Darwin
import DoryCore
import DorydKit
@@ -6,6 +7,113 @@ import Virtualization
import XCTest
final class DoryVMMKitTests: XCTestCase {
+ @MainActor
+ func testClipboardRetriesInitialHostPushUntilDesktopSessionIsReady() async throws {
+ let pasteboard = NSPasteboard.withUniqueName()
+ defer { pasteboard.releaseGlobally() }
+ pasteboard.clearContents()
+ pasteboard.setString("host clipboard before guest login", forType: .string)
+ let recorder = ClipboardWriteRecorder()
+ let coordinator = DoryDesktopClipboardCoordinator(
+ policy: .hostToGuest,
+ execute: { argv, stdin, _, _ in
+ if argv == ["/usr/bin/test", "-x", "/usr/lib/dory/clipboard"] {
+ return Self.execResult(exitCode: 0)
+ }
+ XCTAssertEqual(argv, [
+ "/usr/lib/dory/clipboard", "set", "text/plain;charset=utf-8",
+ ])
+ let attempt = recorder.record(stdin)
+ return Self.execResult(exitCode: attempt == 1 ? 1 : 0)
+ },
+ sendShortcut: { _ in },
+ pasteboard: pasteboard,
+ startupRetryDelay: 0.01,
+ startupRetryLimit: 3,
+ log: { _ in }
+ )
+
+ coordinator.start()
+ coordinator.markGuestReady()
+ defer { coordinator.stop() }
+
+ let deadline = ContinuousClock.now + .seconds(2)
+ while recorder.attemptCount < 2, ContinuousClock.now < deadline {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ XCTAssertEqual(recorder.attemptCount, 2)
+ XCTAssertEqual(recorder.lastPayload, Data("host clipboard before guest login".utf8))
+ }
+
+ @MainActor
+ func testClipboardPullsGuestValueWhenDesktopResignsFocus() async throws {
+ let pasteboard = NSPasteboard.withUniqueName()
+ defer { pasteboard.releaseGlobally() }
+ pasteboard.clearContents()
+ pasteboard.setString("original host value", forType: .string)
+ let recorder = ClipboardWriteRecorder()
+ let coordinator = DoryDesktopClipboardCoordinator(
+ policy: .guestToHost,
+ execute: { argv, _, _, _ in
+ if argv == ["/usr/bin/test", "-x", "/usr/lib/dory/clipboard"] {
+ recorder.recordCapabilityProbe()
+ return Self.execResult(exitCode: 0)
+ }
+ if argv == ["/usr/lib/dory/clipboard", "get", "image/png"] {
+ return Self.execResult(exitCode: 1)
+ }
+ XCTAssertEqual(argv, [
+ "/usr/lib/dory/clipboard", "get", "text/plain;charset=utf-8",
+ ])
+ return DoryExecResult(
+ exitCode: 0,
+ stdout: Data("guest clipboard after copy".utf8),
+ stderr: Data(),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false
+ )
+ },
+ sendShortcut: { _ in },
+ pasteboard: pasteboard,
+ startupRetryDelay: 0.01,
+ startupRetryLimit: 1,
+ log: { _ in }
+ )
+
+ coordinator.start()
+ coordinator.markGuestReady()
+ defer { coordinator.stop() }
+
+ let readyDeadline = ContinuousClock.now + .seconds(2)
+ while recorder.capabilityProbeCount == 0, ContinuousClock.now < readyDeadline {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ try await Task.sleep(for: .milliseconds(20))
+ NotificationCenter.default.post(
+ name: NSApplication.didResignActiveNotification,
+ object: NSApplication.shared
+ )
+
+ let pullDeadline = ContinuousClock.now + .seconds(2)
+ while pasteboard.string(forType: .string) != "guest clipboard after copy",
+ ContinuousClock.now < pullDeadline {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ XCTAssertEqual(pasteboard.string(forType: .string), "guest clipboard after copy")
+ }
+
+ private static func execResult(exitCode: Int32) -> DoryExecResult {
+ DoryExecResult(
+ exitCode: exitCode,
+ stdout: Data(),
+ stderr: Data(),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false
+ )
+ }
+
func testDesktopWindowUsesRetinaBackingPixels() {
XCTAssertEqual(
DoryVMMDesktopApplication.targetPixelSize(
@@ -75,6 +183,8 @@ final class DoryVMMKitTests: XCTestCase {
"--data-drive", "/Volumes/Work/Dory.dorydrive",
"--kernel", "/tmp/vmlinux",
"--rootfs", "/tmp/rootfs.raw",
+ "--boot-mode", "efi",
+ "--installer-iso", "/tmp/ubuntu.iso",
"--gvproxy", "/tmp/gvproxy",
"--ssh-agent-socket", "/private/tmp/com.apple.launchd.fixture/Listeners",
"--publish-host", "0.0.0.0",
@@ -96,6 +206,8 @@ final class DoryVMMKitTests: XCTestCase {
XCTAssertEqual(arguments.dataDriveRoot, "/Volumes/Work/Dory.dorydrive")
XCTAssertEqual(arguments.kernelPath, "/tmp/vmlinux")
XCTAssertEqual(arguments.rootfsPath, "/tmp/rootfs.raw")
+ XCTAssertEqual(arguments.machineBootMode, .efi)
+ XCTAssertEqual(arguments.installerISOPath, "/tmp/ubuntu.iso")
XCTAssertEqual(arguments.gvproxyPath, "/tmp/gvproxy")
XCTAssertEqual(
arguments.sshAgentSocketPath,
@@ -319,6 +431,71 @@ final class DoryVMMKitTests: XCTestCase {
try assertShellSyntax("\(base)/dorycfg/boot.sh")
}
+ func testEFIProfileSeparatesInstallerAndInstalledFirmwareAndAttachesISOReadOnly() throws {
+ let base = "/tmp/dory-vmm-efi-\(getpid())-\(UInt32.random(in: 0...size))
+ }
+ }
+ XCTAssertEqual(connectResult, 0)
+
+ let hostInput = Data("ubuntu\n".utf8)
+ XCTAssertEqual(hostInput.withUnsafeBytes { bytes in
+ Darwin.send(client, bytes.baseAddress, bytes.count, MSG_NOSIGNAL)
+ }, hostInput.count)
+ var inputPoll = pollfd(
+ fd: console.guestInput.fileDescriptor,
+ events: Int16(POLLIN),
+ revents: 0
+ )
+ XCTAssertEqual(poll(&inputPoll, 1, 2_000), 1)
+ var inputBuffer = [UInt8](repeating: 0, count: 64)
+ let inputCount = Darwin.read(
+ console.guestInput.fileDescriptor,
+ &inputBuffer,
+ inputBuffer.count
+ )
+ XCTAssertEqual(Data(inputBuffer.prefix(inputCount)), hostInput)
+
+ let guestOutput = Data("ubuntu login: ".utf8)
+ try console.guestOutput.write(contentsOf: guestOutput)
+ var outputPoll = pollfd(fd: client, events: Int16(POLLIN), revents: 0)
+ XCTAssertEqual(poll(&outputPoll, 1, 2_000), 1)
+ var outputBuffer = [UInt8](repeating: 0, count: 64)
+ let outputCount = Darwin.read(client, &outputBuffer, outputBuffer.count)
+ XCTAssertEqual(Data(outputBuffer.prefix(outputCount)), guestOutput)
+
+ Thread.sleep(forTimeInterval: 0.05)
+ try log.synchronize()
+ XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: logPath)), guestOutput)
+ let attributes = try FileManager.default.attributesOfItem(atPath: socketPath)
+ XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600)
+ }
+
func testDeferredVMMShutdownRequestIsDeliveredExactlyOnceAfterRuntimeAttach() {
let watchdog = ShutdownWatchdogRecorder()
let forcedExit = ForcedExitRecorder()
@@ -608,6 +874,45 @@ final class DoryVMMKitTests: XCTestCase {
}
}
+private final class ClipboardWriteRecorder: @unchecked Sendable {
+ private let lock = NSLock()
+ private var attempts = 0
+ private var payload = Data()
+ private var capabilityProbes = 0
+
+ func record(_ value: Data) -> Int {
+ lock.lock()
+ defer { lock.unlock() }
+ attempts += 1
+ payload = value
+ return attempts
+ }
+
+ var attemptCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return attempts
+ }
+
+ var lastPayload: Data {
+ lock.lock()
+ defer { lock.unlock() }
+ return payload
+ }
+
+ func recordCapabilityProbe() {
+ lock.lock()
+ capabilityProbes += 1
+ lock.unlock()
+ }
+
+ var capabilityProbeCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return capabilityProbes
+ }
+}
+
private final class FakeVMMShutdownTarget: DoryVMMGuestShutdownHandling, @unchecked Sendable {
private let lock = NSLock()
private let requested = DispatchSemaphore(value: 0)
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift
index b80b64db..5976c18e 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift
@@ -897,6 +897,47 @@ final class DorydConfigurationTests: XCTestCase {
XCTAssertTrue(config.requiresReadyHandoff)
}
+ func testMachineManagerConfigurationSelectsRawHVOnlyForEligibleAcceleratedDesktops() throws {
+ let directory = "/tmp/doryd-desktop-hv-config-\(getpid())-\(UInt32.random(in: 0..> "$1"
+ count=$(wc -l < "$1" | tr -d ' ')
+ test "$count" -ne 1 || exit 7
+ exec /bin/sleep 30
+ """.write(toFile: script, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script)
+
+ let process = HvProcess(configuration: HvProcessConfiguration(
+ executablePath: script,
+ arguments: [marker],
+ restartPolicy: HvRestartPolicy(maxRestarts: 2, delaySeconds: 0.2)
+ ))
+ try process.start()
+
+ let firstRunDeadline = Date().addingTimeInterval(2)
+ while Date() < firstRunDeadline,
+ ((try? String(contentsOfFile: marker, encoding: .utf8))?
+ .split(separator: "\n").count ?? 0) < 1 {
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ XCTAssertTrue(process.isRunningOrRestarting)
+
+ let secondRunDeadline = Date().addingTimeInterval(2)
+ while Date() < secondRunDeadline,
+ ((try? String(contentsOfFile: marker, encoding: .utf8))?
+ .split(separator: "\n").count ?? 0) < 2 {
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ let pid = try XCTUnwrap(process.pid)
+ process.disableRestarts()
+ XCTAssertEqual(kill(pid, SIGKILL), 0)
+
+ let stoppedDeadline = Date().addingTimeInterval(2)
+ while Date() < stoppedDeadline, process.isRunningOrRestarting {
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ XCTAssertFalse(process.isRunningOrRestarting)
+ XCTAssertEqual(try String(contentsOfFile: marker, encoding: .utf8).split(separator: "\n").count, 2)
+ }
+
func testUnexpectedExitNotifiesSupervisor() throws {
let callback = expectation(description: "unexpected termination callback")
let captured = LockedTermination()
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index a2448cff..a868aa6c 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -1,5 +1,7 @@
import CryptoKit
+import Darwin
import DoryCore
+import DoryOperations
@testable import DorydKit
import XCTest
@@ -61,9 +63,11 @@ final class MachineManagerTests: XCTestCase {
let created = try manager.create(DoryMachineConfiguration(
id: "dev",
kernelPath: doryTestKernelPath,
- rootfsPath: doryTestRootfsPath
+ rootfsPath: doryTestRootfsPath,
+ displayMode: .desktop
))
XCTAssertEqual(created.state, .created)
+ XCTAssertEqual(created.displayMode, .desktop)
let running = try manager.start(id: "dev")
XCTAssertEqual(running.state, .running)
@@ -78,6 +82,222 @@ final class MachineManagerTests: XCTestCase {
XCTAssertTrue(manager.list().isEmpty)
}
+ func testDesktopUpdatePreservesPersistentDiskAndRetainsLastGoodSnapshot() throws {
+ let base = "/tmp/dory-machine-desktop-update-\(getpid())-\(UInt32.random(in: 0..()
+ DispatchQueue.global(qos: .userInitiated).async {
+ updateResult.store(Result {
+ try manager.update(id: "linux", installerMediaAttached: false)
+ })
+ updateFinished.fulfill()
+ }
+
+ let updatedLaunch = try waitForMachineStatus(manager, id: "linux") {
+ $0.state == .starting
+ && !$0.installerMediaAttached
+ && $0.pid != nil
+ && $0.pid != originallyRunning.pid
+ }
+ let failedStartupPID = try XCTUnwrap(updatedLaunch.pid)
+ XCTAssertEqual(kill(failedStartupPID, SIGKILL), 0)
+
+ let restarted = try waitForMachineStatus(manager, id: "linux", timeout: 3) {
+ $0.state == .starting
+ && !$0.installerMediaAttached
+ && $0.pid != nil
+ && $0.pid != failedStartupPID
+ }
+ try sendVmmHandoff(
+ path: try XCTUnwrap(restarted.handoffSocketPath),
+ ready: VmmReadyMessage(machineID: "linux"),
+ fileDescriptors: []
+ )
+
+ wait(for: [updateFinished], timeout: 5)
+ switch try XCTUnwrap(updateResult.value) {
+ case let .success(status):
+ XCTAssertEqual(status.state, .running)
+ XCTAssertFalse(status.installerMediaAttached)
+ case let .failure(error):
+ XCTFail("installer eject unexpectedly rolled back: \(error)")
+ }
+ }
+
+ func testCreateEFIMachineRejectsKnownWrongArchitectureInstaller() throws {
+ let base = "/tmp/dory-machine-efi-arch-\(getpid())-\(UInt32.random(in: 0.. '\(desktopCapture)'\nsleep 30\n".write(
+ toFile: desktopHelper,
+ atomically: true,
+ encoding: .utf8
+ )
+ try "#!/bin/sh\nprintf '%s\\n' \"$@\" > '\(fallbackCapture)'\nsleep 30\n".write(
+ toFile: fallbackHelper,
+ atomically: true,
+ encoding: .utf8
+ )
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: desktopHelper)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fallbackHelper)
+ let manager = MachineManager(configuration: MachineManagerConfiguration(
+ vmmExecutablePath: fallbackHelper,
+ acceleratedDesktopExecutablePath: desktopHelper,
+ stateDirectory: base + "/machines",
+ baseArguments: ["fallback"],
+ acceleratedDesktopBaseArguments: ["desktop", "--gvproxy", "/tmp/gvproxy"],
+ requiresReadyHandoff: false
+ ))
+ defer {
+ try? manager.delete(id: "desktop")
+ try? manager.delete(id: "compatible")
+ try? manager.delete(id: "headless")
+ try? FileManager.default.removeItem(atPath: base)
+ }
+
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "desktop",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ displayMode: .desktop
+ ))
+ _ = try manager.start(id: "desktop")
+ for _ in 0..<100 where !FileManager.default.fileExists(atPath: desktopCapture) {
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ let desktopArguments = try String(contentsOfFile: desktopCapture, encoding: .utf8)
+ .split(separator: "\n").map(String.init)
+ XCTAssertEqual(Array(desktopArguments.prefix(3)), ["desktop", "--gvproxy", "/tmp/gvproxy"])
+ let desktopDisplayIndex = try XCTUnwrap(desktopArguments.firstIndex(of: "--display-mode"))
+ XCTAssertEqual(desktopArguments[desktopDisplayIndex + 1], "desktop")
+ _ = try manager.stop(id: "desktop")
+
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "compatible",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ displayMode: .desktop,
+ environment: [DoryDesktopVMMPreference.environmentKey: "compatible"]
+ ))
+ _ = try manager.start(id: "compatible")
+ for _ in 0..<100 where !FileManager.default.fileExists(atPath: fallbackCapture) {
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ let compatibleArguments = try String(contentsOfFile: fallbackCapture, encoding: .utf8)
+ .split(separator: "\n").map(String.init)
+ XCTAssertEqual(compatibleArguments.first, "fallback")
+ let compatibleDisplayIndex = try XCTUnwrap(compatibleArguments.firstIndex(of: "--display-mode"))
+ XCTAssertEqual(compatibleArguments[compatibleDisplayIndex + 1], "desktop")
+ _ = try manager.stop(id: "compatible")
+ try FileManager.default.removeItem(atPath: fallbackCapture)
+
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "headless",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ displayMode: .headless
+ ))
+ _ = try manager.start(id: "headless")
+ for _ in 0..<100 where !FileManager.default.fileExists(atPath: fallbackCapture) {
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ let fallbackArguments = try String(contentsOfFile: fallbackCapture, encoding: .utf8)
+ .split(separator: "\n").map(String.init)
+ XCTAssertEqual(fallbackArguments.first, "fallback")
+ let fallbackDisplayIndex = try XCTUnwrap(fallbackArguments.firstIndex(of: "--display-mode"))
+ XCTAssertEqual(fallbackArguments[fallbackDisplayIndex + 1], "headless")
+ _ = try manager.stop(id: "headless")
+ }
+
+ func testInstalledEFIBootBundleLaunchesThroughAcceleratedGenericLinuxRuntime() throws {
+ let base = "/tmp/dory-machine-installed-efi-accelerated-\(getpid())-\(UInt32.random(in: 0.. '\(capture)'\nsleep 30\n".write(
+ toFile: desktopHelper,
+ atomically: true,
+ encoding: .utf8
+ )
+ try "#!/bin/sh\nexit 91\n".write(toFile: fallbackHelper, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: desktopHelper)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fallbackHelper)
+ try Data("installed-disk".utf8).write(to: URL(fileURLWithPath: sourceDisk))
+ let kernel = Data(repeating: 0x41, count: 8_192)
+ let initrd = Data(repeating: 0x42, count: 16_384)
+ try DoryInstalledLinuxBootBundle.write(
+ assets: DoryLinuxInstallerBootAssets(
+ kernel: kernel,
+ initrd: initrd,
+ kernelISOPath: "casper/vmlinuz",
+ initrdISOPath: "casper/initrd"
+ ),
+ rootDevice: "/dev/vda2",
+ toPath: sourceBundle
+ )
+ let state = base + "/machines"
+ let manager = MachineManager(configuration: MachineManagerConfiguration(
+ vmmExecutablePath: fallbackHelper,
+ acceleratedDesktopExecutablePath: desktopHelper,
+ stateDirectory: state,
+ acceleratedDesktopBaseArguments: ["desktop", "--gvproxy", "/tmp/gvproxy"],
+ requiresReadyHandoff: false
+ ))
+ defer { try? manager.delete(id: "ubuntu") }
+
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "ubuntu",
+ kernelPath: sourceBundle,
+ rootfsPath: sourceDisk,
+ bootMode: .efi,
+ memoryMB: 4096,
+ cpuCount: 4,
+ displayMode: .desktop
+ ))
+ _ = try manager.start(id: "ubuntu")
+ let arguments = try waitForFileContent(capture)
+ .split(separator: "\n").map(String.init)
+ func value(after flag: String) throws -> String {
+ let index = try XCTUnwrap(arguments.firstIndex(of: flag))
+ return arguments[index + 1]
+ }
+
+ XCTAssertEqual(Array(arguments.prefix(3)), ["desktop", "--gvproxy", "/tmp/gvproxy"])
+ XCTAssertEqual(try value(after: "--boot-mode"), "efi-installed")
+ XCTAssertEqual(try value(after: "--root-device"), "/dev/vda2")
+ XCTAssertEqual(try value(after: "--kernel"), "\(state)/ubuntu/direct-kernel")
+ XCTAssertEqual(try value(after: "--initrd"), "\(state)/ubuntu/direct-initrd")
+ XCTAssertTrue(arguments.contains("--generic-guest"))
+ XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: "\(state)/ubuntu/direct-kernel")), kernel)
+ XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: "\(state)/ubuntu/direct-initrd")), initrd)
+ }
+
func testWakeClockSyncsRunningMachineAgents() throws {
let base = "/tmp/dory-machine-clock-\(getpid())-\(UInt32.random(in: 0.. Result {
+ let box = LockedResult()
+ DispatchQueue.global(qos: .userInitiated).async {
+ box.store(Result { try manager.updateDesktop(id: id, request: request) })
+ }
+ let deadline = Date().addingTimeInterval(timeout)
+ var handedOffPIDs: Set = []
+ while Date() < deadline, box.value == nil {
+ if let status = manager.status(id: id),
+ status.state == .starting,
+ let pid = status.pid,
+ !handedOffPIDs.contains(pid),
+ let handoffPath = status.handoffSocketPath {
+ try sendVmmHandoff(
+ path: handoffPath,
+ ready: VmmReadyMessage(
+ machineID: id,
+ agentBuild: "dory-agent/update-test",
+ agentSocketPath: "/run/dory-update-test-agent.sock",
+ dockerdSocketPath: "/run/dory-update-test-docker.sock"
+ ),
+ fileDescriptors: []
+ )
+ handedOffPIDs.insert(pid)
+ }
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ return try XCTUnwrap(box.value, "desktop update timed out")
+}
+
+private final class DesktopUpdateAgentConnector: @unchecked Sendable {
+ private let lock = NSLock()
+ private let managedRootfsPath: String
+ private let failsInstall: Bool
+ private var executions = 0
+
+ init(managedRootfsPath: String, failsInstall: Bool = false) {
+ self.managedRootfsPath = managedRootfsPath
+ self.failsInstall = failsInstall
+ }
+
+ var execCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return executions
+ }
+
+ func connect(socketPath: String) throws -> any AgentControlClient {
+ DesktopUpdateAgentControlClient(owner: self)
+ }
+
+ func execute(argv: [String]) -> DoryExecResult {
+ lock.lock()
+ executions += 1
+ lock.unlock()
+ if argv.first?.hasSuffix("/apply.sh") == true {
+ try? Data("rootfs-after".utf8).write(to: URL(fileURLWithPath: managedRootfsPath))
+ if failsInstall {
+ return DoryExecResult(
+ exitCode: 42,
+ stdout: Data(),
+ stderr: Data("injected package failure".utf8),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false
+ )
+ }
+ let output = "Dory desktop update applied: ubuntu 1.2.3+runtime.4.5.6 "
+ + String(repeating: "a", count: 64) + "\n"
+ return DoryExecResult(
+ exitCode: 0,
+ stdout: Data(output.utf8),
+ stderr: Data(),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false
+ )
+ }
+ return DoryExecResult(
+ exitCode: 0,
+ stdout: Data("ok\n".utf8),
+ stderr: Data(),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false
+ )
+ }
+}
+
+private final class DesktopUpdateAgentControlClient: AgentControlClient, @unchecked Sendable {
+ private let owner: DesktopUpdateAgentConnector
+
+ init(owner: DesktopUpdateAgentConnector) {
+ self.owner = owner
+ }
+
+ func info() throws -> DoryAgentInfo {
+ DoryAgentInfo(
+ protocolVersion: 1,
+ kernel: "Linux update-test",
+ agentBuild: "dory-agent/update-test",
+ uptimeSeconds: 1
+ )
+ }
+
+ func clockSync(hostEpochNs: Int64) throws -> Bool { true }
+
+ func portsWatch() throws -> DoryPortsSnapshot {
+ DoryPortsSnapshot(ports: [], added: [], removed: [])
+ }
+
+ func telemetry() throws -> DoryTelemetry {
+ DoryTelemetry(memTotalKB: 2048, memAvailableKB: 1024, psiSomeAvg10: 0, psiFullAvg10: 0)
+ }
+
+ func exec(
+ argv: [String],
+ cwd: String,
+ env: [DoryExecEnvironment],
+ timeoutMs: UInt64,
+ outputLimitBytes: UInt64
+ ) throws -> DoryExecResult {
+ owner.execute(argv: argv)
+ }
+
+ func close() {}
+}
+
private final class RecordingMachineAgentConnector: @unchecked Sendable {
private let lock = NSLock()
private var paths: [String] = []
From c7334f365e66513b9f9c08b3543c8e473f010532 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 10:54:47 +0000
Subject: [PATCH 005/338] feat(guest): build qualified accelerated Linux
desktops
---
guest/desktop/PINS | 5 +-
guest/desktop/apply-update.sh | 122 ++++++++
guest/desktop/build-update-bundle.py | 105 +++++++
guest/desktop/build.sh | 102 +++++-
guest/desktop/input-fingerprint.sh | 10 +-
.../etc/NetworkManager/conf.d/10-dory.conf | 7 +
.../conf.d/10-globally-managed-devices.conf | 4 +
.../dory-wired.nmconnection | 16 +
.../etc/apt/keyrings/packages.mozilla.org.asc | 19 ++
.../etc/apt/preferences.d/mozilla | 3 +
.../etc/apt/sources.list.d/mozilla.list | 1 +
.../etc/dconf/db/dory.d/00-managed-session | 11 +
.../dconf/db/dory.d/locks/00-managed-session | 4 +
.../rootfs-overlay/etc/dconf/profile/user | 2 +
.../etc/environment.d/60-dory-desktop.conf | 3 +
.../rootfs-overlay/etc/gdm3/custom.conf | 15 +
.../rules.d/49-dory-passwordless-admin.rules | 8 +
.../etc/systemd/system/dory-boot.service | 2 +-
.../systemd/system/dory-desktop-ready.service | 2 +-
.../systemd/system/dory-first-boot.service | 2 +-
.../system/dory-graphics-backend.service | 12 +
.../etc/systemd/system/dory-zram.service | 12 +
.../main.lua.d/60-dory-virtio-sound.lua | 56 ++++
.../etc/xdg/autostart/dory-display.desktop | 2 +-
.../rootfs-overlay/usr/lib/dory/clipboard | 59 ++++
.../usr/lib/dory/configure-display | 55 ++++
.../usr/lib/dory/configure-graphics-backend | 67 ++++
.../usr/lib/dory/configure-machine | 9 +-
.../usr/lib/dory/configure-zram | 42 +++
.../rootfs-overlay/usr/lib/dory/first-boot | 20 +-
.../lib/firefox/distribution/policies.json | 14 +
.../firefox-esr/distribution/policies.json | 14 +
guest/desktop/verify-build.sh | 294 +++++++++++++++++-
guest/kernel/build.sh | 5 +-
.../kernel/dory-accelerated-desktop.fragment | 16 +
guest/kernel/dory-desktop.fragment | 5 +
guest/kernel/dory-headless.fragment | 1 +
guest/kernel/input-fingerprint.sh | 5 +-
...6-virtio-gpu-align-host-visible-vram.patch | 84 +++++
guest/kernel/profile.sh | 6 +-
guest/kernel/verify-build.sh | 5 +-
guest/mesa/PINS | 7 +
guest/mesa/README.md | 16 +
guest/mesa/build.sh | 142 +++++++++
guest/mesa/dory-vulkan-probe.c | 101 ++++++
guest/mesa/input-fingerprint.sh | 27 ++
...nus-enable-dory-implicit-fencing-wsi.patch | 210 +++++++++++++
guest/mesa/verify-build.sh | 66 ++++
48 files changed, 1756 insertions(+), 39 deletions(-)
create mode 100755 guest/desktop/apply-update.sh
create mode 100755 guest/desktop/build-update-bundle.py
create mode 100644 guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-globally-managed-devices.conf
create mode 100644 guest/desktop/rootfs-overlay/etc/NetworkManager/system-connections/dory-wired.nmconnection
create mode 100644 guest/desktop/rootfs-overlay/etc/apt/keyrings/packages.mozilla.org.asc
create mode 100644 guest/desktop/rootfs-overlay/etc/apt/preferences.d/mozilla
create mode 100644 guest/desktop/rootfs-overlay/etc/apt/sources.list.d/mozilla.list
create mode 100644 guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/00-managed-session
create mode 100644 guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/locks/00-managed-session
create mode 100644 guest/desktop/rootfs-overlay/etc/dconf/profile/user
create mode 100644 guest/desktop/rootfs-overlay/etc/environment.d/60-dory-desktop.conf
create mode 100644 guest/desktop/rootfs-overlay/etc/gdm3/custom.conf
create mode 100644 guest/desktop/rootfs-overlay/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules
create mode 100644 guest/desktop/rootfs-overlay/etc/systemd/system/dory-graphics-backend.service
create mode 100644 guest/desktop/rootfs-overlay/etc/systemd/system/dory-zram.service
create mode 100644 guest/desktop/rootfs-overlay/etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua
create mode 100644 guest/desktop/rootfs-overlay/usr/lib/dory/clipboard
create mode 100644 guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend
create mode 100644 guest/desktop/rootfs-overlay/usr/lib/dory/configure-zram
create mode 100644 guest/desktop/rootfs-overlay/usr/lib/firefox/distribution/policies.json
create mode 100644 guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json
create mode 100644 guest/kernel/dory-accelerated-desktop.fragment
create mode 100644 guest/kernel/patches/6.12.30/0006-virtio-gpu-align-host-visible-vram.patch
create mode 100644 guest/mesa/PINS
create mode 100644 guest/mesa/README.md
create mode 100755 guest/mesa/build.sh
create mode 100644 guest/mesa/dory-vulkan-probe.c
create mode 100755 guest/mesa/input-fingerprint.sh
create mode 100644 guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch
create mode 100755 guest/mesa/verify-build.sh
diff --git a/guest/desktop/PINS b/guest/desktop/PINS
index ec492063..415700e3 100644
--- a/guest/desktop/PINS
+++ b/guest/desktop/PINS
@@ -9,4 +9,7 @@ UBUNTU_MIRROR=https://ports.ubuntu.com/ubuntu-ports
KALI_BUILDER_IMAGE=kalilinux/kali-rolling@sha256:8a1ea7281085ffef4963e82766c70869d7db910df88dcbb1f03d2899420b9577
KALI_SUITE=kali-rolling
KALI_MIRROR=https://http.kali.org/kali
-DESKTOP_IMAGE_SIZE_MB=6144
+DESKTOP_IMAGE_SIZE_MB=8192
+DESKTOP_FREE_SPACE_MB=2048
+MOZILLA_APT_KEY_FINGERPRINT=35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3
+MOZILLA_APT_KEY_SHA256=3ecc63922b7795eb23fdc449ff9396f9114cb3cf186d6f5b53ad4cc3ebfbb11f
diff --git a/guest/desktop/apply-update.sh b/guest/desktop/apply-update.sh
new file mode 100755
index 00000000..6146e75b
--- /dev/null
+++ b/guest/desktop/apply-update.sh
@@ -0,0 +1,122 @@
+#!/bin/bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+EXPECTED_DISTRO="${1:-}"
+RELEASE_VERSION="${2:-}"
+
+fail() {
+ echo "Dory desktop update failed: $*" >&2
+ exit 1
+}
+
+[ "$(id -u)" -eq 0 ] || fail "the update must run as root"
+case "$EXPECTED_DISTRO" in
+ debian|ubuntu|kali) ;;
+ *) fail "unsupported distribution '$EXPECTED_DISTRO'" ;;
+esac
+[ -n "$RELEASE_VERSION" ] || fail "the target release version is missing"
+[ -f "$ROOT/manifest.env" ] || fail "manifest.env is missing"
+[ -f "$ROOT/SHA256SUMS" ] || fail "SHA256SUMS is missing"
+
+(cd "$ROOT" && sha256sum -c SHA256SUMS) || fail "the signed update payload is corrupt"
+
+manifest_value() {
+ sed -n "s/^$1=//p" "$ROOT/manifest.env"
+}
+
+[ "$(manifest_value schema)" = 2 ] || fail "the update manifest schema is unsupported"
+[ "$(manifest_value arch)" = arm64 ] || fail "the update payload has the wrong architecture"
+[ "$(manifest_value distro)" = "$EXPECTED_DISTRO" ] || fail "the update payload has the wrong distribution"
+INPUT_SHA256="$(manifest_value input_sha256)"
+case "$INPUT_SHA256" in
+ [0-9a-f][0-9a-f]*) ;;
+ *) fail "the update payload fingerprint is invalid" ;;
+esac
+[ "${#INPUT_SHA256}" -eq 64 ] || fail "the update payload fingerprint is invalid"
+
+. /etc/os-release
+[ "${ID:-}" = "$EXPECTED_DISTRO" ] || fail "this guest is ${ID:-unknown}, not $EXPECTED_DISTRO"
+[ "$(uname -m)" = aarch64 ] || fail "this guest is not ARM64"
+
+require_package() {
+ package="$1"
+ dpkg-query -W -f='${Status}\n' "$package" 2>/dev/null \
+ | grep -Fqx 'install ok installed' \
+ || fail "the guest is missing required package '$package'; install a supported desktop image before updating Dory guest tools"
+}
+
+# packages.txt is the signed package provenance for the matching full image. A guest-tools update
+# must not turn into an unreviewed, network-dependent distribution upgrade. Validate the small
+# runtime surface the integration layer needs, then update only Dory-owned files below. Ubuntu,
+# Debian, and Kali remain responsible for their normal package updates inside the guest.
+for package in dconf-cli network-manager openssh-server pipewire spice-vdagent x11-utils zstd; do
+ require_package "$package"
+done
+case "$EXPECTED_DISTRO" in
+ ubuntu)
+ for package in gdm3 gnome-shell firefox; do require_package "$package"; done
+ ;;
+ *)
+ for package in lightdm xfce4; do require_package "$package"; done
+ ;;
+esac
+if [ "$EXPECTED_DISTRO" = ubuntu ]; then
+ # Keep Ubuntu's dock pointed at the Mozilla APT launcher rather than the absent Snap ID.
+ sed -i "s/firefox_firefox\.desktop/firefox.desktop/g" \
+ /usr/share/glib-2.0/schemas/10_ubuntu-settings.gschema.override
+ glib-compile-schemas /usr/share/glib-2.0/schemas
+fi
+
+# The component payload is signature-verified on the host and digest-verified again above. Apply
+# only Dory-owned integration files; package-manager and user configuration remain guest-owned.
+MANAGED_USER="$(cat /var/lib/dory/username 2>/dev/null || printf 'dory\n')"
+id "$MANAGED_USER" >/dev/null 2>&1 || fail "the managed desktop account is missing"
+tar -xf "$ROOT/rootfs-overlay.tar" -C /
+zstd -q -d -c "$ROOT/dory-mesa-venus-arm64.tar.zst" | tar -xf - -C /
+install -m0755 "$ROOT/dory-agent" /usr/bin/dory-agent
+chmod 0755 /usr/lib/dory/clipboard /usr/lib/dory/configure-machine /usr/lib/dory/first-boot \
+ /usr/lib/dory/start-agent /usr/lib/dory/wait-host-configuration \
+ /usr/lib/dory/configure-display /usr/lib/dory/configure-zram \
+ /usr/lib/dory/configure-graphics-backend /usr/lib/dory/dory-vulkan-probe
+chmod 0600 /etc/NetworkManager/system-connections/dory-wired.nmconnection
+chmod 0644 /etc/polkit-1/rules.d/49-dory-passwordless-admin.rules
+if [ -x /usr/bin/dconf ]; then
+ dconf update
+fi
+
+rm -f /etc/resolv.conf
+ln -s ../run/NetworkManager/resolv.conf /etc/resolv.conf
+case "$EXPECTED_DISTRO" in
+ ubuntu)
+ rm -rf /etc/lightdm
+ sed -i "s/^AutomaticLogin=.*/AutomaticLogin=$MANAGED_USER/" /etc/gdm3/custom.conf
+ systemctl enable gdm3.service
+ ;;
+ *)
+ rm -rf /etc/gdm3
+ sed -i "s/^autologin-user=.*/autologin-user=$MANAGED_USER/" \
+ /etc/lightdm/lightdm.conf.d/50-dory.conf
+ systemctl enable lightdm.service
+ ;;
+esac
+systemctl enable NetworkManager.service NetworkManager-wait-online.service ssh.service \
+ dory-first-boot.service dory-boot.service dory-desktop-ready.service dory-zram.service \
+ dory-graphics-backend.service
+systemctl set-default graphical.target
+systemctl daemon-reload
+
+install -d -m0755 /var/lib/dory
+RECEIPT="/var/lib/dory/desktop-update.env"
+TEMP_RECEIPT="${RECEIPT}.tmp.$$"
+{
+ printf 'schema=1\n'
+ printf 'distro=%s\n' "$EXPECTED_DISTRO"
+ printf 'version=%s\n' "$RELEASE_VERSION"
+ printf 'input_sha256=%s\n' "$INPUT_SHA256"
+ printf 'installed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+} > "$TEMP_RECEIPT"
+chmod 0644 "$TEMP_RECEIPT"
+mv -f "$TEMP_RECEIPT" "$RECEIPT"
+
+echo "Dory desktop update applied: $EXPECTED_DISTRO $RELEASE_VERSION $INPUT_SHA256"
diff --git a/guest/desktop/build-update-bundle.py b/guest/desktop/build-update-bundle.py
new file mode 100755
index 00000000..32801ab2
--- /dev/null
+++ b/guest/desktop/build-update-bundle.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""Build the small, signed in-place update payload shipped beside each desktop rootfs."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import os
+import pathlib
+import shutil
+import stat
+import tarfile
+import tempfile
+
+
+def digest(path: pathlib.Path) -> str:
+ value = hashlib.sha256()
+ with path.open("rb") as source:
+ while chunk := source.read(1024 * 1024):
+ value.update(chunk)
+ return value.hexdigest()
+
+
+def add_path(archive: tarfile.TarFile, source: pathlib.Path, name: str) -> None:
+ info = source.lstat()
+ if stat.S_ISLNK(info.st_mode):
+ raise SystemExit(f"desktop update payload refuses symlink: {source}")
+ if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
+ raise SystemExit(f"desktop update payload refuses special file: {source}")
+ tar_info = archive.gettarinfo(str(source), arcname=name)
+ tar_info.uid = 0
+ tar_info.gid = 0
+ tar_info.uname = "root"
+ tar_info.gname = "root"
+ tar_info.mtime = 0
+ if source.is_dir():
+ archive.addfile(tar_info)
+ else:
+ with source.open("rb") as contents:
+ archive.addfile(tar_info, contents)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--distro", choices=("debian", "ubuntu", "kali"), required=True)
+ parser.add_argument("--fingerprint", required=True)
+ parser.add_argument("--packages", type=pathlib.Path, required=True)
+ parser.add_argument("--overlay", type=pathlib.Path, required=True)
+ parser.add_argument("--agent", type=pathlib.Path, required=True)
+ parser.add_argument("--venus-runtime", type=pathlib.Path, required=True)
+ parser.add_argument("--apply", type=pathlib.Path, required=True)
+ parser.add_argument("--output", type=pathlib.Path, required=True)
+ args = parser.parse_args()
+ if len(args.fingerprint) != 64 or any(c not in "0123456789abcdef" for c in args.fingerprint):
+ raise SystemExit("desktop update fingerprint must be a lowercase SHA-256 digest")
+ for path in (args.packages, args.agent, args.venus_runtime, args.apply):
+ if not path.is_file() or path.is_symlink() or path.stat().st_size == 0:
+ raise SystemExit(f"desktop update input is invalid: {path}")
+ if not args.overlay.is_dir() or args.overlay.is_symlink():
+ raise SystemExit(f"desktop update overlay is invalid: {args.overlay}")
+
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory(prefix="dory-desktop-update-") as temporary:
+ staging = pathlib.Path(temporary)
+ shutil.copy2(args.apply, staging / "apply.sh")
+ shutil.copy2(args.agent, staging / "dory-agent")
+ shutil.copy2(args.venus_runtime, staging / "dory-mesa-venus-arm64.tar.zst")
+ shutil.copy2(args.packages, staging / "packages.txt")
+ (staging / "manifest.env").write_text(
+ f"schema=2\narch=arm64\ndistro={args.distro}\ninput_sha256={args.fingerprint}\n",
+ encoding="utf-8",
+ )
+ os.chmod(staging / "apply.sh", 0o755)
+ os.chmod(staging / "dory-agent", 0o755)
+
+ overlay_tar = staging / "rootfs-overlay.tar"
+ with tarfile.open(overlay_tar, "w", format=tarfile.PAX_FORMAT) as archive:
+ add_path(archive, args.overlay, ".")
+ for source in sorted(args.overlay.rglob("*")):
+ add_path(archive, source, source.relative_to(args.overlay).as_posix())
+
+ payloads = [
+ "apply.sh",
+ "dory-agent",
+ "dory-mesa-venus-arm64.tar.zst",
+ "manifest.env",
+ "packages.txt",
+ "rootfs-overlay.tar",
+ ]
+ (staging / "SHA256SUMS").write_text(
+ "".join(f"{digest(staging / name)} {name}\n" for name in payloads),
+ encoding="utf-8",
+ )
+ temporary_output = args.output.with_name(f".{args.output.name}.tmp-{os.getpid()}")
+ try:
+ with tarfile.open(temporary_output, "w", format=tarfile.PAX_FORMAT) as archive:
+ for name in [*payloads, "SHA256SUMS"]:
+ add_path(archive, staging / name, name)
+ os.replace(temporary_output, args.output)
+ finally:
+ temporary_output.unlink(missing_ok=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/guest/desktop/build.sh b/guest/desktop/build.sh
index 97454487..3a0d6427 100755
--- a/guest/desktop/build.sh
+++ b/guest/desktop/build.sh
@@ -37,10 +37,15 @@ ARTIFACT_PREFIX="dory-desktop-$DISTRO"
OUT="$ROOT/guest/out"
mkdir -p "$OUT"
IMAGE_SIZE_MB="${DORY_DESKTOP_IMAGE_SIZE_MB:-$DESKTOP_IMAGE_SIZE_MB}"
+FREE_SPACE_MB="${DORY_DESKTOP_FREE_SPACE_MB:-$DESKTOP_FREE_SPACE_MB}"
case "$IMAGE_SIZE_MB" in
''|*[!0-9]*) echo "DORY_DESKTOP_IMAGE_SIZE_MB must be an integer" >&2; exit 64 ;;
esac
[ "$IMAGE_SIZE_MB" -ge 4096 ] || { echo "desktop build image must be at least 4096 MB" >&2; exit 64; }
+case "$FREE_SPACE_MB" in
+ ''|*[!0-9]*) echo "DORY_DESKTOP_FREE_SPACE_MB must be an integer" >&2; exit 64 ;;
+esac
+[ "$FREE_SPACE_MB" -ge 1024 ] || { echo "desktop image must retain at least 1024 MB free" >&2; exit 64; }
DOCKER_BIN="${DORY_DESKTOP_DOCKER_BIN:-$(command -v docker || true)}"
[ -n "$DOCKER_BIN" ] && [ -x "$DOCKER_BIN" ] || { echo "docker CLI not found" >&2; exit 1; }
@@ -51,6 +56,10 @@ docker_cmd() {
ZSTD_BIN="${DORY_ZSTD:-$(command -v zstd || true)}"
[ -n "$ZSTD_BIN" ] && [ -x "$ZSTD_BIN" ] || { echo "zstd is required" >&2; exit 1; }
+guest/mesa/build.sh arm64
+VENUS_RUNTIME="$OUT/dory-mesa-venus-arm64.tar.zst"
+guest/mesa/verify-build.sh arm64
+
TARGET=aarch64-unknown-linux-musl
if command -v rust-lld >/dev/null 2>&1; then
LINKER="$(command -v rust-lld)"
@@ -83,11 +92,20 @@ rustup target add "$TARGET" >/dev/null
AGENT="$ROOT/dory-core/target/$TARGET/release/dory-agent"
[ -x "$AGENT" ] || { echo "dory-agent was not produced for $TARGET" >&2; exit 1; }
-COMMON_PACKAGES="systemd-sysv,dbus,dbus-user-session,udev,kmod,network-manager,openssh-server,sudo,ca-certificates,curl,git,vim-tiny,less,man-db,bash-completion,xfce4,xfce4-terminal,xfce4-notifyd,xfce4-power-manager,lightdm,lightdm-gtk-greeter,xserver-xorg-core,xserver-xorg-input-libinput,x11-xserver-utils,xterm,libgl1-mesa-dri,mesa-utils,spice-vdagent,pipewire-audio,wireplumber,polkitd,pkexec,mate-polkit,fonts-dejavu-core,fonts-noto-core,locales,util-linux,e2fsprogs,iproute2,iputils-ping,dnsutils,netcat-openbsd,procps,rsync,tar,gzip,xz-utils,zstd,fuse3,gvfs,gvfs-backends,mousepad,ristretto,file-roller"
+COMMON_PACKAGES="systemd-sysv,dbus,dbus-user-session,dconf-cli,udev,kmod,network-manager,openssh-server,sudo,ca-certificates,curl,git,vim-tiny,less,man-db,bash-completion,xserver-xorg-core,xserver-xorg-input-libinput,x11-xserver-utils,x11-utils,xterm,libgl1-mesa-dri,mesa-vulkan-drivers,mesa-utils,vulkan-tools,spice-vdagent,wl-clipboard,xclip,pipewire-audio,wireplumber,polkitd,pkexec,fonts-dejavu-core,fonts-noto-core,locales,util-linux,e2fsprogs,iproute2,iputils-ping,dnsutils,netcat-openbsd,procps,rsync,tar,gzip,xz-utils,zstd,fuse3,gvfs,gvfs-backends"
+XFCE_PACKAGES="xfce4,xfce4-terminal,xfce4-notifyd,xfce4-power-manager,lightdm,lightdm-gtk-greeter,mate-polkit,mousepad,ristretto,file-roller"
case "$DISTRO" in
- debian) PACKAGES="$COMMON_PACKAGES,network-manager-gnome,desktop-base" ;;
- ubuntu) PACKAGES="$COMMON_PACKAGES,network-manager-gnome,ubuntu-minimal,xubuntu-default-settings" ;;
- kali) PACKAGES="$COMMON_PACKAGES,network-manager-applet,nm-connection-editor,kali-desktop-xfce,kali-defaults,kali-menu" ;;
+ debian) PACKAGES="$COMMON_PACKAGES,$XFCE_PACKAGES,network-manager-gnome,desktop-base,firefox-esr,evince,galculator" ;;
+ ubuntu)
+ # Ubuntu is intentionally its real Canonical GNOME session, not an Ubuntu rootfs wearing
+ # Dory's shared Xfce profile. Hardware-only recommendations (printing, Bluetooth, firmware,
+ # laptop sensors) stay out of the VM, while the normal shell, dock, settings, files, themes,
+ # utilities are explicit so the image is complete offline. Firefox is installed below from
+ # Mozilla's signed ARM64 APT repository; Ubuntu's `firefox` package is only a Snap transition,
+ # and WebKitGTK saturated the software-rendered desktop during real browser use.
+ PACKAGES="$COMMON_PACKAGES,ubuntu-minimal,ubuntu-desktop-minimal,network-manager-gnome,appstream,baobab,eog,evince,file-roller,fonts-liberation,fonts-noto-color-emoji,fonts-ubuntu,gnome-calculator,gnome-characters,gnome-clocks,gnome-disk-utility,gnome-keyring,gnome-system-monitor,gnome-terminal,gnome-text-editor,gsettings-ubuntu-schemas,gvfs-fuse,libglib2.0-bin,libnss-mdns,libpam-gnome-keyring,nautilus-sendto,network-manager-config-connectivity-ubuntu,packagekit,policykit-desktop-privileges,seahorse,ubuntu-wallpapers,xcursor-themes,xdg-desktop-portal-gnome,xdg-utils,yaru-theme-gnome-shell,yaru-theme-gtk,yaru-theme-icon,yaru-theme-sound"
+ ;;
+ kali) PACKAGES="$COMMON_PACKAGES,$XFCE_PACKAGES,network-manager-applet,nm-connection-editor,kali-desktop-xfce,kali-defaults,kali-menu" ;;
esac
# systemd's package scripts need proc/sys mounts while the rootfs is assembled.
@@ -101,6 +119,9 @@ CID="$(docker_cmd create --privileged --platform linux/arm64 \
-e DORY_DESKTOP_ARTIFACT_PREFIX="$ARTIFACT_PREFIX" \
-e DORY_DESKTOP_PACKAGES="$PACKAGES" \
-e DORY_DESKTOP_IMAGE_SIZE_MB="$IMAGE_SIZE_MB" \
+ -e DORY_DESKTOP_FREE_SPACE_MB="$FREE_SPACE_MB" \
+ -e DORY_MOZILLA_APT_KEY_FINGERPRINT="$MOZILLA_APT_KEY_FINGERPRINT" \
+ -e DORY_MOZILLA_APT_KEY_SHA256="$MOZILLA_APT_KEY_SHA256" \
-w /build \
"$BUILDER_IMAGE" bash -euc '
find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) \
@@ -114,7 +135,7 @@ CID="$(docker_cmd create --privileged --platform linux/arm64 \
fi
apt-get -o Acquire::Retries=5 update
apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
- ca-certificates e2fsprogs mmdebstrap zstd
+ ca-certificates e2fsprogs gnupg mmdebstrap zstd
mmdebstrap \
--mode=root \
@@ -128,10 +149,36 @@ CID="$(docker_cmd create --privileged --platform linux/arm64 \
"$DORY_DESKTOP_SUITE" /rootfs "$DORY_DESKTOP_MIRROR"
cp -a --no-preserve=ownership /tmp/rootfs-overlay/. /rootfs/
+ zstd -q -d -c /tmp/dory-mesa-venus-arm64.tar.zst | tar -xf - -C /rootfs
install -m0755 /tmp/dory-agent /rootfs/usr/bin/dory-agent
- chmod 0755 /rootfs/usr/lib/dory/configure-machine /rootfs/usr/lib/dory/first-boot \
+ chmod 0755 /rootfs/usr/lib/dory/clipboard /rootfs/usr/lib/dory/configure-machine /rootfs/usr/lib/dory/first-boot \
/rootfs/usr/lib/dory/start-agent /rootfs/usr/lib/dory/wait-host-configuration \
- /rootfs/usr/lib/dory/configure-display
+ /rootfs/usr/lib/dory/configure-display /rootfs/usr/lib/dory/configure-zram \
+ /rootfs/usr/lib/dory/configure-graphics-backend \
+ /rootfs/usr/lib/dory/dory-vulkan-probe
+
+ if [ "$DORY_DESKTOP_DISTRO" = ubuntu ]; then
+ key=/rootfs/etc/apt/keyrings/packages.mozilla.org.asc
+ actual_sha256="$(sha256sum "$key")"
+ actual_sha256="${actual_sha256%% *}"
+ [ "$actual_sha256" = "$DORY_MOZILLA_APT_KEY_SHA256" ] || {
+ echo "Mozilla APT key digest mismatch" >&2
+ exit 1
+ }
+ actual_fingerprint="$(gpg --show-keys --with-colons --fingerprint "$key" 2>/dev/null \
+ | sed -n "s/^fpr:::::::::\\([^:]*\\):$/\\1/p" | head -1)"
+ [ "$actual_fingerprint" = "$DORY_MOZILLA_APT_KEY_FINGERPRINT" ] || {
+ echo "Mozilla APT key fingerprint mismatch" >&2
+ exit 1
+ }
+ chroot /rootfs apt-get -o Acquire::Retries=5 update
+ chroot /rootfs apt-get -o Acquire::Retries=5 install -y --no-install-recommends firefox
+ # The ubuntu-desktop vendor favorite points at the Snap-only desktop ID. Dory deliberately
+ # installs the signed Mozilla APT package, whose launcher is firefox.desktop.
+ sed -i "s/firefox_firefox\.desktop/firefox.desktop/g" \
+ /rootfs/usr/share/glib-2.0/schemas/10_ubuntu-settings.gschema.override
+ chroot /rootfs glib-compile-schemas /usr/share/glib-2.0/schemas
+ fi
printf "dory\n" > /rootfs/etc/hostname
cat > /rootfs/etc/hosts < /rootfs/etc/locale.gen
chroot /rootfs locale-gen
printf "LANG=en_US.UTF-8\n" > /rootfs/etc/default/locale
+ # The rootfs is assembled inside Docker, whose generated resolv.conf points at the build
+ # engine. At runtime the desktop VM receives different DHCP details, so NetworkManager must
+ # own the guest resolver file instead of inheriting the build-container nameserver.
+ rm -f /rootfs/etc/resolv.conf
+ ln -s ../run/NetworkManager/resolv.conf /rootfs/etc/resolv.conf
for group in sudo audio video render input; do
chroot /rootfs getent group "$group" >/dev/null || chroot /rootfs groupadd "$group"
@@ -153,6 +205,11 @@ EOF
chroot /rootfs passwd --lock dory
printf "dory ALL=(ALL:ALL) NOPASSWD: ALL\n" > /rootfs/etc/sudoers.d/dory
chmod 0440 /rootfs/etc/sudoers.d/dory
+ chmod 0644 /rootfs/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules
+ # The managed account has no password and is entered through display-manager autologin.
+ # Compile the system dconf policy that prevents GNOME from creating an impossible-to-unlock
+ # password prompt after the session has been idle.
+ chroot /rootfs dconf update
rm -f /rootfs/etc/apt/sources.list /rootfs/etc/apt/sources.list.d/*.list \
/rootfs/etc/apt/sources.list.d/*.sources
@@ -189,8 +246,19 @@ EOF
esac
chroot /rootfs systemctl enable NetworkManager.service NetworkManager-wait-online.service
- chroot /rootfs systemctl enable lightdm.service ssh.service dory-first-boot.service \
- dory-boot.service dory-desktop-ready.service
+ case "$DORY_DESKTOP_DISTRO" in
+ ubuntu)
+ rm -rf /rootfs/etc/lightdm
+ chroot /rootfs systemctl enable gdm3.service
+ ;;
+ *)
+ rm -rf /rootfs/etc/gdm3
+ chroot /rootfs systemctl enable lightdm.service
+ ;;
+ esac
+ chroot /rootfs systemctl enable ssh.service dory-first-boot.service \
+ dory-boot.service dory-desktop-ready.service dory-zram.service \
+ dory-graphics-backend.service
chroot /rootfs systemctl set-default graphical.target
rm -f /rootfs/etc/machine-id /rootfs/var/lib/dbus/machine-id /rootfs/etc/ssh/ssh_host_*
@@ -208,7 +276,7 @@ EOF
resize2fs -M "$image"
block_count="$(dumpe2fs -h "$image" 2>/dev/null | awk "/^Block count:/{print \$3}")"
block_size="$(dumpe2fs -h "$image" 2>/dev/null | awk "/^Block size:/{print \$3}")"
- extra_blocks="$((512 * 1024 * 1024 / block_size))"
+ extra_blocks="$((DORY_DESKTOP_FREE_SPACE_MB * 1024 * 1024 / block_size))"
final_blocks="$((block_count + extra_blocks))"
resize2fs "$image" "$final_blocks"
truncate -s "$((final_blocks * block_size))" "$image"
@@ -217,11 +285,21 @@ EOF
docker_cmd cp "$AGENT" "$CID:/tmp/dory-agent"
docker_cmd cp guest/desktop/rootfs-overlay "$CID:/tmp/rootfs-overlay"
+docker_cmd cp "$VENUS_RUNTIME" "$CID:/tmp/dory-mesa-venus-arm64.tar.zst"
docker_cmd start -a "$CID"
docker_cmd cp "$CID:/out/$ARTIFACT_PREFIX-rootfs-arm64.ext4.zst" "$STAGING/"
docker_cmd cp "$CID:/out/$ARTIFACT_PREFIX-packages-arm64.txt" "$STAGING/"
docker_cmd rm "$CID" >/dev/null
CID=""
+python3 guest/desktop/build-update-bundle.py \
+ --distro "$DISTRO" \
+ --fingerprint "$INPUT_FINGERPRINT" \
+ --packages "$STAGING/$ARTIFACT_PREFIX-packages-arm64.txt" \
+ --overlay guest/desktop/rootfs-overlay \
+ --agent "$AGENT" \
+ --venus-runtime "$VENUS_RUNTIME" \
+ --apply guest/desktop/apply-update.sh \
+ --output "$STAGING/$ARTIFACT_PREFIX-update-arm64.tar"
"$ZSTD_BIN" -q -d --sparse -f "$STAGING/$ARTIFACT_PREFIX-rootfs-arm64.ext4.zst" \
-o "$STAGING/$ARTIFACT_PREFIX-rootfs-arm64.ext4"
@@ -236,13 +314,15 @@ STAMP="$STAGING/$ARTIFACT_PREFIX-build-arm64.stamp"
printf 'image_sha256=%s\n' "$(shasum -a 256 "$STAGING/$ARTIFACT_PREFIX-rootfs-arm64.ext4" | awk '{print $1}')"
printf 'compressed_sha256=%s\n' "$(shasum -a 256 "$STAGING/$ARTIFACT_PREFIX-rootfs-arm64.ext4.zst" | awk '{print $1}')"
printf 'packages_sha256=%s\n' "$(shasum -a 256 "$STAGING/$ARTIFACT_PREFIX-packages-arm64.txt" | awk '{print $1}')"
+ printf 'update_sha256=%s\n' "$(shasum -a 256 "$STAGING/$ARTIFACT_PREFIX-update-arm64.tar" | awk '{print $1}')"
} > "$STAMP"
DORY_DESKTOP_OUT_DIR="$STAGING" guest/desktop/verify-build.sh arm64 "$DISTRO"
for artifact in \
"$ARTIFACT_PREFIX-rootfs-arm64.ext4" \
"$ARTIFACT_PREFIX-rootfs-arm64.ext4.zst" \
- "$ARTIFACT_PREFIX-packages-arm64.txt"; do
+ "$ARTIFACT_PREFIX-packages-arm64.txt" \
+ "$ARTIFACT_PREFIX-update-arm64.tar"; do
mv -f "$STAGING/$artifact" "$OUT/$artifact"
done
mv -f "$STAMP" "$OUT/$ARTIFACT_PREFIX-build-arm64.stamp"
diff --git a/guest/desktop/input-fingerprint.sh b/guest/desktop/input-fingerprint.sh
index 3db6061e..4e4a9edd 100755
--- a/guest/desktop/input-fingerprint.sh
+++ b/guest/desktop/input-fingerprint.sh
@@ -35,6 +35,8 @@ fi
INPUTS=(
guest/desktop/PINS
guest/desktop/build.sh
+ guest/desktop/apply-update.sh
+ guest/desktop/build-update-bundle.py
guest/desktop/input-fingerprint.sh
guest/desktop/verify-build.sh
dory-core/Cargo.lock
@@ -43,6 +45,9 @@ INPUTS=(
while IFS= read -r input; do
INPUTS+=("$input")
done < <(find guest/desktop/rootfs-overlay -type f | LC_ALL=C sort)
+while IFS= read -r input; do
+ INPUTS+=("$input")
+done < <(find guest/mesa -type f | LC_ALL=C sort)
for package in agent pb proto sync; do
while IFS= read -r input; do
INPUTS+=("$input")
@@ -58,8 +63,9 @@ for package in agent pb proto sync; do
done
{
- printf 'schema=2\narch=arm64\ndistro=%s\ntarget=%s\nimage_size_mb=%s\nrustflags=%s\n' \
- "$DISTRO" "$TARGET" "${DORY_DESKTOP_IMAGE_SIZE_MB:-$DESKTOP_IMAGE_SIZE_MB}" "$RUSTFLAGS_EFFECTIVE"
+ printf 'schema=2\narch=arm64\ndistro=%s\ntarget=%s\nimage_size_mb=%s\nfree_space_mb=%s\nrustflags=%s\n' \
+ "$DISTRO" "$TARGET" "${DORY_DESKTOP_IMAGE_SIZE_MB:-$DESKTOP_IMAGE_SIZE_MB}" \
+ "${DORY_DESKTOP_FREE_SPACE_MB:-$DESKTOP_FREE_SPACE_MB}" "$RUSTFLAGS_EFFECTIVE"
rustc -Vv
cargo -V
printf 'linker_sha256=%s\n' "$(shasum -a 256 "$LINKER" | awk '{print $1}')"
diff --git a/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-dory.conf b/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-dory.conf
index f2aa7d9c..4b2c863a 100644
--- a/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-dory.conf
+++ b/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-dory.conf
@@ -1,5 +1,12 @@
[main]
plugins=keyfile
+dns=default
+rc-manager=file
+
+[keyfile]
+# Ubuntu's NetworkManager package otherwise marks every Ethernet device unmanaged unless Netplan
+# generated a policy. Dory owns one virtio NIC and supplies its connection profile directly.
+unmanaged-devices=
[device]
wifi.scan-rand-mac-address=no
diff --git a/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-globally-managed-devices.conf b/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-globally-managed-devices.conf
new file mode 100644
index 00000000..9e392173
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/NetworkManager/conf.d/10-globally-managed-devices.conf
@@ -0,0 +1,4 @@
+# Shadow Ubuntu's vendor policy, which excludes Ethernet devices unless Netplan generated a
+# machine-specific override. Dory's single virtio NIC is managed by the connection profile below.
+[keyfile]
+unmanaged-devices=
diff --git a/guest/desktop/rootfs-overlay/etc/NetworkManager/system-connections/dory-wired.nmconnection b/guest/desktop/rootfs-overlay/etc/NetworkManager/system-connections/dory-wired.nmconnection
new file mode 100644
index 00000000..9005d5d6
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/NetworkManager/system-connections/dory-wired.nmconnection
@@ -0,0 +1,16 @@
+[connection]
+id=Dory Wired
+type=ethernet
+autoconnect=true
+autoconnect-priority=100
+
+[ethernet]
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=auto
+addr-gen-mode=default
+
+[proxy]
diff --git a/guest/desktop/rootfs-overlay/etc/apt/keyrings/packages.mozilla.org.asc b/guest/desktop/rootfs-overlay/etc/apt/keyrings/packages.mozilla.org.asc
new file mode 100644
index 00000000..1e2a71ed
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/apt/keyrings/packages.mozilla.org.asc
@@ -0,0 +1,19 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsBNBGCRt7MBCADkYJHHQQoL6tKrW/LbmfR9ljz7ib2aWno4JO3VKQvLwjyUMPpq
+/SXXMOnx8jXwgWizpPxQYDRJ0SQXS9ULJ1hXRL/OgMnZAYvYDeV2jBnKsAIEdiG/
+e1qm8P4W9qpWJc+hNq7FOT13RzGWRx57SdLWSXo0KeY38r9lvjjOmT/cuOcmjwlD
+T9XYf/RSO+yJ/AsyMdAr+ZbDeQUd9HYJiPdI04lGaGM02MjDMnx+monc+y54t+Z+
+ry1WtQdzoQt9dHlIPlV1tR+xV5DHHsejCZxu9TWzzSlL5wfBBeEz7R/OIzivGJpW
+QdJzd+2QDXSRg9q2XYWP5ZVtSgjVVJjNlb6ZABEBAAHNVEFydGlmYWN0IFJlZ2lz
+dHJ5IFJlcG9zaXRvcnkgU2lnbmVyIDxhcnRpZmFjdC1yZWdpc3RyeS1yZXBvc2l0
+b3J5LXNpZ25lckBnb29nbGUuY29tPsLAjgQTAQoAOBYhBDW6oLM+nrOW9ZyoOMC6
+XObcYxWjBQJgkbezAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEMC6XObc
+YxWj+igIAMFh6DrAYMeq9sbZ1ZG6oAMrinUheGQbEqe76nIDQNsZnhDwZ2wWqgVC
+7DgOMqlhQmOmzm7M6Nzmq2dvPwq3xC2OeI9fQyzjT72deBTzLP7PJok9PJFOMdLf
+ILSsUnmMsheQt4DUO0jYAX2KUuWOIXXJaZ319QyoRNBPYa5qz7qXS7wHLOY89IDq
+fHt6Aud8ER5zhyOyhytcYMeaGC1g1IKWmgewnhEq02FantMJGlmmFi2eA0EPD02G
+C3742QGqRxLwjWsm5/TpyuU24EYKRGCRm7QdVIo3ugFSetKrn0byOxWGBvtu4fH8
+XWvZkRT+u+yzH1s5yFYBqc2JTrrJvRU=
+=QnvN
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/guest/desktop/rootfs-overlay/etc/apt/preferences.d/mozilla b/guest/desktop/rootfs-overlay/etc/apt/preferences.d/mozilla
new file mode 100644
index 00000000..ae0a1f9e
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/apt/preferences.d/mozilla
@@ -0,0 +1,3 @@
+Package: *
+Pin: origin packages.mozilla.org
+Pin-Priority: 1000
diff --git a/guest/desktop/rootfs-overlay/etc/apt/sources.list.d/mozilla.list b/guest/desktop/rootfs-overlay/etc/apt/sources.list.d/mozilla.list
new file mode 100644
index 00000000..bee9edb4
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/apt/sources.list.d/mozilla.list
@@ -0,0 +1 @@
+deb [signed-by=/etc/apt/keyrings/packages.mozilla.org.asc] https://packages.mozilla.org/apt mozilla main
diff --git a/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/00-managed-session b/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/00-managed-session
new file mode 100644
index 00000000..41d2943f
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/00-managed-session
@@ -0,0 +1,11 @@
+# Dory's managed desktop account is password-locked and enters through display-manager autologin.
+# A conventional screen lock would therefore present a password prompt that can never succeed.
+[org/gnome/desktop/screensaver]
+lock-enabled=false
+
+[org/gnome/desktop/session]
+idle-delay=uint32 0
+
+[org/gnome/settings-daemon/plugins/power]
+sleep-inactive-ac-type='nothing'
+sleep-inactive-battery-type='nothing'
diff --git a/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/locks/00-managed-session b/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/locks/00-managed-session
new file mode 100644
index 00000000..01888f6c
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/dconf/db/dory.d/locks/00-managed-session
@@ -0,0 +1,4 @@
+/org/gnome/desktop/screensaver/lock-enabled
+/org/gnome/desktop/session/idle-delay
+/org/gnome/settings-daemon/plugins/power/sleep-inactive-ac-type
+/org/gnome/settings-daemon/plugins/power/sleep-inactive-battery-type
diff --git a/guest/desktop/rootfs-overlay/etc/dconf/profile/user b/guest/desktop/rootfs-overlay/etc/dconf/profile/user
new file mode 100644
index 00000000..f7a0336c
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/dconf/profile/user
@@ -0,0 +1,2 @@
+user-db:user
+system-db:dory
diff --git a/guest/desktop/rootfs-overlay/etc/environment.d/60-dory-desktop.conf b/guest/desktop/rootfs-overlay/etc/environment.d/60-dory-desktop.conf
new file mode 100644
index 00000000..4266f528
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/environment.d/60-dory-desktop.conf
@@ -0,0 +1,3 @@
+# Firefox's native Wayland surface can remain alive without ever mapping a usable window on the
+# raw accelerated desktop. XWayland presents the same SWGL-rendered browser reliably.
+MOZ_ENABLE_WAYLAND=0
diff --git a/guest/desktop/rootfs-overlay/etc/gdm3/custom.conf b/guest/desktop/rootfs-overlay/etc/gdm3/custom.conf
new file mode 100644
index 00000000..3b5e9658
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/gdm3/custom.conf
@@ -0,0 +1,15 @@
+[daemon]
+AutomaticLoginEnable=true
+AutomaticLogin=dory
+# Native Wayland clients can remain alive without reaching Dory's raw-HV scanout. Ubuntu's stock
+# Xorg session keeps every application on the accelerated VirGL path that Dory presents reliably.
+WaylandEnable=false
+DefaultSession=ubuntu-xorg.desktop
+
+[security]
+
+[xdmcp]
+
+[chooser]
+
+[debug]
diff --git a/guest/desktop/rootfs-overlay/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules b/guest/desktop/rootfs-overlay/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules
new file mode 100644
index 00000000..4ff47427
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/polkit-1/rules.d/49-dory-passwordless-admin.rules
@@ -0,0 +1,8 @@
+// Dory's managed desktop account is deliberately password-locked and receives passwordless sudo.
+// Let the same active, local administrator complete graphical settings and package-management
+// actions; otherwise polkit presents a password dialog that can never succeed.
+polkit.addRule(function(action, subject) {
+ if (subject.local && subject.active && subject.isInGroup("sudo")) {
+ return polkit.Result.YES;
+ }
+});
diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-boot.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-boot.service
index 1d0ac26d..64446885 100644
--- a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-boot.service
+++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-boot.service
@@ -1,7 +1,7 @@
[Unit]
Description=Dory guest integration agent
After=dory-first-boot.service
-Before=lightdm.service
+Before=display-manager.service
Requires=dory-first-boot.service
[Service]
diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-desktop-ready.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-desktop-ready.service
index 25765c89..95244e17 100644
--- a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-desktop-ready.service
+++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-desktop-ready.service
@@ -1,7 +1,7 @@
[Unit]
Description=Wait for Dory host desktop configuration
After=dory-boot.service
-Before=lightdm.service
+Before=display-manager.service
Requires=dory-boot.service
[Service]
diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-first-boot.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-first-boot.service
index 8d9809e6..fc3f85cd 100644
--- a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-first-boot.service
+++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-first-boot.service
@@ -1,7 +1,7 @@
[Unit]
Description=Prepare the Dory desktop machine
After=local-fs.target systemd-remount-fs.service
-Before=NetworkManager.service lightdm.service ssh.service dory-boot.service
+Before=NetworkManager.service display-manager.service ssh.service dory-boot.service
ConditionPathExists=!/var/lib/dory/first-boot-complete
[Service]
diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-graphics-backend.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-graphics-backend.service
new file mode 100644
index 00000000..b4ac7c73
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-graphics-backend.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Select Dory desktop graphics backend
+After=local-fs.target systemd-remount-fs.service
+Before=display-manager.service dory-boot.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/lib/dory/configure-graphics-backend
+RemainAfterExit=yes
+
+[Install]
+WantedBy=graphical.target
diff --git a/guest/desktop/rootfs-overlay/etc/systemd/system/dory-zram.service b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-zram.service
new file mode 100644
index 00000000..2224f2e6
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/systemd/system/dory-zram.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Dory compressed desktop swap
+After=systemd-modules-load.service
+Before=display-manager.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/lib/dory/configure-zram
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/guest/desktop/rootfs-overlay/etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua b/guest/desktop/rootfs-overlay/etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua
new file mode 100644
index 00000000..9ea44ad2
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua
@@ -0,0 +1,56 @@
+-- Dory's virtio-snd device intentionally has no legacy analog mixer or UCM description. ACP
+-- therefore exposes only its generic Pro Audio profile, which WirePlumber 0.4 will not select
+-- automatically. Activate that profile so both the speaker and microphone nodes are present from
+-- the beginning of every desktop session.
+table.insert(alsa_monitor.rules, {
+ matches = {
+ {
+ { "device.name", "matches", "alsa_card.platform-*.virtio_mmio" },
+ { "device.nick", "matches", "VirtIO SoundCard" },
+ },
+ },
+ apply_properties = {
+ ["device.profile"] = "pro-audio",
+ ["device.description"] = "Dory Audio",
+ ["device.nick"] = "Dory Audio",
+ ["api.alsa.use-acp"] = true,
+ ["api.alsa.use-ucm"] = false,
+ ["api.alsa.soft-mixer"] = true,
+ ["api.alsa.ignore-dB"] = true,
+ ["api.acp.auto-profile"] = false,
+ ["api.acp.auto-port"] = false,
+ },
+})
+
+-- Pro Audio normally puts every PCM direction in one start/stop group. Dory maps speaker and
+-- microphone onto independent Mac audio graphs, so group them independently as well: opening
+-- Firefox must not start an unused microphone, and recording must not keep an unused speaker
+-- running. Expose conventional stereo channel names instead of the generic AUX labels produced
+-- without a UCM profile.
+table.insert(alsa_monitor.rules, {
+ matches = {
+ {
+ { "node.name", "matches", "alsa_output.platform-*.virtio_mmio.pro-output-*" },
+ },
+ },
+ apply_properties = {
+ ["node.pause-on-idle"] = true,
+ ["node.group"] = "dory-playback",
+ ["node.link-group"] = "dory-playback",
+ ["audio.position"] = "FL,FR",
+ },
+})
+
+table.insert(alsa_monitor.rules, {
+ matches = {
+ {
+ { "node.name", "matches", "alsa_input.platform-*.virtio_mmio.pro-input-*" },
+ },
+ },
+ apply_properties = {
+ ["node.pause-on-idle"] = true,
+ ["node.group"] = "dory-capture",
+ ["node.link-group"] = "dory-capture",
+ ["audio.position"] = "FL,FR",
+ },
+})
diff --git a/guest/desktop/rootfs-overlay/etc/xdg/autostart/dory-display.desktop b/guest/desktop/rootfs-overlay/etc/xdg/autostart/dory-display.desktop
index adf1a06e..7e8e52d5 100644
--- a/guest/desktop/rootfs-overlay/etc/xdg/autostart/dory-display.desktop
+++ b/guest/desktop/rootfs-overlay/etc/xdg/autostart/dory-display.desktop
@@ -2,6 +2,6 @@
Type=Application
Name=Dory Retina Display
Exec=/usr/lib/dory/configure-display
-OnlyShowIn=XFCE;
+OnlyShowIn=XFCE;GNOME;Unity;
NoDisplay=true
X-GNOME-Autostart-enabled=true
diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/clipboard b/guest/desktop/rootfs-overlay/usr/lib/dory/clipboard
new file mode 100644
index 00000000..f05f35ca
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/usr/lib/dory/clipboard
@@ -0,0 +1,59 @@
+#!/bin/bash
+set -euo pipefail
+
+operation="${1:-}"
+mime="${2:-text/plain;charset=utf-8}"
+case "$operation" in
+ get|set|types) ;;
+ *) echo "usage: dory-clipboard get|set|types [mime-type]" >&2; exit 64 ;;
+esac
+case "$mime" in
+ text/plain|text/plain\;charset=utf-8|image/png) ;;
+ *) echo "unsupported clipboard type: $mime" >&2; exit 64 ;;
+esac
+
+user="$(cat /var/lib/dory/username 2>/dev/null || printf 'dory\n')"
+uid="$(id -u "$user")"
+home="$(getent passwd "$user" | cut -d: -f6)"
+runtime="/run/user/$uid"
+
+run_as_user() {
+ runuser -u "$user" -- env \
+ HOME="$home" \
+ USER="$user" \
+ LOGNAME="$user" \
+ XDG_RUNTIME_DIR="$runtime" \
+ DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime/bus" \
+ "$@"
+}
+
+wayland_socket=""
+for candidate in "$runtime"/wayland-*; do
+ [ -S "$candidate" ] || continue
+ wayland_socket="${candidate##*/}"
+ break
+done
+
+if [ -n "$wayland_socket" ] && command -v wl-copy >/dev/null 2>&1; then
+ case "$operation" in
+ set) run_as_user env WAYLAND_DISPLAY="$wayland_socket" wl-copy --type "$mime" ;;
+ get) run_as_user env WAYLAND_DISPLAY="$wayland_socket" wl-paste --no-newline --type "$mime" ;;
+ types) run_as_user env WAYLAND_DISPLAY="$wayland_socket" wl-paste --list-types ;;
+ esac
+ exit $?
+fi
+
+display="${DISPLAY:-:0}"
+xauthority="$home/.Xauthority"
+[ -f "$xauthority" ] || xauthority=""
+x11_env=(DISPLAY="$display")
+[ -z "$xauthority" ] || x11_env+=(XAUTHORITY="$xauthority")
+x11_target="$mime"
+case "$mime" in
+ text/plain|text/plain\;charset=utf-8) x11_target="UTF8_STRING" ;;
+esac
+case "$operation" in
+ set) run_as_user env "${x11_env[@]}" xclip -selection clipboard -in -target "$x11_target" ;;
+ get) run_as_user env "${x11_env[@]}" xclip -selection clipboard -out -target "$x11_target" ;;
+ types) run_as_user env "${x11_env[@]}" xclip -selection clipboard -out -target TARGETS ;;
+esac
diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display
index 73862de3..c970b485 100644
--- a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display
+++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-display
@@ -10,6 +10,54 @@ set_xfce_value() {
fi
}
+configure_gnome() {
+ # GNOME autostart begins before Ubuntu's shell has finished loading its vendor defaults. Wait
+ # until the shell is reachable so those defaults cannot overwrite Dory's display and launcher
+ # corrections a moment later.
+ if command -v gdbus >/dev/null 2>&1; then
+ for _ in $(seq 1 100); do
+ if gdbus call --session \
+ --dest org.gnome.Shell \
+ --object-path /org/gnome/Shell \
+ --method org.freedesktop.DBus.Properties.Get \
+ org.gnome.Shell ShellVersion >/dev/null 2>&1; then
+ sleep 2
+ break
+ fi
+ sleep 0.1
+ done
+ fi
+
+ gsettings set org.gnome.desktop.interface scaling-factor 2
+ gsettings set org.gnome.desktop.interface text-scaling-factor 1.0
+ gsettings set org.gnome.desktop.interface cursor-size 32
+ # Software scanout benefits from fewer compositor effects. Both qualified accelerated modes can
+ # retain Ubuntu's normal motion without coupling the desktop policy to a particular renderer.
+ if [ "$(cat /run/dory/graphics-backend 2>/dev/null || printf software)" != software ]; then
+ gsettings set org.gnome.desktop.interface enable-animations true
+ else
+ gsettings set org.gnome.desktop.interface enable-animations false
+ fi
+ gsettings set org.gnome.desktop.wm.preferences button-layout 'appmenu:minimize,maximize,close'
+ # ubuntu-desktop's stock schema assumes the Snap desktop ID. Dory installs Mozilla's signed APT
+ # build instead, so repair an existing profile without discarding any favorites the user added.
+ local favorites
+ favorites="$(gsettings get org.gnome.shell favorite-apps)"
+ if [[ "$favorites" == *firefox_firefox.desktop* ]]; then
+ favorites="${favorites//firefox_firefox.desktop/firefox.desktop}"
+ fi
+ if [[ "$favorites" == *gnome-control-center.desktop* ]]; then
+ favorites="${favorites//gnome-control-center.desktop/org.gnome.Settings.desktop}"
+ fi
+ if [[ "$favorites" != *firefox.desktop* || "$favorites" != *org.gnome.Settings.desktop* ]]; then
+ gsettings set org.gnome.shell favorite-apps \
+ "['org.gnome.Nautilus.desktop', 'org.gnome.Terminal.desktop', 'firefox.desktop', 'org.gnome.Settings.desktop']"
+ else
+ gsettings set org.gnome.shell favorite-apps "$favorites"
+ fi
+ xdg-settings set default-web-browser firefox.desktop >/dev/null 2>&1 || true
+}
+
follow_preferred_display_mode() {
local current_mode output_name preferred_mode snapshot
@@ -28,6 +76,13 @@ follow_preferred_display_mode() {
}
for _ in $(seq 1 100); do
+ if [[ "${XDG_CURRENT_DESKTOP:-}" == *GNOME* || "${XDG_CURRENT_DESKTOP:-}" == *ubuntu* ]] \
+ && command -v gsettings >/dev/null 2>&1 \
+ && gsettings writable org.gnome.desktop.interface scaling-factor 2>/dev/null \
+ | grep -qx true; then
+ configure_gnome
+ follow_preferred_display_mode
+ fi
if xfconf-query --channel xsettings --list >/dev/null 2>&1; then
set_xfce_value /Gdk/WindowScalingFactor int 2
set_xfce_value /Xft/Antialias int 1
diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend
new file mode 100644
index 00000000..f1bcc051
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend
@@ -0,0 +1,67 @@
+#!/bin/bash
+set -euo pipefail
+
+environment_file=/etc/environment.d/70-dory-graphics.conf
+session_environment=/etc/X11/Xsession.d/70dory-graphics
+graphics_status=/run/dory/graphics-status
+venus_root=/opt/dory/mesa
+venus_icd=$venus_root/share/vulkan/icd.d/virtio_icd.aarch64.json
+venus_probe=/usr/lib/dory/dory-vulkan-probe
+
+# The host publishes the backend it actually attached. Mesa selects its native driver from the
+# advertised virtio-gpu capsets; a global Zink override would incorrectly force the desktop itself
+# through Venus and is both slower and less compatible on MoltenVK.
+rm -f "$environment_file" "$session_environment"
+install -d -m0755 /run/dory
+case " $(cat /proc/cmdline) " in
+ *' dory.graphics=virgl-venus '*) backend=virgl2+venus ;;
+ *' dory.graphics=virgl '*) backend=virgl2 ;;
+ *' dory.graphics=software '*) backend=software ;;
+ # Compatibility with images and VMMs from before the versioned graphics contract.
+ *' dory.gpu=venus '*) backend=virgl2+venus ;;
+ *) backend=software ;;
+esac
+printf '%s\n' "$backend" > /run/dory/graphics-backend
+printf '%s\n' "$backend" > "$graphics_status"
+
+case "$backend" in
+ virgl2|virgl2+venus)
+ # GTK4's newer renderer corrupts large textures with the current VirGL/MoltenVK bridge. The
+ # established GL renderer uses the same accelerated virgl driver, but correctly renders
+ # libadwaita applications such as Files, Settings, and Calculator. Keep this backend-specific:
+ # the software display path should retain GTK's native fallback selection.
+ install -d -m0755 "$(dirname "$environment_file")"
+ printf '%s\n' \
+ '# Dory accelerated GTK4 compatibility policy; managed by configure-graphics-backend.' \
+ 'GSK_RENDERER=gl' > "$environment_file"
+ install -d -m0755 "$(dirname "$session_environment")"
+ printf '%s\n' \
+ '# Dory accelerated desktop environment; managed by configure-graphics-backend.' \
+ 'export GSK_RENDERER=gl' > "$session_environment"
+ ;;
+esac
+
+if [ "$backend" = virgl2+venus ]; then
+ if [ -x "$venus_probe" ] && [ -r "$venus_icd" ]; then
+ if probe_output="$(env \
+ LD_LIBRARY_PATH="$venus_root/lib" \
+ VK_DRIVER_FILES="$venus_icd" \
+ timeout 10 "$venus_probe" 2>&1)"; then
+ printf '%s\n' \
+ "LD_LIBRARY_PATH=$venus_root/lib" \
+ "VK_DRIVER_FILES=$venus_icd" >> "$environment_file"
+ printf '%s\n' \
+ "export LD_LIBRARY_PATH=$venus_root/lib" \
+ "export VK_DRIVER_FILES=$venus_icd" >> "$session_environment"
+ printf 'venus-ready: %s\n' "$probe_output" > "$graphics_status"
+ else
+ printf 'venus-unavailable: %s\n' "$probe_output" > "$graphics_status"
+ fi
+ else
+ printf 'venus-unavailable: Dory Venus runtime is missing\n' > "$graphics_status"
+ fi
+fi
+
+[ ! -e "$environment_file" ] || chmod 0644 "$environment_file"
+[ ! -e "$session_environment" ] || chmod 0644 "$session_environment"
+chmod 0644 "$graphics_status" /run/dory/graphics-backend
diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-machine b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-machine
index 8c0ef9c6..0347f459 100755
--- a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-machine
+++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-machine
@@ -63,6 +63,11 @@ fi
if [ "$requested" != "$current" ]; then
sed -i "s/^$current /$requested /" /etc/sudoers.d/dory
fi
-sed -i "s/^autologin-user=.*/autologin-user=$requested/" \
- /etc/lightdm/lightdm.conf.d/50-dory.conf
+if [ -f /etc/lightdm/lightdm.conf.d/50-dory.conf ]; then
+ sed -i "s/^autologin-user=.*/autologin-user=$requested/" \
+ /etc/lightdm/lightdm.conf.d/50-dory.conf
+fi
+if [ -f /etc/gdm3/custom.conf ]; then
+ sed -i "s/^AutomaticLogin=.*/AutomaticLogin=$requested/" /etc/gdm3/custom.conf
+fi
printf '%s\n' "$requested" > /var/lib/dory/username
diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/configure-zram b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-zram
new file mode 100644
index 00000000..c3093d59
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/usr/lib/dory/configure-zram
@@ -0,0 +1,42 @@
+#!/bin/bash
+set -euo pipefail
+
+# A desktop browser can create short-lived memory peaks well beyond its steady working set. Keep
+# those peaks compressed inside the guest rather than letting the kernel kill the browser or shell.
+if grep -q '^/dev/zram0 ' /proc/swaps 2>/dev/null; then
+ exit 0
+fi
+
+if [ ! -b /dev/zram0 ]; then
+ # Dory's appliance kernel links zram into the image rather than shipping a separate module tree.
+ # A built-in driver exposes zram-control directly, so `modprobe` correctly reports that there is
+ # no module file even though the driver is available.
+ if [ ! -e /sys/class/zram-control ]; then
+ modprobe zram num_devices=1
+ fi
+ if [ ! -b /dev/zram0 ] && [ -e /sys/class/zram-control/hot_add ]; then
+ cat /sys/class/zram-control/hot_add >/dev/null
+ fi
+fi
+udevadm settle 2>/dev/null || true
+for _ in $(seq 1 100); do
+ [ -b /dev/zram0 ] && break
+ sleep 0.05
+done
+[ -b /dev/zram0 ] || { echo 'zram device is unavailable' >&2; exit 1; }
+
+memory_bytes="$(awk '/^MemTotal:/ { print $2 * 1024; exit }' /proc/meminfo)"
+size_bytes="$((memory_bytes / 2))"
+maximum_bytes=$((2 * 1024 * 1024 * 1024))
+if [ "$size_bytes" -gt "$maximum_bytes" ]; then
+ size_bytes="$maximum_bytes"
+fi
+
+# `zramctl --reset` removes a dynamically hot-added device on current util-linux. Reset through
+# sysfs only when a previous, inactive configuration exists, then size the same device in place.
+if [ "$(cat /sys/block/zram0/disksize)" -ne 0 ]; then
+ printf '1\n' > /sys/block/zram0/reset
+fi
+printf '%s\n' "$size_bytes" > /sys/block/zram0/disksize
+mkswap --quiet /dev/zram0
+swapon --priority 100 /dev/zram0
diff --git a/guest/desktop/rootfs-overlay/usr/lib/dory/first-boot b/guest/desktop/rootfs-overlay/usr/lib/dory/first-boot
index 3df6c6ca..a04f78f2 100755
--- a/guest/desktop/rootfs-overlay/usr/lib/dory/first-boot
+++ b/guest/desktop/rootfs-overlay/usr/lib/dory/first-boot
@@ -10,5 +10,23 @@ if [ ! -s /etc/machine-id ]; then
fi
rm -f /etc/ssh/ssh_host_*
ssh-keygen -A
-resize2fs /dev/vda
+
+# Published component images already fill their virtual disk. Installed machines may extend that
+# disk before first boot; grow only in that case. An unconditional resize can be rounded down by
+# the kernel and rejected as an online shrink when the two capacities are already equal.
+device_bytes="$(blockdev --getsize64 /dev/vda)"
+filesystem_blocks="$(LC_ALL=C dumpe2fs -h /dev/vda 2>/dev/null \
+ | awk -F: '/^Block count:/{gsub(/[[:space:]]/, "", $2); print $2; exit}')"
+filesystem_block_size="$(LC_ALL=C dumpe2fs -h /dev/vda 2>/dev/null \
+ | awk -F: '/^Block size:/{gsub(/[[:space:]]/, "", $2); print $2; exit}')"
+case "$device_bytes:$filesystem_blocks:$filesystem_block_size" in
+ *[!0-9:]*|:*|*::*|*:) echo "could not determine root filesystem capacity" >&2; exit 1 ;;
+esac
+filesystem_bytes="$((filesystem_blocks * filesystem_block_size))"
+if [ "$device_bytes" -gt "$filesystem_bytes" ]; then
+ resize2fs /dev/vda
+elif [ "$device_bytes" -lt "$filesystem_bytes" ]; then
+ echo "root device is smaller than its filesystem ($device_bytes < $filesystem_bytes)" >&2
+ exit 1
+fi
touch /var/lib/dory/first-boot-complete
diff --git a/guest/desktop/rootfs-overlay/usr/lib/firefox/distribution/policies.json b/guest/desktop/rootfs-overlay/usr/lib/firefox/distribution/policies.json
new file mode 100644
index 00000000..23c54271
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/usr/lib/firefox/distribution/policies.json
@@ -0,0 +1,14 @@
+{
+ "policies": {
+ "Preferences": {
+ "gfx.webrender.software": {
+ "Value": true,
+ "Status": "locked"
+ },
+ "widget.dmabuf.enabled": {
+ "Value": false,
+ "Status": "locked"
+ }
+ }
+ }
+}
diff --git a/guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json b/guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json
new file mode 100644
index 00000000..23c54271
--- /dev/null
+++ b/guest/desktop/rootfs-overlay/usr/share/firefox-esr/distribution/policies.json
@@ -0,0 +1,14 @@
+{
+ "policies": {
+ "Preferences": {
+ "gfx.webrender.software": {
+ "Value": true,
+ "Status": "locked"
+ },
+ "widget.dmabuf.enabled": {
+ "Value": false,
+ "Status": "locked"
+ }
+ }
+ }
+}
diff --git a/guest/desktop/verify-build.sh b/guest/desktop/verify-build.sh
index b7491e67..1d29ff12 100755
--- a/guest/desktop/verify-build.sh
+++ b/guest/desktop/verify-build.sh
@@ -3,6 +3,8 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
+source guest/desktop/PINS
+source guest/mesa/PINS
case "${1:-arm64}" in
arm64|aarch64) ;;
@@ -19,14 +21,16 @@ OUT="${DORY_DESKTOP_OUT_DIR:-$ROOT/guest/out}"
IMAGE="$OUT/$ARTIFACT_PREFIX-rootfs-arm64.ext4"
COMPRESSED="$IMAGE.zst"
PACKAGES="$OUT/$ARTIFACT_PREFIX-packages-arm64.txt"
+UPDATE="$OUT/$ARTIFACT_PREFIX-update-arm64.tar"
STAMP="$OUT/$ARTIFACT_PREFIX-build-arm64.stamp"
+VENUS_RUNTIME="$ROOT/guest/out/dory-mesa-venus-arm64.tar.zst"
fail() {
echo "desktop image verification failed: $*" >&2
exit 1
}
-for path in "$IMAGE" "$COMPRESSED" "$PACKAGES" "$STAMP"; do
+for path in "$IMAGE" "$COMPRESSED" "$PACKAGES" "$UPDATE" "$STAMP"; do
[ -s "$path" ] || fail "missing or empty $path"
done
@@ -45,6 +49,35 @@ EXPECTED_INPUT="$(guest/desktop/input-fingerprint.sh arm64 "$DISTRO")"
|| fail "$COMPRESSED digest does not match its stamp"
[ "$(stamp_value packages_sha256)" = "$(shasum -a 256 "$PACKAGES" | awk '{print $1}')" ] \
|| fail "$PACKAGES digest does not match its stamp"
+[ "$(stamp_value update_sha256)" = "$(shasum -a 256 "$UPDATE" | awk '{print $1}')" ] \
+ || fail "$UPDATE digest does not match its stamp"
+
+UPDATE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dory-desktop-update-verify.XXXXXX")"
+trap 'rm -rf "$UPDATE_DIR"' EXIT
+tar -xf "$UPDATE" -C "$UPDATE_DIR"
+guest/mesa/verify-build.sh arm64 >/dev/null
+for path in apply.sh dory-agent dory-mesa-venus-arm64.tar.zst manifest.env packages.txt rootfs-overlay.tar SHA256SUMS; do
+ [ -s "$UPDATE_DIR/$path" ] || fail "$UPDATE is missing $path"
+done
+(cd "$UPDATE_DIR" && shasum -a 256 -c SHA256SUMS) \
+ || fail "$UPDATE payload digests do not match"
+grep -Fqx 'schema=2' "$UPDATE_DIR/manifest.env" || fail "$UPDATE has an unsupported schema"
+grep -Fqx 'arch=arm64' "$UPDATE_DIR/manifest.env" || fail "$UPDATE has the wrong architecture"
+grep -Fqx "distro=$DISTRO" "$UPDATE_DIR/manifest.env" || fail "$UPDATE has the wrong distribution"
+grep -Fqx "input_sha256=$EXPECTED_INPUT" "$UPDATE_DIR/manifest.env" \
+ || fail "$UPDATE has a stale input fingerprint"
+cmp -s "$PACKAGES" "$UPDATE_DIR/packages.txt" || fail "$UPDATE package manifest is stale"
+cmp -s "$VENUS_RUNTIME" "$UPDATE_DIR/dory-mesa-venus-arm64.tar.zst" \
+ || fail "$UPDATE contains a stale Dory Venus runtime"
+grep -Fq 'packages.txt is the signed package provenance' "$UPDATE_DIR/apply.sh" \
+ || fail "$UPDATE does not keep package provenance separate from guest-tools installation"
+grep -Fq 'dory-mesa-venus-arm64.tar.zst' "$UPDATE_DIR/apply.sh" \
+ || fail "$UPDATE does not install the Dory Venus runtime"
+grep -Fq 'dconf update' "$UPDATE_DIR/apply.sh" \
+ || fail "$UPDATE does not compile the managed desktop session policy"
+if grep -Eq 'apt-get|with-new-pkgs|xargs .*install' "$UPDATE_DIR/apply.sh"; then
+ fail "$UPDATE performs a network-dependent distribution package mutation"
+fi
DEBUGFS="${DORY_DEBUGFS:-}"
if [ -z "$DEBUGFS" ]; then
@@ -60,27 +93,103 @@ if [ -z "$DEBUGFS" ]; then
fi
[ -n "$DEBUGFS" ] || fail "debugfs is required"
+case "$DISTRO" in
+ ubuntu) BROWSER_POLICY_PATH=/usr/lib/firefox/distribution/policies.json ;;
+ *) BROWSER_POLICY_PATH=/usr/share/firefox-esr/distribution/policies.json ;;
+esac
+
for guest_path in \
/sbin/init \
/usr/bin/dory-agent \
+ /usr/lib/dory/clipboard \
/usr/lib/dory/configure-machine \
/usr/lib/dory/configure-display \
+ /usr/lib/dory/configure-graphics-backend \
+ /usr/lib/dory/dory-vulkan-probe \
+ /usr/lib/dory/configure-zram \
/usr/lib/dory/first-boot \
/usr/lib/dory/start-agent \
/usr/lib/dory/wait-host-configuration \
/etc/systemd/system/dory-first-boot.service \
/etc/systemd/system/dory-boot.service \
/etc/systemd/system/dory-desktop-ready.service \
- /etc/lightdm/lightdm.conf.d/50-dory.conf \
+ /etc/systemd/system/dory-graphics-backend.service \
+ /etc/systemd/system/dory-zram.service \
+ /etc/NetworkManager/conf.d/10-dory.conf \
+ /etc/NetworkManager/conf.d/10-globally-managed-devices.conf \
+ /etc/NetworkManager/system-connections/dory-wired.nmconnection \
+ /etc/dconf/profile/user \
+ /etc/dconf/db/dory.d/00-managed-session \
+ /etc/dconf/db/dory.d/locks/00-managed-session \
+ /etc/dconf/db/dory \
+ /etc/polkit-1/rules.d/49-dory-passwordless-admin.rules \
+ /etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua \
/etc/xdg/autostart/dory-display.desktop \
+ "$BROWSER_POLICY_PATH" \
/home/dory/.profile \
- /usr/bin/startxfce4 \
/usr/bin/spice-vdagent \
/usr/bin/pipewire; do
"$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \
|| fail "$IMAGE is missing $guest_path"
done
+for guest_path in \
+ /opt/dory/mesa/lib/libvulkan_virtio.so \
+ /opt/dory/mesa/lib/libxcb-keysyms.so.1.0.0 \
+ /opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json \
+ /opt/dory/mesa/share/dory/runtime.env; do
+ "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \
+ || fail "$IMAGE is missing $guest_path"
+done
+
+VENUS_ICD="$($DEBUGFS -R \
+ 'cat /opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json' "$IMAGE" 2>/dev/null)"
+grep -Fq '"library_path": "/opt/dory/mesa/lib/libvulkan_virtio.so"' <<<"$VENUS_ICD" \
+ || fail "the desktop image Venus ICD does not select Dory's isolated library"
+VENUS_MANIFEST="$($DEBUGFS -R \
+ 'cat /opt/dory/mesa/share/dory/runtime.env' "$IMAGE" 2>/dev/null)"
+grep -Fqx "mesa_version=$MESA_VERSION" <<<"$VENUS_MANIFEST" \
+ || fail "the desktop image contains the wrong Mesa Venus version"
+grep -Fqx "mesa_source_sha256=$MESA_SOURCE_SHA256" <<<"$VENUS_MANIFEST" \
+ || fail "the desktop image contains an unpinned Mesa Venus source"
+
+CLIPBOARD_HELPER="$($DEBUGFS -R 'cat /usr/lib/dory/clipboard' "$IMAGE" 2>/dev/null)"
+grep -Fq 'wl-copy --type "$mime"' <<<"$CLIPBOARD_HELPER" \
+ || fail "clipboard helper does not provide Wayland writes"
+grep -Fq 'xclip -selection clipboard -out' <<<"$CLIPBOARD_HELPER" \
+ || fail "clipboard helper does not provide X11 reads"
+
+WIREPLUMBER_SOUND_RULE="$($DEBUGFS -R \
+ 'cat /etc/wireplumber/main.lua.d/60-dory-virtio-sound.lua' "$IMAGE" 2>/dev/null)"
+grep -Fq '["device.profile"] = "pro-audio"' <<<"$WIREPLUMBER_SOUND_RULE" \
+ || fail "WirePlumber does not activate Dory's virtio sound profile"
+grep -Fq 'alsa_card.platform-*.virtio_mmio' <<<"$WIREPLUMBER_SOUND_RULE" \
+ || fail "WirePlumber sound policy is not scoped to the virtio card"
+grep -Fq 'alsa_output.platform-*.virtio_mmio.pro-output-*' <<<"$WIREPLUMBER_SOUND_RULE" \
+ || fail "WirePlumber does not configure Dory playback"
+grep -Fq 'alsa_input.platform-*.virtio_mmio.pro-input-*' <<<"$WIREPLUMBER_SOUND_RULE" \
+ || fail "WirePlumber does not configure Dory capture"
+[ "$(grep -Fc '["node.pause-on-idle"] = true' <<<"$WIREPLUMBER_SOUND_RULE")" -eq 2 ] \
+ || fail "WirePlumber does not suspend both idle Dory audio directions"
+grep -Fq '["node.group"] = "dory-playback"' <<<"$WIREPLUMBER_SOUND_RULE" \
+ || fail "WirePlumber does not isolate Dory playback lifecycle"
+grep -Fq '["node.group"] = "dory-capture"' <<<"$WIREPLUMBER_SOUND_RULE" \
+ || fail "WirePlumber does not isolate Dory capture lifecycle"
+[ "$(grep -Fc '["audio.position"] = "FL,FR"' <<<"$WIREPLUMBER_SOUND_RULE")" -eq 2 ] \
+ || fail "WirePlumber does not expose conventional stereo channel positions"
+
+BROWSER_POLICY="$($DEBUGFS -R "cat $BROWSER_POLICY_PATH" "$IMAGE" 2>/dev/null)"
+ grep -Fq '"gfx.webrender.software"' <<<"$BROWSER_POLICY" \
+ || fail "$BROWSER_POLICY_PATH does not select Firefox SWGL"
+ grep -Fq '"widget.dmabuf.enabled"' <<<"$BROWSER_POLICY" \
+ || fail "$BROWSER_POLICY_PATH does not disable Firefox DMA-BUF presentation"
+ [ "$(grep -Fc '"Value": true' <<<"$BROWSER_POLICY")" -eq 1 ] \
+ || fail "$BROWSER_POLICY_PATH must enable exactly the SWGL preference"
+ [ "$(grep -Fc '"Value": false' <<<"$BROWSER_POLICY")" -eq 1 ] \
+ || fail "$BROWSER_POLICY_PATH must disable exactly the DMA-BUF preference"
+ [ "$(grep -Fc '"Status": "locked"' <<<"$BROWSER_POLICY")" -eq 2 ] \
+ || fail "$BROWSER_POLICY_PATH preferences are not locked"
+
for user_owned_path in /home/dory /home/dory/.profile; do
"$DEBUGFS" -R "stat $user_owned_path" "$IMAGE" 2>/dev/null \
| grep -Eq 'User:[[:space:]]+1000[[:space:]]+Group:[[:space:]]+1000' \
@@ -90,11 +199,15 @@ done
for root_owned_path in \
/usr/lib/dory/configure-machine \
/usr/lib/dory/configure-display \
+ /usr/lib/dory/configure-graphics-backend \
+ /usr/lib/dory/configure-zram \
/usr/lib/dory/first-boot \
/usr/lib/dory/start-agent \
/usr/lib/dory/wait-host-configuration \
/etc/systemd/system/dory-boot.service \
- /etc/systemd/system/dory-desktop-ready.service; do
+ /etc/systemd/system/dory-desktop-ready.service \
+ /etc/systemd/system/dory-graphics-backend.service \
+ /etc/systemd/system/dory-zram.service; do
"$DEBUGFS" -R "stat $root_owned_path" "$IMAGE" 2>/dev/null \
| grep -Eq 'User:[[:space:]]+0[[:space:]]+Group:[[:space:]]+0' \
|| fail "$root_owned_path is not owned by root in $IMAGE"
@@ -108,6 +221,15 @@ case "$DISTRO" in
grep -Fqx 'ID=debian' <<<"$OS_RELEASE" || fail "$IMAGE is not Debian"
DEBIAN_VERSION="$($DEBUGFS -R 'cat /etc/debian_version' "$IMAGE" 2>/dev/null | tr -d '\r\n')"
case "$DEBIAN_VERSION" in 13.*) ;; *) fail "$IMAGE contains unexpected Debian version $DEBIAN_VERSION" ;; esac
+ for guest_path in \
+ /etc/lightdm/lightdm.conf.d/50-dory.conf \
+ /usr/bin/startxfce4 \
+ /usr/bin/firefox-esr \
+ /usr/bin/evince \
+ /usr/bin/galculator; do
+ "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \
+ || fail "$IMAGE is missing $guest_path"
+ done
;;
ubuntu)
grep -Fqx 'ID=ubuntu' <<<"$OS_RELEASE" || fail "$IMAGE is not Ubuntu"
@@ -115,9 +237,22 @@ case "$DISTRO" in
"$DEBUGFS" -R 'cat /etc/apt/sources.list.d/ubuntu.sources' "$IMAGE" 2>/dev/null \
| grep -Fqx 'URIs: https://ports.ubuntu.com/ubuntu-ports' \
|| fail "$IMAGE does not use the official Ubuntu HTTPS repository"
+ for guest_path in \
+ /etc/gdm3/custom.conf \
+ /usr/bin/gnome-shell \
+ /usr/bin/gnome-terminal \
+ /usr/bin/firefox \
+ /usr/share/xsessions/ubuntu.desktop; do
+ "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \
+ || fail "$IMAGE is missing $guest_path"
+ done
;;
kali)
grep -Fqx 'ID=kali' <<<"$OS_RELEASE" || fail "$IMAGE is not Kali Linux"
+ for guest_path in /etc/lightdm/lightdm.conf.d/50-dory.conf /usr/bin/startxfce4; do
+ "$DEBUGFS" -R "stat $guest_path" "$IMAGE" 2>&1 | grep -Fq 'Inode:' \
+ || fail "$IMAGE is missing $guest_path"
+ done
"$DEBUGFS" -R 'stat /home/dory/.config/xfce4/panel' "$IMAGE" 2>&1 \
| grep -Fq 'Inode:' || fail "$IMAGE is missing the Kali Xfce user defaults"
"$DEBUGFS" -R 'cat /etc/apt/sources.list' "$IMAGE" 2>/dev/null \
@@ -127,17 +262,156 @@ case "$DISTRO" in
esac
"$DEBUGFS" -R 'cat /etc/ssh/sshd_config.d/50-dory.conf' "$IMAGE" 2>/dev/null \
| grep -Fqx 'PasswordAuthentication no' || fail "SSH password login is not disabled"
-"$DEBUGFS" -R 'cat /etc/lightdm/lightdm.conf.d/50-dory.conf' "$IMAGE" 2>/dev/null \
- | grep -Fqx 'autologin-user=dory' || fail "desktop autologin is not configured"
+case "$DISTRO" in
+ ubuntu)
+ "$DEBUGFS" -R 'cat /etc/gdm3/custom.conf' "$IMAGE" 2>/dev/null \
+ | grep -Fqx 'AutomaticLogin=dory' || fail "Ubuntu GNOME autologin is not configured"
+ "$DEBUGFS" -R 'cat /etc/gdm3/custom.conf' "$IMAGE" 2>/dev/null \
+ | grep -Fqx 'WaylandEnable=false' || fail "Ubuntu GNOME does not select accelerated Xorg"
+ "$DEBUGFS" -R 'cat /etc/gdm3/custom.conf' "$IMAGE" 2>/dev/null \
+ | grep -Fqx 'DefaultSession=ubuntu-xorg.desktop' \
+ || fail "Ubuntu GNOME does not default to Ubuntu on Xorg"
+ DCONF_SESSION="$($DEBUGFS -R \
+ 'cat /etc/dconf/db/dory.d/00-managed-session' "$IMAGE" 2>/dev/null)"
+ grep -Fqx 'lock-enabled=false' <<<"$DCONF_SESSION" \
+ || fail "Ubuntu GNOME can lock its passwordless managed account"
+ grep -Fqx 'idle-delay=uint32 0' <<<"$DCONF_SESSION" \
+ || fail "Ubuntu GNOME can idle into an impossible password prompt"
+ DCONF_LOCKS="$($DEBUGFS -R \
+ 'cat /etc/dconf/db/dory.d/locks/00-managed-session' "$IMAGE" 2>/dev/null)"
+ grep -Fqx '/org/gnome/desktop/screensaver/lock-enabled' <<<"$DCONF_LOCKS" \
+ || fail "Ubuntu GNOME screen-lock policy is not immutable"
+ ;;
+ *)
+ "$DEBUGFS" -R 'cat /etc/lightdm/lightdm.conf.d/50-dory.conf' "$IMAGE" 2>/dev/null \
+ | grep -Fqx 'autologin-user=dory' || fail "Xfce autologin is not configured"
+ ;;
+esac
+"$DEBUGFS" -R 'cat /etc/NetworkManager/conf.d/10-globally-managed-devices.conf' "$IMAGE" 2>/dev/null \
+ | grep -Fqx 'unmanaged-devices=' || fail "virtio Ethernet is not opted into NetworkManager"
+"$DEBUGFS" -R 'cat /etc/NetworkManager/system-connections/dory-wired.nmconnection' "$IMAGE" 2>/dev/null \
+ | grep -Fqx 'type=ethernet' || fail "the Dory wired connection is not an Ethernet profile"
+if "$DEBUGFS" -R 'cat /etc/NetworkManager/system-connections/dory-wired.nmconnection' "$IMAGE" 2>/dev/null \
+ | grep -q '^interface-name='; then
+ fail "the Dory wired connection is pinned to a distro-specific interface name"
+fi
+"$DEBUGFS" -R 'stat /etc/NetworkManager/system-connections/dory-wired.nmconnection' "$IMAGE" 2>/dev/null \
+ | grep -Eq 'Mode:[[:space:]]+0600' || fail "the Dory wired connection is not private"
+"$DEBUGFS" -R 'cat /etc/NetworkManager/conf.d/10-dory.conf' "$IMAGE" 2>/dev/null \
+ | grep -Fqx 'rc-manager=file' || fail "NetworkManager does not own the guest resolver file"
+RESOLV_LINK="$($DEBUGFS -R 'stat /etc/resolv.conf' "$IMAGE" 2>/dev/null \
+ | sed -n 's/^Fast link dest: "\(.*\)"$/\1/p')"
+[ "$RESOLV_LINK" = '../run/NetworkManager/resolv.conf' ] \
+ || fail "resolv.conf does not follow NetworkManager: $RESOLV_LINK"
DISPLAY_CONFIGURATION="$($DEBUGFS -R 'cat /usr/lib/dory/configure-display' "$IMAGE" 2>/dev/null)"
-grep -Fq 'set_xfce_value /Gdk/WindowScalingFactor int 2' <<<"$DISPLAY_CONFIGURATION" \
- || fail "Retina desktop scaling is not configured"
grep -Fq 'xrandr --output "$output_name" --mode "$preferred_mode"' <<<"$DISPLAY_CONFIGURATION" \
|| fail "dynamic desktop resizing is not configured"
-grep -q $'^xfce4\t' "$PACKAGES" || fail "Xfce package provenance is missing"
-grep -q $'^lightdm\t' "$PACKAGES" || fail "LightDM package provenance is missing"
+case "$DISTRO" in
+ ubuntu)
+ grep -Fq 'gsettings set org.gnome.desktop.interface scaling-factor 2' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "GNOME Retina scaling is not configured"
+ grep -Fq '/run/dory/graphics-backend' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "GNOME does not select effects based on the active graphics backend"
+ grep -Fq 'gsettings set org.gnome.desktop.interface enable-animations true' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "GNOME accelerated animations are not configured"
+ grep -Fq 'gsettings set org.gnome.desktop.interface enable-animations false' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "GNOME software-rendering fallback mode is not configured"
+ grep -Fq 'xdg-settings set default-web-browser firefox.desktop' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "Firefox is not configured as the default Ubuntu browser"
+ grep -Fq 'firefox_firefox.desktop/firefox.desktop' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "GNOME does not repair Ubuntu's stale Snap Firefox favorite"
+ grep -Fq 'gnome-control-center.desktop/org.gnome.Settings.desktop' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "GNOME does not repair Ubuntu's stale Settings favorite"
+ grep -Fq 'org.gnome.Settings.desktop' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "GNOME does not install Ubuntu's real Settings favorite"
+ UBUNTU_SCHEMA_OVERRIDE="$($DEBUGFS -R \
+ 'cat /usr/share/glib-2.0/schemas/10_ubuntu-settings.gschema.override' \
+ "$IMAGE" 2>/dev/null)"
+ grep -Fq "'firefox.desktop'" <<<"$UBUNTU_SCHEMA_OVERRIDE" \
+ || fail "Ubuntu's dock default does not point at the installed Firefox launcher"
+ if grep -Fq 'firefox_firefox.desktop' <<<"$UBUNTU_SCHEMA_OVERRIDE"; then
+ fail "Ubuntu's dock default still points at the absent Firefox Snap launcher"
+ fi
+ for package in ubuntu-desktop-minimal ubuntu-session gdm3 firefox yaru-theme-gtk; do
+ grep -q "^${package}[[:space:]]" "$PACKAGES" || fail "$package provenance is missing"
+ done
+ "$DEBUGFS" -R 'cat /etc/apt/keyrings/packages.mozilla.org.asc' "$IMAGE" 2>/dev/null \
+ | shasum -a 256 | grep -Fq "$MOZILLA_APT_KEY_SHA256" \
+ || fail "the Mozilla APT key is not the pinned key"
+ "$DEBUGFS" -R 'cat /etc/environment.d/60-dory-desktop.conf' "$IMAGE" 2>/dev/null \
+ | grep -Fqx 'MOZ_ENABLE_WAYLAND=0' \
+ || fail "Firefox is not configured for reliable XWayland presentation"
+ if grep -Eq '^(xfce4|lightdm)[[:space:]]' "$PACKAGES"; then
+ fail "the Ubuntu image still contains the retired Xfce/LightDM session"
+ fi
+ ;;
+ *)
+ grep -Fq 'set_xfce_value /Gdk/WindowScalingFactor int 2' <<<"$DISPLAY_CONFIGURATION" \
+ || fail "Xfce Retina scaling is not configured"
+ grep -q $'^xfce4\t' "$PACKAGES" || fail "Xfce package provenance is missing"
+ grep -q $'^lightdm\t' "$PACKAGES" || fail "LightDM package provenance is missing"
+ if [ "$DISTRO" = debian ]; then
+ for package in firefox-esr evince galculator; do
+ grep -q "^${package}[[:space:]]" "$PACKAGES" || fail "$package provenance is missing"
+ done
+ fi
+ ;;
+esac
grep -q $'^spice-vdagent\t' "$PACKAGES" || fail "SPICE package provenance is missing"
+grep -q $'^x11-utils\t' "$PACKAGES" || fail "X11 window qualification tools are missing"
+grep -q $'^wl-clipboard\t' "$PACKAGES" || fail "Wayland clipboard package provenance is missing"
+grep -q $'^xclip\t' "$PACKAGES" || fail "X11 clipboard package provenance is missing"
grep -q $'^pipewire-audio\t' "$PACKAGES" || fail "PipeWire package provenance is missing"
+for package in mesa-vulkan-drivers vulkan-tools; do
+ # dpkg's ${binary:Package} field appends an architecture qualifier for
+ # Multi-Arch packages (for example, mesa-vulkan-drivers:arm64).
+ grep -Eq "^${package}(:arm64)?[[:space:]]" "$PACKAGES" \
+ || fail "$package provenance is missing"
+done
+
+GRAPHICS_CONFIGURATION="$($DEBUGFS -R 'cat /usr/lib/dory/configure-graphics-backend' "$IMAGE" 2>/dev/null)"
+grep -Fq "dory.graphics=virgl-venus" <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "graphics backend configuration does not recognize VirGL plus Venus"
+grep -Fq "dory.graphics=virgl" <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "graphics backend configuration does not recognize stable VirGL"
+grep -Fq "dory.graphics=software" <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "graphics backend configuration does not recognize software fallback"
+if grep -Fq "printf '%s\\n' 'virgl2+venus'" <<<"$GRAPHICS_CONFIGURATION"; then
+ fail "graphics backend writes a hard-coded backend instead of the resolved runtime"
+fi
+grep -Fq "printf '%s\\n' \"\$backend\" > /run/dory/graphics-backend" \
+ <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "graphics backend does not record the resolved runtime"
+grep -Fq "virgl2|virgl2+venus" <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "accelerated graphics backends do not share a GTK renderer policy"
+grep -Fq "'GSK_RENDERER=gl' > \"\$environment_file\"" <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "accelerated graphics does not select the qualified GTK4 GL renderer"
+grep -Fq 'VK_DRIVER_FILES="$venus_icd"' <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "Venus applications do not select Dory's isolated Vulkan ICD"
+grep -Fq 'LD_LIBRARY_PATH="$venus_root/lib"' <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "Venus applications do not select Dory's isolated Vulkan library"
+grep -Fq 'timeout 10 "$venus_probe"' <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "Venus is enabled without a bounded hardware capability probe"
+grep -Fq 'venus-ready:' <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "Venus readiness is not recorded for support diagnostics"
+grep -Fq '/etc/X11/Xsession.d/70dory-graphics' <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "desktop sessions do not receive the qualified graphics environment"
+grep -Fq 'export VK_DRIVER_FILES=$venus_icd' <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "GDM and LightDM sessions do not select Dory's Venus ICD"
+grep -Fq 'export LD_LIBRARY_PATH=$venus_root/lib' <<<"$GRAPHICS_CONFIGURATION" \
+ || fail "GDM and LightDM sessions do not select Dory's Venus library"
+if grep -Fq 'ZED_ALLOW_EMULATED_GPU' <<<"$GRAPHICS_CONFIGURATION"; then
+ fail "graphics configuration masks Zed's hardware-GPU requirement"
+fi
+if grep -Fq 'GSK_RENDERER=cairo' <<<"$GRAPHICS_CONFIGURATION"; then
+ fail "accelerated graphics falls back to CPU-rendered GTK instead of qualified GL"
+fi
+if grep -Eq 'MESA_LOADER_DRIVER_OVERRIDE|GALLIUM_DRIVER|LIBGL_KOPPER_DRI2' <<<"$GRAPHICS_CONFIGURATION"; then
+ fail "graphics backend globally overrides Mesa instead of allowing API-native driver selection"
+fi
+if grep -Fq "grep -qw 'dory.gpu=venus' /proc/cmdline" <<<"$GRAPHICS_CONFIGURATION"; then
+ fail "graphics backend still relies only on the legacy Venus token"
+fi
ZSTD="${DORY_ZSTD:-$(command -v zstd 2>/dev/null || true)}"
[ -n "$ZSTD" ] || fail "zstd is required"
diff --git a/guest/kernel/build.sh b/guest/kernel/build.sh
index 8fa2985c..f617f0d4 100755
--- a/guest/kernel/build.sh
+++ b/guest/kernel/build.sh
@@ -63,9 +63,10 @@ case "$PROFILE" in
headless) CONFIGS="$CONFIGS dory-headless.fragment" ;;
venus) CONFIGS="$CONFIGS dory-virtual-display.fragment dory-gpu.fragment" ;;
desktop) CONFIGS="$CONFIGS dory-virtual-display.fragment dory-desktop.fragment" ;;
+ accelerated-desktop) CONFIGS="$CONFIGS dory-virtual-display.fragment dory-gpu.fragment dory-desktop.fragment dory-accelerated-desktop.fragment" ;;
esac
-if [ "$PROFILE" = "desktop" ] && [ "$ARCH" != "arm64" ]; then
- echo "the desktop kernel profile currently supports arm64 only" >&2
+if { [ "$PROFILE" = "desktop" ] || [ "$PROFILE" = "accelerated-desktop" ]; } && [ "$ARCH" != "arm64" ]; then
+ echo "desktop kernel profiles currently support arm64 only" >&2
exit 64
fi
diff --git a/guest/kernel/dory-accelerated-desktop.fragment b/guest/kernel/dory-accelerated-desktop.fragment
new file mode 100644
index 00000000..7160d40c
--- /dev/null
+++ b/guest/kernel/dory-accelerated-desktop.fragment
@@ -0,0 +1,16 @@
+# Raw Hypervisor.framework desktop profile. Keep the Linux userspace ABI at 4 KiB so FEX can run
+# supported x86-64 applications. Dory's virtio-gpu kernel patch separately aligns host-visible
+# Venus blobs to Hypervisor.framework's 16 KiB stage-2 mapping granule.
+CONFIG_ARM64_4K_PAGES=y
+CONFIG_ARM64_16K_PAGES=n
+CONFIG_INPUT=y
+CONFIG_INPUT_EVDEV=y
+CONFIG_VIRTIO_INPUT=y
+
+# Keep the framebuffer console and KMS handoff used by graphical desktop guests. The generic Venus
+# compute fragment disables these because the Docker engine has no display attached.
+CONFIG_FB=y
+CONFIG_DRM_FBDEV_EMULATION=y
+CONFIG_VT=y
+CONFIG_VT_CONSOLE=y
+CONFIG_FRAMEBUFFER_CONSOLE=y
diff --git a/guest/kernel/dory-desktop.fragment b/guest/kernel/dory-desktop.fragment
index 0c6903c3..79b75e24 100644
--- a/guest/kernel/dory-desktop.fragment
+++ b/guest/kernel/dory-desktop.fragment
@@ -17,6 +17,11 @@ CONFIG_USB_XHCI_HCD=y
CONFIG_USB_XHCI_PLATFORM=y
CONFIG_USB_HID=y
+# Give full desktop guests compressed swap without consuming another host-backed disk. Browsers
+# and GNOME otherwise run out of reclaimable memory abruptly at the VM memory ceiling.
+CONFIG_ZSMALLOC=y
+CONFIG_ZRAM=y
+
# VZVirtioSoundDeviceConfiguration output stream.
CONFIG_SOUND=y
CONFIG_SND=y
diff --git a/guest/kernel/dory-headless.fragment b/guest/kernel/dory-headless.fragment
index bf826fa9..3534c2bb 100644
--- a/guest/kernel/dory-headless.fragment
+++ b/guest/kernel/dory-headless.fragment
@@ -1,3 +1,4 @@
# FEX must expose the x86-64 ABI's 4 KiB page contract. Keep the release headless kernel explicit
# so a future defconfig change cannot silently make common amd64 jemalloc builds unusable.
CONFIG_ARM64_4K_PAGES=y
+CONFIG_ARM64_16K_PAGES=n
diff --git a/guest/kernel/input-fingerprint.sh b/guest/kernel/input-fingerprint.sh
index 72a87471..0458d315 100755
--- a/guest/kernel/input-fingerprint.sh
+++ b/guest/kernel/input-fingerprint.sh
@@ -24,9 +24,10 @@ case "$PROFILE" in
headless) CONFIGS+=(dory-headless.fragment) ;;
venus) CONFIGS+=(dory-virtual-display.fragment dory-gpu.fragment) ;;
desktop) CONFIGS+=(dory-virtual-display.fragment dory-desktop.fragment) ;;
+ accelerated-desktop) CONFIGS+=(dory-virtual-display.fragment dory-gpu.fragment dory-desktop.fragment dory-accelerated-desktop.fragment) ;;
esac
-if [ "$PROFILE" = "desktop" ] && [ "$ARCH" != "arm64" ]; then
- echo "the desktop kernel profile currently supports arm64 only" >&2
+if { [ "$PROFILE" = "desktop" ] || [ "$PROFILE" = "accelerated-desktop" ]; } && [ "$ARCH" != "arm64" ]; then
+ echo "desktop kernel profiles currently support arm64 only" >&2
exit 64
fi
diff --git a/guest/kernel/patches/6.12.30/0006-virtio-gpu-align-host-visible-vram.patch b/guest/kernel/patches/6.12.30/0006-virtio-gpu-align-host-visible-vram.patch
new file mode 100644
index 00000000..e90b1c08
--- /dev/null
+++ b/guest/kernel/patches/6.12.30/0006-virtio-gpu-align-host-visible-vram.patch
@@ -0,0 +1,84 @@
+From: Dory maintainers
+Subject: [PATCH] drm/virtio: align host-visible VRAM to the Apple host page
+
+Hypervisor.framework maps memory at the Apple Silicon host-page granule (16
+KiB), independently of the Linux guest's stage-1 page size. Keep the GEM
+object and userspace mmap at the guest's 4 KiB page granule, but allocate the
+renderer blob and its host-visible aperture node at the 16 KiB host granule.
+
+Do not expose the padded aperture size as the GEM object size. Mesa maps the
+size it originally requested, and virtio_gpu_vram_mmap rejects that mapping if
+the GEM object was enlarged to the aperture size. The host padding remains
+mapped at stage 2 while userspace sees only the original guest-page-aligned
+object, preserving the Linux ABI and making RESOURCE_MAP_BLOB legal for
+hv_vm_map.
+---
+ drivers/gpu/drm/virtio/virtgpu_vram.c | 25 +++++++++++++++++++------
+ 1 file changed, 19 insertions(+), 6 deletions(-)
+
+diff --git a/drivers/gpu/drm/virtio/virtgpu_vram.c b/drivers/gpu/drm/virtio/virtgpu_vram.c
+index 25df81c5ed91..21751119eb7b 100644
+--- a/drivers/gpu/drm/virtio/virtgpu_vram.c
++++ b/drivers/gpu/drm/virtio/virtgpu_vram.c
+@@ -2,6 +2,9 @@
+ #include "virtgpu_drv.h"
+
+ #include
++#include
++
++#define DORY_HOST_VISIBLE_ALIGNMENT SZ_16K
+
+ static void virtio_gpu_vram_free(struct drm_gem_object *obj)
+ {
+@@ -56,8 +59,12 @@ static int virtio_gpu_vram_mmap(struct drm_gem_object *obj,
+ else if (vram->map_info == VIRTIO_GPU_MAP_CACHE_UNCACHED)
+ vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
+
+- /* Partial mappings of GEM buffers don't happen much in practice. */
+- if (vm_size != vram->vram_node.size)
++ /*
++ * The aperture node is padded to the Apple host-page granule. Map the
++ * guest-page-sized GEM object and leave the private stage-2 padding out
++ * of the userspace ABI.
++ */
++ if (vm_size != obj->size)
+ return -EINVAL;
+
+ ret = io_remap_pfn_range(vma, vma->vm_start,
+@@ -150,8 +157,12 @@ static int virtio_gpu_vram_map(struct virtio_gpu_object *bo)
+ return -EINVAL;
+
+ spin_lock(&vgdev->host_visible_lock);
+- ret = drm_mm_insert_node(&vgdev->host_visible_mm, &vram->vram_node,
+- bo->base.base.size);
++ ret = drm_mm_insert_node_generic(&vgdev->host_visible_mm,
++ &vram->vram_node,
++ roundup(bo->base.base.size,
++ DORY_HOST_VISIBLE_ALIGNMENT),
++ DORY_HOST_VISIBLE_ALIGNMENT,
++ 0, 0);
+ spin_unlock(&vgdev->host_visible_lock);
+
+ if (ret)
+@@ -188,6 +199,7 @@ int virtio_gpu_vram_create(struct virtio_gpu_device *vgdev,
+ {
+ struct drm_gem_object *obj;
+ struct virtio_gpu_object_vram *vram;
++ size_t guest_size;
+ int ret;
+
+ vram = kzalloc(sizeof(*vram), GFP_KERNEL);
+@@ -197,8 +209,9 @@ int virtio_gpu_vram_create(struct virtio_gpu_device *vgdev,
+ obj = &vram->base.base.base;
+ obj->funcs = &virtio_gpu_vram_funcs;
+
+- params->size = PAGE_ALIGN(params->size);
+- drm_gem_private_object_init(vgdev->ddev, obj, params->size);
++ guest_size = PAGE_ALIGN(params->size);
++ params->size = roundup(params->size, DORY_HOST_VISIBLE_ALIGNMENT);
++ drm_gem_private_object_init(vgdev->ddev, obj, guest_size);
+
+ /* Create fake offset */
+ ret = drm_gem_create_mmap_offset(obj);
+--
+2.50.1
diff --git a/guest/kernel/profile.sh b/guest/kernel/profile.sh
index cfda38e5..f366ea01 100644
--- a/guest/kernel/profile.sh
+++ b/guest/kernel/profile.sh
@@ -18,8 +18,8 @@ dory_kernel_resolve_profile() {
fi
case "$profile" in
- headless|venus|desktop) ;;
- *) echo "DORY_KERNEL_PROFILE must be headless, venus, or desktop" >&2; return 64 ;;
+ headless|venus|desktop|accelerated-desktop) ;;
+ *) echo "DORY_KERNEL_PROFILE must be headless, venus, desktop, or accelerated-desktop" >&2; return 64 ;;
esac
if [ "$legacy_gpu" = "1" ] && [ "$profile" != "venus" ]; then
echo "DORY_EXPERIMENTAL_GPU=1 conflicts with DORY_KERNEL_PROFILE=$profile" >&2
@@ -33,6 +33,8 @@ dory_kernel_profile_suffix() {
headless) printf '\n' ;;
venus) printf '%s\n' '-gpu' ;;
desktop) printf '%s\n' '-desktop' ;;
+ # This is the production desktop profile, so retain the established release artifact names.
+ accelerated-desktop) printf '%s\n' '-desktop' ;;
*) return 64 ;;
esac
}
diff --git a/guest/kernel/verify-build.sh b/guest/kernel/verify-build.sh
index 6b660810..6add2d4c 100755
--- a/guest/kernel/verify-build.sh
+++ b/guest/kernel/verify-build.sh
@@ -29,8 +29,8 @@ case "$ARCH" in
exit 64
;;
esac
-if [ "$PROFILE" = "desktop" ] && [ "$ARCH" != "arm64" ]; then
- echo "the desktop kernel profile currently supports arm64 only" >&2
+if { [ "$PROFILE" = "desktop" ] || [ "$PROFILE" = "accelerated-desktop" ]; } && [ "$ARCH" != "arm64" ]; then
+ echo "desktop kernel profiles currently support arm64 only" >&2
exit 64
fi
@@ -88,6 +88,7 @@ case "$PROFILE" in
headless) CONFIG_FRAGMENTS+=(dory-headless.fragment) ;;
venus) CONFIG_FRAGMENTS+=(dory-virtual-display.fragment dory-gpu.fragment) ;;
desktop) CONFIG_FRAGMENTS+=(dory-virtual-display.fragment dory-desktop.fragment) ;;
+ accelerated-desktop) CONFIG_FRAGMENTS+=(dory-virtual-display.fragment dory-gpu.fragment dory-desktop.fragment dory-accelerated-desktop.fragment) ;;
esac
REQUIRED_POLICIES="$({
diff --git a/guest/mesa/PINS b/guest/mesa/PINS
new file mode 100644
index 00000000..cf196c6a
--- /dev/null
+++ b/guest/mesa/PINS
@@ -0,0 +1,7 @@
+# Dory-owned Venus runtime inputs for Apple-silicon desktop guests.
+MESA_VERSION=26.1.4
+MESA_SOURCE_URL=https://archive.mesa3d.org/mesa-26.1.4.tar.xz
+MESA_SOURCE_SHA256=072705caa9adf4740f1489194b13e278ad959166863b5271fe423a86353c9ab6
+MESA_BUILDER_IMAGE=ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
+MESON_VERSION=1.10.0
+MESON_WHEEL_SHA256=4b27aafce281e652dcb437b28007457411245d975c48b5db3a797d3e93ae1585
diff --git a/guest/mesa/README.md b/guest/mesa/README.md
new file mode 100644
index 00000000..f44a9b12
--- /dev/null
+++ b/guest/mesa/README.md
@@ -0,0 +1,16 @@
+# Dory Venus runtime
+
+Dory ships a small, isolated Venus Vulkan ICD for accelerated ARM64 desktop guests. It lives at
+`/opt/dory/mesa` and does not replace the distribution's Mesa OpenGL stack. The distribution keeps
+rendering GNOME/Xfce through VirGL, while Vulkan applications such as Zed use this pinned ICD.
+
+The macOS renderer cannot expose Linux `sync_fd` semaphore imports. Dory's virtio-gpu transport
+already orders access to shared resources, so the patch in `patches/` enables Venus' existing
+implicit-fencing path, exposes WSI when that path is active, and avoids requesting the absent host
+extension. Acquired-image synchronization is still consumed by Venus' driver-side semaphore path;
+the extension is not merely advertised.
+
+`build.sh` produces `guest/out/dory-mesa-venus-arm64.tar.zst`. The archive contains only the Venus
+ICD, its one non-baseline XCB dependency, a capability probe, and provenance. Desktop image and
+in-place update builders consume the same archive, so a full image and an updated image cannot
+silently diverge.
diff --git a/guest/mesa/build.sh b/guest/mesa/build.sh
new file mode 100755
index 00000000..5701d3c0
--- /dev/null
+++ b/guest/mesa/build.sh
@@ -0,0 +1,142 @@
+#!/bin/bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+source guest/mesa/PINS
+source guest/kernel/docker-endpoint.sh
+
+case "${1:-arm64}" in
+ arm64|aarch64) ;;
+ *) echo "the Dory Venus runtime currently supports arm64 only" >&2; exit 64 ;;
+esac
+
+OUT="$ROOT/guest/out"
+mkdir -p "$OUT"
+RUNTIME="$OUT/dory-mesa-venus-arm64.tar.zst"
+STAMP="$OUT/dory-mesa-venus-build-arm64.stamp"
+INPUT_SHA256="$(guest/mesa/input-fingerprint.sh arm64)"
+
+if [ -s "$RUNTIME" ] && [ -s "$STAMP" ] \
+ && grep -Fqx "input_sha256=$INPUT_SHA256" "$STAMP" \
+ && guest/mesa/verify-build.sh arm64 >/dev/null 2>&1; then
+ echo "using current $RUNTIME"
+ exit 0
+fi
+
+DOCKER_BIN="${DORY_MESA_DOCKER_BIN:-$(command -v docker || true)}"
+[ -n "$DOCKER_BIN" ] && [ -x "$DOCKER_BIN" ] || { echo "docker CLI not found" >&2; exit 1; }
+DOCKER_ENDPOINT="$(dory_kernel_resolve_docker_endpoint "$DOCKER_BIN" "${DORY_MESA_DOCKER_HOST:-}")"
+docker_cmd() {
+ dory_kernel_docker "$DOCKER_BIN" "$DOCKER_ENDPOINT" "$@"
+}
+
+STAGING="$(mktemp -d "$OUT/.mesa-venus-build-arm64.XXXXXX")"
+CID=""
+cleanup() {
+ [ -z "$CID" ] || docker_cmd rm -f "$CID" >/dev/null 2>&1 || true
+ rm -rf "$STAGING"
+}
+trap cleanup EXIT
+
+CID="$(docker_cmd create --platform linux/arm64 \
+ -e DEBIAN_FRONTEND=noninteractive \
+ -e DORY_MESA_VERSION="$MESA_VERSION" \
+ -e DORY_MESA_SOURCE_URL="$MESA_SOURCE_URL" \
+ -e DORY_MESA_SOURCE_SHA256="$MESA_SOURCE_SHA256" \
+ -e DORY_MESON_VERSION="$MESON_VERSION" \
+ -e DORY_MESON_WHEEL_SHA256="$MESON_WHEEL_SHA256" \
+ -w /build "$MESA_BUILDER_IMAGE" sleep infinity)"
+docker_cmd start "$CID" >/dev/null
+docker_cmd cp guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch \
+ "$CID:/tmp/dory-venus-implicit-fencing.patch"
+docker_cmd cp guest/mesa/dory-vulkan-probe.c "$CID:/tmp/dory-vulkan-probe.c"
+
+docker_cmd exec "$CID" bash -euo pipefail -c '
+ apt-get update -qq
+ apt-get install -y -qq --no-install-recommends \
+ build-essential bison ca-certificates curl flex libdrm-dev libexpat1-dev \
+ libvulkan-dev libwayland-dev libx11-xcb-dev libxcb-dri3-dev \
+ libxcb-keysyms1-dev libxcb-present-dev libxcb-randr0-dev libxcb-shm0-dev \
+ libxcb-sync-dev libxcb-xfixes0-dev libxrandr-dev libxshmfence-dev \
+ libzstd-dev ninja-build patch pkg-config python3-mako python3-packaging \
+ python3-pip python3-ply python3-yaml wayland-protocols xz-utils zlib1g-dev zstd
+
+ source_archive="/build/mesa-${DORY_MESA_VERSION}.tar.xz"
+ curl --fail --location --retry 5 --output "$source_archive" "$DORY_MESA_SOURCE_URL"
+ printf "%s %s\n" "$DORY_MESA_SOURCE_SHA256" "$source_archive" | sha256sum -c -
+ tar -xf "$source_archive" -C /build
+ source_dir="/build/mesa-${DORY_MESA_VERSION}"
+ patch -d "$source_dir" -p1 < /tmp/dory-venus-implicit-fencing.patch
+
+ python3 -m pip download --disable-pip-version-check --no-deps \
+ --dest /build "meson==${DORY_MESON_VERSION}" >/dev/null
+ meson_wheel="$(find /build -maxdepth 1 -type f -name "meson-${DORY_MESON_VERSION}-*.whl" -print -quit)"
+ [ -n "$meson_wheel" ]
+ printf "%s %s\n" "$DORY_MESON_WHEEL_SHA256" "$meson_wheel" | sha256sum -c -
+ python3 -m pip install --break-system-packages --disable-pip-version-check \
+ --no-index "$meson_wheel" >/dev/null
+
+ meson setup /build/mesa-build "$source_dir" \
+ --prefix=/opt/dory/mesa \
+ --libdir=lib \
+ --buildtype=release \
+ -Dplatforms=x11,wayland \
+ -Dgallium-drivers= \
+ -Dvulkan-drivers=virtio \
+ -Dvulkan-layers= \
+ -Dglx=disabled \
+ -Degl=disabled \
+ -Dgbm=disabled \
+ -Dopengl=false \
+ -Dgles1=disabled \
+ -Dgles2=disabled \
+ -Dllvm=disabled \
+ -Dvideo-codecs= \
+ -Dvalgrind=disabled \
+ -Dlibunwind=disabled \
+ -Dlmsensors=disabled \
+ -Dzstd=enabled \
+ -Dxmlconfig=disabled \
+ -Dbuild-tests=false
+ ninja -C /build/mesa-build
+ DESTDIR=/stage ninja -C /build/mesa-build install
+
+ install -d -m0755 /stage/usr/lib/dory /stage/opt/dory/mesa/share/dory
+ cc -O2 -Wall -Wextra -Werror /tmp/dory-vulkan-probe.c \
+ -o /stage/usr/lib/dory/dory-vulkan-probe -lvulkan
+ install -m0644 /usr/lib/aarch64-linux-gnu/libxcb-keysyms.so.1.0.0 \
+ /stage/opt/dory/mesa/lib/libxcb-keysyms.so.1.0.0
+ ln -s libxcb-keysyms.so.1.0.0 /stage/opt/dory/mesa/lib/libxcb-keysyms.so.1
+ xcb_version="$(dpkg-query -W -f="\${Version}" libxcb-keysyms1)"
+ printf "schema=1\nmesa_version=%s\nmesa_source_sha256=%s\nxcb_keysyms_version=%s\n" \
+ "$DORY_MESA_VERSION" "$DORY_MESA_SOURCE_SHA256" "$xcb_version" \
+ > /stage/opt/dory/mesa/share/dory/runtime.env
+
+ mkdir -p /out
+ tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner \
+ -C /stage -cf - . | zstd -19 -T0 -o /out/dory-mesa-venus-arm64.tar.zst
+'
+
+docker_cmd cp "$CID:/out/dory-mesa-venus-arm64.tar.zst" "$STAGING/"
+docker_cmd rm -f "$CID" >/dev/null
+CID=""
+chmod 0644 "$STAGING/dory-mesa-venus-arm64.tar.zst"
+
+TEMP_STAMP="$STAGING/dory-mesa-venus-build-arm64.stamp"
+{
+ printf 'schema=1\narch=arm64\ninput_sha256=%s\n' "$INPUT_SHA256"
+ printf 'runtime_sha256=%s\n' \
+ "$(shasum -a 256 "$STAGING/dory-mesa-venus-arm64.tar.zst" | awk '{print $1}')"
+ printf 'mesa_version=%s\nmesa_source_sha256=%s\n' "$MESA_VERSION" "$MESA_SOURCE_SHA256"
+} > "$TEMP_STAMP"
+
+DORY_MESA_OUT_DIR="$STAGING" guest/mesa/verify-build.sh arm64
+mv -f "$STAGING/dory-mesa-venus-arm64.tar.zst" "$RUNTIME"
+mv -f "$TEMP_STAMP" "$STAMP"
+rmdir "$STAGING"
+STAGING=""
+trap - EXIT
+
+guest/mesa/verify-build.sh arm64
+echo "built $RUNTIME"
diff --git a/guest/mesa/dory-vulkan-probe.c b/guest/mesa/dory-vulkan-probe.c
new file mode 100644
index 00000000..84ff9c99
--- /dev/null
+++ b/guest/mesa/dory-vulkan-probe.c
@@ -0,0 +1,101 @@
+#include
+#include
+#include
+#include
+
+static int fail(const char *message, VkResult result)
+{
+ fprintf(stderr, "dory-vulkan-probe: %s (%d)\n", message, result);
+ return 1;
+}
+
+int main(void)
+{
+ VkApplicationInfo application = {
+ .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
+ .pApplicationName = "dory-vulkan-probe",
+ .applicationVersion = 1,
+ .pEngineName = "Dory",
+ .engineVersion = 1,
+ .apiVersion = VK_API_VERSION_1_2,
+ };
+ VkInstanceCreateInfo create = {
+ .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
+ .pApplicationInfo = &application,
+ };
+ VkInstance instance = VK_NULL_HANDLE;
+ VkResult result = vkCreateInstance(&create, NULL, &instance);
+ if (result != VK_SUCCESS)
+ return fail("vkCreateInstance failed", result);
+
+ uint32_t count = 0;
+ result = vkEnumeratePhysicalDevices(instance, &count, NULL);
+ if (result != VK_SUCCESS || count == 0) {
+ vkDestroyInstance(instance, NULL);
+ return fail("no Vulkan physical device", result);
+ }
+ VkPhysicalDevice *devices = calloc(count, sizeof(*devices));
+ if (!devices) {
+ vkDestroyInstance(instance, NULL);
+ return fail("out of memory", VK_ERROR_OUT_OF_HOST_MEMORY);
+ }
+ result = vkEnumeratePhysicalDevices(instance, &count, devices);
+ if (result != VK_SUCCESS) {
+ free(devices);
+ vkDestroyInstance(instance, NULL);
+ return fail("physical-device enumeration failed", result);
+ }
+
+ int exit_code = 2;
+ for (uint32_t i = 0; i < count; i++) {
+ VkPhysicalDeviceDriverProperties driver = {
+ .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES,
+ };
+ VkPhysicalDeviceProperties2 properties = {
+ .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2,
+ .pNext = &driver,
+ };
+ vkGetPhysicalDeviceProperties2(devices[i], &properties);
+ if (strcmp(driver.driverName, "venus") != 0 ||
+ properties.properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_CPU)
+ continue;
+
+ uint32_t extension_count = 0;
+ result = vkEnumerateDeviceExtensionProperties(
+ devices[i], NULL, &extension_count, NULL);
+ if (result != VK_SUCCESS)
+ continue;
+ VkExtensionProperties *extensions = calloc(extension_count, sizeof(*extensions));
+ if (!extensions) {
+ exit_code = 1;
+ break;
+ }
+ result = vkEnumerateDeviceExtensionProperties(
+ devices[i], NULL, &extension_count, extensions);
+ int has_swapchain = 0;
+ if (result == VK_SUCCESS) {
+ for (uint32_t j = 0; j < extension_count; j++) {
+ if (strcmp(extensions[j].extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) {
+ has_swapchain = 1;
+ break;
+ }
+ }
+ }
+ free(extensions);
+ if (!has_swapchain) {
+ fprintf(stderr, "dory-vulkan-probe: Venus lacks VK_KHR_swapchain\n");
+ exit_code = 3;
+ break;
+ }
+ printf("device=%s driver=%s info=%s swapchain=yes\n",
+ properties.properties.deviceName, driver.driverName, driver.driverInfo);
+ exit_code = 0;
+ break;
+ }
+
+ free(devices);
+ vkDestroyInstance(instance, NULL);
+ if (exit_code == 2)
+ fprintf(stderr, "dory-vulkan-probe: no hardware Venus device\n");
+ return exit_code;
+}
diff --git a/guest/mesa/input-fingerprint.sh b/guest/mesa/input-fingerprint.sh
new file mode 100755
index 00000000..fe9edd5d
--- /dev/null
+++ b/guest/mesa/input-fingerprint.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+source guest/mesa/PINS
+
+case "${1:-arm64}" in
+ arm64|aarch64) ;;
+ *) echo "the Dory Venus runtime currently supports arm64 only" >&2; exit 64 ;;
+esac
+
+{
+ printf 'schema=1\narch=arm64\nmesa_version=%s\nmeson_version=%s\nbuilder=%s\n' \
+ "$MESA_VERSION" "$MESON_VERSION" "$MESA_BUILDER_IMAGE"
+ for input in \
+ guest/mesa/PINS \
+ guest/mesa/README.md \
+ guest/mesa/build.sh \
+ guest/mesa/dory-vulkan-probe.c \
+ guest/mesa/input-fingerprint.sh \
+ guest/mesa/verify-build.sh \
+ guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch; do
+ printf 'input=%s\n' "$input"
+ shasum -a 256 "$input"
+ done
+} | shasum -a 256 | awk '{print $1}'
diff --git a/guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch b/guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch
new file mode 100644
index 00000000..5bad00ed
--- /dev/null
+++ b/guest/mesa/patches/0001-venus-enable-dory-implicit-fencing-wsi.patch
@@ -0,0 +1,210 @@
+From: Dory Linux
+Subject: [PATCH] venus: enable implicit-fencing WSI without host sync-fd import
+
+The Dory macOS renderer provides ordered implicit fencing for virtio-gpu
+resources but MoltenVK cannot expose Linux sync-fd semaphore imports. Permit
+Venus WSI and synchronization2 when implicit fencing is active, avoid
+requesting the absent renderer extension, and bridge WSI acquire signals with
+an ordinary host queue submission instead of a fake imported sync-fd payload.
+Keep the normal sync-fd path for Linux renderers.
+---
+ src/virtio/vulkan/vn_device.c | 3 ++-
+ src/virtio/vulkan/vn_instance.c | 2 +-
+ src/virtio/vulkan/vn_physical_device.c | 7 +++++--
+ src/virtio/vulkan/vn_pipeline.c | 9 +++++++--
+ src/virtio/vulkan/vn_queue.c | 3 ++-
+ src/virtio/vulkan/vn_wsi.c | 63 ++++++++++++++++++++++++++++++----
+ 6 files changed, 75 insertions(+), 11 deletions(-)
+
+diff --git a/src/virtio/vulkan/vn_device.c b/src/virtio/vulkan/vn_device.c
+--- a/src/virtio/vulkan/vn_device.c
++++ b/src/virtio/vulkan/vn_device.c
+@@ -327,7 +327,8 @@ vn_device_fix_create_info(const struct vn_device *dev,
+ }
+
+ /* see vn_queue_submission_count_batch_semaphores */
+- if (!app_exts->KHR_external_semaphore_fd && has_wsi) {
++ if (!app_exts->KHR_external_semaphore_fd && has_wsi &&
++ !physical_dev->instance->renderer->info.has_implicit_fencing) {
+ assert(physical_dev->renderer_sync_fd.semaphore_importable);
+ extra_exts[extra_count++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME;
+ }
+diff --git a/src/virtio/vulkan/vn_instance.c b/src/virtio/vulkan/vn_instance.c
+--- a/src/virtio/vulkan/vn_instance.c
++++ b/src/virtio/vulkan/vn_instance.c
+@@ -75,7 +75,7 @@ static const driOptionDescription vn_dri_options[] = {
+ DRI_CONF_VK_X11_OVERRIDE_MIN_IMAGE_COUNT(0)
+ DRI_CONF_VK_X11_STRICT_IMAGE_COUNT(false)
+ DRI_CONF_VK_XWAYLAND_WAIT_READY(true)
+- DRI_CONF_VENUS_IMPLICIT_FENCING(false)
++ DRI_CONF_VENUS_IMPLICIT_FENCING(true)
+ DRI_CONF_VENUS_WSI_MULTI_PLANE_MODIFIERS(false)
+ DRI_CONF_SECTION_END
+ DRI_CONF_SECTION_DEBUG
+diff --git a/src/virtio/vulkan/vn_physical_device.c b/src/virtio/vulkan/vn_physical_device.c
+--- a/src/virtio/vulkan/vn_physical_device.c
++++ b/src/virtio/vulkan/vn_physical_device.c
+@@ -1216,7 +1216,8 @@ vn_physical_device_get_native_extensions(
+ #endif /* VK_USE_PLATFORM_ANDROID_KHR */
+
+ #ifdef VN_USE_WSI_PLATFORM
+- if (physical_dev->renderer_sync_fd.semaphore_importable) {
++ if (physical_dev->renderer_sync_fd.semaphore_importable ||
++ physical_dev->instance->renderer->info.has_implicit_fencing) {
+ exts->KHR_incremental_present = true;
+ #ifndef VK_USE_PLATFORM_WIN32_KHR
+ exts->KHR_present_id = true;
+@@ -1272,6 +1273,8 @@ vn_physical_device_get_passthrough_extensions(
+ * (aka no corresponding renderer side object).
+ */
+- const bool can_sync2 = physical_dev->renderer_sync_fd.semaphore_importable;
++ const bool can_sync2 =
++ physical_dev->renderer_sync_fd.semaphore_importable ||
++ physical_dev->instance->renderer->info.has_implicit_fencing;
+ #else
+ static const bool can_sync2 = true;
+ #endif
+diff --git a/src/virtio/vulkan/vn_pipeline.c b/src/virtio/vulkan/vn_pipeline.c
+--- a/src/virtio/vulkan/vn_pipeline.c
++++ b/src/virtio/vulkan/vn_pipeline.c
+@@ -18,6 +18,7 @@
+ #include "vn_descriptor_set.h"
+ #include "vn_device.h"
+ #include "vn_physical_device.h"
++#include "vn_renderer.h"
+ #include "vn_render_pass.h"
+
+ /**
+@@ -1685,6 +1685,8 @@ vn_CreateGraphicsPipelines(VkDevice device,
+ const VkAllocationCallbacks *alloc =
+ pAllocator ? pAllocator : &dev->base.vk.alloc;
+- bool want_sync = false;
++ /* MoltenVK can reject a pipeline that native Linux Vulkan accepts. Do not
++ * lose that result through Venus' fire-and-forget pipeline fast path. */
++ bool want_sync = dev->renderer->info.has_implicit_fencing;
+ VkResult result;
+
+ /* silence -Wmaybe-uninitialized false alarm on release build with gcc */
+@@ -1768,6 +1770,8 @@ vn_CreateComputePipelines(VkDevice device,
+ const VkAllocationCallbacks *alloc =
+ pAllocator ? pAllocator : &dev->base.vk.alloc;
+- bool want_sync = false;
++ /* Keep renderer object creation and the guest-visible result atomic on the
++ * implicit-fencing compatibility path. */
++ bool want_sync = dev->renderer->info.has_implicit_fencing;
+ VkResult result;
+
+ memset(pPipelines, 0, sizeof(*pPipelines) * createInfoCount);
+diff --git a/src/virtio/vulkan/vn_queue.c b/src/virtio/vulkan/vn_queue.c
+--- a/src/virtio/vulkan/vn_queue.c
++++ b/src/virtio/vulkan/vn_queue.c
+@@ -404,7 +404,8 @@ vn_queue_submission_fix_batch_semaphores(struct vn_queue_submission *submit,
+ if (!vn_semaphore_wait_external(dev, sem))
+ return VK_ERROR_DEVICE_LOST;
+
+- assert(dev->physical_device->renderer_sync_fd.semaphore_importable);
++ assert(dev->physical_device->renderer_sync_fd.semaphore_importable ||
++ dev->renderer->info.has_implicit_fencing);
+
+ const VkImportSemaphoreResourceInfoMESA res_info = {
+ .sType = VK_STRUCTURE_TYPE_IMPORT_SEMAPHORE_RESOURCE_INFO_MESA,
+diff --git a/src/virtio/vulkan/vn_wsi.c b/src/virtio/vulkan/vn_wsi.c
+--- a/src/virtio/vulkan/vn_wsi.c
++++ b/src/virtio/vulkan/vn_wsi.c
+@@ -890,9 +890,26 @@ vn_AcquireNextImage2KHR(VkDevice device,
+ struct vn_swapchain *chain =
+ vn_wsi_chain_lookup(dev, pAcquireInfo->swapchain);
+
++ /* The common acquire helper turns the platform acquire sync into an
++ * imported sync-fd payload when the application provides a semaphore or
++ * fence. Dory has no Linux sync-fd on its MoltenVK renderer, and signaling
++ * the same object later does not clear that imported payload. Hide the
++ * application sync objects from common WSI for the implicit-fencing path;
++ * they are signaled with an ordinary host queue submission below.
++ */
++ VkAcquireNextImageInfoKHR acquire_info;
++ const VkAcquireNextImageInfoKHR *common_acquire_info = pAcquireInfo;
++ if (dev->renderer->info.has_implicit_fencing) {
++ acquire_info = *pAcquireInfo;
++ acquire_info.semaphore = VK_NULL_HANDLE;
++ acquire_info.fence = VK_NULL_HANDLE;
++ common_acquire_info = &acquire_info;
++ }
++
+ simple_mtx_lock(&chain->acquire_mutex);
+ result = wsi_common_acquire_next_image2(&dev->physical_device->wsi_device,
+- device, pAcquireInfo, pImageIndex);
++ device, common_acquire_info,
++ pImageIndex);
+ simple_mtx_unlock(&chain->acquire_mutex);
+
+ if (VN_DEBUG(WSI) && result != VK_SUCCESS) {
+@@ -908,24 +908,51 @@ vn_AcquireNextImage2KHR(VkDevice device,
+ if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR)
+ return vn_error(dev->instance, result);
+
++ /* Dory's MoltenVK renderer has no Linux sync-fd import. The common WSI
++ * acquire path still needs to signal the application's binary semaphore
++ * and/or fence, however. Submit an empty batch on a real renderer queue
++ * before returning from acquire so the host Vulkan objects receive an
++ * ordinary queue signal instead of the sync-fd import used on Linux.
++ *
++ * The submit is queued before the application can submit a wait for the
++ * acquire semaphore, preserving Vulkan ordering without relying on a
++ * dma-buf reservation object that does not exist on macOS.
++ */
++ if (dev->renderer->info.has_implicit_fencing) {
++ assert(dev->queue_count > 0);
++ const VkSubmitInfo submit = {
++ .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
++ .signalSemaphoreCount =
++ pAcquireInfo->semaphore != VK_NULL_HANDLE ? 1 : 0,
++ .pSignalSemaphores =
++ pAcquireInfo->semaphore != VK_NULL_HANDLE
++ ? &pAcquireInfo->semaphore
++ : NULL,
++ };
++ const VkResult signal_result = vn_QueueSubmit(
++ vn_queue_to_handle(&dev->queues[0]), 1, &submit,
++ pAcquireInfo->fence);
++ if (signal_result != VK_SUCCESS)
++ return signal_result;
++ return vn_result(dev->instance, result);
++ }
++
+ int sync_fd = -1;
+- if (!dev->renderer->info.has_implicit_fencing) {
+- VkDeviceMemory mem_handle =
+- wsi_common_get_memory(pAcquireInfo->swapchain, *pImageIndex);
+- struct vn_device_memory *mem = vn_device_memory_from_handle(mem_handle);
+- struct vn_image *img = mem->dedicated_img;
++ VkDeviceMemory mem_handle =
++ wsi_common_get_memory(pAcquireInfo->swapchain, *pImageIndex);
++ struct vn_device_memory *mem = vn_device_memory_from_handle(mem_handle);
++ struct vn_image *img = mem->dedicated_img;
+
+- if (img->wsi.is_prime_blit_src)
+- mem = img->wsi.blit_mem;
++ if (img->wsi.is_prime_blit_src)
++ mem = img->wsi.blit_mem;
+
+- /* Only image buffer blit is tracked (both cpu and prime), so we use if
+- * condition below for potential new blit path supported on non-Linux
+- * platforms.
+- */
+- if (mem) {
+- sync_fd =
+- vn_renderer_bo_export_sync_file(dev->renderer, mem->base_bo);
+- }
++ /* Only image buffer blit is tracked (both cpu and prime), so we use if
++ * condition below for potential new blit path supported on non-Linux
++ * platforms.
++ */
++ if (mem) {
++ sync_fd =
++ vn_renderer_bo_export_sync_file(dev->renderer, mem->base_bo);
+ }
+
+ int sem_fd = -1, fence_fd = -1;
+--
+2.50.1
diff --git a/guest/mesa/verify-build.sh b/guest/mesa/verify-build.sh
new file mode 100755
index 00000000..f5084a71
--- /dev/null
+++ b/guest/mesa/verify-build.sh
@@ -0,0 +1,66 @@
+#!/bin/bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+source guest/mesa/PINS
+
+case "${1:-arm64}" in
+ arm64|aarch64) ;;
+ *) echo "the Dory Venus runtime currently supports arm64 only" >&2; exit 64 ;;
+esac
+
+OUT="${DORY_MESA_OUT_DIR:-$ROOT/guest/out}"
+RUNTIME="$OUT/dory-mesa-venus-arm64.tar.zst"
+STAMP="$OUT/dory-mesa-venus-build-arm64.stamp"
+fail() {
+ echo "Dory Venus runtime verification failed: $*" >&2
+ exit 1
+}
+
+[ -s "$RUNTIME" ] || fail "missing $RUNTIME"
+[ -s "$STAMP" ] || fail "missing $STAMP"
+stamp_value() { sed -n "s/^$1=//p" "$STAMP"; }
+[ "$(stamp_value schema)" = 1 ] || fail "unsupported stamp schema"
+[ "$(stamp_value arch)" = arm64 ] || fail "runtime was built for another architecture"
+[ "$(stamp_value mesa_version)" = "$MESA_VERSION" ] || fail "Mesa version is stale"
+[ "$(stamp_value mesa_source_sha256)" = "$MESA_SOURCE_SHA256" ] \
+ || fail "Mesa source pin is stale"
+[ "$(stamp_value input_sha256)" = "$(guest/mesa/input-fingerprint.sh arm64)" ] \
+ || fail "runtime inputs are stale"
+[ "$(stamp_value runtime_sha256)" = "$(shasum -a 256 "$RUNTIME" | awk '{print $1}')" ] \
+ || fail "runtime digest does not match its stamp"
+
+ZSTD="${DORY_ZSTD:-$(command -v zstd 2>/dev/null || true)}"
+[ -n "$ZSTD" ] || fail "zstd is required"
+"$ZSTD" -q -t "$RUNTIME" || fail "runtime archive is corrupt"
+
+EXTRACT="$(mktemp -d "${TMPDIR:-/tmp}/dory-mesa-verify.XXXXXX")"
+trap 'rm -rf "$EXTRACT"' EXIT
+"$ZSTD" -q -d -c "$RUNTIME" | tar -xf - -C "$EXTRACT"
+ICD="$EXTRACT/opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json"
+LIBRARY="$EXTRACT/opt/dory/mesa/lib/libvulkan_virtio.so"
+PROBE="$EXTRACT/usr/lib/dory/dory-vulkan-probe"
+MANIFEST="$EXTRACT/opt/dory/mesa/share/dory/runtime.env"
+for path in "$ICD" "$LIBRARY" "$PROBE" "$MANIFEST"; do
+ [ -s "$path" ] || fail "runtime archive is missing ${path#$EXTRACT}"
+done
+[ -L "$EXTRACT/opt/dory/mesa/lib/libxcb-keysyms.so.1" ] \
+ || fail "runtime archive is missing its XCB keysyms ABI link"
+grep -Fq '"api_version": "1.4.' "$ICD" || fail "ICD API version is invalid"
+grep -Fq '"library_path": "/opt/dory/mesa/lib/libvulkan_virtio.so"' "$ICD" \
+ || fail "ICD does not select the isolated Dory library"
+grep -Fqx "mesa_version=$MESA_VERSION" "$MANIFEST" || fail "runtime manifest is stale"
+grep -Fqx "mesa_source_sha256=$MESA_SOURCE_SHA256" "$MANIFEST" \
+ || fail "runtime manifest source digest is stale"
+file "$LIBRARY" | grep -Fq 'ELF 64-bit LSB shared object, ARM aarch64' \
+ || fail "Venus ICD is not an ARM64 ELF shared library"
+file "$PROBE" | grep -Fq 'ELF 64-bit LSB pie executable, ARM aarch64' \
+ || fail "Vulkan probe is not an ARM64 ELF executable"
+grep -aFq 'VK_KHR_swapchain' "$LIBRARY" \
+ || fail "Venus ICD does not contain swapchain support"
+if grep -aFq 'llvmpipe' "$LIBRARY"; then
+ fail "isolated Venus ICD unexpectedly contains the software renderer"
+fi
+
+echo "verified Dory Mesa $MESA_VERSION Venus runtime"
From b246283f6a12f81fe2b048ee9ca9f3470911f651 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 10:57:00 +0000
Subject: [PATCH 006/338] build(app): authorize desktop audio and harden helper
pruning
---
Dory.xcodeproj/project.pbxproj | 6 +++++-
Dory/Dory.entitlements | 2 ++
DoryTests/LSUIElementBuildSettingTests.swift | 4 +++-
3 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/Dory.xcodeproj/project.pbxproj b/Dory.xcodeproj/project.pbxproj
index 286e4079..ae4147fa 100644
--- a/Dory.xcodeproj/project.pbxproj
+++ b/Dory.xcodeproj/project.pbxproj
@@ -345,7 +345,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "set -eu\nAPP=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\nHELPERS=\"$APP/Contents/Helpers\"\nif [ -d \"$HELPERS\" ]; then\n find \"$HELPERS\" -maxdepth 1 -type f -exec rm -f {} +\n rmdir \"$HELPERS\" 2>/dev/null || true\nfi\n";
+ shellScript = "set -eu\nAPP=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\nHELPERS=\"$APP/Contents/Helpers\"\ncase \"$HELPERS\" in\n \"${TARGET_BUILD_DIR}\"/*.app/Contents/Helpers) ;;\n *) echo \"error: refusing to prune unexpected helper path: $HELPERS\" >&2; exit 1 ;;\nesac\nif [ -d \"$HELPERS\" ]; then\n find \"$HELPERS\" -depth -delete\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
@@ -583,6 +583,7 @@
ENABLE_DEBUG_DYLIB = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Dory-Info.plist;
@@ -590,6 +591,7 @@
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Dory connects to its local Linux VM so your container ports are reachable at localhost.";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "Dory shares your Mac microphone with Linux desktop machines when you use audio input.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -620,6 +622,7 @@
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Dory-Info.plist;
@@ -627,6 +630,7 @@
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Dory connects to its local Linux VM so your container ports are reachable at localhost.";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "Dory shares your Mac microphone with Linux desktop machines when you use audio input.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
diff --git a/Dory/Dory.entitlements b/Dory/Dory.entitlements
index 9fddc7b3..d69c0ad5 100644
--- a/Dory/Dory.entitlements
+++ b/Dory/Dory.entitlements
@@ -10,5 +10,7 @@
com.apple.security.network.server
+ com.apple.security.device.audio-input
+
diff --git a/DoryTests/LSUIElementBuildSettingTests.swift b/DoryTests/LSUIElementBuildSettingTests.swift
index 052d13d6..b4e6341d 100644
--- a/DoryTests/LSUIElementBuildSettingTests.swift
+++ b/DoryTests/LSUIElementBuildSettingTests.swift
@@ -62,9 +62,11 @@ struct LSUIElementBuildSettingTests {
@Test func appBuildPrunesStaleBundledHelpersBeforeSigning() throws {
let text = try pbxproj()
#expect(text.contains("Prune Stale Bundled Helpers"))
- #expect(text.contains("find \\\"$HELPERS\\\" -maxdepth 1 -type f -exec rm -f {} +"))
+ #expect(text.contains("find \\\"$HELPERS\\\" -depth -delete"))
+ #expect(text.contains("refusing to prune unexpected helper path"))
#expect(text.contains("$(TARGET_BUILD_DIR)/$(WRAPPER_NAME)/Contents/Helpers"))
#expect(!text.contains("$(TARGET_BUILD_DIR)/$(WRAPPER_NAME)/Contents/Helpers/dory-vm"))
+ #expect(text.components(separatedBy: "ENABLE_USER_SCRIPT_SANDBOXING = NO;").count - 1 >= 2)
}
@Test func buildAndPublicTestRunnerScrubTransientXcodeProducts() throws {
From 103ea20259745dfba04290ed82c1e5d6c21f5558 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 11:27:33 +0000
Subject: [PATCH 007/338] feat(platform): add versioned VM planning contracts
---
.gitignore | 5 +-
docs/virtual-workspace-platform.md | 977 ++++++++++
.../DoryOperations/DoryVMResourcePolicy.swift | 594 ++++++
.../DoryVirtualMachineBackendPlanner.swift | 339 ++++
.../DoryVirtualMachineCapabilities.swift | 1597 +++++++++++++++++
.../DoryVirtualMachineDefinition.swift | 977 ++++++++++
.../DoryVMResourcePolicyTests.swift | 394 ++++
...oryVirtualMachineBackendPlannerTests.swift | 696 +++++++
.../DoryVirtualMachineCapabilitiesTests.swift | 1064 +++++++++++
.../DoryVirtualMachineDefinitionTests.swift | 556 ++++++
10 files changed, 7198 insertions(+), 1 deletion(-)
create mode 100644 docs/virtual-workspace-platform.md
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVMResourcePolicy.swift
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVMResourcePolicyTests.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
diff --git a/.gitignore b/.gitignore
index c8e10231..28db686b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -142,8 +142,11 @@ docs-build/
guest/agent/agent
# Built GitHub Pages site (CI builds website/ and deploys the output directly) plus
-# local-only planning notes. Nothing under docs/ is tracked.
+# local-only planning notes. Keep only the source platform ADR under docs/.
docs/
+!/docs/
+/docs/*
+!/docs/virtual-workspace-platform.md
!website/public/docs/
!website/public/docs/**
diff --git a/docs/virtual-workspace-platform.md b/docs/virtual-workspace-platform.md
new file mode 100644
index 00000000..56c0faae
--- /dev/null
+++ b/docs/virtual-workspace-platform.md
@@ -0,0 +1,977 @@
+# ADR: Dory virtual workspace platform architecture
+
+- **Status:** Accepted direction; implementation is staged
+- **Date:** 2026-08-20
+- **Owners:** Dory platform team
+- **Scope:** Apple-silicon Dory host first; Linux, Windows, and macOS guests
+- **First release target:** Production-qualified, accelerated ARM64 Linux desktops
+
+## Decision
+
+Dory will become a virtual-workspace platform, not a collection of distribution-specific VM
+launch paths. A user installs one small, signed Dory application, chooses only the components and
+guest media they need, and creates persistent workspaces through one consistent product surface.
+
+The platform will have six explicit layers:
+
+1. product UI;
+2. orchestration and control plane;
+3. virtualization backend adapters;
+4. a backend-independent virtual device model;
+5. versioned guest integration;
+6. a signed component and artifact supply chain.
+
+The control plane will select a backend by negotiating declared capabilities against a machine's
+requirements. It will not select implementations by scattering checks such as “if Ubuntu,” “if
+desktop,” or “if EFI” across the UI and daemon. Operating-system identity remains useful for
+catalog presentation, image preparation, and guest-tools installation, but it is not the
+virtualization architecture.
+
+The existing Linux implementation is the foundation, not a prototype to discard. The immediate
+work is to extract stable contracts around it, productize the accelerated path, and qualify it.
+Windows and macOS are then added as new image families, guest-integration providers, and backend
+capability combinations without cloning the control plane.
+
+## Why this decision
+
+Dory already has much of the difficult machinery:
+
+- one per-user daemon owns local lifecycle and exposes authenticated XPC through
+ [`DorydService`](../dory-core-swift/Sources/DorydKit/DorydService.swift);
+- [`MachineManager`](../dory-core-swift/Sources/DorydKit/MachineManager.swift) persists machine
+ configuration, supervises helpers, waits for readiness, and owns snapshot/clone/export/import;
+- [`DoryVMM`](../dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift) configures
+ Virtualization.framework EFI/direct-kernel guests with storage, networking, display, input,
+ audio, VirtioFS, NVRAM, and a persistent machine identity;
+- [`DesktopMode`](../Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift) and the
+ [`DoryHV`](../Packages/ContainerizationEngine/Sources/DoryHV) device implementations provide a
+ custom Hypervisor.framework Linux runtime with VirtIO block, network, vsock, balloon, GPU,
+ input, sound, and filesystem sharing;
+- [`VirglRenderer`](../Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift) provides
+ capability-checked VirGL2 and Venus acceleration over Metal/MoltenVK rather than pretending
+ `llvmpipe` is acceptable;
+- [`DoryInstallerISOInspector`](../dory-core-swift/Sources/DoryOperations/DoryInstallerISO.swift)
+ performs bounded, non-executing ISO architecture inspection and exact-media hashing before
+ import;
+- [`DoryInstalledLinuxBootBundle`](../dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift)
+ preserves a verified kernel/initrd/root-device contract for an EFI-installed Linux disk;
+- [`DoryComponentCatalog`](../dory-core-swift/Sources/DoryOperations/DoryComponents.swift) already
+ signs catalogs, verifies asset digests, resolves dependencies, installs immutably, and activates
+ atomically on the selected Dory data drive;
+- [`MachineBackupScheduler`](../dory-core-swift/Sources/DorydKit/MachineBackupScheduler.swift),
+ [`HealthReporter`](../dory-core-swift/Sources/DorydKit/HealthReporter.swift), and
+ [`IncidentWriter`](../dory-core-swift/Sources/DorydKit/IncidentWriter.swift) establish recovery
+ and diagnostics foundations;
+- [`guest/desktop`](../guest/desktop), [`guest/kernel`](../guest/kernel), and
+ [`guest/mesa`](../guest/mesa) provide reproducible guest integration, kernels, and an isolated
+ accelerated Vulkan runtime.
+
+The present joining layer, however, still encodes product concepts in implementation switches.
+For example, `DoryMachineConfiguration` combines kernel paths, rootfs paths, EFI media, display
+mode, resources, shares, and an untyped environment dictionary. `MachineManager.processTarget`
+then recognizes a particular combination as an “accelerated desktop.” The UI in
+[`NewMachineSheet`](../Dory/Features/Sheets/NewMachineSheet.swift) similarly starts from “Linux
+desktop” and “custom Linux ISO.” This works for proving Linux, but extending those switches to
+Windows and macOS would produce three intertwined products and make qualification unreliable.
+
+The new architecture keeps the proven engines and replaces the joining logic with durable,
+versioned contracts.
+
+## North-star user journey
+
+1. The user downloads and opens Dory. The core app is useful by itself and clearly reports host
+ compatibility.
+2. A workspace gallery offers supported templates and **Install from image**. Templates display
+ their download size, guest architecture, qualification level, license requirements, and needed
+ Dory components before anything downloads.
+3. Selecting Ubuntu, Windows 11 ARM, macOS, or a user-owned installer image creates one draft
+ `WorkspaceSpec`. Dory inspects the media without executing it and shows facts separately:
+ architecture compatibility, backend availability, device support, acceleration, guest-tools
+ status, and whether the exact combination is qualified.
+4. The settings editor presents CPU, memory, storage, graphics, displays, network adapters, audio,
+ input, shared folders, clipboard, USB, and recovery in the same structure for every workspace.
+ Controls unavailable for the chosen host/image are disabled with the missing capability and a
+ supported alternative. Settings never silently downgrade.
+5. **Create and Run** resolves components, downloads and verifies only missing artifacts, creates
+ durable machine state transactionally, boots the installer or template, and opens its display.
+6. The workspace card exposes start, stop, pause, resume, restart, duplicate, snapshot, restore,
+ export, and delete. Closing a display window does not destroy or ambiguously stop the VM.
+7. Dory Tools installs or becomes active when supported. Resize, clipboard, shared folders, time
+ sync, graceful shutdown, telemetry, application launch, and recovery report their negotiated
+ status in one **Integration health** surface.
+8. Updates change replaceable components transactionally. User disks, firmware identity, license
+ state, snapshots, and settings remain intact. A failed update returns to the last qualified
+ component selection.
+9. Diagnostics can answer “what ran?” exactly: Dory version, host build and hardware, backend,
+ virtual-hardware ABI, component and media digests, guest-tools version, negotiated capabilities,
+ and the failed lifecycle operation.
+
+The product promise is a physical-machine-like development and testing environment. That means
+durable state, correct device semantics, responsive accelerated graphics, and reproducible
+configuration. It does **not** mean claiming physical GPU passthrough, nested virtualization, or
+bit-identical hardware when the host platform cannot provide it.
+
+## System boundaries and ownership
+
+```mermaid
+flowchart TB
+ UI["Dory.app: library, create flow, settings, console"]
+ CP["doryd: desired state, operations, policy, recovery"]
+ SOLVER["Capability solver and launch planner"]
+ VZL["VZ Linux adapter"]
+ RHV["Raw-HV Linux adapter"]
+ WIN["Experimental QEMU/HVF/SBSA Windows adapter"]
+ VZM["VZ macOS adapter"]
+ DEV["Virtual hardware and device contracts"]
+ GUEST["Dory Tools guest protocol"]
+ SUPPLY["Signed component catalog and content-addressed store"]
+
+ UI -->|intent and operation stream| CP
+ CP --> SOLVER
+ SOLVER --> VZL
+ SOLVER --> RHV
+ SOLVER --> WIN
+ SOLVER --> VZM
+ VZL --> DEV
+ RHV --> DEV
+ WIN --> DEV
+ VZM --> DEV
+ CP <-->|versioned authenticated channel| GUEST
+ CP --> SUPPLY
+ SOLVER --> SUPPLY
+```
+
+### 1. Product UI
+
+The UI owns presentation, editing draft intent, and explaining support. It does not select helper
+executables, mutate disks, or infer readiness from a process existing.
+
+The machine surface should be reorganized around:
+
+- **Workspace library:** all local VMs, grouped or filtered by guest family and state;
+- **Create workspace:** source, requirements, resources, devices, integration, review;
+- **Workspace inspector:** Summary, Hardware, Network, Sharing, Recovery, Integration, Diagnostics;
+- **Components:** installed runtimes, templates, guest-tools packs, sizes, provenance, updates;
+- **Operations:** durable progress with cancelability and recovery, not transient button spinners.
+
+[`MachinesView`](../Dory/Features/Machines/MachinesView.swift) and
+[`ComponentsView`](../Dory/Features/Components/ComponentsView.swift) remain the initial views, but
+they consume control-plane projections. They must not reimplement compatibility decisions.
+
+### 2. Orchestration and control plane
+
+`doryd` remains the only production owner. Introduce the following logical contracts in a package
+that both the app and daemon can import without linking UI or VM frameworks:
+
+```swift
+struct WorkspaceSpec: Codable, Sendable {
+ let schemaVersion: Int
+ let id: WorkspaceID
+ var source: GuestSource
+ var requirements: CapabilityRequirements
+ var hardware: VirtualHardwareSpec
+ var integration: GuestIntegrationPolicy
+ var recovery: RecoveryPolicy
+}
+
+protocol MachineBackend: Sendable {
+ var descriptor: BackendDescriptor { get }
+ func probe(_ context: HostContext) async -> BackendProbe
+ func plan(_ request: BackendPlanRequest) throws -> BackendLaunchPlan
+ func start(_ plan: BackendLaunchPlan, events: MachineEventSink) async throws
+ func requestStop(_ id: WorkspaceID, deadline: Duration) async throws
+ func pause(_ id: WorkspaceID) async throws
+ func resume(_ id: WorkspaceID) async throws
+}
+```
+
+The concrete type names may change, but these boundaries may not collapse back into environment
+variables or path tuples.
+
+Control-plane responsibilities:
+
+- validate and version desired state;
+- serialize mutating operations per workspace;
+- resolve and pin a launch plan before changing state;
+- ensure required components and permissions;
+- own helper process lifetime and readiness deadlines;
+- reconcile observed state after daemon/host restart;
+- own stable MAC addresses, device IDs, firmware files, and guest identity;
+- journal every multi-step operation and perform recovery;
+- publish ordered machine events and an immutable status projection over XPC;
+- authorize access to files selected through the app and materialize security-scoped bookmarks;
+- refuse unsupported or unqualified combinations rather than applying hidden workarounds.
+
+[`MachineManager`](../dory-core-swift/Sources/DorydKit/MachineManager.swift) becomes the migration
+host for a `WorkspaceCoordinator`, `WorkspaceRepository`, `OperationJournal`, and
+`BackendRegistry`. Its existing persistence, helper supervision, readiness handoff, snapshot logic,
+and artifact safety checks should move behind those focused types incrementally.
+
+### 3. Backend adapters
+
+Backends implement host mechanisms; they do not own product policy.
+
+| Adapter | Initial scope | Existing foundation |
+|---|---|---|
+| `RawHVLinuxBackend` | Managed ARM64 Linux and supported installed ARM64 Linux with accelerated VirGL2/Venus | [`dory-hv`](../Packages/ContainerizationEngine/Sources/dory-hv), [`DoryHV`](../Packages/ContainerizationEngine/Sources/DoryHV) |
+| `VZLinuxBackend` | ARM64 Linux direct boot and EFI installation; compatibility fallback | [`DoryVMMKit`](../dory-core-swift/Sources/DoryVMMKit) |
+| `VZMacBackend` | macOS restore-image installation and macOS VM lifecycle | New adapter using Apple macOS-specific Virtualization.framework configuration |
+| `QEMUHVFWindowsBackend` | Experimental Windows 11 ARM64 on an SBSA-style machine, accelerated by Hypervisor.framework through QEMU/HVF; unavailable in public builds until authorization, device, driver, and qualification gates pass | New, separately packaged adapter; not an extension of `DoryVMMKit` |
+| `EmulatedBackend` | Possible future non-native whole-guest emulation, explicitly labeled and separately qualified | Not a release dependency |
+
+Backend probes return structured facts, including host OS/API requirements, guest architectures,
+boot mechanisms, supported device models, maximum vCPU/memory limits, pause/save support, and
+graphics API levels. `automatic` is a solver policy, not a backend.
+
+The current `DoryDesktopVMMPreference` and `DoryDesktopGraphicsPreference` in
+[`DoryDesktopRuntimeContract`](../dory-core-swift/Sources/DoryOperations/DoryDesktopRuntimeContract.swift)
+remain a compatibility input during migration. New persisted workspaces store typed preference and
+requirements. Environment keys remain test/diagnostic overrides only and are recorded in
+diagnostics when used.
+
+### 4. Backend-independent device model
+
+`VirtualHardwareSpec` describes stable guest-visible hardware. A backend must either map each
+required device to an implementation with the declared semantics or reject the plan.
+
+```text
+VirtualHardwareSpec
+ cpu: architecture, count, feature policy
+ memory: boot size, minimum, balloon policy
+ firmware: direct kernel | UEFI | macOS platform
+ storage[]: stable ID, role, bus, format, durability, discard, read-only
+ networks[]: stable MAC, attachment profile, MTU, forwards, filters
+ displays[]: dimensions, scale, acceleration requirements
+ input[]: keyboard, absolute pointer, relative pointer, tablet
+ audio[]: direction, channels, format policy, host device policy
+ shares[]: stable tag, host authorization, guest mount, read-only
+ channels[]: console, agent, clipboard, file transfer, diagnostics
+ security: secure boot, TPM, entropy, isolation policy
+```
+
+Device identity and ordering are part of a versioned **virtual-hardware ABI**. A backend update may
+not reorder disks, change NIC MACs, replace firmware identity, or change a controller visible to an
+installed OS without an explicit migration. This is essential for Windows activation, macOS
+identity, Linux boot, snapshots, and reliable testing.
+
+The raw-HV devices—[`VirtioBlk`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift),
+[`VirtioNet`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift),
+[`VirtioGPU`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift),
+[`VirtioInput`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift),
+[`VirtioSound`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift),
+[`VirtioFS`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift), and
+[`VirtioVsock`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift)—become one
+implementation of those contracts. Virtualization.framework mappings in
+[`DoryVMM`](../dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift) become another.
+
+### 5. Guest integration
+
+“Dory Tools” is a versioned protocol with optional capabilities, not a monolithic Linux script.
+The daemon authenticates the VM/channel established by the launch plan, then the guest reports a
+protocol version and supported operations.
+
+Capability groups:
+
+- readiness, shutdown, reboot, clock synchronization, and health;
+- display topology and resize acknowledgement;
+- clipboard text/image and directional policy;
+- shared-folder discovery and mount status;
+- file transfer and drag/drop with progress and cancellation;
+- network identity and telemetry;
+- filesystem freeze/thaw for application-consistent snapshots;
+- package/integration update with rollback health;
+- process/app launch for qualification and developer automation.
+
+Linux initially uses the existing Rust agent, vsock transport, and integration under
+[`guest/desktop/rootfs-overlay/usr/lib/dory`](../guest/desktop/rootfs-overlay/usr/lib/dory).
+Windows requires a signed Windows service and drivers where inbox drivers are insufficient. macOS
+uses only supported guest-side mechanisms and Virtualization.framework facilities. Missing tools
+must degrade individual integrations, not make the display or recovery console inaccessible.
+
+### 6. Components and artifact supply chain
+
+The current signed catalog is retained and generalized. A component is immutable content plus
+declared capabilities; a template is metadata referencing components and permitted source media.
+Examples include:
+
+- raw-HV Linux runtime and renderer;
+- VZ Linux runtime support files;
+- Linux kernel/runtime packs;
+- Ubuntu/Debian/Kali templates;
+- experimental QEMU/HVF/SBSA Windows runtime;
+- Windows VirtIO/guest-tools driver ISO;
+- macOS integration support;
+- architecture-specific CLI tools;
+- qualification evidence packs.
+
+Catalog v2 should add typed `provides`, `requires`, host constraints, artifact roles, provenance,
+and qualification references while preserving v1 installation during migration:
+
+```json
+{
+ "kind": "dev.dory.component",
+ "schemaVersion": 2,
+ "id": "runtime.rawhv-linux",
+ "version": "0.5.0",
+ "architectures": ["arm64"],
+ "hostRequirements": { "platform": "macos", "minimumVersion": "15.0" },
+ "provides": [
+ "backend.rawhv-linux@2",
+ "device.virtio-gpu.virgl2@1",
+ "device.virtio-gpu.venus@1"
+ ],
+ "requires": ["app.dory-core>=0.5.0"],
+ "artifacts": [
+ {
+ "role": "host-helper",
+ "path": "dory-hv",
+ "bytes": 123,
+ "sha256": "<64 lowercase hex characters>",
+ "executable": true,
+ "codeRequirement": ""
+ }
+ ],
+ "provenance": {
+ "sourceCommit": "",
+ "builder": "",
+ "recipeDigest": "",
+ "sbomDigest": "",
+ "attestationDigest": ""
+ },
+ "qualification": ["linux-desktop-arm64.apple-m2.macos-15"]
+}
+```
+
+Supply-chain rules:
+
+- the catalog is signed by an offline-rotatable root; metadata supports key IDs, expiry, and
+ rollback/freeze protection;
+- every downloaded byte has a declared size and digest and is fetched over HTTPS;
+- executable host artifacts also pass code-signature/designated-requirement validation;
+- guest images include an SBOM, package manifest, source/recipe digests, and reproducible build
+ inputs;
+- activation is an atomic pointer to an immutable installation; no consumer reads from a partial
+ download;
+- active launch plans pin component digests, so an update cannot change a running or restoring VM;
+- removal is dependency- and lease-aware; artifacts referenced by workspaces/snapshots remain;
+- rollback restores the exact prior component selection;
+- local overrides are visibly “developer/unqualified,” never indistinguishable from release bits.
+
+[`DoryComponentStore`](../dory-core-swift/Sources/DoryOperations/DoryComponents.swift) and
+[`scripts/build-components.py`](../scripts/build-components.py) are the migration starting points.
+
+## Capability negotiation
+
+### Capability model
+
+A capability has a stable identifier, semantic version, attributes, quality, and evidence:
+
+```swift
+struct Capability: Hashable, Codable, Sendable {
+ let id: CapabilityID // e.g. graphics.vulkan, lifecycle.pause
+ let version: SemanticVersion
+ let attributes: [String: Value] // API level, limits, formats, directions
+ let quality: CapabilityQuality // native, accelerated, translated, emulated
+ let evidence: EvidenceReference?
+}
+
+enum RequirementStrength: Codable { case required, preferred, optional }
+```
+
+Capabilities come from five sources:
+
+1. the host probe;
+2. installed backend components;
+3. selected media/template metadata;
+4. the virtual-hardware ABI implementation;
+5. the live guest-tools handshake.
+
+The resolver takes `WorkspaceSpec + HostCapabilities + ComponentInventory + MediaInspection` and
+returns either a fully pinned `ResolvedMachinePlan` or an ordered list of unsatisfied requirements
+and alternatives. Resolution is pure and testable. Starting a VM executes the pinned plan; it does
+not resolve again halfway through launch.
+
+Examples:
+
+- a requirement for `graphics.vulkan >= 1.3, quality >= accelerated` selects raw-HV
+ VirGL2/Venus only when renderer symbols, pinned MoltenVK, guest Mesa, and the relevant
+ qualification evidence are present;
+- a Linux ISO with ARM64 EFI can select VZ Linux installation even without Dory Tools, while clipboard
+ remains `unavailable` until the guest reports it;
+- an x86_64-only ISO on Apple silicon fails architecture resolution before disk allocation;
+- Windows never selects the VZ Linux adapter. It requires the separately authorized and qualified
+ QEMU/HVF/SBSA Windows backend and does not inherit Linux's `graphics.vulkan` result; graphics
+ requires a Windows capability such as a qualified WDDM/DirectX level;
+- macOS requires `platform.macos-vm`, a compatible restore image/hardware model, and the macOS VZ
+ adapter; it never selects the generic Linux EFI adapter based only on ARM64.
+
+### No hidden fallback
+
+Preferred requirements may produce a user-approved alternative. Required requirements fail.
+Every fallback is persisted in the plan and visible in the UI and diagnostics. In particular,
+software rendering is not an acceptable automatic substitute when a template or app qualification
+requires accelerated graphics.
+
+## Lifecycle state machine
+
+Persist a stable condition plus at most one durable mutating operation. Do not encode every
+operation combination in a single ever-growing enum.
+
+Stable conditions:
+
+- `defined`: desired state and durable artifacts exist; never booted;
+- `stopped`: bootable, not executing;
+- `running`: backend and required readiness gates are healthy;
+- `paused`: execution is resident but paused;
+- `suspended`: durable saved state exists and no helper runs;
+- `failed`: the last transition failed and includes a recovery disposition;
+- `deleting`: tombstoned; only cleanup/recovery may proceed.
+
+Durable operations:
+
+- `importing`, `provisioning`, `resolving`, `starting`, `stopping`, `pausing`, `resuming`,
+ `suspending`, `restoring`, `snapshotting`, `cloning`, `updating`, `repairing`, `deleting`.
+
+```mermaid
+stateDiagram-v2
+ [*] --> defined: create/import committed
+ defined --> stopped: provision
+ stopped --> running: resolve + start + readiness gates
+ running --> stopped: graceful stop
+ running --> paused: pause
+ paused --> running: resume
+ running --> suspended: quiesce + save
+ paused --> suspended: save
+ suspended --> running: validate pinned plan + restore
+ running --> failed: helper/device/readiness failure
+ stopped --> failed: validation/restore failure
+ failed --> stopped: repair or rollback
+ defined --> deleting
+ stopped --> deleting
+ suspended --> deleting
+ failed --> deleting
+ deleting --> [*]: artifacts released
+```
+
+Rules:
+
+- every operation has an ID, expected source condition, target condition, step journal, deadline,
+ cancellation policy, and rollback/recovery recipe;
+- events carry monotonically increasing sequence numbers so UI reconnects cannot reorder state;
+- readiness is a set of named gates (backend running, display frame where required, agent where
+ required, network where required), not one generic handoff;
+- closing the UI never changes desired power state;
+- daemon restart reconstructs observed state from the journal, helper identity, locks, and durable
+ artifacts before accepting another mutation;
+- unexpected helper exit after readiness is `failed`, not an unbounded restart loop;
+- retry budgets apply to individual, classified startup failures and are recorded;
+- stop first requests guest shutdown, then a virtual power button when supported, then force-stops
+ after a visible deadline;
+- pause is not suspend; a paused VM still owns RAM and host resources;
+- resource/device changes declare whether they are live, require restart, or require an explicit
+ device-ABI migration.
+
+The current `DoryMachineState` and `HvProcess` restart/readiness behavior are the compatibility
+implementation while this model is introduced.
+
+## Device and subsystem decisions
+
+### Storage
+
+- Store each workspace as a manifest referencing disks and firmware artifacts by stable IDs, not
+ as paths embedded throughout UI/XPC models.
+- Distinguish disk roles: boot, system, data, removable installer, tools, recovery.
+- Default to sparse, host-native files with explicit logical/allocated size. Reject shrinking.
+- Preserve write ordering and flush semantics. A backend is not qualified until power-loss and
+ forced-exit tests prove filesystem recovery.
+- EFI NVRAM, machine identifier, macOS auxiliary storage, secure-boot identity, and TPM state are
+ first-class artifacts included in snapshot/export compatibility checks.
+- A snapshot manifest records parent content, storage-controller ABI, component digests, and
+ consistency level. Linked clones arrive only after reference counting and garbage collection are
+ crash-safe.
+- Keep imported user media immutable in daemon-owned storage and retain its original digest and
+ source bookmark. Media ejection changes attachment state, not bytes.
+
+The current selected-drive and private-materialization foundations live in
+[`DoryDataDrive`](../dory-core-swift/Sources/DoryOperations/DoryDataDrive.swift),
+[`DesktopMachineAssetProvisioner`](../Dory/Runtime/Machines/DesktopMachineAssets.swift), and
+`MachineManager.prepareMachineArtifacts`.
+
+### Networking
+
+Represent networking per NIC through named attachment profiles:
+
+- **Shared/NAT:** default, isolated inbound, optional explicit port forwarding;
+- **Host-only:** deterministic private connectivity with no external route;
+- **Bridged:** explicit interface choice and permission, only when the backend/host can implement
+ and qualify it;
+- **Disconnected:** device present, link down;
+- future policy networks for test labs.
+
+Each NIC has a stable MAC, MTU, DNS policy, address policy, firewall/ingress policy, and counters.
+Port forwards are resources with conflict detection and lifecycle reconciliation. Network changes
+must survive host sleep, Wi-Fi/interface changes, VPN route changes, and daemon restarts.
+
+The first adapter continues to use the provenance-pinned `gvproxy` launch path in
+[`GVProxyDesktopLaunchPlan`](../Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift)
+and existing Dory DNS/routing. Bridged and host-only modes remain unavailable until an adapter
+reports and passes those capabilities; the UI must not imply that NAT is bridged networking.
+
+### Display and graphics
+
+- A display is a stable device with point size, pixel size, scale, refresh policy, and color-space
+ metadata. Backends report maximum display count and dimensions.
+- Window resize is a negotiated display event completed only after the guest acknowledges a mode
+ and renders a correctly sized frame.
+- Retina scale and guest UI scale are distinct and recorded.
+- The display surface and graphics acceleration capability are distinct: a VM can have a console
+ without 3D acceleration.
+- Graphics capabilities are guest-API specific: VirGL/OpenGL, Venus/Vulkan, Windows
+ WDDM/DirectX, and macOS Metal are not interchangeable labels.
+- Renderer and shader failures are fatal capability-health events with backend, context, and guest
+ application evidence. They may not silently switch the machine to `llvmpipe`.
+- Multi-display, full-screen, cursor shape/hotspot, capture/release, and sleep/wake are explicit
+ qualification cases.
+
+Linux accelerated display continues through [`DesktopMetalDisplay`](../Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift),
+`VirtioGPU`, and `VirglRenderer`. The VZ compatibility display remains
+[`DoryVMMDesktopApplication`](../dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift).
+
+### Input
+
+- Model keyboard, absolute pointer, relative pointer, and tablet independently.
+- Persist keyboard layout policy and translate host shortcuts in the display frontend, not the
+ device backend.
+- Keep input queues bounded and prioritize release events to avoid stuck keys/buttons.
+- Clipboard shortcuts invoke negotiated clipboard actions; they are not a substitute for a
+ clipboard transport.
+- Accessibility, international layouts, key repeat, modifier chords, gaming/raw input, and focus
+ changes require automated and live qualification.
+
+### Audio
+
+- Model output and input as separate optional devices with host permission and routing state.
+- Negotiate sample formats/rates and perform bounded conversion behind the backend interface.
+- Handle mute, underflow/overflow, host device changes, Bluetooth latency, sleep/wake, and
+ microphone permission revocation.
+- Never block a vCPU on host audio I/O. Metrics include queue depth, dropped frames, latency, and
+ device reconnections.
+
+The raw-HV foundation is [`VirtioSound`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift)
+with [`DoryMacAudioBackend`](../Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift).
+VZ already configures host audio devices in `DoryVMM`.
+
+### Shared folders, clipboard, and transfer
+
+- Host paths are authorized through persistent security-scoped bookmarks, canonicalized, and
+ opened with least privilege. A textual path is never authorization.
+- Each share has a stable tag, explicit read-only/read-write mode, guest mount point, case and
+ ownership policy, and backend compatibility result.
+- Default shares are minimal; the user's entire home is never an implicit desktop-workspace grant.
+- Runtime add/remove is transactional and requires guest acknowledgement or a documented restart.
+- DAX-like direct mappings remain disabled unless coherence and invalidation are proven.
+- Clipboard has off, host-to-guest, guest-to-host, and bidirectional modes, independently for text,
+ image, and files.
+- Drag/drop uses a versioned transfer protocol with staging, digest, conflict policy, cancellation,
+ progress, quarantine metadata, and cleanup; it is not implemented as an unrestricted share.
+
+Reuse [`DoryMachineShareConfiguration`](../dory-core-swift/Sources/DorydKit/MachineManager.swift),
+[`VirtioFSShareConfiguration`](../Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift),
+and [`DoryDesktopClipboardCoordinator`](../dory-core-swift/Sources/DoryVMMKit/DoryDesktopClipboardCoordinator.swift)
+behind these contracts.
+
+## Snapshots, recovery, clone, and export
+
+Snapshot consistency levels are explicit:
+
+1. **stopped:** strongest default; backend stopped and all files flushed;
+2. **guest-quiesced:** guest tools froze filesystems/applications, disks and optional device state
+ captured, then thawed;
+3. **crash-consistent:** storage barriers completed but the guest was not quiesced;
+4. **saved-state:** CPU/RAM/device state captured by a backend that declares a compatible saved
+ state format.
+
+No live snapshot is labeled application-consistent without a successful guest quiesce receipt.
+Snapshot metadata contains:
+
+- workspace spec revision and virtual-hardware ABI;
+- backend ID/version and required saved-state compatibility, if any;
+- component catalog and artifact digests;
+- disk graph and consistency level;
+- firmware/NVRAM/machine identity/TPM/macOS auxiliary storage as applicable;
+- guest-tools build and quiesce receipt;
+- media identity, host qualification key, creation operation, and checksum tree.
+
+Restore is transactional: verify all content, snapshot current replaceable state or retain a
+rollback reference, materialize to temporary names, fsync, atomically publish, then boot-verify.
+Export uses a versioned archive with a signed/checksummed manifest and never assumes another host
+supports the original backend. Import first reports portability and missing components; it does not
+partially create a workspace.
+
+Extend the proven snapshot/export logic in `MachineManager`, rather than maintaining the older
+container-image snapshots in [`MachineSnapshot`](../Dory/Runtime/Machines/MachineSnapshot.swift)
+as a second VM truth.
+
+## Security model
+
+### Trust boundaries
+
+| Boundary | Rule |
+|---|---|
+| Dory.app | Unprivileged presentation client. Sends typed intent over same-user, production-signature-authenticated XPC. |
+| `doryd` | Per-user authority for desired state, artifacts, network policy, operations, and helper supervision. Validates every client input again. |
+| VM helper | One process per VM or explicit shared engine. Holds only required VM entitlements/files/descriptors. Never becomes a second control plane. |
+| Privileged network helper | Performs only a pre-derived, ownership-checked network plan. No general command execution. |
+| Guest | Untrusted even for Dory-built images. Device parsers, queues, agent messages, filenames, and telemetry are hostile inputs. |
+| Components/media | Untrusted until signature/digest/type/size/architecture checks succeed. User media is never executed on the host. |
+| Host shares | Capability-granted by bookmark and mode, scoped to one workspace/device. |
+
+Required controls:
+
+- owner-only state directories, `O_NOFOLLOW`, regular-file/link-count/owner validation, atomic
+ writes, fsync, and bounded parsers;
+- authenticated, versioned XPC and guest channels with message-size/time limits;
+- least-privilege entitlements and descriptor passing instead of global filesystem reach;
+- no secrets in environment dictionaries, command lines, diagnostics, or component manifests;
+- explicit network exposure and collision checks; localhost is the port-forward default;
+- device fuzzing for VirtIO descriptor chains, protocol messages, ISO/GPT parsing, and archives;
+- signed guest tools/drivers and verifiable updates; Windows kernel drivers require the appropriate
+ Microsoft signing path before public distribution;
+- no automatic mounting of untrusted installer media on the host;
+- clear license/consent gates for Windows and macOS media and guest integration.
+
+## Observability and supportability
+
+Every lifecycle mutation receives an operation ID propagated through UI, daemon, backend, helper,
+guest agent, and component installer. Emit structured events with:
+
+- monotonic sequence and wall-clock time;
+- workspace ID, operation ID, spec revision, backend/ABI IDs;
+- host model/OS build and component/media digests;
+- lifecycle transition, readiness gate, duration, deadline, and classified error;
+- device health: queue stalls, resets, GPU fences/device loss, display frames, audio drops, storage
+ flush latency, network reconnects, share invalidations;
+- resource data: vCPU time, guest/host memory, balloon target, I/O, network, renderer memory, helper
+ process tree, file descriptors, and threads.
+
+Keep a bounded per-workspace flight recorder and serial/firmware console independent of Dory Tools.
+Support bundles are opt-in, redacted, size-bounded, and show the user what will be included. Raw
+clipboard, file contents, credentials, host paths, and keystrokes are excluded by default.
+
+Build on `HealthReporter`, `IncidentWriter`, raw-HV serial logs, `DorydMachineStats`, and the current
+VMM handoff. Replace string-only `lastError` as the primary diagnostic with stable error codes,
+causal chains, recovery disposition, and relevant evidence references.
+
+## Qualification and release gates
+
+Code existence is not support. A capability may be advertised only when the exact release
+candidate has evidence at all applicable levels:
+
+1. **Schema and solver tests:** serialization, migration, deterministic resolution, rejection,
+ fallback visibility, and stable hardware identity.
+2. **Device conformance:** protocol/unit/property/fuzz tests, reset/error paths, queue saturation,
+ flush/barrier semantics, and backend contract tests.
+3. **Backend integration:** boot, readiness, shutdown, pause/resume, failure injection, daemon
+ restart, host sleep/wake, component replacement, and low-disk recovery.
+4. **Guest matrix:** every supported OS release/kernel/tools combination, clean install and update,
+ with and without guest tools.
+5. **Application workloads:** browser/video/audio, IDEs including Zed, terminals, file managers,
+ package installers, compilers, containers where supported, graphics API probes, and sustained
+ I/O/network/GPU stress.
+6. **Lifecycle/data safety:** snapshot/restore/clone/export/import, corrupted/truncated artifacts,
+ force-kill at every journal step, and boot verification of recovered data.
+7. **Physical Macs:** every supported macOS major and qualified Apple-silicon generation, multiple
+ display scales, sleep/wake, network/VPN changes, microphone permissions, and external storage.
+8. **Signed-candidate binding:** app digest, helper code signatures, component catalog digest,
+ artifact/media digests, guest package manifest, host model/build, and test result are inseparable.
+
+The public compatibility UI has four levels:
+
+- **Supported:** exact matrix passed and regressions gate release;
+- **Preview:** bounded scope passed but the full release matrix has not;
+- **Unqualified:** architecture appears possible but no exact evidence;
+- **Unavailable:** a required capability is absent or policy forbids the combination.
+
+Software fallback never turns a failed acceleration gate into a supported accelerated result.
+[`LINUX_DESKTOP_PARITY.md`](../LINUX_DESKTOP_PARITY.md) remains the Linux product checklist until
+its gates are represented in executable qualification manifests.
+
+## Apple-silicon constraints and honest product scope
+
+### Linux
+
+- Hardware virtualization runs ARM64 guests on Apple silicon. An x86_64-only whole Linux ISO is
+ not a hardware-virtualized VM on this host. Dory rejects it before allocation today through
+ `DoryInstallerISOInspector`.
+- A future whole-system emulator may run x86_64 media, but it must be labeled **emulated**, has a
+ different performance/compatibility promise, and cannot satisfy native-workspace qualification.
+- x86_64 **applications** can run inside an ARM64 Linux guest through a separately supported
+ translation facility. Apple's Rosetta-for-Linux integration belongs to its
+ Virtualization.framework path and has host/guest requirements; Dory's other backends need their
+ own translated-app capability (the current Docker engine uses bundled FEX).
+- Virtualization.framework supplies a reliable generic Linux display/device path, but Dory's
+ current high-performance Linux 3D path is the custom raw-HV VirtIO GPU plus VirGL2/Venus. It is
+ translated graphics, not PCIe GPU passthrough.
+- Arbitrary installer compatibility is never inferred from ARM64 EFI alone. Kernel/device behavior
+ and sustained workload tests remain tied to media digest, host build/model, backend, and
+ virtual-hardware ABI.
+
+### Windows
+
+- The native target on Apple silicon is Windows 11 ARM64. Microsoft publishes ARM64 ISO media and
+ states that ARM64 VMs can be created on Apple-silicon Macs. Dory must not present x64 Windows ISO
+ installation as native virtualization.
+- Apple's shipped Virtualization.framework headers document Linux boot and macOS platform/install
+ configurations, but expose no Windows guest platform. `VZGenericPlatformConfiguration` and an
+ EFI loader are not a Windows support contract. Dory will therefore not disguise Windows as a
+ `DoryVMM`/VZ Linux variant.
+- The engineering backend is an experimental QEMU ARM `virt`/SBSA-style machine accelerated by
+ Hypervisor.framework through HVF. It is a separately packaged adapter with a separately versioned
+ virtual-hardware ABI. QEMU's ability to reach an installer is research evidence, not public Dory
+ support.
+- Windows 11 on ARM can translate many x86/x64 user applications, but that does not make x64
+ kernel drivers, anti-cheat, low-level hardware software, or every application compatible.
+- A booting QEMU/HVF EFI VM is not a supported Windows product. Dory must qualify stable SBSA/UEFI
+ firmware, TPM/Secure Boot policy, storage/network/input/audio devices, recovery, installer
+ drivers, and a signed Dory Tools package.
+- Linux VirGL/Venus does not provide Windows DirectX acceleration. Windows GPU support requires a
+ signed and qualified ARM64 WDDM display driver plus a host graphics translation path. The
+ Windows component cannot graduate from experimental, and Dory cannot advertise GPU acceleration,
+ until that path passes DirectX conformance and application qualification. Linux renderer code is
+ not a shortcut around this gate.
+- Microsoft's current Apple-silicon support page names Windows 365 and Parallels Desktop 18–20 as
+ the available solutions, calls those Parallels versions authorized, and documents DirectX 12 and
+ nested-virtualization limitations. Before Dory distributes or advertises a local Windows product,
+ it needs an explicit Microsoft support/authorization and licensing review; technical boot success
+ cannot substitute for that review.
+- Users supply or obtain Windows through an authorized Microsoft channel and are responsible for
+ a valid license/activation. Dory does not redistribute Windows or bypass installation/security
+ requirements.
+
+### macOS
+
+- macOS guests on Apple silicon use Apple's macOS-specific Virtualization.framework model, not
+ the generic Linux EFI model, the experimental Windows SBSA machine, or Dory's raw-HV Linux device
+ tree.
+- Creation and installation use `VZMacOSRestoreImage`/`VZMacOSInstaller` with a host-supported IPSW,
+ `VZMacPlatformConfiguration`, a restore-image-derived `VZMacHardwareModel`, a persistent
+ `VZMacMachineIdentifier`, and matching `VZMacAuxiliaryStorage`. These artifacts are part of
+ machine identity and snapshot/export compatibility.
+- Dory must use Apple-provided macOS graphics/input/storage mechanisms and capability-probe the
+ host API. The Linux VirGL/Venus stack is irrelevant to macOS guest Metal support.
+- Dory will not bundle or mirror macOS restore images. The user selects an Apple-fetched IPSW or
+ explicitly asks Dory to fetch Apple's latest image supported by the current host. Dory validates
+ the restore image's supported configuration before allocation and clearly communicates applicable
+ Apple software-license restrictions.
+- Cross-Mac restore is conditional on restore-image/hardware-model compatibility; an archive being
+ intact does not guarantee it is bootable on every Mac.
+
+### Cross-cutting host limits
+
+- Dory's current product is a macOS host application. “For everyone” first means a consistent
+ workspace product on supported Macs; Windows/Linux host implementations require their own host
+ adapters and qualification and are a later program.
+- Nested virtualization, device passthrough, saved-state APIs, bridged networking, and other host
+ features are capability-probed and version-qualified. They are not inferred from marketing names
+ or macOS version alone.
+- No public Apple API currently used by Dory provides general physical GPU passthrough. Product
+ language must describe translated, API-level acceleration and its tested limits.
+
+Primary platform references:
+
+- [Apple: Running GUI Linux in a virtual machine on a Mac](https://developer.apple.com/documentation/virtualization/running-gui-linux-in-a-virtual-machine-on-a-mac)
+- [Apple: Running macOS in a virtual machine on Apple silicon](https://developer.apple.com/documentation/virtualization/running-macos-in-a-virtual-machine-on-apple-silicon)
+- [Apple: Hypervisor framework](https://developer.apple.com/documentation/hypervisor)
+- [Microsoft: Windows 11 Arm ISO files](https://learn.microsoft.com/windows/arm/iso)
+- [Microsoft: Options for using Windows 11 with Apple-silicon Macs](https://support.microsoft.com/en-US/Windows/Experience/Platform-variants/options-for-using-windows-11-with-mac-computers-with-apple-m1-m2-and-m3-chips)
+- [Microsoft: How emulation works on Arm](https://learn.microsoft.com/windows/arm/apps-on-arm-x86-emulation)
+
+## Delivery milestones
+
+Each milestone ends in a demonstrable, releasable vertical slice. Parallel implementation is
+welcome only after shared contracts and file ownership are assigned; merging uncoordinated backend
+patches into `MachineManager` is not progress.
+
+### Milestone 0 — Contract extraction and migration safety
+
+**Goal:** Make the current Linux system expressible without behavior changes.
+
+- Add `WorkspaceSpec v2`, capability, launch-plan, device, operation, and backend contracts.
+- Add lossless migration between persisted `DoryMachineConfiguration` and the v2 Linux spec.
+- Introduce `WorkspaceRepository`, `OperationJournal`, `BackendRegistry`, and pure
+ `CapabilityResolver` behind existing XPC calls.
+- Wrap current `dory-vmm` and `dory-hv` selection as adapters; keep current launch tests passing.
+- Version the virtual-hardware ABI and record it in status, snapshots, exports, and diagnostics.
+- Replace new persisted environment settings with typed fields; retain read compatibility.
+- Gate: existing managed desktops, headless machines, custom ISO, snapshots, and backups pass with
+ byte-compatible durable artifacts and rollback tests.
+
+### Milestone 1 — Accelerated Linux as the first complete workspace
+
+**Goal:** Ship a Linux desktop that users can trust for real application work.
+
+- Make raw-HV VirGL2/Venus the resolved backend for qualified managed ARM64 Linux desktops;
+ preserve VZ as an explicit compatibility/recovery backend.
+- Productize import/install/eject/direct-boot for qualified ARM64 Linux ISOs without converting an
+ installed disk into a distro-specific image.
+- Finish reliable dynamic resolution, Retina scale, full screen, cursor, input, output/input audio,
+ clipboard, shares, graceful shutdown, and host sleep/wake.
+- Surface backend, GPU, guest tools, software fallback, and exact qualification in the UI.
+- Run representative desktop/app stress, including Zed on native Venus, browsers, package updates,
+ compilers, media, file I/O, networking, snapshot/restore, and forced recovery.
+- Gate: no known login trap, invisible text/render corruption, `llvmpipe` substitution, installer
+ freeze, or unexplained whole-guest stall in the signed-candidate matrix.
+
+### Milestone 2 — Unified Dory Tools and integration health
+
+**Goal:** Make integrations independently discoverable, updateable, and diagnosable.
+
+- Formalize the guest handshake and capability versions.
+- Turn Linux overlay scripts into a versioned tools pack with transactional update/rollback.
+- Implement transfer/drag-drop and snapshot freeze/thaw.
+- Add integration health, repair actions, and unambiguous missing-tools behavior.
+- Define Windows service/driver and macOS guest-integration packaging contracts without claiming
+ support yet.
+
+### Milestone 3 — Storage, networking, and recovery parity
+
+**Goal:** Make workspaces safe and configurable enough to replace a daily-use VM product.
+
+- Add backend-neutral NAT, host-only, disconnected, and qualified bridged profiles.
+- Finish runtime share mutation, stable NIC/device identity, port-forward reconciliation, and VPN
+ recovery.
+- Add guest-quiesced/live snapshot semantics, linked clones with safe reference accounting, durable
+ suspend where supported, and cross-host portability reports.
+- Complete low-disk, corruption, interruption, daemon crash, helper crash, and host-reboot gates.
+
+### Milestone 4 — Windows 11 ARM workspace
+
+**Goal:** Determine whether Dory can become an authorized, supportable Windows 11 ARM product, then
+create, install, run, integrate, and recover it without special-case control-plane code.
+
+- Complete Microsoft support/authorization, redistribution, driver-signing, and licensing review.
+ The milestone remains research-only unless that gate passes.
+- Build the optional `QEMUHVFWindowsBackend` around pinned QEMU/HVF with an explicit ARM
+ `virt`/SBSA virtual-hardware ABI; do not route Windows through `DoryVMM`.
+- Inspect/import user-authorized ARM64 ISO media and resolve only that Windows-capable backend/device
+ ABI.
+- Implement and qualify UEFI/SBSA firmware, TPM/Secure Boot policy, storage, network, display,
+ input, audio, and removable/tools media.
+- Deliver signed Dory Tools for readiness, shutdown, resize, clipboard, sharing, time sync,
+ telemetry, and quiesced snapshots.
+- Deliver a signed ARM64 WDDM driver and host graphics translation plan, then pass DirectX and
+ representative application qualification before advertising acceleration or graduating the
+ backend from experimental.
+- Test ARM64 and Windows-translated x86/x64 development applications; report exclusions honestly.
+- Gate: clean install, activation-preserving identity, update, sleep/wake, recovery, snapshots,
+ app workloads, authorization/support sign-off, WDDM graphics evidence, and signed-candidate
+ evidence on physical Macs.
+
+### Milestone 5 — macOS workspace
+
+**Goal:** Add supported macOS guests as a native VZ-backed workspace family.
+
+- Add restore-image discovery/download consent and compatibility inspection.
+- Implement `VZMacBackend` with stable hardware model, machine ID, auxiliary storage, display,
+ input, network, audio, and lifecycle.
+- Define macOS guest integration using supported mechanisms.
+- Extend snapshot/export portability checks for macOS-specific identity and restore compatibility.
+- Gate: installation, OS update, Xcode/developer workloads, graphics/display, sleep/wake, recovery,
+ and applicable license UX.
+
+### Milestone 6 — Broader host platform program
+
+**Goal:** Evaluate Windows and Linux Dory hosts without contaminating guest/control-plane contracts.
+
+- Implement host services and backends behind the same `MachineBackend` and artifact contracts.
+- Keep workspace manifests portable where the destination capability solver proves compatibility.
+- Do not announce a host until security boundaries, installers, updates, devices, and physical-host
+ qualification reach the same standard as the Mac product.
+
+## First implementation slices and agent ownership
+
+To move quickly without creating another patch stack, assign agents to bounded seams:
+
+| Workstream | First output | Must not edit |
+|---|---|---|
+| Contracts | Workspace/capability/device/operation types, schema fixtures, migration tests | Backend implementations and UI |
+| Resolver | Pure capability solver, rejection diagnostics, deterministic-plan tests | VM process launch code |
+| Linux backend | Adapters around existing `dory-hv`/`dory-vmm`, probes, readiness gates | Product policy and component catalog |
+| Supply chain | Catalog v2/provenance/leases, v1 migration, builder/verifier tests | Machine lifecycle |
+| Product UI | New create/settings projections driven by resolver fixtures | Direct file/process/backend selection |
+| Qualification | Machine-readable matrices and signed-candidate runner | Runtime behavior except dedicated test hooks |
+
+Integration order is contracts, resolver, adapters, daemon/XPC projection, UI, then release gates.
+Every workstream supplies tests and an explicit migration story. One owner reviews virtual-hardware
+ABI changes because a “small” disk/controller/device reorder can invalidate installed machines.
+
+## Performance engineering rules
+
+- Establish budgets before optimization: cold/warm start, first frame, input-to-present latency,
+ sustained frame pacing, storage fsync latency, network throughput/latency, audio latency, idle CPU,
+ and host memory overhead.
+- Never block a vCPU on host UI, audio, network control, filesystem watching, logging, or component
+ work. Use bounded queues and observable backpressure.
+- Avoid whole-frame copies and synchronous GPU completion on presentation paths; preserve fence and
+ resource lifetimes explicitly.
+- Treat host memory as a VM process-tree cost. Balloon targets may not hide renderer/helper memory.
+- Pin build inputs and benchmark the signed release configuration; debug/local artifacts are not
+ performance evidence.
+- Prefer a measured fast path with a correct qualified fallback. A fallback must preserve data and
+ be visible; it need not claim equal performance.
+- Any optimization that weakens flush, snapshot, share-coherence, or isolation semantics requires
+ a new capability/ABI version and qualification, not a comment.
+
+## Rejected approaches
+
+### Add more OS checks to the existing create sheet and `MachineManager`
+
+This is fast for one boot demo and expensive forever. It couples product, media, backend, devices,
+guest tools, and qualification; Windows and macOS would multiply untestable state combinations.
+
+### One backend for every guest
+
+No current Apple host API provides the best device, graphics, firmware, and lifecycle path for all
+three guest families. A common control plane and device contracts are valuable; forcing one helper
+implementation is not.
+
+### Treat “boots” as “supported”
+
+Installer boot does not prove storage durability, graphics correctness, application behavior,
+updates, sleep/wake, or recovery. Support requires exact-candidate evidence.
+
+### Ship full operating systems in Dory.app
+
+It makes the core download large, entangles update cadence, complicates licensing, and expands the
+trusted payload. Signed optional components and user-authorized media keep installation focused and
+replaceable.
+
+### Promise physical hardware or GPU passthrough equivalence
+
+Dory can provide excellent translated acceleration and stable virtual hardware, but unsupported
+passthrough claims would be technically false and would make application test results misleading.
+
+## Consequences
+
+Positive consequences:
+
+- Linux, Windows, and macOS share lifecycle, settings, recovery, diagnostics, and artifact logic;
+- new backends or devices become capability providers rather than UI/daemon rewrites;
+- the UI can explain precisely why a configuration is fast, degraded, unqualified, or impossible;
+- signed components stay small and independently updateable;
+- persistent device identity and transaction rules protect user work;
+- performance work occurs in measurable device/backend paths;
+- product claims become evidence-backed.
+
+Costs and risks:
+
+- contract extraction temporarily adds adapters and schema migration code;
+- the capability vocabulary and virtual-hardware ABI require disciplined ownership;
+- Windows graphics and drivers are a distinct engineering program, not reuse of Linux Vulkan;
+- macOS restore/licensing/platform identity constrain distribution and portability;
+- the full physical qualification matrix is expensive and must be automated aggressively;
+- a platform this broad must say “unavailable” rather than accumulating hidden compatibility
+ switches.
+
+These costs are accepted because they are smaller than maintaining three separate VM products and
+because correctness, performance, and user trust are the core differentiators Dory needs.
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVMResourcePolicy.swift b/dory-core-swift/Sources/DoryOperations/DoryVMResourcePolicy.swift
new file mode 100644
index 00000000..d30e9512
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryVMResourcePolicy.swift
@@ -0,0 +1,594 @@
+import Foundation
+
+/// Host capacity captured by the platform layer and supplied to the resource policy.
+///
+/// The policy intentionally does not read `ProcessInfo`, filesystem state, or any other
+/// process-global value. A caller can therefore evaluate the same facts during creation,
+/// editing, recovery, and tests and receive the same result.
+public struct DoryVMHostResources: Codable, Sendable, Equatable {
+ public let logicalCPUCount: UInt64
+ public let physicalMemoryBytes: UInt64
+ public let freeStorageBytes: UInt64
+ /// Virtual CPUs admitted to VMs that are running or in the process of starting.
+ /// Stopped VM configurations must not be included in this admission counter.
+ public let admittedVirtualCPUCount: UInt64
+ /// Memory admitted to VMs that are running or in the process of starting.
+ /// Stopped VM configurations must not be included in this admission counter.
+ public let admittedMemoryBytes: UInt64
+ /// Free-storage capacity already reserved for other VM disks.
+ public let reservedStorageBytes: UInt64
+
+ public init(
+ logicalCPUCount: UInt64,
+ physicalMemoryBytes: UInt64,
+ freeStorageBytes: UInt64,
+ admittedVirtualCPUCount: UInt64 = 0,
+ admittedMemoryBytes: UInt64 = 0,
+ reservedStorageBytes: UInt64 = 0
+ ) {
+ self.logicalCPUCount = logicalCPUCount
+ self.physicalMemoryBytes = physicalMemoryBytes
+ self.freeStorageBytes = freeStorageBytes
+ self.admittedVirtualCPUCount = admittedVirtualCPUCount
+ self.admittedMemoryBytes = admittedMemoryBytes
+ self.reservedStorageBytes = reservedStorageBytes
+ }
+
+ /// Compatibility bridge for callers of the initial admission API. The old "committed" name
+ /// was ambiguous; values passed here must represent running + starting VMs only.
+ @available(*, deprecated, message: "Use admittedVirtualCPUCount/admittedMemoryBytes; exclude stopped VMs")
+ public init(
+ logicalCPUCount: UInt64,
+ physicalMemoryBytes: UInt64,
+ freeStorageBytes: UInt64,
+ committedVirtualCPUCount: UInt64,
+ committedMemoryBytes: UInt64,
+ reservedStorageBytes: UInt64 = 0
+ ) {
+ self.init(
+ logicalCPUCount: logicalCPUCount,
+ physicalMemoryBytes: physicalMemoryBytes,
+ freeStorageBytes: freeStorageBytes,
+ admittedVirtualCPUCount: committedVirtualCPUCount,
+ admittedMemoryBytes: committedMemoryBytes,
+ reservedStorageBytes: reservedStorageBytes
+ )
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case logicalCPUCount
+ case physicalMemoryBytes
+ case freeStorageBytes
+ case admittedVirtualCPUCount
+ case admittedMemoryBytes
+ // Schema-zero aliases decoded for migration only; new payloads never encode them.
+ case committedVirtualCPUCount
+ case committedMemoryBytes
+ case reservedStorageBytes
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ logicalCPUCount = try container.decode(UInt64.self, forKey: .logicalCPUCount)
+ physicalMemoryBytes = try container.decode(UInt64.self, forKey: .physicalMemoryBytes)
+ freeStorageBytes = try container.decode(UInt64.self, forKey: .freeStorageBytes)
+ admittedVirtualCPUCount = try container.decodeIfPresent(
+ UInt64.self,
+ forKey: .admittedVirtualCPUCount
+ ) ?? (try container.decodeIfPresent(UInt64.self, forKey: .committedVirtualCPUCount)) ?? 0
+ admittedMemoryBytes = try container.decodeIfPresent(
+ UInt64.self,
+ forKey: .admittedMemoryBytes
+ ) ?? (try container.decodeIfPresent(UInt64.self, forKey: .committedMemoryBytes)) ?? 0
+ reservedStorageBytes = try container.decodeIfPresent(
+ UInt64.self,
+ forKey: .reservedStorageBytes
+ ) ?? 0
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(logicalCPUCount, forKey: .logicalCPUCount)
+ try container.encode(physicalMemoryBytes, forKey: .physicalMemoryBytes)
+ try container.encode(freeStorageBytes, forKey: .freeStorageBytes)
+ try container.encode(admittedVirtualCPUCount, forKey: .admittedVirtualCPUCount)
+ try container.encode(admittedMemoryBytes, forKey: .admittedMemoryBytes)
+ try container.encode(reservedStorageBytes, forKey: .reservedStorageBytes)
+ }
+}
+
+/// Resources requested for a single virtual machine.
+public struct DoryVMResourceRequest: Codable, Sendable, Equatable {
+ public let virtualCPUCount: UInt64
+ public let memoryBytes: UInt64
+ public let diskBytes: UInt64
+
+ public init(virtualCPUCount: UInt64, memoryBytes: UInt64, diskBytes: UInt64) {
+ self.virtualCPUCount = virtualCPUCount
+ self.memoryBytes = memoryBytes
+ self.diskBytes = diskBytes
+ }
+}
+
+public enum DoryVMResourceRequirementKind: String, Codable, CaseIterable, Sendable {
+ case image
+ case component
+}
+
+/// Explicit sizing requirements supplied by an image manifest or optional VM component.
+/// Workload policy stays guest-neutral; platform-specific requirements are composed here.
+public struct DoryVMResourceRequirement: Codable, Sendable, Equatable {
+ public let kind: DoryVMResourceRequirementKind
+ public let identifier: String
+ public let minimum: DoryVMResourceRequest
+ public let recommended: DoryVMResourceRequest
+
+ public init(
+ kind: DoryVMResourceRequirementKind,
+ identifier: String,
+ minimum: DoryVMResourceRequest,
+ recommended: DoryVMResourceRequest
+ ) {
+ self.kind = kind
+ self.identifier = identifier
+ self.minimum = minimum
+ self.recommended = recommended
+ }
+}
+
+/// The primary workload the virtual machine must support.
+public enum DoryVMWorkloadProfile: String, Codable, CaseIterable, Sendable {
+ /// An interactive graphical workstation, including development tools and browsers.
+ case desktop
+ /// A headless or service-oriented machine with a smaller interactive footprint.
+ case server
+ /// A temporary live environment that must have enough capacity to install an OS.
+ case installer
+}
+
+/// Guidance for one resource dimension.
+///
+/// `minimum` is the workload's hard requirement, `recommended` is the best target that fits
+/// this host, and `maximum` is the most the host can safely offer after its reserve is
+/// protected. On an undersized host, `maximum` can be lower than `minimum`; `isFeasible`
+/// makes that state explicit rather than disguising it by clamping one of the limits.
+public struct DoryVMResourceGuidance: Codable, Sendable, Equatable {
+ public let minimum: UInt64
+ public let recommended: UInt64
+ public let maximum: UInt64
+
+ public init(minimum: UInt64, recommended: UInt64, maximum: UInt64) {
+ self.minimum = minimum
+ self.recommended = recommended
+ self.maximum = maximum
+ }
+
+ public var isFeasible: Bool {
+ maximum >= minimum
+ }
+}
+
+/// Capacity deliberately retained for macOS and other host applications.
+public struct DoryVMHostReserve: Codable, Sendable, Equatable {
+ public let logicalCPUCount: UInt64
+ public let memoryBytes: UInt64
+ public let storageBytes: UInt64
+
+ public init(logicalCPUCount: UInt64, memoryBytes: UInt64, storageBytes: UInt64) {
+ self.logicalCPUCount = logicalCPUCount
+ self.memoryBytes = memoryBytes
+ self.storageBytes = storageBytes
+ }
+}
+
+public struct DoryVMResourceRecommendation: Codable, Sendable, Equatable {
+ public let virtualCPUCount: DoryVMResourceGuidance
+ public let memoryBytes: DoryVMResourceGuidance
+ public let diskBytes: DoryVMResourceGuidance
+ public let hostReserve: DoryVMHostReserve
+
+ public init(
+ virtualCPUCount: DoryVMResourceGuidance,
+ memoryBytes: DoryVMResourceGuidance,
+ diskBytes: DoryVMResourceGuidance,
+ hostReserve: DoryVMHostReserve
+ ) {
+ self.virtualCPUCount = virtualCPUCount
+ self.memoryBytes = memoryBytes
+ self.diskBytes = diskBytes
+ self.hostReserve = hostReserve
+ }
+
+ public var isFeasible: Bool {
+ virtualCPUCount.isFeasible && memoryBytes.isFeasible && diskBytes.isFeasible
+ }
+}
+
+public enum DoryVMResourceDimension: String, Codable, CaseIterable, Sendable {
+ case cpu
+ case memory
+ case storage
+}
+
+public enum DoryVMResourceValidationSeverity: String, Codable, CaseIterable, Sendable {
+ case warning
+ case error
+}
+
+public enum DoryVMResourceValidationCode: String, Codable, CaseIterable, Sendable {
+ case invalidHostCapacity
+ case hostAdmissionExceedsCapacity
+ case storageReservationExceedsCapacity
+ case hostCapacityBelowWorkloadMinimum
+ case requestBelowWorkloadMinimum
+ case requestBelowRecommendation
+ case requestExceedsHostSafeMaximum
+}
+
+/// A stable, machine-readable validation result suitable for UI, CLI, and API clients.
+public struct DoryVMResourceValidationIssue: Codable, Sendable, Equatable {
+ public let code: DoryVMResourceValidationCode
+ public let severity: DoryVMResourceValidationSeverity
+ public let resource: DoryVMResourceDimension
+ /// The value being diagnosed: host capacity for host issues, requested capacity otherwise.
+ public let actual: UInt64
+ /// The workload minimum, recommendation, or host-safe maximum associated with the issue.
+ public let threshold: UInt64
+
+ public init(
+ code: DoryVMResourceValidationCode,
+ severity: DoryVMResourceValidationSeverity,
+ resource: DoryVMResourceDimension,
+ actual: UInt64,
+ threshold: UInt64
+ ) {
+ self.code = code
+ self.severity = severity
+ self.resource = resource
+ self.actual = actual
+ self.threshold = threshold
+ }
+}
+
+public struct DoryVMResourceAssessment: Codable, Sendable, Equatable {
+ public let host: DoryVMHostResources
+ public let workload: DoryVMWorkloadProfile
+ public let requirements: [DoryVMResourceRequirement]
+ public let request: DoryVMResourceRequest
+ public let recommendation: DoryVMResourceRecommendation
+ public let issues: [DoryVMResourceValidationIssue]
+
+ public init(
+ host: DoryVMHostResources,
+ workload: DoryVMWorkloadProfile,
+ requirements: [DoryVMResourceRequirement] = [],
+ request: DoryVMResourceRequest,
+ recommendation: DoryVMResourceRecommendation,
+ issues: [DoryVMResourceValidationIssue]
+ ) {
+ self.host = host
+ self.workload = workload
+ self.requirements = requirements
+ self.request = request
+ self.recommendation = recommendation
+ self.issues = issues
+ }
+
+ public var isValid: Bool {
+ !issues.contains { $0.severity == .error }
+ }
+}
+
+/// Deterministic policy for sizing one VM while preserving enough capacity for the host.
+public enum DoryVMResourcePolicy {
+ private static let gibibyte: UInt64 = 1_073_741_824
+
+ /// Compute host-aware sizing guidance before a user has made a selection.
+ public static func recommend(
+ host: DoryVMHostResources,
+ workload: DoryVMWorkloadProfile
+ ) -> DoryVMResourceRecommendation {
+ recommend(host: host, workload: workload, requirements: [])
+ }
+
+ /// Compute guidance after composing image and component requirements with the workload.
+ /// Each resource dimension is merged independently using its strictest requirement.
+ public static func recommend(
+ host: DoryVMHostResources,
+ workload: DoryVMWorkloadProfile,
+ requirements: [DoryVMResourceRequirement]
+ ) -> DoryVMResourceRecommendation {
+ let requirements = mergedRequirements(for: workload, additional: requirements)
+ let reserve = hostReserve(for: host)
+ let maximum = DoryVMResourceRequest(
+ virtualCPUCount: availableCapacity(
+ total: host.logicalCPUCount,
+ allocated: host.admittedVirtualCPUCount,
+ reserve: reserve.logicalCPUCount
+ ),
+ memoryBytes: availableCapacity(
+ total: host.physicalMemoryBytes,
+ allocated: host.admittedMemoryBytes,
+ reserve: reserve.memoryBytes
+ ),
+ diskBytes: availableCapacity(
+ total: host.freeStorageBytes,
+ allocated: host.reservedStorageBytes,
+ reserve: reserve.storageBytes
+ )
+ )
+ return DoryVMResourceRecommendation(
+ virtualCPUCount: guidance(
+ minimum: requirements.minimum.virtualCPUCount,
+ ideal: requirements.recommended.virtualCPUCount,
+ maximum: maximum.virtualCPUCount
+ ),
+ memoryBytes: guidance(
+ minimum: requirements.minimum.memoryBytes,
+ ideal: requirements.recommended.memoryBytes,
+ maximum: maximum.memoryBytes
+ ),
+ diskBytes: guidance(
+ minimum: requirements.minimum.diskBytes,
+ ideal: requirements.recommended.diskBytes,
+ maximum: maximum.diskBytes
+ ),
+ hostReserve: reserve
+ )
+ }
+
+ /// Evaluate a request against explicit host facts and workload requirements.
+ public static func assess(
+ host: DoryVMHostResources,
+ workload: DoryVMWorkloadProfile,
+ request: DoryVMResourceRequest
+ ) -> DoryVMResourceAssessment {
+ assess(host: host, workload: workload, requirements: [], request: request)
+ }
+
+ /// Evaluate a request using workload, image, and component requirements together.
+ public static func assess(
+ host: DoryVMHostResources,
+ workload: DoryVMWorkloadProfile,
+ requirements: [DoryVMResourceRequirement],
+ request: DoryVMResourceRequest
+ ) -> DoryVMResourceAssessment {
+ let recommendation = recommend(
+ host: host,
+ workload: workload,
+ requirements: requirements
+ )
+
+ var issues: [DoryVMResourceValidationIssue] = []
+ appendIssues(
+ resource: .cpu,
+ hostCapacity: host.logicalCPUCount,
+ allocated: host.admittedVirtualCPUCount,
+ overCapacityCode: .hostAdmissionExceedsCapacity,
+ requested: request.virtualCPUCount,
+ guidance: recommendation.virtualCPUCount,
+ to: &issues
+ )
+ appendIssues(
+ resource: .memory,
+ hostCapacity: host.physicalMemoryBytes,
+ allocated: host.admittedMemoryBytes,
+ overCapacityCode: .hostAdmissionExceedsCapacity,
+ requested: request.memoryBytes,
+ guidance: recommendation.memoryBytes,
+ to: &issues
+ )
+ appendIssues(
+ resource: .storage,
+ hostCapacity: host.freeStorageBytes,
+ allocated: host.reservedStorageBytes,
+ overCapacityCode: .storageReservationExceedsCapacity,
+ requested: request.diskBytes,
+ guidance: recommendation.diskBytes,
+ to: &issues
+ )
+
+ return DoryVMResourceAssessment(
+ host: host,
+ workload: workload,
+ requirements: requirements,
+ request: request,
+ recommendation: recommendation,
+ issues: issues
+ )
+ }
+
+ private struct Requirements {
+ let minimum: DoryVMResourceRequest
+ let recommended: DoryVMResourceRequest
+ }
+
+ private static func mergedRequirements(
+ for workload: DoryVMWorkloadProfile,
+ additional: [DoryVMResourceRequirement]
+ ) -> Requirements {
+ let baseline = requirements(for: workload)
+ let minimum = additional.reduce(baseline.minimum) { current, requirement in
+ maximum(current, requirement.minimum)
+ }
+ let requestedRecommendation = additional.reduce(baseline.recommended) {
+ current,
+ requirement in
+ maximum(current, requirement.recommended)
+ }
+ return Requirements(
+ minimum: minimum,
+ // A malformed manifest cannot lower the recommendation below its own minimum.
+ recommended: maximum(minimum, requestedRecommendation)
+ )
+ }
+
+ private static func maximum(
+ _ lhs: DoryVMResourceRequest,
+ _ rhs: DoryVMResourceRequest
+ ) -> DoryVMResourceRequest {
+ DoryVMResourceRequest(
+ virtualCPUCount: max(lhs.virtualCPUCount, rhs.virtualCPUCount),
+ memoryBytes: max(lhs.memoryBytes, rhs.memoryBytes),
+ diskBytes: max(lhs.diskBytes, rhs.diskBytes)
+ )
+ }
+
+ private static func requirements(for workload: DoryVMWorkloadProfile) -> Requirements {
+ switch workload {
+ case .desktop:
+ return Requirements(
+ minimum: DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 4 * gibibyte,
+ diskBytes: 32 * gibibyte
+ ),
+ recommended: DoryVMResourceRequest(
+ virtualCPUCount: 4,
+ memoryBytes: 8 * gibibyte,
+ diskBytes: 64 * gibibyte
+ )
+ )
+ case .server:
+ return Requirements(
+ minimum: DoryVMResourceRequest(
+ virtualCPUCount: 1,
+ memoryBytes: 2 * gibibyte,
+ diskBytes: 16 * gibibyte
+ ),
+ recommended: DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 4 * gibibyte,
+ diskBytes: 32 * gibibyte
+ )
+ )
+ case .installer:
+ return Requirements(
+ minimum: DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 4 * gibibyte,
+ diskBytes: 40 * gibibyte
+ ),
+ recommended: DoryVMResourceRequest(
+ virtualCPUCount: 4,
+ memoryBytes: 8 * gibibyte,
+ diskBytes: 64 * gibibyte
+ )
+ )
+ }
+ }
+
+ private static func hostReserve(for host: DoryVMHostResources) -> DoryVMHostReserve {
+ // Keep at least one logical CPU and 25% of compute capacity available to the host.
+ let cpuReserve = max(1, ceilingFraction(host.logicalCPUCount, denominator: 4))
+ // Keep at least 2 GiB and 25% of physical memory available to the host.
+ let memoryReserve = max(2 * gibibyte, ceilingFraction(host.physicalMemoryBytes, denominator: 4))
+ // Do not consume the host's final 10 GiB or final 10% of reported free storage.
+ let storageReserve = max(10 * gibibyte, ceilingFraction(host.freeStorageBytes, denominator: 10))
+ return DoryVMHostReserve(
+ logicalCPUCount: min(cpuReserve, host.logicalCPUCount),
+ memoryBytes: min(memoryReserve, host.physicalMemoryBytes),
+ storageBytes: min(storageReserve, host.freeStorageBytes)
+ )
+ }
+
+ /// Computes ceil(value / denominator) without multiplication or addition overflow.
+ private static func ceilingFraction(_ value: UInt64, denominator: UInt64) -> UInt64 {
+ let quotient = value / denominator
+ return quotient + (value % denominator == 0 ? 0 : 1)
+ }
+
+ private static func subtractingWithoutUnderflow(_ value: UInt64, _ amount: UInt64) -> UInt64 {
+ value >= amount ? value - amount : 0
+ }
+
+ private static func availableCapacity(
+ total: UInt64,
+ allocated: UInt64,
+ reserve: UInt64
+ ) -> UInt64 {
+ subtractingWithoutUnderflow(subtractingWithoutUnderflow(total, allocated), reserve)
+ }
+
+ private static func guidance(
+ minimum: UInt64,
+ ideal: UInt64,
+ maximum: UInt64
+ ) -> DoryVMResourceGuidance {
+ // Keep the selectable default inside the safe range whenever the workload is feasible.
+ let recommended = maximum >= minimum ? min(ideal, maximum) : minimum
+ return DoryVMResourceGuidance(
+ minimum: minimum,
+ recommended: recommended,
+ maximum: maximum
+ )
+ }
+
+ private static func appendIssues(
+ resource: DoryVMResourceDimension,
+ hostCapacity: UInt64,
+ allocated: UInt64,
+ overCapacityCode: DoryVMResourceValidationCode,
+ requested: UInt64,
+ guidance: DoryVMResourceGuidance,
+ to issues: inout [DoryVMResourceValidationIssue]
+ ) {
+ if hostCapacity == 0 {
+ issues.append(DoryVMResourceValidationIssue(
+ code: .invalidHostCapacity,
+ severity: .error,
+ resource: resource,
+ actual: hostCapacity,
+ threshold: 1
+ ))
+ }
+
+ if allocated > hostCapacity {
+ issues.append(DoryVMResourceValidationIssue(
+ code: overCapacityCode,
+ severity: .error,
+ resource: resource,
+ actual: allocated,
+ threshold: hostCapacity
+ ))
+ }
+
+ if !guidance.isFeasible {
+ issues.append(DoryVMResourceValidationIssue(
+ code: .hostCapacityBelowWorkloadMinimum,
+ severity: .error,
+ resource: resource,
+ actual: guidance.maximum,
+ threshold: guidance.minimum
+ ))
+ }
+
+ if requested < guidance.minimum {
+ issues.append(DoryVMResourceValidationIssue(
+ code: .requestBelowWorkloadMinimum,
+ severity: .error,
+ resource: resource,
+ actual: requested,
+ threshold: guidance.minimum
+ ))
+ } else if requested < guidance.recommended {
+ issues.append(DoryVMResourceValidationIssue(
+ code: .requestBelowRecommendation,
+ severity: .warning,
+ resource: resource,
+ actual: requested,
+ threshold: guidance.recommended
+ ))
+ }
+
+ if requested > guidance.maximum {
+ issues.append(DoryVMResourceValidationIssue(
+ code: .requestExceedsHostSafeMaximum,
+ severity: .error,
+ resource: resource,
+ actual: requested,
+ threshold: guidance.maximum
+ ))
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
new file mode 100644
index 00000000..d00b0d60
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
@@ -0,0 +1,339 @@
+public enum DoryVirtualMachineBackendPreferencePolicy: String, Codable, Sendable, CaseIterable, Hashable {
+ /// Try the listed backends in order, then append Dory's automatic fallbacks.
+ case preferred
+ /// Evaluate only the listed backends, in the exact order supplied.
+ case required
+}
+
+public struct DoryVirtualMachineBackendPlanRequest: Codable, Sendable, Equatable, Hashable {
+ public var guest: DoryGuestPlatform
+ public var bootMedia: DoryBootMedia
+ /// Ordered graphics contracts the caller is willing to accept. A lower level is only a
+ /// fallback when the caller includes it here explicitly.
+ public var acceptableGraphics: [DoryGraphicsAccelerationLevel]
+ public var devices: DoryVirtualMachineDeviceCapabilityRequest
+ public var virtualHardwareABIVersion: UInt16
+ /// An ordered backend preference. `nil` asks the planner to use guest/media defaults.
+ public var backendPreferences: [DoryVirtualizationBackendIdentity]?
+ public var backendPreferencePolicy: DoryVirtualMachineBackendPreferencePolicy
+ public var allowsExperimentalBackends: Bool
+
+ public init(
+ guest: DoryGuestPlatform,
+ bootMedia: DoryBootMedia,
+ acceptableGraphics: [DoryGraphicsAccelerationLevel],
+ devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable,
+ virtualHardwareABIVersion: UInt16 = 1,
+ backendPreferences: [DoryVirtualizationBackendIdentity]? = nil,
+ backendPreferencePolicy: DoryVirtualMachineBackendPreferencePolicy = .preferred,
+ allowsExperimentalBackends: Bool = false
+ ) {
+ self.guest = guest
+ self.bootMedia = bootMedia
+ self.acceptableGraphics = acceptableGraphics
+ self.devices = devices
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.backendPreferences = backendPreferences
+ self.backendPreferencePolicy = backendPreferencePolicy
+ self.allowsExperimentalBackends = allowsExperimentalBackends
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case guest
+ case bootMedia
+ case acceptableGraphics
+ case devices
+ case virtualHardwareABIVersion
+ case backendPreferences
+ case backendPreferencePolicy
+ case allowsExperimentalBackends
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ guest = try container.decode(DoryGuestPlatform.self, forKey: .guest)
+ bootMedia = try container.decode(DoryBootMedia.self, forKey: .bootMedia)
+ acceptableGraphics = try container.decode(
+ [DoryGraphicsAccelerationLevel].self,
+ forKey: .acceptableGraphics
+ )
+ devices = try container.decodeIfPresent(
+ DoryVirtualMachineDeviceCapabilityRequest.self,
+ forKey: .devices
+ ) ?? .minimumBootable
+ virtualHardwareABIVersion = try container.decodeIfPresent(
+ UInt16.self,
+ forKey: .virtualHardwareABIVersion
+ ) ?? 1
+ backendPreferences = try container.decodeIfPresent(
+ [DoryVirtualizationBackendIdentity].self,
+ forKey: .backendPreferences
+ )
+ backendPreferencePolicy = try container.decodeIfPresent(
+ DoryVirtualMachineBackendPreferencePolicy.self,
+ forKey: .backendPreferencePolicy
+ ) ?? .preferred
+ allowsExperimentalBackends = try container.decodeIfPresent(
+ Bool.self,
+ forKey: .allowsExperimentalBackends
+ ) ?? false
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(guest, forKey: .guest)
+ try container.encode(bootMedia, forKey: .bootMedia)
+ try container.encode(acceptableGraphics, forKey: .acceptableGraphics)
+ try container.encode(devices, forKey: .devices)
+ try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion)
+ try container.encodeIfPresent(backendPreferences, forKey: .backendPreferences)
+ try container.encode(backendPreferencePolicy, forKey: .backendPreferencePolicy)
+ try container.encode(allowsExperimentalBackends, forKey: .allowsExperimentalBackends)
+ }
+}
+
+public enum DoryVirtualMachineBackendPlanningFailureCode: String, Codable, Sendable, Hashable {
+ case invalidPreference = "invalid-preference"
+ case noCandidate = "no-candidate"
+}
+
+public enum DoryVirtualMachineBackendPreferenceField: String, Codable, Sendable, Hashable {
+ case graphics
+ case backend
+}
+
+public enum DoryVirtualMachineBackendPreferenceIssue: String, Codable, Sendable, Hashable {
+ case empty
+ case duplicate
+ case missing
+}
+
+public struct DoryVirtualMachineBackendPlanningFailure: Codable, Sendable, Equatable, Hashable {
+ public var code: DoryVirtualMachineBackendPlanningFailureCode
+ public var preferenceField: DoryVirtualMachineBackendPreferenceField?
+ public var preferenceIssue: DoryVirtualMachineBackendPreferenceIssue?
+ public var message: String
+
+ public init(
+ code: DoryVirtualMachineBackendPlanningFailureCode,
+ preferenceField: DoryVirtualMachineBackendPreferenceField? = nil,
+ preferenceIssue: DoryVirtualMachineBackendPreferenceIssue? = nil,
+ message: String
+ ) {
+ self.code = code
+ self.preferenceField = preferenceField
+ self.preferenceIssue = preferenceIssue
+ self.message = message
+ }
+}
+
+/// A complete planning result. Failed plans retain every descriptor the evaluator considered so
+/// callers can explain missing components and unsupported combinations without re-running policy.
+public struct DoryVirtualMachineBackendPlanResult: Codable, Sendable, Equatable, Hashable {
+ public var selectedDescriptor: DoryVirtualMachineCapabilityDescriptor?
+ public var evaluatedDescriptors: [DoryVirtualMachineCapabilityDescriptor]
+ public var failure: DoryVirtualMachineBackendPlanningFailure?
+
+ public var isSuccess: Bool {
+ selectedDescriptor != nil && failure == nil
+ }
+
+ public init(
+ selectedDescriptor: DoryVirtualMachineCapabilityDescriptor?,
+ evaluatedDescriptors: [DoryVirtualMachineCapabilityDescriptor],
+ failure: DoryVirtualMachineBackendPlanningFailure?
+ ) {
+ self.selectedDescriptor = selectedDescriptor
+ self.evaluatedDescriptors = evaluatedDescriptors
+ self.failure = failure
+ }
+}
+
+/// Pure backend selection for Apple Silicon. Host discovery belongs to the daemon; this planner
+/// consumes its immutable facts and performs no I/O or framework probing.
+public enum DoryAppleSiliconVirtualMachineBackendPlanner {
+ public static func plan(
+ _ request: DoryVirtualMachineBackendPlanRequest,
+ host: DoryAppleSiliconHostFacts,
+ trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil,
+ trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil,
+ trustedMutableBootMediaProvenance: DoryTrustedMutableBootMediaProvenance? = nil,
+ trustedRuntimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = []
+ ) -> DoryVirtualMachineBackendPlanResult {
+ if request.acceptableGraphics.isEmpty {
+ return invalidPreference(
+ field: .graphics,
+ issue: .empty,
+ message: "At least one acceptable graphics level is required."
+ )
+ }
+ if hasDuplicates(request.acceptableGraphics) {
+ return invalidPreference(
+ field: .graphics,
+ issue: .duplicate,
+ message: "Acceptable graphics levels must not contain duplicates."
+ )
+ }
+ if let backendPreferences = request.backendPreferences {
+ if backendPreferences.isEmpty {
+ return invalidPreference(
+ field: .backend,
+ issue: .empty,
+ message: "An explicit backend preference list must not be empty."
+ )
+ }
+ if hasDuplicates(backendPreferences) {
+ return invalidPreference(
+ field: .backend,
+ issue: .duplicate,
+ message: "Backend preferences must not contain duplicates."
+ )
+ }
+ } else if request.backendPreferencePolicy == .required {
+ return invalidPreference(
+ field: .backend,
+ issue: .missing,
+ message: "Required backend policy needs at least one backend."
+ )
+ }
+
+ let defaults = defaultBackends(for: request.guest, bootMedia: request.bootMedia.kind)
+ let backends: [DoryVirtualizationBackendIdentity]
+ if let preferences = request.backendPreferences {
+ switch request.backendPreferencePolicy {
+ case .preferred:
+ backends = orderedUnique(preferences + defaults)
+ case .required:
+ backends = preferences
+ }
+ } else {
+ backends = defaults
+ }
+ var evaluated: [DoryVirtualMachineCapabilityDescriptor] = []
+ evaluated.reserveCapacity(request.acceptableGraphics.count * backends.count)
+
+ for graphics in request.acceptableGraphics {
+ for backend in backends {
+ let capabilityRequest = DoryVirtualMachineCapabilityRequest(
+ guest: request.guest,
+ bootMedia: request.bootMedia,
+ backend: backend,
+ graphics: graphics,
+ devices: request.devices,
+ virtualHardwareABIVersion: request.virtualHardwareABIVersion
+ )
+ evaluated.append(DoryAppleSiliconCapabilityEvaluator.evaluate(
+ capabilityRequest,
+ host: host,
+ trustedGuestImageGraphicsQualification: trustedGuestImageGraphicsQualification,
+ trustedBootMediaInspection: trustedBootMediaInspection,
+ trustedMutableBootMediaProvenance: trustedMutableBootMediaProvenance,
+ trustedRuntimeQualification: matchingRuntimeQualification(
+ for: capabilityRequest,
+ host: host,
+ in: trustedRuntimeQualifications
+ )
+ ))
+ }
+ }
+
+ let selected = evaluated.first { descriptor in
+ guard descriptor.availability.isUsable else { return false }
+ return descriptor.availability.supportTier == .supported
+ || (request.allowsExperimentalBackends
+ && descriptor.availability.supportTier == .experimental)
+ }
+
+ if let selected {
+ return DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: selected,
+ evaluatedDescriptors: evaluated,
+ failure: nil
+ )
+ }
+
+ return DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: nil,
+ evaluatedDescriptors: evaluated,
+ failure: DoryVirtualMachineBackendPlanningFailure(
+ code: .noCandidate,
+ message: request.allowsExperimentalBackends
+ ? "No evaluated configuration satisfies the requested guest, media, and graphics contracts."
+ : "No supported configuration satisfies the request; experimental backends were not allowed."
+ )
+ )
+ }
+
+ public static func defaultBackends(
+ for guest: DoryGuestPlatform,
+ bootMedia: DoryBootMediaKind
+ ) -> [DoryVirtualizationBackendIdentity] {
+ switch (guest.family, bootMedia) {
+ case (.linux, .installedLinuxBootBundle):
+ return [.doryHypervisor]
+ case (.linux, _):
+ return [.appleVirtualizationFramework, .qemuHypervisorFramework]
+ case (.macOS, _):
+ return [.appleVirtualizationFramework]
+ case (.windows, _):
+ return [.qemuHypervisorFramework]
+ }
+ }
+
+ private static func hasDuplicates(_ values: [Value]) -> Bool {
+ Set(values).count != values.count
+ }
+
+ private static func orderedUnique(_ values: [Value]) -> [Value] {
+ var seen: Set = []
+ return values.filter { seen.insert($0).inserted }
+ }
+
+ private static func matchingRuntimeQualification(
+ for request: DoryVirtualMachineCapabilityRequest,
+ host: DoryAppleSiliconHostFacts,
+ in qualifications: [DoryTrustedVirtualMachineRuntimeQualification]
+ ) -> DoryTrustedVirtualMachineRuntimeQualification? {
+ guard let hostContext = host.runtimeQualificationContext else { return nil }
+ return qualifications.first { qualification in
+ let evidence = qualification.auditEvidence
+ guard evidence.guest == request.guest
+ && evidence.bootMediaKind == request.bootMedia.kind
+ && evidence.backend == request.backend
+ && evidence.backendRuntimeBuildID == hostContext.runtimeBuildID(for: request.backend)
+ && evidence.graphics == request.graphics
+ && evidence.devices == request.devices
+ && evidence.virtualHardwareABIVersion == request.virtualHardwareABIVersion
+ && evidence.virtualHardwareABIVersion == hostContext.virtualHardwareABIVersion else {
+ return false
+ }
+ if request.bootMedia.kind == .virtualDisk {
+ return evidence.immutableArtifactSHA256 == nil
+ && evidence.mutableProvenance == request.bootMedia.mutableProvenance
+ }
+ guard let requestDigest = request.bootMedia.artifactSHA256,
+ let evidenceDigest = evidence.immutableArtifactSHA256 else {
+ return false
+ }
+ return evidence.mutableProvenance == nil
+ && evidenceDigest.lowercased() == requestDigest.lowercased()
+ }
+ }
+
+ private static func invalidPreference(
+ field: DoryVirtualMachineBackendPreferenceField,
+ issue: DoryVirtualMachineBackendPreferenceIssue,
+ message: String
+ ) -> DoryVirtualMachineBackendPlanResult {
+ DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: nil,
+ evaluatedDescriptors: [],
+ failure: DoryVirtualMachineBackendPlanningFailure(
+ code: .invalidPreference,
+ preferenceField: field,
+ preferenceIssue: issue,
+ message: message
+ )
+ )
+ }
+}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
new file mode 100644
index 00000000..c03b3069
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
@@ -0,0 +1,1597 @@
+/// The operating-system family installed in a virtual machine.
+public enum DoryGuestFamily: String, Codable, Sendable, CaseIterable, Hashable {
+ case linux
+ case windows
+ case macOS = "macos"
+}
+
+/// The instruction-set architecture executed by the guest kernel.
+public enum DoryGuestArchitecture: String, Codable, Sendable, CaseIterable, Hashable {
+ case arm64
+ case x86_64
+}
+
+public struct DoryGuestPlatform: Codable, Sendable, Equatable, Hashable {
+ public var family: DoryGuestFamily
+ public var architecture: DoryGuestArchitecture
+
+ public init(family: DoryGuestFamily, architecture: DoryGuestArchitecture) {
+ self.family = family
+ self.architecture = architecture
+ }
+}
+
+/// The format presented to the virtual machine's firmware or boot loader.
+public enum DoryBootMediaKind: String, Codable, Sendable, CaseIterable, Hashable {
+ case installerISO = "installer-iso"
+ case virtualDisk = "virtual-disk"
+ case installedLinuxBootBundle = "installed-linux-boot-bundle"
+ case macOSRestoreImage = "macos-restore-image"
+}
+
+/// Where boot media originates. This is part of capability negotiation because some operating
+/// systems may be virtualized but cannot be redistributed as a Dory-managed image.
+public enum DoryBootMediaSource: String, Codable, Sendable, CaseIterable, Hashable {
+ case bundledByDory = "dory-bundled"
+ case vendorDownload = "vendor-download"
+ case userProvided = "user-provided"
+}
+
+/// Resolver identity for mutable media. A revision is stable across planning but changes whenever
+/// the daemon publishes a new disk state; it is not a caller assertion of disk contents.
+public struct DoryMutableBootMediaProvenanceReference: Codable, Sendable, Equatable, Hashable {
+ public var repositoryIdentity: String
+ public var mediaIdentity: String
+ public var revision: UInt64
+
+ public init(repositoryIdentity: String, mediaIdentity: String, revision: UInt64) {
+ self.repositoryIdentity = repositoryIdentity
+ self.mediaIdentity = mediaIdentity
+ self.revision = revision
+ }
+}
+
+public struct DoryBootMedia: Codable, Sendable, Equatable, Hashable {
+ public var kind: DoryBootMediaKind
+ public var source: DoryBootMediaSource
+ /// SHA-256 of the selected artifact, when content-addressed identity has been resolved.
+ public var artifactSHA256: String?
+ /// Resolver-owned identity requested for mutable virtual disks. Authority comes from the
+ /// daemon's trusted provenance receipt, not this wire reference.
+ public var mutableProvenance: DoryMutableBootMediaProvenanceReference?
+
+ public init(
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ artifactSHA256: String? = nil,
+ mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil
+ ) {
+ self.kind = kind
+ self.source = source
+ self.artifactSHA256 = artifactSHA256
+ self.mutableProvenance = mutableProvenance
+ }
+}
+
+/// A stable backend identifier shared by the daemon, app, and external API.
+public enum DoryVirtualizationBackendIdentity: String, Codable, Sendable, CaseIterable, Hashable {
+ /// Dory's native Hypervisor.framework virtual machine monitor.
+ case doryHypervisor = "dory-hypervisor"
+ /// Apple's higher-level Virtualization.framework runtime.
+ case appleVirtualizationFramework = "apple-virtualization-framework"
+ /// QEMU using Hypervisor.framework for same-architecture CPU virtualization.
+ case qemuHypervisorFramework = "qemu-hvf"
+}
+
+/// The graphics guarantee made to the guest. Higher cases are not implied by lower cases; callers
+/// must negotiate the exact level they require.
+public enum DoryGraphicsAccelerationLevel: String, Codable, Sendable, CaseIterable, Hashable {
+ case none
+ case software
+ /// The host compositor presents a guest framebuffer efficiently. This does not promise a
+ /// guest-visible 3D API or driver.
+ case hostAcceleratedDisplay = "host-accelerated-display"
+ /// A qualified guest driver exposes hardware-backed 3D APIs inside the virtual machine.
+ case hardwareAccelerated3D = "hardware-accelerated-3d"
+}
+
+/// Exact virtual-device contract requested by a definition or API client. Features are booleans
+/// rather than inferred from "desktop" so a backend cannot silently omit hardware or guest tools.
+public enum DoryVirtualMachineNetworkAttachmentMode: String, Codable, Sendable, CaseIterable, Hashable {
+ case disconnected
+ case sharedNAT = "shared-nat"
+ case bridged
+ case isolated
+}
+
+public struct DoryVirtualMachineDeviceCapabilityRequest: Codable, Sendable, Equatable, Hashable {
+ public var networkAttachment: DoryVirtualMachineNetworkAttachmentMode
+ public var audioInput: Bool
+ public var audioOutput: Bool
+ public var keyboard: Bool
+ public var pointer: Bool
+ public var directorySharing: Bool
+ public var clipboard: Bool
+ public var clockSynchronization: Bool
+ public var dynamicDisplay: Bool
+ public var gracefulShutdown: Bool
+
+ public init(
+ networkAttachment: DoryVirtualMachineNetworkAttachmentMode = .sharedNAT,
+ audioInput: Bool = false,
+ audioOutput: Bool = false,
+ keyboard: Bool = false,
+ pointer: Bool = false,
+ directorySharing: Bool = false,
+ clipboard: Bool = false,
+ clockSynchronization: Bool = false,
+ dynamicDisplay: Bool = false,
+ gracefulShutdown: Bool = false
+ ) {
+ self.networkAttachment = networkAttachment
+ self.audioInput = audioInput
+ self.audioOutput = audioOutput
+ self.keyboard = keyboard
+ self.pointer = pointer
+ self.directorySharing = directorySharing
+ self.clipboard = clipboard
+ self.clockSynchronization = clockSynchronization
+ self.dynamicDisplay = dynamicDisplay
+ self.gracefulShutdown = gracefulShutdown
+ }
+
+ /// The smallest currently implemented cross-backend device contract.
+ public static let minimumBootable = DoryVirtualMachineDeviceCapabilityRequest()
+}
+
+/// Product maturity is independent of whether the required host component is installed.
+public enum DoryCapabilitySupportTier: String, Codable, Sendable, CaseIterable, Hashable {
+ case supported
+ case experimental
+ case unsupported
+}
+
+public enum DoryCapabilityAvailabilityState: String, Codable, Sendable, CaseIterable, Hashable {
+ case available
+ case unavailable
+}
+
+public enum DoryCapabilityReasonCode: String, Codable, Sendable, CaseIterable, Hashable {
+ case hostOperatingSystemUnsupported = "host-os-unsupported"
+ case guestArchitectureRequiresEmulation = "guest-architecture-requires-emulation"
+ case backendDoesNotSupportGuest = "backend-does-not-support-guest"
+ case bootMediaDoesNotSupportGuest = "boot-media-does-not-support-guest"
+ case bootMediaDoesNotSupportBackend = "boot-media-does-not-support-backend"
+ case guestMediaRedistributionUnavailable = "guest-media-redistribution-unavailable"
+ case graphicsModeUnsupported = "graphics-mode-unsupported"
+ case windows3DAccelerationUnsupported = "windows-3d-acceleration-unsupported"
+ case metalUnavailable = "metal-unavailable"
+ case acceleratedRendererUnavailable = "accelerated-renderer-unavailable"
+ case bootMediaArtifactDigestUnavailable = "boot-media-artifact-digest-unavailable"
+ case bootMediaArtifactDigestInvalid = "boot-media-artifact-digest-invalid"
+ case mutableBootMediaProvenanceUnavailable = "mutable-boot-media-provenance-unavailable"
+ case mutableBootMediaProvenanceInvalid = "mutable-boot-media-provenance-invalid"
+ case trustedMutableBootMediaProvenanceUnavailable = "trusted-mutable-boot-media-provenance-unavailable"
+ case mutableBootMediaProvenanceMismatch = "mutable-boot-media-provenance-mismatch"
+ case trustedBootMediaInspectionUnavailable = "trusted-boot-media-inspection-unavailable"
+ case bootMediaInspectionEvidenceInvalid = "boot-media-inspection-evidence-invalid"
+ case bootMediaArtifactInspectionMismatch = "boot-media-artifact-inspection-mismatch"
+ case bootMediaKindInspectionMismatch = "boot-media-kind-inspection-mismatch"
+ case bootMediaGuestInspectionMismatch = "boot-media-guest-inspection-mismatch"
+ case bootMediaArchitectureInspectionMismatch = "boot-media-architecture-inspection-mismatch"
+ case bootMediaNotEFIBootable = "boot-media-not-efi-bootable"
+ case macOSRestoreBuildIdentityUnavailable = "macos-restore-build-identity-unavailable"
+ case macOSRestoreHardwareModelIncompatible = "macos-restore-hardware-model-incompatible"
+ case macOSRestoreAuxiliaryStorageIncompatible = "macos-restore-auxiliary-storage-incompatible"
+ case guestImageArtifactDigestUnavailable = "guest-image-artifact-digest-unavailable"
+ case trustedGuestImageGraphicsQualificationUnavailable = "trusted-guest-image-graphics-qualification-unavailable"
+ case guestImageArtifactDigestInvalid = "guest-image-artifact-digest-invalid"
+ case guestImageQualificationManifestIdentityInvalid = "guest-image-qualification-manifest-identity-invalid"
+ case guestImageQualificationAuditEvidenceInvalid = "guest-image-qualification-audit-evidence-invalid"
+ case guestImageArtifactQualificationMismatch = "guest-image-artifact-qualification-mismatch"
+ case linuxVirtioGPUKernelDeviceUnqualified = "linux-virtio-gpu-kernel-device-unqualified"
+ case linuxVenusVulkanRuntimeUnqualified = "linux-venus-vulkan-runtime-unqualified"
+ case virtualizationFrameworkUnavailable = "virtualization-framework-unavailable"
+ case hypervisorFrameworkUnavailable = "hypervisor-framework-unavailable"
+ case backendComponentUnavailable = "backend-component-unavailable"
+ case backendComponentUnqualified = "backend-component-unqualified"
+ case windowsUEFIFirmwareUnavailable = "windows-uefi-firmware-unavailable"
+ case windowsSecureBootUnavailable = "windows-secure-boot-unavailable"
+ case windowsSBSADeviceModelUnavailable = "windows-sbsa-device-model-unavailable"
+ case virtualTPM20Unavailable = "virtual-tpm-2-unavailable"
+ case windowsStorageDriverUnavailable = "windows-storage-driver-unavailable"
+ case windowsNetworkDriverUnavailable = "windows-network-driver-unavailable"
+ case windowsDisplayDriverUnavailable = "windows-display-driver-unavailable"
+ case windowsInputDriverUnavailable = "windows-input-driver-unavailable"
+ case macOSGuestVirtualizationUnavailable = "macos-guest-virtualization-unavailable"
+ case macOSRestoreImageUnsupported = "macos-restore-image-unsupported"
+ case networkAttachmentUnsupported = "network-attachment-unsupported"
+ case audioInputUnsupported = "audio-input-unsupported"
+ case audioOutputUnsupported = "audio-output-unsupported"
+ case keyboardInputUnsupported = "keyboard-input-unsupported"
+ case pointerInputUnsupported = "pointer-input-unsupported"
+ case directorySharingUnsupported = "directory-sharing-unsupported"
+ case clipboardIntegrationUnsupported = "clipboard-integration-unsupported"
+ case clockSynchronizationUnsupported = "clock-synchronization-unsupported"
+ case dynamicDisplayUnsupported = "dynamic-display-unsupported"
+ case gracefulShutdownUnsupported = "graceful-shutdown-unsupported"
+ case runtimeQualificationUnavailable = "runtime-qualification-unavailable"
+ case runtimeQualificationEvidenceInvalid = "runtime-qualification-evidence-invalid"
+ case runtimeQualificationRequestMismatch = "runtime-qualification-request-mismatch"
+ case runtimeQualificationHostMismatch = "runtime-qualification-host-mismatch"
+ case backendSupportIsExperimental = "backend-support-is-experimental"
+ case windowsSupportIsExperimental = "windows-support-is-experimental"
+}
+
+public struct DoryCapabilityReason: Codable, Sendable, Equatable, Hashable {
+ public var code: DoryCapabilityReasonCode
+ public var message: String
+
+ public init(code: DoryCapabilityReasonCode, message: String) {
+ self.code = code
+ self.message = message
+ }
+}
+
+public struct DoryCapabilityAvailability: Codable, Sendable, Equatable, Hashable {
+ public var supportTier: DoryCapabilitySupportTier
+ public var state: DoryCapabilityAvailabilityState
+ public var reason: DoryCapabilityReason?
+
+ public var isUsable: Bool {
+ supportTier != .unsupported && state == .available
+ }
+
+ public init(
+ supportTier: DoryCapabilitySupportTier,
+ state: DoryCapabilityAvailabilityState,
+ reason: DoryCapabilityReason? = nil
+ ) {
+ self.supportTier = supportTier
+ self.state = state
+ self.reason = reason
+ }
+}
+
+/// Driver qualification is explicit so the presence of a QEMU executable alone can never make a
+/// Windows VM appear runnable. Each fact represents a tested ARM64 guest driver, not merely a file.
+public struct DoryWindowsGuestDriverFacts: Codable, Sendable, Equatable, Hashable {
+ public var storageAvailable: Bool
+ public var networkAvailable: Bool
+ public var displayAvailable: Bool
+ public var inputAvailable: Bool
+
+ public init(
+ storageAvailable: Bool,
+ networkAvailable: Bool,
+ displayAvailable: Bool,
+ inputAvailable: Bool
+ ) {
+ self.storageAvailable = storageAvailable
+ self.networkAvailable = networkAvailable
+ self.displayAvailable = displayAvailable
+ self.inputAvailable = inputAvailable
+ }
+}
+
+/// Non-secret evidence persisted with a plan so the daemon can re-resolve and revalidate the exact
+/// signed manifest that authorized an artifact. It is audit evidence, never a trust decision.
+public struct DorySignedArtifactQualificationEvidence: Codable, Sendable, Equatable, Hashable {
+ public var manifestIdentity: String
+ public var artifactSHA256: String
+ public var manifestSHA256: String
+ public var signingKeyID: String
+ public var manifestFormatVersion: UInt16
+
+ public init(
+ manifestIdentity: String,
+ artifactSHA256: String,
+ manifestSHA256: String,
+ signingKeyID: String,
+ manifestFormatVersion: UInt16
+ ) {
+ self.manifestIdentity = manifestIdentity
+ self.artifactSHA256 = artifactSHA256
+ self.manifestSHA256 = manifestSHA256
+ self.signingKeyID = signingKeyID
+ self.manifestFormatVersion = manifestFormatVersion
+ }
+}
+
+/// Daemon inspection receipt for arbitrary boot media. User-provided ISOs do not need a Dory
+/// signature; the receipt pins the inspected bytes and deterministic inspector report instead.
+/// Catalog media may additionally retain its signed manifest reference.
+public struct DoryBootMediaInspectionAuditEvidence: Codable, Sendable, Equatable, Hashable {
+ public var inspectionIdentity: String
+ public var artifactSHA256: String
+ public var inspectionReportSHA256: String
+ public var inspectorID: String
+ public var inspectorVersion: UInt16
+ public var catalogManifestEvidence: DorySignedArtifactQualificationEvidence?
+
+ public init(
+ inspectionIdentity: String,
+ artifactSHA256: String,
+ inspectionReportSHA256: String,
+ inspectorID: String,
+ inspectorVersion: UInt16,
+ catalogManifestEvidence: DorySignedArtifactQualificationEvidence? = nil
+ ) {
+ self.inspectionIdentity = inspectionIdentity
+ self.artifactSHA256 = artifactSHA256
+ self.inspectionReportSHA256 = inspectionReportSHA256
+ self.inspectorID = inspectorID
+ self.inspectorVersion = inspectorVersion
+ self.catalogManifestEvidence = catalogManifestEvidence
+ }
+}
+
+/// Resolver-owned qualification for an exact guest artifact. It is intentionally not Codable and
+/// has no public initializer or public trust facts: UI/API intent cannot manufacture signature or
+/// driver qualification. A signed-manifest resolver in DoryOperations creates these records.
+public struct DoryTrustedGuestImageGraphicsQualification: Sendable, Equatable, Hashable {
+ let auditEvidence: DorySignedArtifactQualificationEvidence
+ let virtioGPUKernelAndDeviceSupportQualified: Bool
+ let venusVulkanGuestRuntimeQualified: Bool
+
+ init(
+ auditEvidence: DorySignedArtifactQualificationEvidence,
+ virtioGPUKernelAndDeviceSupportQualified: Bool,
+ venusVulkanGuestRuntimeQualified: Bool
+ ) {
+ self.auditEvidence = auditEvidence
+ self.virtioGPUKernelAndDeviceSupportQualified = virtioGPUKernelAndDeviceSupportQualified
+ self.venusVulkanGuestRuntimeQualified = venusVulkanGuestRuntimeQualified
+ }
+}
+
+/// Daemon-owned inspection of exact boot media. Detected format/architecture and compatibility
+/// results are deliberately neither Codable nor publicly constructible; only the audit reference
+/// is copied into a successful descriptor.
+public struct DoryTrustedBootMediaInspection: Sendable, Equatable, Hashable {
+ let auditEvidence: DoryBootMediaInspectionAuditEvidence
+ let detectedKind: DoryBootMediaKind
+ let detectedGuestFamily: DoryGuestFamily
+ let detectedArchitecture: DoryGuestArchitecture
+ let isEFIBootable: Bool
+ let macOSBuildIdentifier: String?
+ let macOSHardwareModelCompatible: Bool
+ let macOSAuxiliaryStorageCompatible: Bool
+
+ init(
+ auditEvidence: DoryBootMediaInspectionAuditEvidence,
+ detectedKind: DoryBootMediaKind,
+ detectedGuestFamily: DoryGuestFamily,
+ detectedArchitecture: DoryGuestArchitecture,
+ isEFIBootable: Bool,
+ macOSBuildIdentifier: String? = nil,
+ macOSHardwareModelCompatible: Bool = false,
+ macOSAuxiliaryStorageCompatible: Bool = false
+ ) {
+ self.auditEvidence = auditEvidence
+ self.detectedKind = detectedKind
+ self.detectedGuestFamily = detectedGuestFamily
+ self.detectedArchitecture = detectedArchitecture
+ self.isEFIBootable = isEFIBootable
+ self.macOSBuildIdentifier = macOSBuildIdentifier
+ self.macOSHardwareModelCompatible = macOSHardwareModelCompatible
+ self.macOSAuxiliaryStorageCompatible = macOSAuxiliaryStorageCompatible
+ }
+}
+
+/// Codable receipt reference emitted by the daemon after it resolves an exact mutable disk
+/// revision. The receipt can be revalidated without treating request fields as authority.
+public struct DoryMutableBootMediaProvenanceAuditEvidence: Codable, Sendable, Equatable, Hashable {
+ public var receiptIdentity: String
+ public var provenance: DoryMutableBootMediaProvenanceReference
+ public var receiptSHA256: String
+ public var resolverID: String
+ public var resolverVersion: UInt16
+
+ public init(
+ receiptIdentity: String,
+ provenance: DoryMutableBootMediaProvenanceReference,
+ receiptSHA256: String,
+ resolverID: String,
+ resolverVersion: UInt16
+ ) {
+ self.receiptIdentity = receiptIdentity
+ self.provenance = provenance
+ self.receiptSHA256 = receiptSHA256
+ self.resolverID = resolverID
+ self.resolverVersion = resolverVersion
+ }
+}
+
+public struct DoryTrustedMutableBootMediaProvenance: Sendable, Equatable, Hashable {
+ let auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence
+
+ init(auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence) {
+ self.auditEvidence = auditEvidence
+ }
+}
+
+/// Host runtime identities used to prevent a qualification for one adapter or virtual-device ABI
+/// from authorizing a different installed runtime.
+public struct DoryVirtualMachineRuntimeQualificationHostContext: Codable, Sendable, Equatable, Hashable {
+ public var virtualHardwareABIVersion: UInt16
+ public var doryHypervisorRuntimeBuildID: String
+ public var virtualizationFrameworkAdapterBuildID: String
+ public var qemuRuntimeBuildID: String
+
+ public init(
+ virtualHardwareABIVersion: UInt16,
+ doryHypervisorRuntimeBuildID: String,
+ virtualizationFrameworkAdapterBuildID: String,
+ qemuRuntimeBuildID: String
+ ) {
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.doryHypervisorRuntimeBuildID = doryHypervisorRuntimeBuildID
+ self.virtualizationFrameworkAdapterBuildID = virtualizationFrameworkAdapterBuildID
+ self.qemuRuntimeBuildID = qemuRuntimeBuildID
+ }
+
+ func runtimeBuildID(for backend: DoryVirtualizationBackendIdentity) -> String {
+ switch backend {
+ case .doryHypervisor:
+ doryHypervisorRuntimeBuildID
+ case .appleVirtualizationFramework:
+ virtualizationFrameworkAdapterBuildID
+ case .qemuHypervisorFramework:
+ qemuRuntimeBuildID
+ }
+ }
+}
+
+/// Non-secret audit reference for exact runtime qualification. Exactly one media binding is used:
+/// an immutable artifact digest or a mutable daemon provenance revision.
+public struct DoryVirtualMachineRuntimeQualificationEvidence: Codable, Sendable, Equatable, Hashable {
+ public var qualificationIdentity: String
+ public var qualificationReportSHA256: String
+ public var signingKeyID: String
+ public var qualificationFormatVersion: UInt16
+ public var guest: DoryGuestPlatform
+ public var bootMediaKind: DoryBootMediaKind
+ public var immutableArtifactSHA256: String?
+ public var mutableProvenance: DoryMutableBootMediaProvenanceReference?
+ public var backend: DoryVirtualizationBackendIdentity
+ public var backendRuntimeBuildID: String
+ public var virtualHardwareABIVersion: UInt16
+ public var graphics: DoryGraphicsAccelerationLevel
+ public var devices: DoryVirtualMachineDeviceCapabilityRequest
+
+ public init(
+ qualificationIdentity: String,
+ qualificationReportSHA256: String,
+ signingKeyID: String,
+ qualificationFormatVersion: UInt16,
+ guest: DoryGuestPlatform,
+ bootMediaKind: DoryBootMediaKind,
+ immutableArtifactSHA256: String? = nil,
+ mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil,
+ backend: DoryVirtualizationBackendIdentity,
+ backendRuntimeBuildID: String,
+ virtualHardwareABIVersion: UInt16,
+ graphics: DoryGraphicsAccelerationLevel,
+ devices: DoryVirtualMachineDeviceCapabilityRequest
+ ) {
+ self.qualificationIdentity = qualificationIdentity
+ self.qualificationReportSHA256 = qualificationReportSHA256
+ self.signingKeyID = signingKeyID
+ self.qualificationFormatVersion = qualificationFormatVersion
+ self.guest = guest
+ self.bootMediaKind = bootMediaKind
+ self.immutableArtifactSHA256 = immutableArtifactSHA256
+ self.mutableProvenance = mutableProvenance
+ self.backend = backend
+ self.backendRuntimeBuildID = backendRuntimeBuildID
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.graphics = graphics
+ self.devices = devices
+ }
+}
+
+/// Signature verification and the qualification decision remain daemon-owned and non-Codable.
+public struct DoryTrustedVirtualMachineRuntimeQualification: Sendable, Equatable, Hashable {
+ let auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence
+ let runtimeQualified: Bool
+
+ init(
+ auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence,
+ runtimeQualified: Bool
+ ) {
+ self.auditEvidence = auditEvidence
+ self.runtimeQualified = runtimeQualified
+ }
+}
+
+/// A request is deliberately free of detected host state so it can cross process and API
+/// boundaries and be evaluated by the daemon that owns the virtualization components.
+public struct DoryVirtualMachineCapabilityRequest: Codable, Sendable, Equatable, Hashable {
+ public var guest: DoryGuestPlatform
+ public var bootMedia: DoryBootMedia
+ public var backend: DoryVirtualizationBackendIdentity
+ public var graphics: DoryGraphicsAccelerationLevel
+ public var devices: DoryVirtualMachineDeviceCapabilityRequest
+ public var virtualHardwareABIVersion: UInt16
+
+ public init(
+ guest: DoryGuestPlatform,
+ bootMedia: DoryBootMedia,
+ backend: DoryVirtualizationBackendIdentity,
+ graphics: DoryGraphicsAccelerationLevel,
+ devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable,
+ virtualHardwareABIVersion: UInt16 = 1
+ ) {
+ self.guest = guest
+ self.bootMedia = bootMedia
+ self.backend = backend
+ self.graphics = graphics
+ self.devices = devices
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case guest
+ case bootMedia
+ case backend
+ case graphics
+ case devices
+ case virtualHardwareABIVersion
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ guest = try container.decode(DoryGuestPlatform.self, forKey: .guest)
+ bootMedia = try container.decode(DoryBootMedia.self, forKey: .bootMedia)
+ backend = try container.decode(DoryVirtualizationBackendIdentity.self, forKey: .backend)
+ graphics = try container.decode(DoryGraphicsAccelerationLevel.self, forKey: .graphics)
+ devices = try container.decodeIfPresent(
+ DoryVirtualMachineDeviceCapabilityRequest.self,
+ forKey: .devices
+ ) ?? .minimumBootable
+ virtualHardwareABIVersion = try container.decodeIfPresent(
+ UInt16.self,
+ forKey: .virtualHardwareABIVersion
+ ) ?? 1
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(guest, forKey: .guest)
+ try container.encode(bootMedia, forKey: .bootMedia)
+ try container.encode(backend, forKey: .backend)
+ try container.encode(graphics, forKey: .graphics)
+ try container.encode(devices, forKey: .devices)
+ try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion)
+ }
+}
+
+/// The daemon's immutable answer to a capability request. `schemaVersion` versions the wire shape,
+/// while the evaluator version identifies the support policy used to produce the answer.
+public struct DoryVirtualMachineCapabilityDescriptor: Codable, Sendable, Equatable, Hashable {
+ public static let currentSchemaVersion: UInt16 = 2
+ public static let appleSiliconEvaluatorVersion: UInt16 = 2
+
+ public var schemaVersion: UInt16
+ public var evaluatorVersion: UInt16
+ public var request: DoryVirtualMachineCapabilityRequest
+ public var availability: DoryCapabilityAvailability
+ /// Exact device contract selected for a usable descriptor. No partial device downgrade occurs.
+ public var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?
+ public var graphicsQualificationEvidence: DorySignedArtifactQualificationEvidence?
+ public var bootMediaInspectionEvidence: DoryBootMediaInspectionAuditEvidence?
+ public var mutableBootMediaProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence?
+ public var runtimeQualificationEvidence: DoryVirtualMachineRuntimeQualificationEvidence?
+
+ public init(
+ schemaVersion: UInt16 = Self.currentSchemaVersion,
+ evaluatorVersion: UInt16,
+ request: DoryVirtualMachineCapabilityRequest,
+ availability: DoryCapabilityAvailability,
+ resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? = nil,
+ graphicsQualificationEvidence: DorySignedArtifactQualificationEvidence? = nil,
+ bootMediaInspectionEvidence: DoryBootMediaInspectionAuditEvidence? = nil,
+ mutableBootMediaProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? = nil,
+ runtimeQualificationEvidence: DoryVirtualMachineRuntimeQualificationEvidence? = nil
+ ) {
+ self.schemaVersion = schemaVersion
+ self.evaluatorVersion = evaluatorVersion
+ self.request = request
+ self.availability = availability
+ self.resolvedDevices = resolvedDevices
+ self.graphicsQualificationEvidence = graphicsQualificationEvidence
+ self.bootMediaInspectionEvidence = bootMediaInspectionEvidence
+ self.mutableBootMediaProvenanceEvidence = mutableBootMediaProvenanceEvidence
+ self.runtimeQualificationEvidence = runtimeQualificationEvidence
+ }
+}
+
+/// Host observations used by the Apple Silicon policy. No global process or framework probing is
+/// performed here, keeping evaluation deterministic and making stale daemon/UI state detectable.
+public struct DoryAppleSiliconHostFacts: Codable, Sendable, Equatable, Hashable {
+ public var macOSMajorVersion: Int
+ public var virtualizationFrameworkAvailable: Bool
+ public var hypervisorFrameworkAvailable: Bool
+ public var doryHypervisorAvailable: Bool
+ public var qemuHypervisorFrameworkAvailable: Bool
+ public var windowsUEFIFirmwareAvailable: Bool
+ public var windowsSecureBootAvailable: Bool
+ public var windowsSBSADeviceModelAvailable: Bool
+ public var virtualTPM20Available: Bool
+ public var windowsGuestDrivers: DoryWindowsGuestDriverFacts
+ public var macOSGuestVirtualizationSupported: Bool
+ public var macOSRestoreImageInstallationSupported: Bool
+ public var doryMacOSBackendAvailable: Bool
+ public var doryMacOSBackendQualified: Bool
+ public var metalAvailable: Bool
+ public var doryAcceleratedRendererAvailable: Bool
+ public var runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext?
+
+ public init(
+ macOSMajorVersion: Int,
+ virtualizationFrameworkAvailable: Bool,
+ hypervisorFrameworkAvailable: Bool,
+ doryHypervisorAvailable: Bool,
+ qemuHypervisorFrameworkAvailable: Bool,
+ windowsUEFIFirmwareAvailable: Bool,
+ windowsSecureBootAvailable: Bool,
+ windowsSBSADeviceModelAvailable: Bool,
+ virtualTPM20Available: Bool,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts,
+ macOSGuestVirtualizationSupported: Bool,
+ macOSRestoreImageInstallationSupported: Bool,
+ doryMacOSBackendAvailable: Bool,
+ doryMacOSBackendQualified: Bool,
+ metalAvailable: Bool,
+ doryAcceleratedRendererAvailable: Bool,
+ runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext? = nil
+ ) {
+ self.macOSMajorVersion = macOSMajorVersion
+ self.virtualizationFrameworkAvailable = virtualizationFrameworkAvailable
+ self.hypervisorFrameworkAvailable = hypervisorFrameworkAvailable
+ self.doryHypervisorAvailable = doryHypervisorAvailable
+ self.qemuHypervisorFrameworkAvailable = qemuHypervisorFrameworkAvailable
+ self.windowsUEFIFirmwareAvailable = windowsUEFIFirmwareAvailable
+ self.windowsSecureBootAvailable = windowsSecureBootAvailable
+ self.windowsSBSADeviceModelAvailable = windowsSBSADeviceModelAvailable
+ self.virtualTPM20Available = virtualTPM20Available
+ self.windowsGuestDrivers = windowsGuestDrivers
+ self.macOSGuestVirtualizationSupported = macOSGuestVirtualizationSupported
+ self.macOSRestoreImageInstallationSupported = macOSRestoreImageInstallationSupported
+ self.doryMacOSBackendAvailable = doryMacOSBackendAvailable
+ self.doryMacOSBackendQualified = doryMacOSBackendQualified
+ self.metalAvailable = metalAvailable
+ self.doryAcceleratedRendererAvailable = doryAcceleratedRendererAvailable
+ self.runtimeQualificationContext = runtimeQualificationContext
+ }
+}
+
+/// Conservative Apple Silicon capability policy. It only reports a configuration as usable after
+/// the guest/backend contract and the supplied host facts both satisfy the request.
+public enum DoryAppleSiliconCapabilityEvaluator {
+ public static func evaluate(
+ _ request: DoryVirtualMachineCapabilityRequest,
+ host: DoryAppleSiliconHostFacts,
+ trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil,
+ trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil,
+ trustedMutableBootMediaProvenance: DoryTrustedMutableBootMediaProvenance? = nil,
+ trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? = nil
+ ) -> DoryVirtualMachineCapabilityDescriptor {
+ let availability = evaluateAvailability(
+ request,
+ host: host,
+ trustedGuestImageGraphicsQualification: trustedGuestImageGraphicsQualification,
+ trustedBootMediaInspection: trustedBootMediaInspection,
+ trustedMutableBootMediaProvenance: trustedMutableBootMediaProvenance,
+ trustedRuntimeQualification: trustedRuntimeQualification
+ )
+ return DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: request,
+ availability: availability,
+ resolvedDevices: availability.isUsable ? request.devices : nil,
+ graphicsQualificationEvidence: availability.isUsable
+ ? graphicsAuditEvidence(
+ for: request,
+ trustedQualification: trustedGuestImageGraphicsQualification
+ )
+ : nil,
+ bootMediaInspectionEvidence: availability.isUsable
+ ? bootMediaAuditEvidence(
+ for: request,
+ trustedInspection: trustedBootMediaInspection
+ )
+ : nil,
+ mutableBootMediaProvenanceEvidence: availability.isUsable
+ ? mutableBootMediaProvenanceAuditEvidence(
+ for: request,
+ trustedProvenance: trustedMutableBootMediaProvenance
+ )
+ : nil,
+ runtimeQualificationEvidence: availability.supportTier == .supported
+ && availability.isUsable
+ ? trustedRuntimeQualification?.auditEvidence
+ : nil
+ )
+ }
+
+ private static func evaluateAvailability(
+ _ request: DoryVirtualMachineCapabilityRequest,
+ host: DoryAppleSiliconHostFacts,
+ trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification?,
+ trustedBootMediaInspection: DoryTrustedBootMediaInspection?,
+ trustedMutableBootMediaProvenance: DoryTrustedMutableBootMediaProvenance?,
+ trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification?
+ ) -> DoryCapabilityAvailability {
+ if let artifactSHA256 = request.bootMedia.artifactSHA256,
+ !isSHA256(artifactSHA256) {
+ return unsupported(
+ .bootMediaArtifactDigestInvalid,
+ "The selected boot-media artifact identity is not a valid SHA-256 digest."
+ )
+ }
+
+ guard request.guest.architecture == .arm64 else {
+ return unsupported(
+ .guestArchitectureRequiresEmulation,
+ "Apple Silicon can virtualize ARM64 guests; x86_64 guests require a CPU emulation backend."
+ )
+ }
+
+ guard let supportTier = supportTier(for: request) else {
+ return unsupported(
+ .backendDoesNotSupportGuest,
+ "The selected virtualization backend does not support this guest family."
+ )
+ }
+
+ guard bootMediaIsCompatible(request.bootMedia.kind, with: request.guest.family) else {
+ return unsupported(
+ .bootMediaDoesNotSupportGuest,
+ "The selected boot-media format is not supported for this guest family."
+ )
+ }
+
+ guard bootMediaIsCompatibleWithBackend(request) else {
+ return unsupported(
+ .bootMediaDoesNotSupportBackend,
+ "The selected backend cannot boot this media format for the requested guest."
+ )
+ }
+
+ if request.guest.family == .macOS || request.guest.family == .windows,
+ request.bootMedia.source == .bundledByDory {
+ return unsupported(
+ .guestMediaRedistributionUnavailable,
+ "This guest's installation media must come from its vendor or the user; Dory does not redistribute it."
+ )
+ }
+
+ if let unavailable = mutableBootMediaProvenanceFailure(
+ for: request,
+ trustedProvenance: trustedMutableBootMediaProvenance,
+ tier: supportTier
+ ) {
+ return unavailable
+ }
+
+ if let unavailable = bootMediaInspectionFailure(
+ for: request,
+ trustedInspection: trustedBootMediaInspection,
+ tier: supportTier
+ ) {
+ return unavailable
+ }
+
+ if request.guest.family == .windows, request.graphics == .hardwareAccelerated3D {
+ return unsupported(
+ .windows3DAccelerationUnsupported,
+ "Windows 3D acceleration is not currently supported on Apple Silicon."
+ )
+ }
+
+ guard graphicsContractIsSupported(for: request) else {
+ return unsupported(
+ .graphicsModeUnsupported,
+ "The selected backend cannot provide the requested graphics acceleration level for this guest."
+ )
+ }
+
+ if let unavailable = guestImageGraphicsQualificationFailure(
+ for: request,
+ trustedQualification: trustedGuestImageGraphicsQualification
+ ) {
+ return unavailable
+ }
+ if let unavailable = deviceCapabilityFailure(for: request, tier: supportTier) {
+ return unavailable
+ }
+
+ if let unavailable = backendAvailabilityFailure(request, host: host, tier: supportTier) {
+ return unavailable
+ }
+
+ if request.graphics == .hostAcceleratedDisplay || request.graphics == .hardwareAccelerated3D {
+ guard host.metalAvailable else {
+ return unavailable(
+ tier: supportTier,
+ code: .metalUnavailable,
+ message: "The requested graphics mode requires Metal on the host."
+ )
+ }
+ }
+
+ if request.backend == .doryHypervisor,
+ request.graphics == .hostAcceleratedDisplay || request.graphics == .hardwareAccelerated3D,
+ !host.doryAcceleratedRendererAvailable {
+ return unavailable(
+ tier: supportTier,
+ code: .acceleratedRendererUnavailable,
+ message: "Dory's accelerated graphics renderer is not installed or failed validation."
+ )
+ }
+
+ if supportTier == .supported,
+ let runtimeDecision = runtimeQualificationDecision(
+ for: request,
+ host: host,
+ trustedQualification: trustedRuntimeQualification
+ ) {
+ return runtimeDecision
+ }
+
+ if supportTier == .experimental, request.guest.family == .windows {
+ return DoryCapabilityAvailability(
+ supportTier: .experimental,
+ state: .available,
+ reason: DoryCapabilityReason(
+ code: .windowsSupportIsExperimental,
+ message: "Windows virtualization is experimental and does not include 3D acceleration."
+ )
+ )
+ }
+ if supportTier == .experimental {
+ return DoryCapabilityAvailability(
+ supportTier: .experimental,
+ state: .available,
+ reason: DoryCapabilityReason(
+ code: .backendSupportIsExperimental,
+ message: "This virtualization backend is experimental for the requested guest."
+ )
+ )
+ }
+
+ return DoryCapabilityAvailability(supportTier: .supported, state: .available)
+ }
+
+ private static func supportTier(
+ for request: DoryVirtualMachineCapabilityRequest
+ ) -> DoryCapabilitySupportTier? {
+ switch (request.guest.family, request.backend) {
+ case (.linux, .doryHypervisor),
+ (.linux, .appleVirtualizationFramework):
+ return .supported
+ case (.linux, .qemuHypervisorFramework),
+ (.macOS, .appleVirtualizationFramework),
+ (.windows, .qemuHypervisorFramework):
+ return .experimental
+ default:
+ return nil
+ }
+ }
+
+ private static func bootMediaIsCompatible(
+ _ media: DoryBootMediaKind,
+ with family: DoryGuestFamily
+ ) -> Bool {
+ switch family {
+ case .linux:
+ media == .installerISO || media == .virtualDisk || media == .installedLinuxBootBundle
+ case .windows:
+ media == .installerISO || media == .virtualDisk
+ case .macOS:
+ media == .macOSRestoreImage || media == .virtualDisk
+ }
+ }
+
+ private static func bootMediaIsCompatibleWithBackend(
+ _ request: DoryVirtualMachineCapabilityRequest
+ ) -> Bool {
+ switch (request.guest.family, request.backend) {
+ case (.linux, .doryHypervisor):
+ return request.bootMedia.kind == .installedLinuxBootBundle
+ case (.linux, .appleVirtualizationFramework),
+ (.linux, .qemuHypervisorFramework),
+ (.windows, .qemuHypervisorFramework):
+ return request.bootMedia.kind == .installerISO || request.bootMedia.kind == .virtualDisk
+ case (.macOS, .appleVirtualizationFramework):
+ return request.bootMedia.kind == .macOSRestoreImage || request.bootMedia.kind == .virtualDisk
+ default:
+ return false
+ }
+ }
+
+ private static func graphicsContractIsSupported(
+ for request: DoryVirtualMachineCapabilityRequest
+ ) -> Bool {
+ switch request.graphics {
+ case .none, .software:
+ return true
+ case .hostAcceleratedDisplay:
+ return true
+ case .hardwareAccelerated3D:
+ return (request.guest.family == .linux && request.backend == .doryHypervisor)
+ || (request.guest.family == .macOS && request.backend == .appleVirtualizationFramework)
+ }
+ }
+
+ private static func backendAvailabilityFailure(
+ _ request: DoryVirtualMachineCapabilityRequest,
+ host: DoryAppleSiliconHostFacts,
+ tier: DoryCapabilitySupportTier
+ ) -> DoryCapabilityAvailability? {
+ switch request.backend {
+ case .doryHypervisor:
+ guard host.macOSMajorVersion >= 15 else {
+ return unavailable(
+ tier: tier,
+ code: .hostOperatingSystemUnsupported,
+ message: "Dory's native hypervisor backend requires macOS 15 or newer."
+ )
+ }
+ guard host.hypervisorFrameworkAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .hypervisorFrameworkUnavailable,
+ message: "Hypervisor.framework is unavailable on this host."
+ )
+ }
+ guard host.doryHypervisorAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .backendComponentUnavailable,
+ message: "Dory's native hypervisor component is not installed or failed validation."
+ )
+ }
+ case .appleVirtualizationFramework:
+ guard host.macOSMajorVersion >= 14 else {
+ return unavailable(
+ tier: tier,
+ code: .hostOperatingSystemUnsupported,
+ message: "This Dory runtime requires macOS 14 or newer."
+ )
+ }
+ guard host.virtualizationFrameworkAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .virtualizationFrameworkUnavailable,
+ message: "Virtualization.framework is unavailable on this host."
+ )
+ }
+ if request.guest.family == .macOS {
+ guard host.macOSGuestVirtualizationSupported else {
+ return unavailable(
+ tier: tier,
+ code: .macOSGuestVirtualizationUnavailable,
+ message: "This Apple Silicon host does not report support for macOS guests."
+ )
+ }
+ guard host.doryMacOSBackendAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .backendComponentUnavailable,
+ message: "Dory's VZMac backend adapter is not installed or failed validation."
+ )
+ }
+ guard host.doryMacOSBackendQualified else {
+ return unavailable(
+ tier: tier,
+ code: .backendComponentUnqualified,
+ message: "Dory's VZMac backend adapter has not passed signed runtime qualification."
+ )
+ }
+ if request.bootMedia.kind == .macOSRestoreImage,
+ !host.macOSRestoreImageInstallationSupported {
+ return unavailable(
+ tier: tier,
+ code: .macOSRestoreImageUnsupported,
+ message: "The selected Apple restore image cannot be installed on this host."
+ )
+ }
+ }
+ case .qemuHypervisorFramework:
+ guard host.macOSMajorVersion >= 14 else {
+ return unavailable(
+ tier: tier,
+ code: .hostOperatingSystemUnsupported,
+ message: "This Dory runtime requires macOS 14 or newer."
+ )
+ }
+ guard host.hypervisorFrameworkAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .hypervisorFrameworkUnavailable,
+ message: "Hypervisor.framework is unavailable on this host."
+ )
+ }
+ guard host.qemuHypervisorFrameworkAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .backendComponentUnavailable,
+ message: "The QEMU Hypervisor.framework component is not installed or failed validation."
+ )
+ }
+ if request.guest.family == .windows {
+ guard host.windowsUEFIFirmwareAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .windowsUEFIFirmwareUnavailable,
+ message: "Qualified ARM64 UEFI firmware is required to boot this Windows guest."
+ )
+ }
+ guard host.windowsSecureBootAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .windowsSecureBootUnavailable,
+ message: "A qualified Secure Boot implementation is required for this Windows guest."
+ )
+ }
+ guard host.windowsSBSADeviceModelAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .windowsSBSADeviceModelUnavailable,
+ message: "A qualified SBSA virtual device model is required for this Windows guest."
+ )
+ }
+ guard host.virtualTPM20Available else {
+ return unavailable(
+ tier: tier,
+ code: .virtualTPM20Unavailable,
+ message: "A compatible virtual TPM 2.0 device is required for this Windows guest."
+ )
+ }
+ if let driverFailure = windowsDriverAvailabilityFailure(host.windowsGuestDrivers, tier: tier) {
+ return driverFailure
+ }
+ }
+ }
+ return nil
+ }
+
+ private static func mutableBootMediaProvenanceFailure(
+ for request: DoryVirtualMachineCapabilityRequest,
+ trustedProvenance: DoryTrustedMutableBootMediaProvenance?,
+ tier: DoryCapabilitySupportTier
+ ) -> DoryCapabilityAvailability? {
+ guard request.bootMedia.kind == .virtualDisk else {
+ guard request.bootMedia.mutableProvenance == nil else {
+ return unavailable(
+ tier: tier,
+ code: .mutableBootMediaProvenanceInvalid,
+ message: "Mutable provenance is only valid for virtual-disk boot media."
+ )
+ }
+ return nil
+ }
+ guard let requested = request.bootMedia.mutableProvenance else {
+ return unavailable(
+ tier: tier,
+ code: .mutableBootMediaProvenanceUnavailable,
+ message: "A mutable virtual disk requires a daemon-resolved provenance revision."
+ )
+ }
+ guard mutableProvenanceIsValid(requested) else {
+ return unavailable(
+ tier: tier,
+ code: .mutableBootMediaProvenanceInvalid,
+ message: "The requested mutable-disk provenance reference is malformed."
+ )
+ }
+ guard let trustedProvenance else {
+ return unavailable(
+ tier: tier,
+ code: .trustedMutableBootMediaProvenanceUnavailable,
+ message: "The daemon has not resolved the requested mutable-disk revision."
+ )
+ }
+ guard mutableProvenanceEvidenceIsValid(trustedProvenance.auditEvidence) else {
+ return unavailable(
+ tier: tier,
+ code: .mutableBootMediaProvenanceInvalid,
+ message: "The daemon's mutable-disk provenance receipt is invalid."
+ )
+ }
+ guard trustedProvenance.auditEvidence.provenance == requested else {
+ return unavailable(
+ tier: tier,
+ code: .mutableBootMediaProvenanceMismatch,
+ message: "The trusted provenance receipt describes a different mutable-disk revision."
+ )
+ }
+ return nil
+ }
+
+ private static func runtimeQualificationDecision(
+ for request: DoryVirtualMachineCapabilityRequest,
+ host: DoryAppleSiliconHostFacts,
+ trustedQualification: DoryTrustedVirtualMachineRuntimeQualification?
+ ) -> DoryCapabilityAvailability? {
+ guard let trustedQualification else {
+ return DoryCapabilityAvailability(
+ supportTier: .experimental,
+ state: .available,
+ reason: DoryCapabilityReason(
+ code: .runtimeQualificationUnavailable,
+ message: "The media is structurally bootable, but this exact backend, "
+ + "device ABI, and runtime combination is not qualified."
+ )
+ )
+ }
+ guard trustedQualification.runtimeQualified else {
+ return unavailable(
+ tier: .experimental,
+ code: .runtimeQualificationUnavailable,
+ message: "This exact media, backend, device ABI, and runtime combination failed qualification."
+ )
+ }
+ let evidence = trustedQualification.auditEvidence
+ guard runtimeQualificationEvidenceIsValid(evidence) else {
+ return unavailable(
+ tier: .supported,
+ code: .runtimeQualificationEvidenceInvalid,
+ message: "The runtime qualification has incomplete or invalid audit evidence."
+ )
+ }
+ guard evidence.guest == request.guest,
+ evidence.bootMediaKind == request.bootMedia.kind,
+ evidence.backend == request.backend,
+ evidence.graphics == request.graphics,
+ evidence.devices == request.devices,
+ evidence.virtualHardwareABIVersion == request.virtualHardwareABIVersion else {
+ return unavailable(
+ tier: .supported,
+ code: .runtimeQualificationRequestMismatch,
+ message: "The runtime qualification does not match the requested guest, backend, "
+ + "graphics, devices, or virtual-hardware ABI."
+ )
+ }
+ guard let hostContext = host.runtimeQualificationContext,
+ hostContext.virtualHardwareABIVersion == request.virtualHardwareABIVersion,
+ evidence.backendRuntimeBuildID == hostContext.runtimeBuildID(for: request.backend) else {
+ return unavailable(
+ tier: .supported,
+ code: .runtimeQualificationHostMismatch,
+ message: "The runtime qualification does not match the installed backend build or device ABI."
+ )
+ }
+
+ if request.bootMedia.kind == .virtualDisk {
+ guard evidence.immutableArtifactSHA256 == nil,
+ evidence.mutableProvenance == request.bootMedia.mutableProvenance else {
+ return unavailable(
+ tier: .supported,
+ code: .runtimeQualificationRequestMismatch,
+ message: "The runtime qualification describes a different mutable-disk revision."
+ )
+ }
+ } else {
+ guard let artifactSHA256 = request.bootMedia.artifactSHA256,
+ evidence.mutableProvenance == nil,
+ evidence.immutableArtifactSHA256?.lowercased()
+ == artifactSHA256.lowercased() else {
+ return unavailable(
+ tier: .supported,
+ code: .runtimeQualificationRequestMismatch,
+ message: "The runtime qualification describes a different immutable media artifact."
+ )
+ }
+ }
+ return nil
+ }
+
+ private static func bootMediaInspectionFailure(
+ for request: DoryVirtualMachineCapabilityRequest,
+ trustedInspection: DoryTrustedBootMediaInspection?,
+ tier: DoryCapabilitySupportTier
+ ) -> DoryCapabilityAvailability? {
+ guard request.bootMedia.kind == .installerISO
+ || request.bootMedia.kind == .macOSRestoreImage else {
+ return nil
+ }
+ guard let selectedArtifactSHA256 = request.bootMedia.artifactSHA256 else {
+ return unavailable(
+ tier: tier,
+ code: .bootMediaArtifactDigestUnavailable,
+ message: "Installer and restore media must have a daemon-resolved SHA-256 identity."
+ )
+ }
+ guard let inspection = trustedInspection else {
+ return unavailable(
+ tier: tier,
+ code: .trustedBootMediaInspectionUnavailable,
+ message: "The daemon has not inspected this exact boot-media artifact."
+ )
+ }
+ guard bootMediaInspectionEvidenceIsValid(inspection.auditEvidence) else {
+ return unavailable(
+ tier: tier,
+ code: .bootMediaInspectionEvidenceInvalid,
+ message: "The boot-media inspection has an incomplete or invalid audit receipt."
+ )
+ }
+ guard inspection.auditEvidence.artifactSHA256.lowercased()
+ == selectedArtifactSHA256.lowercased() else {
+ return unavailable(
+ tier: tier,
+ code: .bootMediaArtifactInspectionMismatch,
+ message: "The trusted inspection does not describe the selected boot-media artifact."
+ )
+ }
+ guard inspection.detectedKind == request.bootMedia.kind else {
+ return unavailable(
+ tier: tier,
+ code: .bootMediaKindInspectionMismatch,
+ message: "The inspected media format does not match the requested boot-media format."
+ )
+ }
+ guard inspection.detectedGuestFamily == request.guest.family else {
+ return unavailable(
+ tier: tier,
+ code: .bootMediaGuestInspectionMismatch,
+ message: "The inspected media does not contain the requested guest operating-system family."
+ )
+ }
+ guard inspection.detectedArchitecture == request.guest.architecture else {
+ return unavailable(
+ tier: tier,
+ code: .bootMediaArchitectureInspectionMismatch,
+ message: "The inspected media architecture does not match the requested guest architecture."
+ )
+ }
+ guard request.bootMedia.kind != .installerISO || inspection.isEFIBootable else {
+ return unavailable(
+ tier: tier,
+ code: .bootMediaNotEFIBootable,
+ message: "The inspected media has no compatible ARM64 EFI boot path."
+ )
+ }
+
+ if request.bootMedia.kind == .macOSRestoreImage {
+ guard let build = inspection.macOSBuildIdentifier, !build.isEmpty else {
+ return unavailable(
+ tier: tier,
+ code: .macOSRestoreBuildIdentityUnavailable,
+ message: "The inspected Apple restore image has no resolved build identity."
+ )
+ }
+ guard inspection.macOSHardwareModelCompatible else {
+ return unavailable(
+ tier: tier,
+ code: .macOSRestoreHardwareModelIncompatible,
+ message: "The exact Apple restore image is incompatible with the resolved virtual hardware model."
+ )
+ }
+ guard inspection.macOSAuxiliaryStorageCompatible else {
+ return unavailable(
+ tier: tier,
+ code: .macOSRestoreAuxiliaryStorageIncompatible,
+ message: "The exact Apple restore image is incompatible with the resolved auxiliary storage."
+ )
+ }
+ }
+ return nil
+ }
+
+ private static func deviceCapabilityFailure(
+ for request: DoryVirtualMachineCapabilityRequest,
+ tier: DoryCapabilitySupportTier
+ ) -> DoryCapabilityAvailability? {
+ let devices = request.devices
+ switch devices.networkAttachment {
+ case .sharedNAT:
+ break
+ case .disconnected, .bridged, .isolated:
+ return unavailable(
+ tier: tier,
+ code: .networkAttachmentUnsupported,
+ message: "The selected backend does not implement the requested network attachment mode."
+ )
+ }
+
+ let isGraphical = request.graphics != .none
+ let isLinuxVZ = request.guest.family == .linux
+ && request.backend == .appleVirtualizationFramework
+ if devices.audioInput, !(isLinuxVZ && isGraphical) {
+ return unavailable(
+ tier: tier,
+ code: .audioInputUnsupported,
+ message: "The selected guest/backend contract does not implement host audio input."
+ )
+ }
+ if devices.audioOutput, !(isLinuxVZ && isGraphical) {
+ return unavailable(
+ tier: tier,
+ code: .audioOutputUnsupported,
+ message: "The selected guest/backend contract does not implement host audio output."
+ )
+ }
+
+ let hasImplementedInput = isGraphical && (
+ (request.guest.family == .linux
+ && (request.backend == .doryHypervisor
+ || request.backend == .appleVirtualizationFramework))
+ || (request.guest.family == .macOS
+ && request.backend == .appleVirtualizationFramework)
+ || (request.guest.family == .windows
+ && request.backend == .qemuHypervisorFramework)
+ )
+ if devices.keyboard, !hasImplementedInput {
+ return unavailable(
+ tier: tier,
+ code: .keyboardInputUnsupported,
+ message: "The selected guest/backend contract does not implement keyboard input."
+ )
+ }
+ if devices.pointer, !hasImplementedInput {
+ return unavailable(
+ tier: tier,
+ code: .pointerInputUnsupported,
+ message: "The selected guest/backend contract does not implement pointer input."
+ )
+ }
+
+ if devices.directorySharing {
+ return unavailable(
+ tier: tier,
+ code: .directorySharingUnsupported,
+ message: "Directory sharing requires a digest-bound guest-integration "
+ + "qualification that is not yet modeled."
+ )
+ }
+ if devices.clipboard {
+ return unavailable(
+ tier: tier,
+ code: .clipboardIntegrationUnsupported,
+ message: "Clipboard integration requires a digest-bound guest-integration "
+ + "qualification that is not yet modeled."
+ )
+ }
+ if devices.clockSynchronization {
+ return unavailable(
+ tier: tier,
+ code: .clockSynchronizationUnsupported,
+ message: "Clock synchronization is not yet part of a qualified backend contract."
+ )
+ }
+ if devices.dynamicDisplay {
+ return unavailable(
+ tier: tier,
+ code: .dynamicDisplayUnsupported,
+ message: "Dynamic display resizing is not yet part of a qualified backend contract."
+ )
+ }
+ if devices.gracefulShutdown {
+ return unavailable(
+ tier: tier,
+ code: .gracefulShutdownUnsupported,
+ message: "Graceful shutdown requires a digest-bound guest-integration "
+ + "qualification that is not yet modeled."
+ )
+ }
+ return nil
+ }
+
+ private static func guestImageGraphicsQualificationFailure(
+ for request: DoryVirtualMachineCapabilityRequest,
+ trustedQualification: DoryTrustedGuestImageGraphicsQualification?
+ ) -> DoryCapabilityAvailability? {
+ guard request.guest.family == .linux,
+ request.backend == .doryHypervisor,
+ request.graphics != .none else {
+ return nil
+ }
+ guard let selectedArtifactSHA256 = request.bootMedia.artifactSHA256 else {
+ return unavailable(
+ tier: .supported,
+ code: .guestImageArtifactDigestUnavailable,
+ message: "The selected Linux guest artifact has no content-addressed SHA-256 identity."
+ )
+ }
+ guard let qualification = trustedQualification else {
+ return unavailable(
+ tier: .supported,
+ code: .trustedGuestImageGraphicsQualificationUnavailable,
+ message: "The daemon has no trusted signed graphics qualification for this Linux guest artifact."
+ )
+ }
+ guard signedEvidenceIsValid(qualification.auditEvidence) else {
+ return unavailable(
+ tier: .supported,
+ code: .guestImageQualificationAuditEvidenceInvalid,
+ message: "The guest graphics qualification has incomplete signed-manifest audit evidence."
+ )
+ }
+ guard qualification.auditEvidence.artifactSHA256.lowercased()
+ == selectedArtifactSHA256.lowercased() else {
+ return unavailable(
+ tier: .supported,
+ code: .guestImageArtifactQualificationMismatch,
+ message: "The signed graphics qualification does not match the selected guest artifact."
+ )
+ }
+ guard qualification.virtioGPUKernelAndDeviceSupportQualified else {
+ return unavailable(
+ tier: .supported,
+ code: .linuxVirtioGPUKernelDeviceUnqualified,
+ message: "The Linux kernel and image are not qualified for Dory's virtio-gpu device contract."
+ )
+ }
+ guard request.graphics != .hardwareAccelerated3D
+ || qualification.venusVulkanGuestRuntimeQualified else {
+ return unavailable(
+ tier: .supported,
+ code: .linuxVenusVulkanRuntimeUnqualified,
+ message: "The Linux image's Venus and Vulkan guest runtime has not passed qualification."
+ )
+ }
+ return nil
+ }
+
+ private static func graphicsAuditEvidence(
+ for request: DoryVirtualMachineCapabilityRequest,
+ trustedQualification: DoryTrustedGuestImageGraphicsQualification?
+ ) -> DorySignedArtifactQualificationEvidence? {
+ guard request.guest.family == .linux,
+ request.backend == .doryHypervisor,
+ request.graphics != .none else {
+ return nil
+ }
+ return trustedQualification?.auditEvidence
+ }
+
+ private static func bootMediaAuditEvidence(
+ for request: DoryVirtualMachineCapabilityRequest,
+ trustedInspection: DoryTrustedBootMediaInspection?
+ ) -> DoryBootMediaInspectionAuditEvidence? {
+ guard request.bootMedia.kind == .installerISO
+ || request.bootMedia.kind == .macOSRestoreImage else {
+ return nil
+ }
+ return trustedInspection?.auditEvidence
+ }
+
+ private static func mutableBootMediaProvenanceAuditEvidence(
+ for request: DoryVirtualMachineCapabilityRequest,
+ trustedProvenance: DoryTrustedMutableBootMediaProvenance?
+ ) -> DoryMutableBootMediaProvenanceAuditEvidence? {
+ guard request.bootMedia.kind == .virtualDisk else { return nil }
+ return trustedProvenance?.auditEvidence
+ }
+
+ private static func signedEvidenceIsValid(
+ _ evidence: DorySignedArtifactQualificationEvidence
+ ) -> Bool {
+ !evidence.manifestIdentity.isEmpty
+ && isSHA256(evidence.artifactSHA256)
+ && isSHA256(evidence.manifestSHA256)
+ && !evidence.signingKeyID.isEmpty
+ && evidence.manifestFormatVersion > 0
+ }
+
+ private static func bootMediaInspectionEvidenceIsValid(
+ _ evidence: DoryBootMediaInspectionAuditEvidence
+ ) -> Bool {
+ guard !evidence.inspectionIdentity.isEmpty,
+ isSHA256(evidence.artifactSHA256),
+ isSHA256(evidence.inspectionReportSHA256),
+ !evidence.inspectorID.isEmpty,
+ evidence.inspectorVersion > 0 else {
+ return false
+ }
+ guard let manifest = evidence.catalogManifestEvidence else { return true }
+ return signedEvidenceIsValid(manifest)
+ && manifest.artifactSHA256.lowercased() == evidence.artifactSHA256.lowercased()
+ }
+
+ private static func mutableProvenanceIsValid(
+ _ provenance: DoryMutableBootMediaProvenanceReference
+ ) -> Bool {
+ !provenance.repositoryIdentity.isEmpty
+ && !provenance.mediaIdentity.isEmpty
+ && provenance.revision > 0
+ }
+
+ private static func mutableProvenanceEvidenceIsValid(
+ _ evidence: DoryMutableBootMediaProvenanceAuditEvidence
+ ) -> Bool {
+ !evidence.receiptIdentity.isEmpty
+ && mutableProvenanceIsValid(evidence.provenance)
+ && isSHA256(evidence.receiptSHA256)
+ && !evidence.resolverID.isEmpty
+ && evidence.resolverVersion > 0
+ }
+
+ private static func runtimeQualificationEvidenceIsValid(
+ _ evidence: DoryVirtualMachineRuntimeQualificationEvidence
+ ) -> Bool {
+ let mediaBindingCount = (evidence.immutableArtifactSHA256 == nil ? 0 : 1)
+ + (evidence.mutableProvenance == nil ? 0 : 1)
+ let immutableArtifactIsValid = evidence.immutableArtifactSHA256.map(isSHA256) ?? true
+ let mutableProvenanceIsValid = evidence.mutableProvenance.map(Self.mutableProvenanceIsValid)
+ ?? true
+ return !evidence.qualificationIdentity.isEmpty
+ && isSHA256(evidence.qualificationReportSHA256)
+ && !evidence.signingKeyID.isEmpty
+ && evidence.qualificationFormatVersion > 0
+ && mediaBindingCount == 1
+ && immutableArtifactIsValid
+ && mutableProvenanceIsValid
+ && !evidence.backendRuntimeBuildID.isEmpty
+ && evidence.virtualHardwareABIVersion > 0
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy { byte in
+ (48...57).contains(byte) || (65...70).contains(byte) || (97...102).contains(byte)
+ }
+ }
+
+ private static func windowsDriverAvailabilityFailure(
+ _ drivers: DoryWindowsGuestDriverFacts,
+ tier: DoryCapabilitySupportTier
+ ) -> DoryCapabilityAvailability? {
+ guard drivers.storageAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .windowsStorageDriverUnavailable,
+ message: "A qualified Windows ARM64 storage driver is required."
+ )
+ }
+ guard drivers.networkAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .windowsNetworkDriverUnavailable,
+ message: "A qualified Windows ARM64 network driver is required."
+ )
+ }
+ guard drivers.displayAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .windowsDisplayDriverUnavailable,
+ message: "A qualified Windows ARM64 display driver is required."
+ )
+ }
+ guard drivers.inputAvailable else {
+ return unavailable(
+ tier: tier,
+ code: .windowsInputDriverUnavailable,
+ message: "Qualified Windows ARM64 input drivers are required."
+ )
+ }
+ return nil
+ }
+
+ private static func unsupported(
+ _ code: DoryCapabilityReasonCode,
+ _ message: String
+ ) -> DoryCapabilityAvailability {
+ DoryCapabilityAvailability(
+ supportTier: .unsupported,
+ state: .unavailable,
+ reason: DoryCapabilityReason(code: code, message: message)
+ )
+ }
+
+ private static func unavailable(
+ tier: DoryCapabilitySupportTier,
+ code: DoryCapabilityReasonCode,
+ message: String
+ ) -> DoryCapabilityAvailability {
+ DoryCapabilityAvailability(
+ supportTier: tier,
+ state: .unavailable,
+ reason: DoryCapabilityReason(code: code, message: message)
+ )
+ }
+}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
new file mode 100644
index 00000000..0a42efa6
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
@@ -0,0 +1,977 @@
+import Foundation
+
+/// Stable identity persisted independently from a VM's runtime process identity.
+public struct DoryVirtualMachineIdentity: Codable, Sendable, Equatable {
+ public var id: String
+ public var name: String
+
+ public init(id: String, name: String) {
+ self.id = id
+ self.name = name
+ }
+}
+
+public enum DoryVMBootMediaRole: String, Codable, Sendable, CaseIterable {
+ case installer
+ case system
+ case recovery
+}
+
+/// Structured, non-secret key resolved by an artifact or share registry.
+/// Both components are deliberately path/URL hostile and bounded for safe persistence.
+public struct DoryVMResolverReference: Codable, Sendable, Equatable, Hashable {
+ public var namespace: String
+ public var identifier: String
+
+ public init(namespace: String, identifier: String) {
+ self.namespace = namespace
+ self.identifier = identifier
+ }
+}
+
+public enum DoryVMBootPhase: String, Codable, Sendable, CaseIterable {
+ case install
+ case normal
+ case live
+ case recovery
+}
+
+/// A non-secret reference to media resolved by an artifact store at launch time.
+///
+/// `artifact` is a structured catalog key. Credentials, signed URLs, access tokens, host paths,
+/// and other secret material must remain in the resolver and are never persisted here.
+public struct DoryVMBootMediaReference: Codable, Sendable, Equatable {
+ public var id: String
+ public var role: DoryVMBootMediaRole
+ public var kind: DoryBootMediaKind
+ public var source: DoryBootMediaSource
+ public var artifact: DoryVMResolverReference
+ public var removable: Bool
+
+ public init(
+ id: String,
+ role: DoryVMBootMediaRole,
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ artifact: DoryVMResolverReference,
+ removable: Bool
+ ) {
+ self.id = id
+ self.role = role
+ self.kind = kind
+ self.source = source
+ self.artifact = artifact
+ self.removable = removable
+ }
+
+ public var capabilityMedia: DoryBootMedia {
+ DoryBootMedia(kind: kind, source: source)
+ }
+}
+
+/// Boot phase, attached boot media, and deterministic firmware/device priority.
+public struct DoryVMBootConfiguration: Codable, Sendable, Equatable {
+ public var phase: DoryVMBootPhase
+ public var devices: [DoryVMBootMediaReference]
+ public var order: [String]
+
+ public init(
+ phase: DoryVMBootPhase,
+ devices: [DoryVMBootMediaReference],
+ order: [String]
+ ) {
+ self.phase = phase
+ self.devices = devices
+ self.order = order
+ }
+}
+
+public enum DoryVMBackendPreferenceMode: String, Codable, Sendable, CaseIterable {
+ case automatic
+ case preferred
+ case required
+}
+
+/// Backend intent, not a backend selection or availability claim.
+///
+/// The daemon must still negotiate this preference through its capability evaluator.
+public struct DoryVMBackendPreference: Codable, Sendable, Equatable {
+ public var mode: DoryVMBackendPreferenceMode
+ public var backend: DoryVirtualizationBackendIdentity?
+
+ public init(
+ mode: DoryVMBackendPreferenceMode = .automatic,
+ backend: DoryVirtualizationBackendIdentity? = nil
+ ) {
+ self.mode = mode
+ self.backend = backend
+ }
+}
+
+/// Ordered graphics contracts acceptable to the user, from most to least preferred.
+/// Runtime capability negotiation must select one exact entry or reject the definition.
+public struct DoryVMGraphicsPolicy: Codable, Sendable, Equatable {
+ public var acceptableLevels: [DoryGraphicsAccelerationLevel]
+
+ public init(acceptableLevels: [DoryGraphicsAccelerationLevel]) {
+ self.acceptableLevels = acceptableLevels
+ }
+}
+
+public enum DoryVMStorageRole: String, Codable, Sendable, CaseIterable {
+ case system
+ case data
+}
+
+/// A disk artifact attached to the VM. Host paths and storage credentials are resolved outside
+/// this persistence contract from its structured artifact reference.
+public struct DoryVMStorageAttachment: Codable, Sendable, Equatable {
+ public var id: String
+ public var role: DoryVMStorageRole
+ public var artifact: DoryVMResolverReference
+ public var capacityBytes: UInt64
+ public var readOnly: Bool
+
+ public init(
+ id: String,
+ role: DoryVMStorageRole,
+ artifact: DoryVMResolverReference,
+ capacityBytes: UInt64,
+ readOnly: Bool = false
+ ) {
+ self.id = id
+ self.role = role
+ self.artifact = artifact
+ self.capacityBytes = capacityBytes
+ self.readOnly = readOnly
+ }
+}
+
+public enum DoryVMNetworkMode: String, Codable, Sendable, CaseIterable {
+ case sharedNAT = "shared-nat"
+ case bridged
+ case isolated
+}
+
+public struct DoryVMDisplayConfiguration: Codable, Sendable, Equatable {
+ public var enabled: Bool
+ public var widthPixels: UInt32
+ public var heightPixels: UInt32
+ public var pixelsPerInch: UInt16
+
+ public init(
+ enabled: Bool = true,
+ widthPixels: UInt32 = 1_920,
+ heightPixels: UInt32 = 1_080,
+ pixelsPerInch: UInt16 = 110
+ ) {
+ self.enabled = enabled
+ self.widthPixels = widthPixels
+ self.heightPixels = heightPixels
+ self.pixelsPerInch = pixelsPerInch
+ }
+
+ public static let disabled = DoryVMDisplayConfiguration(
+ enabled: false,
+ widthPixels: 0,
+ heightPixels: 0,
+ pixelsPerInch: 0
+ )
+}
+
+public struct DoryVMAudioConfiguration: Codable, Sendable, Equatable {
+ public var inputEnabled: Bool
+ public var outputEnabled: Bool
+
+ public init(inputEnabled: Bool = false, outputEnabled: Bool = true) {
+ self.inputEnabled = inputEnabled
+ self.outputEnabled = outputEnabled
+ }
+}
+
+public struct DoryVMInputConfiguration: Codable, Sendable, Equatable {
+ public var keyboardEnabled: Bool
+ public var pointerEnabled: Bool
+
+ public init(keyboardEnabled: Bool = true, pointerEnabled: Bool = true) {
+ self.keyboardEnabled = keyboardEnabled
+ self.pointerEnabled = pointerEnabled
+ }
+}
+
+/// A host location shared into the guest. The host location is resolved from a non-secret opaque
+/// identity so bookmarks, paths, and authorization material can be managed separately.
+public struct DoryVMShare: Codable, Sendable, Equatable {
+ public var id: String
+ public var hostLocation: DoryVMResolverReference
+ public var guestMountPath: String
+ public var readOnly: Bool
+
+ public init(
+ id: String,
+ hostLocation: DoryVMResolverReference,
+ guestMountPath: String,
+ readOnly: Bool = false
+ ) {
+ self.id = id
+ self.hostLocation = hostLocation
+ self.guestMountPath = guestMountPath
+ self.readOnly = readOnly
+ }
+}
+
+public enum DoryVMGuestIntegration: String, Codable, Sendable, CaseIterable {
+ case clipboard
+ case clockSynchronization = "clock-synchronization"
+ case dynamicDisplay = "dynamic-display"
+ case gracefulShutdown = "graceful-shutdown"
+}
+
+/// Minimal metadata needed for optimistic persistence and ordering.
+public struct DoryVMLifecycleMetadata: Codable, Sendable, Equatable {
+ public var revision: UInt64
+ /// Unix epoch milliseconds, encoded as an integer for stable cross-language persistence.
+ public var createdAtUnixMilliseconds: Int64
+ /// Unix epoch milliseconds, encoded as an integer for stable cross-language persistence.
+ public var updatedAtUnixMilliseconds: Int64
+
+ public init(
+ revision: UInt64 = 1,
+ createdAtUnixMilliseconds: Int64,
+ updatedAtUnixMilliseconds: Int64
+ ) {
+ self.revision = revision
+ self.createdAtUnixMilliseconds = createdAtUnixMilliseconds
+ self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds
+ }
+
+ /// Source-compatible construction bridge. Persistence always uses integer epoch milliseconds.
+ public init(revision: UInt64 = 1, createdAt: Date, updatedAt: Date) {
+ self.init(
+ revision: revision,
+ createdAtUnixMilliseconds: Self.unixMilliseconds(createdAt),
+ updatedAtUnixMilliseconds: Self.unixMilliseconds(updatedAt)
+ )
+ }
+
+ private static func unixMilliseconds(_ date: Date) -> Int64 {
+ let value = date.timeIntervalSince1970 * 1_000
+ guard value.isFinite else { return 0 }
+ if value >= Double(Int64.max) { return .max }
+ if value <= Double(Int64.min) { return .min }
+ return Int64(value.rounded(.towardZero))
+ }
+}
+
+public enum DoryVMDefinitionValidationCode: String, Codable, Sendable, CaseIterable {
+ case unsupportedSchemaVersion = "unsupported-schema-version"
+ case unsupportedVirtualHardwareABIVersion = "unsupported-virtual-hardware-abi-version"
+ case emptyIdentifier = "empty-identifier"
+ case invalidIdentifier = "invalid-identifier"
+ case emptyName = "empty-name"
+ case invalidResolverReference = "invalid-resolver-reference"
+ case duplicateBootDeviceIdentifier = "duplicate-boot-device-identifier"
+ case invalidBootOrder = "invalid-boot-order"
+ case invalidBootPhase = "invalid-boot-phase"
+ case invalidBootAttachment = "invalid-boot-attachment"
+ case bootMediaRoleMismatch = "boot-media-role-mismatch"
+ case multipleSystemBootDevices = "multiple-system-boot-devices"
+ case bootMediaIncompatibleWithGuest = "boot-media-incompatible-with-guest"
+ case guestMediaCannotBeBundled = "guest-media-cannot-be-bundled"
+ case backendPreferenceMalformed = "backend-preference-malformed"
+ case emptyGraphicsPolicy = "empty-graphics-policy"
+ case duplicateGraphicsLevel = "duplicate-graphics-level"
+ case nonPositiveResource = "non-positive-resource"
+ case emptyAttachmentIdentifier = "empty-attachment-identifier"
+ case duplicateAttachmentIdentifier = "duplicate-attachment-identifier"
+ case duplicateWritableStorageArtifactIdentity = "duplicate-writable-storage-artifact-identity"
+ case nonPositiveAttachmentCapacity = "non-positive-attachment-capacity"
+ case missingSystemDisk = "missing-system-disk"
+ case multipleSystemDisks = "multiple-system-disks"
+ case readOnlySystemDisk = "read-only-system-disk"
+ case systemDiskCapacityMismatch = "system-disk-capacity-mismatch"
+ case bootDiskArtifactMismatch = "boot-disk-artifact-mismatch"
+ case invalidDisplayConfiguration = "invalid-display-configuration"
+ case emptyShareIdentifier = "empty-share-identifier"
+ case duplicateShareIdentifier = "duplicate-share-identifier"
+ case duplicateGuestMountPath = "duplicate-guest-mount-path"
+ case unsafeGuestMountPath = "unsafe-guest-mount-path"
+ case duplicateIntegration = "duplicate-integration"
+ case integrationRequiresDisplay = "integration-requires-display"
+ case legacyInstallerWorkload = "legacy-installer-workload"
+ case invalidLifecycleMetadata = "invalid-lifecycle-metadata"
+}
+
+/// Machine-readable validation output. `field` uses stable dotted/indexed paths so clients can
+/// associate an issue with a form control without parsing localized prose.
+public struct DoryVMDefinitionValidationIssue: Codable, Sendable, Equatable {
+ public var code: DoryVMDefinitionValidationCode
+ public var field: String
+
+ public init(code: DoryVMDefinitionValidationCode, field: String) {
+ self.code = code
+ self.field = field
+ }
+}
+
+/// Versioned, persistence-safe VM intent shared by the app, daemon, and external API.
+///
+/// This definition deliberately contains no runtime capability result, selected backend,
+/// passwords, tokens, host filesystem paths, or volatile process state.
+public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
+ public static let oldestSupportedSchemaVersion: UInt16 = 1
+ public static let currentSchemaVersion: UInt16 = 2
+ public static let currentVirtualHardwareABIVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var virtualHardwareABIVersion: UInt16
+ public var identity: DoryVirtualMachineIdentity
+ public var guest: DoryGuestPlatform
+ public var workload: DoryVMWorkloadProfile
+ public var boot: DoryVMBootConfiguration
+ public var backendPreference: DoryVMBackendPreference
+ public var graphics: DoryVMGraphicsPolicy
+ public var resources: DoryVMResourceRequest
+ public var storage: [DoryVMStorageAttachment]
+ public var networkMode: DoryVMNetworkMode
+ public var display: DoryVMDisplayConfiguration
+ public var audio: DoryVMAudioConfiguration
+ public var input: DoryVMInputConfiguration
+ public var shares: [DoryVMShare]
+ public var integrations: [DoryVMGuestIntegration]
+ public var lifecycle: DoryVMLifecycleMetadata
+
+ public init(
+ schemaVersion: UInt16 = Self.currentSchemaVersion,
+ virtualHardwareABIVersion: UInt16 = Self.currentVirtualHardwareABIVersion,
+ identity: DoryVirtualMachineIdentity,
+ guest: DoryGuestPlatform,
+ workload: DoryVMWorkloadProfile,
+ boot: DoryVMBootConfiguration,
+ backendPreference: DoryVMBackendPreference = DoryVMBackendPreference(),
+ graphics: DoryVMGraphicsPolicy,
+ resources: DoryVMResourceRequest,
+ storage: [DoryVMStorageAttachment],
+ networkMode: DoryVMNetworkMode = .sharedNAT,
+ display: DoryVMDisplayConfiguration = DoryVMDisplayConfiguration(),
+ audio: DoryVMAudioConfiguration = DoryVMAudioConfiguration(),
+ input: DoryVMInputConfiguration = DoryVMInputConfiguration(),
+ shares: [DoryVMShare] = [],
+ integrations: [DoryVMGuestIntegration] = [],
+ lifecycle: DoryVMLifecycleMetadata
+ ) {
+ self.schemaVersion = schemaVersion
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.identity = identity
+ self.guest = guest
+ self.workload = workload
+ self.boot = boot
+ self.backendPreference = backendPreference
+ self.graphics = graphics
+ self.resources = resources
+ self.storage = storage
+ self.networkMode = networkMode
+ self.display = display
+ self.audio = audio
+ self.input = input
+ self.shares = shares
+ self.integrations = integrations
+ self.lifecycle = lifecycle
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaVersion
+ case virtualHardwareABIVersion
+ case identity
+ case guest
+ case workload
+ case boot
+ case bootMedia
+ case backendPreference
+ case graphics
+ case resources
+ case storage
+ case networkMode
+ case display
+ case audio
+ case input
+ case shares
+ case integrations
+ case lifecycle
+ }
+
+ private struct LegacyBootMedia: Codable {
+ var role: DoryVMBootMediaRole
+ var kind: DoryBootMediaKind
+ var source: DoryBootMediaSource
+ var artifactID: String
+ }
+
+ private struct LegacyGraphics: Codable {
+ var desiredLevel: DoryGraphicsAccelerationLevel
+ var allowsFallback: Bool
+ }
+
+ private struct LegacyStorage: Codable {
+ var id: String
+ var role: DoryVMStorageRole
+ var artifactID: String
+ var capacityBytes: UInt64
+ var readOnly: Bool
+ }
+
+ private struct LegacyShare: Codable {
+ var id: String
+ var hostLocationID: String
+ var guestMountPath: String
+ var readOnly: Bool
+ }
+
+ private struct LegacyLifecycle: Codable {
+ var revision: UInt64
+ var createdAt: Date
+ var updatedAt: Date
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let persistedSchema = try container.decode(UInt16.self, forKey: .schemaVersion)
+ if persistedSchema == Self.oldestSupportedSchemaVersion {
+ let legacyBoot = try container.decode(LegacyBootMedia.self, forKey: .bootMedia)
+ let legacyGraphics = try container.decode(LegacyGraphics.self, forKey: .graphics)
+ let legacyStorage = try container.decode([LegacyStorage].self, forKey: .storage)
+ let legacyShares = try container.decode([LegacyShare].self, forKey: .shares)
+ let legacyLifecycle = try container.decode(LegacyLifecycle.self, forKey: .lifecycle)
+ let legacyWorkload = try container.decode(DoryVMWorkloadProfile.self, forKey: .workload)
+
+ schemaVersion = Self.currentSchemaVersion
+ virtualHardwareABIVersion = try container.decodeIfPresent(
+ UInt16.self,
+ forKey: .virtualHardwareABIVersion
+ ) ?? Self.currentVirtualHardwareABIVersion
+ identity = try container.decode(DoryVirtualMachineIdentity.self, forKey: .identity)
+ guest = try container.decode(DoryGuestPlatform.self, forKey: .guest)
+ workload = legacyWorkload == .installer ? .desktop : legacyWorkload
+ let deviceID = legacyBoot.role == .system ? "system" : "installer"
+ boot = DoryVMBootConfiguration(
+ phase: legacyBoot.role == .system ? .normal : .install,
+ devices: [DoryVMBootMediaReference(
+ id: deviceID,
+ role: legacyBoot.role,
+ kind: legacyBoot.kind,
+ source: legacyBoot.source,
+ artifact: Self.migrateLegacyReference(
+ legacyBoot.artifactID,
+ defaultNamespace: "artifact"
+ ),
+ removable: legacyBoot.role != .system
+ )],
+ order: [deviceID]
+ )
+ backendPreference = try container.decode(
+ DoryVMBackendPreference.self,
+ forKey: .backendPreference
+ )
+ var levels = [legacyGraphics.desiredLevel]
+ if legacyGraphics.allowsFallback,
+ legacyGraphics.desiredLevel != .software,
+ legacyGraphics.desiredLevel != .none {
+ levels.append(.software)
+ }
+ graphics = DoryVMGraphicsPolicy(acceptableLevels: levels)
+ resources = try container.decode(DoryVMResourceRequest.self, forKey: .resources)
+ storage = legacyStorage.map { attachment in
+ DoryVMStorageAttachment(
+ id: attachment.id,
+ role: attachment.role,
+ artifact: Self.migrateLegacyReference(
+ attachment.artifactID,
+ defaultNamespace: "artifact"
+ ),
+ capacityBytes: attachment.capacityBytes,
+ readOnly: attachment.readOnly
+ )
+ }
+ networkMode = try container.decode(DoryVMNetworkMode.self, forKey: .networkMode)
+ display = try container.decode(DoryVMDisplayConfiguration.self, forKey: .display)
+ audio = try container.decode(DoryVMAudioConfiguration.self, forKey: .audio)
+ input = try container.decode(DoryVMInputConfiguration.self, forKey: .input)
+ shares = legacyShares.map { share in
+ DoryVMShare(
+ id: share.id,
+ hostLocation: Self.migrateLegacyReference(
+ share.hostLocationID,
+ defaultNamespace: "host-location"
+ ),
+ guestMountPath: share.guestMountPath,
+ readOnly: share.readOnly
+ )
+ }
+ integrations = try container.decode(
+ [DoryVMGuestIntegration].self,
+ forKey: .integrations
+ )
+ lifecycle = DoryVMLifecycleMetadata(
+ revision: legacyLifecycle.revision,
+ createdAt: legacyLifecycle.createdAt,
+ updatedAt: legacyLifecycle.updatedAt
+ )
+ return
+ }
+
+ schemaVersion = persistedSchema
+ virtualHardwareABIVersion = try container.decode(
+ UInt16.self,
+ forKey: .virtualHardwareABIVersion
+ )
+ identity = try container.decode(DoryVirtualMachineIdentity.self, forKey: .identity)
+ guest = try container.decode(DoryGuestPlatform.self, forKey: .guest)
+ workload = try container.decode(DoryVMWorkloadProfile.self, forKey: .workload)
+ boot = try container.decode(DoryVMBootConfiguration.self, forKey: .boot)
+ backendPreference = try container.decode(DoryVMBackendPreference.self, forKey: .backendPreference)
+ graphics = try container.decode(DoryVMGraphicsPolicy.self, forKey: .graphics)
+ resources = try container.decode(DoryVMResourceRequest.self, forKey: .resources)
+ storage = try container.decode([DoryVMStorageAttachment].self, forKey: .storage)
+ networkMode = try container.decode(DoryVMNetworkMode.self, forKey: .networkMode)
+ display = try container.decode(DoryVMDisplayConfiguration.self, forKey: .display)
+ audio = try container.decode(DoryVMAudioConfiguration.self, forKey: .audio)
+ input = try container.decode(DoryVMInputConfiguration.self, forKey: .input)
+ shares = try container.decode([DoryVMShare].self, forKey: .shares)
+ integrations = try container.decode([DoryVMGuestIntegration].self, forKey: .integrations)
+ lifecycle = try container.decode(DoryVMLifecycleMetadata.self, forKey: .lifecycle)
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(schemaVersion, forKey: .schemaVersion)
+ try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion)
+ try container.encode(identity, forKey: .identity)
+ try container.encode(guest, forKey: .guest)
+ try container.encode(workload, forKey: .workload)
+ try container.encode(boot, forKey: .boot)
+ try container.encode(backendPreference, forKey: .backendPreference)
+ try container.encode(graphics, forKey: .graphics)
+ try container.encode(resources, forKey: .resources)
+ try container.encode(storage, forKey: .storage)
+ try container.encode(networkMode, forKey: .networkMode)
+ try container.encode(display, forKey: .display)
+ try container.encode(audio, forKey: .audio)
+ try container.encode(input, forKey: .input)
+ try container.encode(shares, forKey: .shares)
+ try container.encode(integrations, forKey: .integrations)
+ try container.encode(lifecycle, forKey: .lifecycle)
+ }
+
+ private static func migrateLegacyReference(
+ _ value: String,
+ defaultNamespace: String
+ ) -> DoryVMResolverReference {
+ let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
+ if parts.count == 2 {
+ let candidate = DoryVMResolverReference(
+ namespace: String(parts[0]).lowercased(),
+ identifier: String(parts[1])
+ )
+ if isSafeResolverReference(candidate) {
+ return candidate
+ }
+ }
+ return DoryVMResolverReference(namespace: defaultNamespace, identifier: value)
+ }
+
+ /// Returns deterministic issues in field order. Backend availability is intentionally absent;
+ /// pass the resulting intent through capability negotiation before launch.
+ public func validate() -> [DoryVMDefinitionValidationIssue] {
+ var issues: [DoryVMDefinitionValidationIssue] = []
+
+ if schemaVersion != Self.currentSchemaVersion {
+ issues.append(issue(.unsupportedSchemaVersion, "schemaVersion"))
+ }
+ if virtualHardwareABIVersion != Self.currentVirtualHardwareABIVersion {
+ issues.append(issue(
+ .unsupportedVirtualHardwareABIVersion,
+ "virtualHardwareABIVersion"
+ ))
+ }
+ if !Self.hasContent(identity.id) {
+ issues.append(issue(.emptyIdentifier, "identity.id"))
+ } else if !Self.isSafeMachineIdentifier(identity.id) {
+ issues.append(issue(.invalidIdentifier, "identity.id"))
+ }
+ if !Self.hasContent(identity.name) {
+ issues.append(issue(.emptyName, "identity.name"))
+ }
+
+ validateBootMedia(into: &issues)
+ validateBackendPreference(into: &issues)
+ validateGraphics(into: &issues)
+ validateResources(into: &issues)
+ validateStorage(into: &issues)
+ validateDisplay(into: &issues)
+ validateShares(into: &issues)
+ validateIntegrations(into: &issues)
+ validateLifecycle(into: &issues)
+ return issues
+ }
+
+ public var isValid: Bool {
+ validate().isEmpty
+ }
+
+ private func validateBootMedia(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ if workload == .installer {
+ issues.append(issue(.legacyInstallerWorkload, "workload"))
+ }
+
+ var deviceIDs: Set = []
+ for (index, device) in boot.devices.enumerated() {
+ let field = "boot.devices[\(index)]"
+ if !Self.isSafeMachineIdentifier(device.id) {
+ issues.append(issue(.invalidIdentifier, "\(field).id"))
+ } else if !deviceIDs.insert(device.id).inserted {
+ issues.append(issue(.duplicateBootDeviceIdentifier, "\(field).id"))
+ }
+ if !Self.isSafeResolverReference(device.artifact) {
+ issues.append(issue(.invalidResolverReference, "\(field).artifact"))
+ }
+
+ let roleMatchesKind: Bool
+ switch device.kind {
+ case .virtualDisk, .installedLinuxBootBundle:
+ roleMatchesKind = device.role == .system
+ case .installerISO, .macOSRestoreImage:
+ roleMatchesKind = device.role == .installer || device.role == .recovery
+ }
+ if !roleMatchesKind {
+ issues.append(issue(.bootMediaRoleMismatch, "\(field).role"))
+ }
+
+ let shouldBeRemovable = device.role != .system
+ if device.removable != shouldBeRemovable {
+ issues.append(issue(.invalidBootAttachment, "\(field).removable"))
+ }
+ if !Self.bootMediaIsCompatible(device.kind, with: guest.family) {
+ issues.append(issue(.bootMediaIncompatibleWithGuest, "\(field).kind"))
+ }
+ if (guest.family == .windows || guest.family == .macOS),
+ device.source == .bundledByDory {
+ issues.append(issue(.guestMediaCannotBeBundled, "\(field).source"))
+ }
+ }
+
+ if boot.devices.filter({ $0.role == .system }).count > 1 {
+ issues.append(issue(.multipleSystemBootDevices, "boot.devices"))
+ }
+
+ let orderIsPermutation = !boot.order.isEmpty
+ && boot.order.count == boot.devices.count
+ && Set(boot.order).count == boot.order.count
+ && Set(boot.order) == Set(boot.devices.map(\.id))
+ if !orderIsPermutation {
+ issues.append(issue(.invalidBootOrder, "boot.order"))
+ }
+
+ let primary = orderedBootDevices().first
+ let primaryIsValid: Bool
+ switch boot.phase {
+ case .normal:
+ primaryIsValid = primary?.role == .system
+ case .install, .live:
+ primaryIsValid = primary?.role == .installer
+ case .recovery:
+ primaryIsValid = primary?.role == .recovery
+ }
+ if !primaryIsValid {
+ issues.append(issue(.invalidBootPhase, "boot.phase"))
+ }
+ }
+
+ private func validateBackendPreference(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ let isWellFormed = switch backendPreference.mode {
+ case .automatic:
+ backendPreference.backend == nil
+ case .preferred, .required:
+ backendPreference.backend != nil
+ }
+ if !isWellFormed {
+ issues.append(issue(.backendPreferenceMalformed, "backendPreference"))
+ }
+ }
+
+ private func validateGraphics(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ if graphics.acceptableLevels.isEmpty {
+ issues.append(issue(.emptyGraphicsPolicy, "graphics.acceptableLevels"))
+ return
+ }
+ var seen: Set = []
+ for (index, level) in graphics.acceptableLevels.enumerated()
+ where !seen.insert(level.rawValue).inserted {
+ issues.append(issue(.duplicateGraphicsLevel, "graphics.acceptableLevels[\(index)]"))
+ }
+ }
+
+ private func validateResources(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ if resources.virtualCPUCount == 0 {
+ issues.append(issue(.nonPositiveResource, "resources.virtualCPUCount"))
+ }
+ if resources.memoryBytes == 0 {
+ issues.append(issue(.nonPositiveResource, "resources.memoryBytes"))
+ }
+ if resources.diskBytes == 0 {
+ issues.append(issue(.nonPositiveResource, "resources.diskBytes"))
+ }
+ }
+
+ private func validateStorage(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ var attachmentIDs: Set = []
+ var artifactAccess: [DoryVMResolverReference: Bool] = [:]
+ for (index, attachment) in storage.enumerated() {
+ let field = "storage[\(index)]"
+ if !Self.hasContent(attachment.id) {
+ issues.append(issue(.emptyAttachmentIdentifier, "\(field).id"))
+ } else if !attachmentIDs.insert(attachment.id).inserted {
+ issues.append(issue(.duplicateAttachmentIdentifier, "\(field).id"))
+ }
+ if !Self.isSafeResolverReference(attachment.artifact) {
+ issues.append(issue(.invalidResolverReference, "\(field).artifact"))
+ } else if let allPreviousReadOnly = artifactAccess[attachment.artifact] {
+ if !allPreviousReadOnly || !attachment.readOnly {
+ issues.append(issue(
+ .duplicateWritableStorageArtifactIdentity,
+ "\(field).artifact"
+ ))
+ }
+ artifactAccess[attachment.artifact] = allPreviousReadOnly && attachment.readOnly
+ } else {
+ artifactAccess[attachment.artifact] = attachment.readOnly
+ }
+ if attachment.capacityBytes == 0 {
+ issues.append(issue(.nonPositiveAttachmentCapacity, "\(field).capacityBytes"))
+ }
+ }
+
+ let systemDisks = storage.enumerated().filter { $0.element.role == .system }
+ if systemDisks.isEmpty, boot.phase != .live {
+ issues.append(issue(.missingSystemDisk, "storage"))
+ } else if systemDisks.count > 1 {
+ issues.append(issue(.multipleSystemDisks, "storage"))
+ } else if let (index, systemDisk) = systemDisks.first {
+ if systemDisk.readOnly {
+ issues.append(issue(.readOnlySystemDisk, "storage[\(index)].readOnly"))
+ }
+ if systemDisk.capacityBytes != resources.diskBytes {
+ issues.append(issue(.systemDiskCapacityMismatch, "storage[\(index)].capacityBytes"))
+ }
+ if let bootDisk = orderedBootDevices().first(where: {
+ $0.role == .system && $0.kind == .virtualDisk
+ }), bootDisk.artifact != systemDisk.artifact {
+ issues.append(issue(.bootDiskArtifactMismatch, "boot.devices"))
+ }
+ }
+ }
+
+ private func validateDisplay(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ let dimensionsArePositive = display.widthPixels > 0
+ && display.heightPixels > 0
+ && display.pixelsPerInch > 0
+ let dimensionsAreZero = display.widthPixels == 0
+ && display.heightPixels == 0
+ && display.pixelsPerInch == 0
+ if (display.enabled && !dimensionsArePositive) || (!display.enabled && !dimensionsAreZero) {
+ issues.append(issue(.invalidDisplayConfiguration, "display"))
+ }
+ }
+
+ private func validateShares(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ var shareIDs: Set = []
+ var guestMountPaths: Set = []
+ for (index, share) in shares.enumerated() {
+ let field = "shares[\(index)]"
+ if !Self.hasContent(share.id) {
+ issues.append(issue(.emptyShareIdentifier, "\(field).id"))
+ } else if !shareIDs.insert(share.id).inserted {
+ issues.append(issue(.duplicateShareIdentifier, "\(field).id"))
+ }
+ if !Self.isSafeResolverReference(share.hostLocation) {
+ issues.append(issue(.invalidResolverReference, "\(field).hostLocation"))
+ }
+ if let canonicalPath = Self.canonicalGuestMountPath(
+ share.guestMountPath,
+ family: guest.family
+ ) {
+ if !guestMountPaths.insert(canonicalPath).inserted {
+ issues.append(issue(.duplicateGuestMountPath, "\(field).guestMountPath"))
+ }
+ } else {
+ issues.append(issue(.unsafeGuestMountPath, "\(field).guestMountPath"))
+ }
+ }
+ }
+
+ private func validateIntegrations(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ var seen: Set = []
+ for (index, integration) in integrations.enumerated()
+ where !seen.insert(integration.rawValue).inserted {
+ issues.append(issue(.duplicateIntegration, "integrations[\(index)]"))
+ }
+ if !display.enabled, integrations.contains(.dynamicDisplay) {
+ issues.append(issue(.integrationRequiresDisplay, "integrations"))
+ }
+ }
+
+ private func validateLifecycle(into issues: inout [DoryVMDefinitionValidationIssue]) {
+ if lifecycle.revision == 0
+ || lifecycle.createdAtUnixMilliseconds <= 0
+ || lifecycle.updatedAtUnixMilliseconds < lifecycle.createdAtUnixMilliseconds {
+ issues.append(issue(.invalidLifecycleMetadata, "lifecycle"))
+ }
+ }
+
+ private func issue(
+ _ code: DoryVMDefinitionValidationCode,
+ _ field: String
+ ) -> DoryVMDefinitionValidationIssue {
+ DoryVMDefinitionValidationIssue(code: code, field: field)
+ }
+
+ /// Resolve boot devices through the persisted order so validation never depends on array layout.
+ private func orderedBootDevices() -> [DoryVMBootMediaReference] {
+ boot.order.compactMap { deviceID in
+ boot.devices.first { $0.id == deviceID }
+ }
+ }
+
+ private static func hasContent(_ value: String) -> Bool {
+ !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ private static func isSafeResolverReference(_ reference: DoryVMResolverReference) -> Bool {
+ let namespace = Array(reference.namespace.utf8)
+ let identifier = Array(reference.identifier.utf8)
+ guard (1...32).contains(namespace.count),
+ namespace[0] >= 97,
+ namespace[0] <= 122,
+ namespace.dropFirst().allSatisfy({ byte in
+ (byte >= 97 && byte <= 122)
+ || (byte >= 48 && byte <= 57)
+ || byte == 45
+ }),
+ (1...64).contains(identifier.count),
+ isASCIIAlphaNumeric(identifier[0]),
+ identifier.dropFirst().allSatisfy({ byte in
+ isASCIIAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45
+ }) else {
+ return false
+ }
+ let lowercase = reference.identifier.lowercased()
+ let secretPrefixes = [
+ "sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-", "xoxa-", "xoxr-",
+ "bearer-", "password-", "secret-", "token-", "akia",
+ ]
+ return !secretPrefixes.contains(where: lowercase.hasPrefix)
+ && !lowercase.hasPrefix("eyj")
+ }
+
+ private static func bootMediaIsCompatible(
+ _ kind: DoryBootMediaKind,
+ with family: DoryGuestFamily
+ ) -> Bool {
+ switch family {
+ case .linux:
+ kind == .installerISO || kind == .virtualDisk || kind == .installedLinuxBootBundle
+ case .windows:
+ kind == .installerISO || kind == .virtualDisk
+ case .macOS:
+ kind == .macOSRestoreImage || kind == .virtualDisk
+ }
+ }
+
+ private static func isSafeMachineIdentifier(_ value: String) -> Bool {
+ let bytes = Array(value.utf8)
+ guard (1...63).contains(bytes.count), isASCIIAlphaNumeric(bytes[0]) else {
+ return false
+ }
+ return bytes.dropFirst().allSatisfy { byte in
+ isASCIIAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45
+ }
+ }
+
+ private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool {
+ (byte >= 48 && byte <= 57)
+ || (byte >= 65 && byte <= 90)
+ || (byte >= 97 && byte <= 122)
+ }
+
+ private static func canonicalGuestMountPath(
+ _ path: String,
+ family: DoryGuestFamily
+ ) -> String? {
+ guard !path.isEmpty, !path.unicodeScalars.contains(where: { $0.value == 0 }) else {
+ return nil
+ }
+ switch family {
+ case .linux, .macOS:
+ guard path.hasPrefix("/"), !path.hasPrefix("//"), path != "/", !path.contains("\\") else {
+ return nil
+ }
+ let components = path.split(separator: "/", omittingEmptySubsequences: false).dropFirst()
+ guard !components.isEmpty, components.allSatisfy({ component in
+ !component.isEmpty && component != "." && component != ".."
+ }) else {
+ return nil
+ }
+ return path
+ case .windows:
+ let bytes = Array(path.utf8)
+ guard bytes.count >= 4,
+ (bytes[0] >= 65 && bytes[0] <= 90) || (bytes[0] >= 97 && bytes[0] <= 122),
+ bytes[1] == 58,
+ bytes[2] == 92,
+ !path.contains("/") else {
+ return nil
+ }
+ let components = path.dropFirst(3).split(
+ separator: "\\",
+ omittingEmptySubsequences: false
+ )
+ let forbidden = "<>:|?*\""
+ guard !components.isEmpty, components.allSatisfy({ component in
+ isSafeWindowsPathComponent(component, forbidden: forbidden)
+ }) else {
+ return nil
+ }
+ return path.lowercased()
+ }
+ }
+
+ private static func isSafeWindowsPathComponent(
+ _ component: Substring,
+ forbidden: String
+ ) -> Bool {
+ guard !component.isEmpty,
+ component != ".",
+ component != "..",
+ component.last != ".",
+ component.last != " ",
+ !component.contains(where: forbidden.contains),
+ !component.unicodeScalars.contains(where: { scalar in
+ scalar.value < 32 || scalar.value == 127
+ }) else {
+ return false
+ }
+ let rawBaseName = component.split(separator: ".", maxSplits: 1).first ?? ""
+ let baseName = String(rawBaseName)
+ .trimmingCharacters(in: CharacterSet(charactersIn: " ."))
+ .uppercased()
+ if ["CON", "PRN", "AUX", "NUL"].contains(baseName) {
+ return false
+ }
+ if baseName.count == 4,
+ (baseName.hasPrefix("COM") || baseName.hasPrefix("LPT")),
+ let suffix = baseName.last,
+ suffix >= "1" && suffix <= "9" {
+ return false
+ }
+ return true
+ }
+}
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVMResourcePolicyTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVMResourcePolicyTests.swift
new file mode 100644
index 00000000..cfcaf759
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVMResourcePolicyTests.swift
@@ -0,0 +1,394 @@
+import Foundation
+import Testing
+@testable import DoryOperations
+
+@Suite("VM resource policy")
+struct DoryVMResourcePolicyTests {
+ private let gibibyte: UInt64 = 1_073_741_824
+
+ @Test("desktop recommendation preserves host reserves")
+ func desktopRecommendation() {
+ let host = host(cpu: 12, memoryGiB: 32, storageGiB: 500)
+ let request = request(cpu: 4, memoryGiB: 8, diskGiB: 64)
+ let result = DoryVMResourcePolicy.assess(
+ host: host,
+ workload: .desktop,
+ request: request
+ )
+
+ #expect(result.isValid)
+ #expect(result.host == host)
+ #expect(result.request == request)
+ #expect(result.issues.isEmpty)
+ #expect(result.recommendation.virtualCPUCount == guidance(2, 4, 9))
+ #expect(result.recommendation.memoryBytes == guidanceGiB(4, 8, 24))
+ #expect(result.recommendation.diskBytes == guidanceGiB(32, 64, 450))
+ #expect(result.recommendation.hostReserve == DoryVMHostReserve(
+ logicalCPUCount: 3,
+ memoryBytes: 8 * gibibyte,
+ storageBytes: 50 * gibibyte
+ ))
+ #expect(result.recommendation.isFeasible)
+ }
+
+ @Test("recommendation is lowered to fit a constrained but viable host")
+ func constrainedRecommendation() {
+ let recommendation = DoryVMResourcePolicy.recommend(
+ host: host(cpu: 4, memoryGiB: 8, storageGiB: 50),
+ workload: .desktop
+ )
+
+ #expect(recommendation.virtualCPUCount == guidance(2, 3, 3))
+ #expect(recommendation.memoryBytes == guidanceGiB(4, 6, 6))
+ #expect(recommendation.diskBytes == guidanceGiB(32, 40, 40))
+ #expect(recommendation.isFeasible)
+ }
+
+ @Test("running and starting VM admissions reduce the next maximum")
+ func existingAdmissions() {
+ let noActiveVMs = DoryVMResourcePolicy.recommend(
+ host: host(cpu: 12, memoryGiB: 32, storageGiB: 500),
+ workload: .desktop
+ )
+ let admittedHost = DoryVMHostResources(
+ logicalCPUCount: 12,
+ physicalMemoryBytes: 32 * gibibyte,
+ freeStorageBytes: 500 * gibibyte,
+ admittedVirtualCPUCount: 4,
+ admittedMemoryBytes: 8 * gibibyte,
+ reservedStorageBytes: 100 * gibibyte
+ )
+ let result = DoryVMResourcePolicy.assess(
+ host: admittedHost,
+ workload: .desktop,
+ request: request(cpu: 4, memoryGiB: 8, diskGiB: 64)
+ )
+
+ #expect(noActiveVMs.virtualCPUCount.maximum == 9)
+ #expect(result.isValid)
+ #expect(result.host == admittedHost)
+ #expect(result.recommendation.virtualCPUCount.maximum == 5)
+ #expect(result.recommendation.memoryBytes.maximum == 16 * gibibyte)
+ #expect(result.recommendation.diskBytes.maximum == 350 * gibibyte)
+ }
+
+ @Test("stopped VM compute is not admitted while its disk reservation remains")
+ func stoppedVMCapacitySemantics() {
+ let host = DoryVMHostResources(
+ logicalCPUCount: 12,
+ physicalMemoryBytes: 32 * gibibyte,
+ freeStorageBytes: 500 * gibibyte,
+ admittedVirtualCPUCount: 0,
+ admittedMemoryBytes: 0,
+ reservedStorageBytes: 100 * gibibyte
+ )
+ let recommendation = DoryVMResourcePolicy.recommend(host: host, workload: .desktop)
+
+ #expect(recommendation.virtualCPUCount.maximum == 9)
+ #expect(recommendation.memoryBytes.maximum == 24 * gibibyte)
+ #expect(recommendation.diskBytes.maximum == 350 * gibibyte)
+ }
+
+ @Test("image and component requirements compose per resource dimension")
+ func composableRequirements() throws {
+ let windowsImage = DoryVMResourceRequirement(
+ kind: .image,
+ identifier: "windows-11-arm64",
+ minimum: request(cpu: 2, memoryGiB: 8, diskGiB: 64),
+ recommended: request(cpu: 4, memoryGiB: 16, diskGiB: 128)
+ )
+ let developmentTools = DoryVMResourceRequirement(
+ kind: .component,
+ identifier: "development-tools",
+ minimum: request(cpu: 6, memoryGiB: 12, diskGiB: 20),
+ recommended: request(cpu: 8, memoryGiB: 32, diskGiB: 40)
+ )
+ let recommendation = DoryVMResourcePolicy.recommend(
+ host: host(cpu: 32, memoryGiB: 128, storageGiB: 1_000),
+ workload: .desktop,
+ requirements: [windowsImage, developmentTools]
+ )
+
+ #expect(recommendation.virtualCPUCount.minimum == 6)
+ #expect(recommendation.virtualCPUCount.recommended == 8)
+ #expect(recommendation.memoryBytes.minimum == 12 * gibibyte)
+ #expect(recommendation.memoryBytes.recommended == 32 * gibibyte)
+ #expect(recommendation.diskBytes.minimum == 64 * gibibyte)
+ #expect(recommendation.diskBytes.recommended == 128 * gibibyte)
+
+ let encoded = try JSONEncoder().encode([windowsImage, developmentTools])
+ let decoded = try JSONDecoder().decode([DoryVMResourceRequirement].self, from: encoded)
+ #expect(decoded == [windowsImage, developmentTools])
+ }
+
+ @Test("explicit minimum cannot be weakened by a lower recommendation")
+ func requirementRecommendationNormalization() {
+ let requirement = DoryVMResourceRequirement(
+ kind: .component,
+ identifier: "large-memory-component",
+ minimum: request(cpu: 8, memoryGiB: 24, diskGiB: 80),
+ recommended: request(cpu: 1, memoryGiB: 1, diskGiB: 1)
+ )
+ let result = DoryVMResourcePolicy.assess(
+ host: host(cpu: 32, memoryGiB: 128, storageGiB: 1_000),
+ workload: .server,
+ requirements: [requirement],
+ request: request(cpu: 8, memoryGiB: 24, diskGiB: 80)
+ )
+
+ #expect(result.isValid)
+ #expect(result.issues.isEmpty)
+ #expect(result.requirements == [requirement])
+ #expect(result.recommendation.virtualCPUCount.recommended == 8)
+ #expect(result.recommendation.memoryBytes.recommended == 24 * gibibyte)
+ #expect(result.recommendation.diskBytes.recommended == 80 * gibibyte)
+ }
+
+ @Test("workload profiles have intentional requirements", arguments: [
+ (DoryVMWorkloadProfile.server, UInt64(1), UInt64(2), UInt64(16), UInt64(2), UInt64(4), UInt64(32)),
+ (DoryVMWorkloadProfile.desktop, UInt64(2), UInt64(4), UInt64(32), UInt64(4), UInt64(8), UInt64(64)),
+ (DoryVMWorkloadProfile.installer, UInt64(2), UInt64(4), UInt64(40), UInt64(4), UInt64(8), UInt64(64)),
+ ])
+ func workloadRequirements(
+ workload: DoryVMWorkloadProfile,
+ minimumCPU: UInt64,
+ minimumMemoryGiB: UInt64,
+ minimumDiskGiB: UInt64,
+ recommendedCPU: UInt64,
+ recommendedMemoryGiB: UInt64,
+ recommendedDiskGiB: UInt64
+ ) {
+ let result = DoryVMResourcePolicy.assess(
+ host: host(cpu: 64, memoryGiB: 128, storageGiB: 2_000),
+ workload: workload,
+ request: request(
+ cpu: recommendedCPU,
+ memoryGiB: recommendedMemoryGiB,
+ diskGiB: recommendedDiskGiB
+ )
+ )
+
+ #expect(result.isValid)
+ #expect(result.recommendation.virtualCPUCount.minimum == minimumCPU)
+ #expect(result.recommendation.virtualCPUCount.recommended == recommendedCPU)
+ #expect(result.recommendation.memoryBytes.minimum == minimumMemoryGiB * gibibyte)
+ #expect(result.recommendation.memoryBytes.recommended == recommendedMemoryGiB * gibibyte)
+ #expect(result.recommendation.diskBytes.minimum == minimumDiskGiB * gibibyte)
+ #expect(result.recommendation.diskBytes.recommended == recommendedDiskGiB * gibibyte)
+ }
+
+ @Test("valid but conservative resources produce warnings")
+ func belowRecommendationWarnings() {
+ let result = DoryVMResourcePolicy.assess(
+ host: host(cpu: 8, memoryGiB: 16, storageGiB: 200),
+ workload: .desktop,
+ request: request(cpu: 2, memoryGiB: 4, diskGiB: 32)
+ )
+
+ #expect(result.isValid)
+ #expect(result.issues.count == 3)
+ #expect(result.issues.allSatisfy { issue in
+ issue.code == .requestBelowRecommendation && issue.severity == .warning
+ })
+ #expect(Set(result.issues.map(\.resource)) == Set(DoryVMResourceDimension.allCases))
+ }
+
+ @Test("requests below minimum and above safe maximum are errors")
+ func invalidRequest() {
+ let result = DoryVMResourcePolicy.assess(
+ host: host(cpu: 8, memoryGiB: 16, storageGiB: 100),
+ workload: .desktop,
+ request: request(cpu: 1, memoryGiB: 13, diskGiB: 95)
+ )
+
+ #expect(!result.isValid)
+ #expect(result.issues.contains(issue(.requestBelowWorkloadMinimum, .cpu, actual: 1, threshold: 2)))
+ #expect(result.issues.contains(issue(
+ .requestExceedsHostSafeMaximum,
+ .memory,
+ actual: 13 * gibibyte,
+ threshold: 12 * gibibyte
+ )))
+ #expect(result.issues.contains(issue(
+ .requestExceedsHostSafeMaximum,
+ .storage,
+ actual: 95 * gibibyte,
+ threshold: 90 * gibibyte
+ )))
+ }
+
+ @Test("undersized hosts report infeasible workload dimensions")
+ func undersizedHost() {
+ let result = DoryVMResourcePolicy.assess(
+ host: host(cpu: 2, memoryGiB: 4, storageGiB: 45),
+ workload: .installer,
+ request: request(cpu: 2, memoryGiB: 4, diskGiB: 40)
+ )
+
+ #expect(!result.isValid)
+ #expect(!result.recommendation.isFeasible)
+ #expect(result.issues.contains(issue(
+ .hostCapacityBelowWorkloadMinimum,
+ .cpu,
+ actual: 1,
+ threshold: 2
+ )))
+ #expect(result.issues.contains(issue(
+ .hostCapacityBelowWorkloadMinimum,
+ .memory,
+ actual: 2 * gibibyte,
+ threshold: 4 * gibibyte
+ )))
+ #expect(result.issues.contains(issue(
+ .hostCapacityBelowWorkloadMinimum,
+ .storage,
+ actual: 35 * gibibyte,
+ threshold: 40 * gibibyte
+ )))
+ }
+
+ @Test("zero host facts are diagnosed independently")
+ func invalidHostFacts() {
+ let result = DoryVMResourcePolicy.assess(
+ host: DoryVMHostResources(
+ logicalCPUCount: 0,
+ physicalMemoryBytes: 0,
+ freeStorageBytes: 0
+ ),
+ workload: .server,
+ request: DoryVMResourceRequest(virtualCPUCount: 0, memoryBytes: 0, diskBytes: 0)
+ )
+
+ #expect(!result.isValid)
+ #expect(result.issues.filter { $0.code == .invalidHostCapacity }.count == 3)
+ #expect(result.issues.filter { $0.code == .hostCapacityBelowWorkloadMinimum }.count == 3)
+ #expect(result.issues.filter { $0.code == .requestBelowWorkloadMinimum }.count == 3)
+ }
+
+ @Test("maximum integer host facts do not overflow")
+ func overflowSafety() {
+ let host = DoryVMHostResources(
+ logicalCPUCount: .max,
+ physicalMemoryBytes: .max,
+ freeStorageBytes: .max
+ )
+ let result = DoryVMResourcePolicy.assess(
+ host: host,
+ workload: .server,
+ request: request(cpu: 2, memoryGiB: 4, diskGiB: 32)
+ )
+
+ let expectedReserve = UInt64.max / 4 + 1
+ #expect(result.isValid)
+ #expect(result.recommendation.hostReserve.logicalCPUCount == expectedReserve)
+ #expect(result.recommendation.hostReserve.memoryBytes == expectedReserve)
+ #expect(result.recommendation.hostReserve.storageBytes == UInt64.max / 10 + 1)
+ #expect(result.recommendation.virtualCPUCount.maximum == UInt64.max - expectedReserve)
+ }
+
+ @Test("admissions and storage reservations beyond capacity are diagnosed separately")
+ func excessiveAdmissions() {
+ let host = DoryVMHostResources(
+ logicalCPUCount: 4,
+ physicalMemoryBytes: 8 * gibibyte,
+ freeStorageBytes: 20 * gibibyte,
+ admittedVirtualCPUCount: .max,
+ admittedMemoryBytes: .max,
+ reservedStorageBytes: .max
+ )
+ let result = DoryVMResourcePolicy.assess(
+ host: host,
+ workload: .server,
+ request: request(cpu: 1, memoryGiB: 2, diskGiB: 16)
+ )
+
+ #expect(!result.isValid)
+ #expect(result.recommendation.virtualCPUCount.maximum == 0)
+ #expect(result.recommendation.memoryBytes.maximum == 0)
+ #expect(result.recommendation.diskBytes.maximum == 0)
+ #expect(result.issues.filter { $0.code == .hostAdmissionExceedsCapacity }.count == 2)
+ #expect(result.issues.filter { $0.code == .storageReservationExceedsCapacity }.count == 1)
+ }
+
+ @Test("assessment has a stable Codable representation")
+ func codableRoundTrip() throws {
+ let original = DoryVMResourcePolicy.assess(
+ host: host(cpu: 10, memoryGiB: 24, storageGiB: 250),
+ workload: .installer,
+ request: request(cpu: 4, memoryGiB: 8, diskGiB: 64)
+ )
+
+ let data = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(DoryVMResourceAssessment.self, from: data)
+ #expect(decoded == original)
+ }
+
+ @Test("older host facts decode with zero active admissions")
+ func hostFactsBackwardCompatibility() throws {
+ let data = Data(#"{"logicalCPUCount":8,"physicalMemoryBytes":17179869184,"freeStorageBytes":214748364800}"#.utf8)
+ let decoded = try JSONDecoder().decode(DoryVMHostResources.self, from: data)
+
+ #expect(decoded == host(cpu: 8, memoryGiB: 16, storageGiB: 200))
+ #expect(decoded.admittedVirtualCPUCount == 0)
+ #expect(decoded.admittedMemoryBytes == 0)
+ #expect(decoded.reservedStorageBytes == 0)
+
+ let legacy = Data(#"""
+ {
+ "logicalCPUCount":8,"physicalMemoryBytes":17179869184,
+ "freeStorageBytes":214748364800,"committedVirtualCPUCount":2,
+ "committedMemoryBytes":4294967296,"reservedStorageBytes":34359738368
+ }
+ """#.utf8)
+ let migrated = try JSONDecoder().decode(DoryVMHostResources.self, from: legacy)
+ #expect(migrated.admittedVirtualCPUCount == 2)
+ #expect(migrated.admittedMemoryBytes == 4 * gibibyte)
+ #expect(migrated.reservedStorageBytes == 32 * gibibyte)
+
+ let currentJSON = try #require(String(
+ data: JSONEncoder().encode(migrated),
+ encoding: .utf8
+ ))
+ #expect(currentJSON.contains("admittedVirtualCPUCount"))
+ #expect(!currentJSON.contains("committedVirtualCPUCount"))
+ }
+
+ private func host(cpu: UInt64, memoryGiB: UInt64, storageGiB: UInt64) -> DoryVMHostResources {
+ DoryVMHostResources(
+ logicalCPUCount: cpu,
+ physicalMemoryBytes: memoryGiB * gibibyte,
+ freeStorageBytes: storageGiB * gibibyte
+ )
+ }
+
+ private func request(cpu: UInt64, memoryGiB: UInt64, diskGiB: UInt64) -> DoryVMResourceRequest {
+ DoryVMResourceRequest(
+ virtualCPUCount: cpu,
+ memoryBytes: memoryGiB * gibibyte,
+ diskBytes: diskGiB * gibibyte
+ )
+ }
+
+ private func guidance(_ minimum: UInt64, _ recommended: UInt64, _ maximum: UInt64) -> DoryVMResourceGuidance {
+ DoryVMResourceGuidance(minimum: minimum, recommended: recommended, maximum: maximum)
+ }
+
+ private func guidanceGiB(_ minimum: UInt64, _ recommended: UInt64, _ maximum: UInt64) -> DoryVMResourceGuidance {
+ guidance(minimum * gibibyte, recommended * gibibyte, maximum * gibibyte)
+ }
+
+ private func issue(
+ _ code: DoryVMResourceValidationCode,
+ _ resource: DoryVMResourceDimension,
+ actual: UInt64,
+ threshold: UInt64
+ ) -> DoryVMResourceValidationIssue {
+ DoryVMResourceValidationIssue(
+ code: code,
+ severity: .error,
+ resource: resource,
+ actual: actual,
+ threshold: threshold
+ )
+ }
+}
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
new file mode 100644
index 00000000..da290f8b
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
@@ -0,0 +1,696 @@
+import Foundation
+import Testing
+@testable import DoryOperations
+
+@Suite("Virtual machine backend planner")
+struct DoryVirtualMachineBackendPlannerTests {
+ private static let guestArtifactSHA256 = String(repeating: "c", count: 64)
+ private static let linuxISOArtifactSHA256 = String(repeating: "a", count: 64)
+ private static let windowsISOArtifactSHA256 = String(repeating: "b", count: 64)
+ private static let macOSRestoreArtifactSHA256 = String(repeating: "d", count: 64)
+ private static let manifestSHA256 = String(repeating: "e", count: 64)
+ private static let inspectionReportSHA256 = String(repeating: "f", count: 64)
+ private static let provenanceReceiptSHA256 = String(repeating: "1", count: 64)
+ private static let runtimeQualificationReportSHA256 = String(repeating: "2", count: 64)
+ private static let qualifiedLinuxGraphics = DoryTrustedGuestImageGraphicsQualification(
+ auditEvidence: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: "dory-linux-desktop-arm64-gpu-v1",
+ artifactSHA256: guestArtifactSHA256,
+ manifestSHA256: manifestSHA256,
+ signingKeyID: "dory-release-2026",
+ manifestFormatVersion: 1
+ ),
+ virtioGPUKernelAndDeviceSupportQualified: true,
+ venusVulkanGuestRuntimeQualified: true
+ )
+
+ private static let host = DoryAppleSiliconHostFacts(
+ macOSMajorVersion: 15,
+ virtualizationFrameworkAvailable: true,
+ hypervisorFrameworkAvailable: true,
+ doryHypervisorAvailable: true,
+ qemuHypervisorFrameworkAvailable: true,
+ windowsUEFIFirmwareAvailable: true,
+ windowsSecureBootAvailable: true,
+ windowsSBSADeviceModelAvailable: true,
+ virtualTPM20Available: true,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: true,
+ networkAvailable: true,
+ displayAvailable: true,
+ inputAvailable: true
+ ),
+ macOSGuestVirtualizationSupported: true,
+ macOSRestoreImageInstallationSupported: true,
+ doryMacOSBackendAvailable: true,
+ doryMacOSBackendQualified: true,
+ metalAvailable: true,
+ doryAcceleratedRendererAvailable: true,
+ runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: "dory-hv-2026.8",
+ virtualizationFrameworkAdapterBuildID: "dory-vz-2026.8",
+ qemuRuntimeBuildID: "qemu-hvf-2026.8"
+ )
+ )
+
+ @Test("default backend order reflects currently runnable boot paths")
+ func truthfulDefaults() {
+ let linux = DoryGuestPlatform(family: .linux, architecture: .arm64)
+ let macOS = DoryGuestPlatform(family: .macOS, architecture: .arm64)
+ let windows = DoryGuestPlatform(family: .windows, architecture: .arm64)
+
+ #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends(
+ for: linux,
+ bootMedia: .installerISO
+ ) == [.appleVirtualizationFramework, .qemuHypervisorFramework])
+ #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends(
+ for: linux,
+ bootMedia: .installedLinuxBootBundle
+ ) == [.doryHypervisor])
+ #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends(
+ for: macOS,
+ bootMedia: .macOSRestoreImage
+ ) == [.appleVirtualizationFramework])
+ #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends(
+ for: windows,
+ bootMedia: .installerISO
+ ) == [.qemuHypervisorFramework])
+ }
+
+ @Test("Linux ISO selects Virtualization.framework before experimental QEMU")
+ func linuxInstallerSelection() throws {
+ let result = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software]
+ )
+ let selected = try #require(result.selectedDescriptor)
+
+ #expect(result.isSuccess)
+ #expect(selected.request.backend == .appleVirtualizationFramework)
+ #expect(selected.availability.supportTier == .supported)
+ #expect(selected.bootMediaInspectionEvidence?.artifactSHA256
+ == Self.linuxISOArtifactSHA256)
+ #expect(result.evaluatedDescriptors.map(\.request.backend) == [
+ .appleVirtualizationFramework,
+ .qemuHypervisorFramework,
+ ])
+ }
+
+ @Test("structurally bootable ISO requires experimental opt-in without runtime qualification")
+ func unqualifiedISORuntimeRequiresOptIn() throws {
+ let denied = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ automaticallyQualifyRuntime: false
+ )
+ let allowed = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ allowsExperimental: true,
+ automaticallyQualifyRuntime: false
+ )
+
+ #expect(denied.failure?.code == .noCandidate)
+ #expect(denied.evaluatedDescriptors.first?.availability.supportTier == .experimental)
+ #expect(denied.evaluatedDescriptors.first?.availability.reason?.code
+ == .runtimeQualificationUnavailable)
+ #expect(try #require(allowed.selectedDescriptor).availability.supportTier
+ == .experimental)
+ }
+
+ @Test("mutable virtual disks cannot be planned from a digest without provenance")
+ func mutableDiskRequiresProvenanceReceipt() {
+ let result = plan(
+ family: .linux,
+ media: .virtualDisk,
+ graphics: [.software],
+ allowsExperimental: true,
+ mediaArtifactSHA256: String(repeating: "9", count: 64),
+ automaticallyResolveMutableProvenance: false,
+ automaticallyQualifyRuntime: false
+ )
+
+ #expect(result.failure?.code == .noCandidate)
+ #expect(result.evaluatedDescriptors.allSatisfy {
+ $0.availability.reason?.code == .mutableBootMediaProvenanceUnavailable
+ })
+ }
+
+ @Test("installed Linux direct boot selects Dory's native hypervisor")
+ func installedLinuxSelection() throws {
+ let result = plan(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ graphics: [.hardwareAccelerated3D, .software],
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics
+ )
+ let selected = try #require(result.selectedDescriptor)
+
+ #expect(selected.request.backend == .doryHypervisor)
+ #expect(selected.request.graphics == .hardwareAccelerated3D)
+ #expect(selected.request.bootMedia.artifactSHA256 == Self.guestArtifactSHA256)
+ #expect(selected.runtimeQualificationEvidence?.backend == .doryHypervisor)
+ #expect(result.evaluatedDescriptors.count == 2)
+ }
+
+ @Test("planner defaults cannot authorize Linux 3D without image qualification")
+ func installedLinuxUnqualifiedGraphicsIsFailClosed() throws {
+ let noFallback = plan(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ graphics: [.hardwareAccelerated3D],
+ mediaArtifactSHA256: Self.guestArtifactSHA256
+ )
+ let explicitHeadlessFallback = plan(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ graphics: [.hardwareAccelerated3D, .none],
+ mediaArtifactSHA256: Self.guestArtifactSHA256
+ )
+ let selected = try #require(explicitHeadlessFallback.selectedDescriptor)
+
+ #expect(noFallback.failure?.code == .noCandidate)
+ #expect(noFallback.evaluatedDescriptors.first?.availability.reason?.code
+ == .trustedGuestImageGraphicsQualificationUnavailable)
+ #expect(selected.request.graphics == .none)
+ #expect(selected.request.bootMedia.artifactSHA256 == Self.guestArtifactSHA256)
+ }
+
+ @Test("preferred backend order is honored across explicitly allowed support tiers")
+ func backendPreferenceOrderIsHonored() throws {
+ let result = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ backends: [.qemuHypervisorFramework, .appleVirtualizationFramework],
+ allowsExperimental: true
+ )
+ let selected = try #require(result.selectedDescriptor)
+
+ #expect(selected.request.backend == .qemuHypervisorFramework)
+ #expect(result.evaluatedDescriptors.map(\.request.backend) == [
+ .qemuHypervisorFramework,
+ .appleVirtualizationFramework,
+ ])
+ }
+
+ @Test("preferred backends permit automatic fallback while required backends do not")
+ func backendPreferencePolicyControlsFallback() throws {
+ var unavailableQEMUHost = Self.host
+ unavailableQEMUHost.qemuHypervisorFrameworkAvailable = false
+ let preferred = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ backends: [.qemuHypervisorFramework],
+ backendPolicy: .preferred,
+ host: unavailableQEMUHost,
+ allowsExperimental: true
+ )
+ let required = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ backends: [.qemuHypervisorFramework],
+ backendPolicy: .required,
+ host: unavailableQEMUHost,
+ allowsExperimental: true
+ )
+
+ #expect(try #require(preferred.selectedDescriptor).request.backend
+ == .appleVirtualizationFramework)
+ #expect(preferred.evaluatedDescriptors.map(\.request.backend) == [
+ .qemuHypervisorFramework,
+ .appleVirtualizationFramework,
+ ])
+ #expect(required.failure?.code == .noCandidate)
+ #expect(required.evaluatedDescriptors.map(\.request.backend)
+ == [.qemuHypervisorFramework])
+ }
+
+ @Test("graphics is never downgraded unless the caller supplied the fallback")
+ func graphicsFallbackMustBeExplicit() throws {
+ let noFallback = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.hardwareAccelerated3D]
+ )
+ let withFallback = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.hardwareAccelerated3D, .software]
+ )
+ let selected = try #require(withFallback.selectedDescriptor)
+
+ #expect(!noFallback.isSuccess)
+ #expect(noFallback.failure?.code == .noCandidate)
+ #expect(noFallback.evaluatedDescriptors.count == 2)
+ #expect(selected.request.graphics == .software)
+ #expect(withFallback.evaluatedDescriptors.count == 4)
+ }
+
+ @Test("Windows remains unavailable unless experimental backends are explicitly allowed")
+ func windowsRequiresExperimentalOptIn() throws {
+ let denied = plan(
+ family: .windows,
+ media: .installerISO,
+ graphics: [.software]
+ )
+ let allowed = plan(
+ family: .windows,
+ media: .installerISO,
+ graphics: [.hardwareAccelerated3D, .software],
+ allowsExperimental: true
+ )
+ let selected = try #require(allowed.selectedDescriptor)
+
+ #expect(denied.failure?.code == .noCandidate)
+ #expect(denied.evaluatedDescriptors.first?.availability.isUsable == true)
+ #expect(selected.availability.supportTier == .experimental)
+ #expect(selected.request.graphics == .software)
+ #expect(allowed.evaluatedDescriptors.first?.availability.reason?.code
+ == .windows3DAccelerationUnsupported)
+ }
+
+ @Test("macOS default is VZ and retains external Apple media provenance")
+ func macOSSelection() throws {
+ let result = plan(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .vendorDownload,
+ graphics: [.hardwareAccelerated3D],
+ allowsExperimental: true
+ )
+ let selected = try #require(result.selectedDescriptor)
+
+ #expect(selected.request.backend == .appleVirtualizationFramework)
+ #expect(selected.availability.supportTier == .experimental)
+ #expect(selected.request.bootMedia.source == .vendorDownload)
+ #expect(result.evaluatedDescriptors.count == 1)
+ }
+
+ @Test("empty and duplicate preferences fail before capability evaluation")
+ func invalidPreferences() {
+ let emptyGraphics = plan(family: .linux, media: .installerISO, graphics: [])
+ let duplicateGraphics = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software, .software]
+ )
+ let emptyBackends = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ backends: []
+ )
+ let duplicateBackends = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ backends: [.appleVirtualizationFramework, .appleVirtualizationFramework]
+ )
+ let missingRequiredBackends = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ backendPolicy: .required
+ )
+
+ #expect(emptyGraphics.failure?.code == .invalidPreference)
+ #expect(emptyGraphics.failure?.preferenceField == .graphics)
+ #expect(emptyGraphics.failure?.preferenceIssue == .empty)
+ #expect(duplicateGraphics.failure?.preferenceIssue == .duplicate)
+ #expect(emptyBackends.failure?.preferenceField == .backend)
+ #expect(emptyBackends.failure?.preferenceIssue == .empty)
+ #expect(duplicateBackends.failure?.preferenceIssue == .duplicate)
+ #expect(missingRequiredBackends.failure?.preferenceIssue == .missing)
+ #expect(emptyGraphics.evaluatedDescriptors.isEmpty)
+ #expect(duplicateBackends.evaluatedDescriptors.isEmpty)
+ }
+
+ @Test("planner results preserve evaluation evidence through Codable")
+ func codableResult() throws {
+ let result = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.hardwareAccelerated3D]
+ )
+ let data = try JSONEncoder().encode(result)
+ let decoded = try JSONDecoder().decode(
+ DoryVirtualMachineBackendPlanResult.self,
+ from: data
+ )
+
+ #expect(decoded == result)
+ #expect(decoded.failure?.code == .noCandidate)
+ #expect(decoded.evaluatedDescriptors.count == 2)
+ }
+
+ @Test("successful 3D plans retain revalidation evidence through Codable")
+ func qualifiedPlanAuditRoundTrip() throws {
+ let result = plan(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ graphics: [.hardwareAccelerated3D],
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics
+ )
+ let encoded = try JSONEncoder().encode(result)
+ let decoded = try JSONDecoder().decode(
+ DoryVirtualMachineBackendPlanResult.self,
+ from: encoded
+ )
+ let selected = try #require(decoded.selectedDescriptor)
+
+ #expect(selected.graphicsQualificationEvidence
+ == Self.qualifiedLinuxGraphics.auditEvidence)
+ #expect(selected.graphicsQualificationEvidence?.manifestSHA256
+ == Self.manifestSHA256)
+ #expect(selected.request.bootMedia.artifactSHA256 == Self.guestArtifactSHA256)
+ #expect(selected.runtimeQualificationEvidence?.qualificationIdentity
+ == "linux-runtime-qualification-v1")
+ }
+
+ @Test("runtime inventory selection is order independent for ISO digest and runtime build")
+ func runtimeInventoryMatchesExactISORecordInEitherOrder() throws {
+ let exact = runtimeQualification(
+ media: .installerISO,
+ immutableArtifactSHA256: Self.linuxISOArtifactSHA256,
+ backend: .appleVirtualizationFramework,
+ runtimeBuildID: "dory-vz-2026.8"
+ )
+ let staleDigest = runtimeQualification(
+ media: .installerISO,
+ immutableArtifactSHA256: String(repeating: "7", count: 64),
+ backend: .appleVirtualizationFramework,
+ runtimeBuildID: "dory-vz-2026.8"
+ )
+ let staleRuntime = runtimeQualification(
+ media: .installerISO,
+ immutableArtifactSHA256: Self.linuxISOArtifactSHA256,
+ backend: .appleVirtualizationFramework,
+ runtimeBuildID: "dory-vz-2026.7"
+ )
+
+ for inventory in [
+ [staleDigest, staleRuntime, exact],
+ [exact, staleRuntime, staleDigest],
+ ] {
+ let result = plan(
+ family: .linux,
+ media: .installerISO,
+ graphics: [.software],
+ trustedRuntimeQualifications: inventory
+ )
+ let selected = try #require(result.selectedDescriptor)
+ #expect(selected.availability.supportTier == .supported)
+ #expect(selected.runtimeQualificationEvidence?.immutableArtifactSHA256
+ == Self.linuxISOArtifactSHA256)
+ #expect(selected.runtimeQualificationEvidence?.backendRuntimeBuildID
+ == "dory-vz-2026.8")
+ }
+ }
+
+ @Test("runtime inventory selection is order independent for mutable revision and runtime build")
+ func runtimeInventoryMatchesExactMutableRecordInEitherOrder() throws {
+ let exactProvenance = DoryMutableBootMediaProvenanceReference(
+ repositoryIdentity: "dory-test-store",
+ mediaIdentity: "linux-system-disk",
+ revision: 7
+ )
+ let staleProvenance = DoryMutableBootMediaProvenanceReference(
+ repositoryIdentity: "dory-test-store",
+ mediaIdentity: "linux-system-disk",
+ revision: 6
+ )
+ let exact = runtimeQualification(
+ media: .virtualDisk,
+ mutableProvenance: exactProvenance,
+ backend: .appleVirtualizationFramework,
+ runtimeBuildID: "dory-vz-2026.8"
+ )
+ let staleRevision = runtimeQualification(
+ media: .virtualDisk,
+ mutableProvenance: staleProvenance,
+ backend: .appleVirtualizationFramework,
+ runtimeBuildID: "dory-vz-2026.8"
+ )
+ let staleRuntime = runtimeQualification(
+ media: .virtualDisk,
+ mutableProvenance: exactProvenance,
+ backend: .appleVirtualizationFramework,
+ runtimeBuildID: "dory-vz-2026.7"
+ )
+
+ for inventory in [
+ [staleRevision, staleRuntime, exact],
+ [exact, staleRuntime, staleRevision],
+ ] {
+ let result = plan(
+ family: .linux,
+ media: .virtualDisk,
+ graphics: [.software],
+ trustedRuntimeQualifications: inventory
+ )
+ let selected = try #require(result.selectedDescriptor)
+ #expect(selected.availability.supportTier == .supported)
+ #expect(selected.runtimeQualificationEvidence?.mutableProvenance?.revision == 7)
+ #expect(selected.runtimeQualificationEvidence?.backendRuntimeBuildID
+ == "dory-vz-2026.8")
+ }
+ }
+
+ @Test("planner carries the exact device request and rejects unsupported modes")
+ func plannerNegotiatesDevices() throws {
+ let minimum = plan(
+ family: .linux,
+ media: .virtualDisk,
+ graphics: [.software]
+ )
+ let disconnected = plan(
+ family: .linux,
+ media: .virtualDisk,
+ graphics: [.software],
+ devices: DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .disconnected)
+ )
+
+ #expect(try #require(minimum.selectedDescriptor).resolvedDevices == .minimumBootable)
+ #expect(disconnected.failure?.code == .noCandidate)
+ #expect(disconnected.evaluatedDescriptors.allSatisfy {
+ $0.availability.reason?.code == .networkAttachmentUnsupported
+ })
+ }
+
+ @Test("legacy planner requests decode with conservative policy and device defaults")
+ func legacyPlanRequestDecoding() throws {
+ let data = Data(#"""
+ {
+ "guest":{"family":"linux","architecture":"arm64"},
+ "bootMedia":{"kind":"virtual-disk","source":"user-provided"},
+ "acceptableGraphics":["software"],
+ "backendPreferences":["apple-virtualization-framework"],
+ "allowsExperimentalBackends":false
+ }
+ """#.utf8)
+ let decoded = try JSONDecoder().decode(
+ DoryVirtualMachineBackendPlanRequest.self,
+ from: data
+ )
+
+ #expect(decoded.devices == .minimumBootable)
+ #expect(decoded.backendPreferencePolicy == .preferred)
+ }
+
+ private func plan(
+ family: DoryGuestFamily,
+ media: DoryBootMediaKind,
+ source: DoryBootMediaSource = .userProvided,
+ graphics: [DoryGraphicsAccelerationLevel],
+ devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable,
+ backends: [DoryVirtualizationBackendIdentity]? = nil,
+ backendPolicy: DoryVirtualMachineBackendPreferencePolicy = .preferred,
+ host: DoryAppleSiliconHostFacts = DoryVirtualMachineBackendPlannerTests.host,
+ allowsExperimental: Bool = false,
+ mediaArtifactSHA256: String? = nil,
+ trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil,
+ automaticallyTrustBootMedia: Bool = true,
+ trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil,
+ automaticallyResolveMutableProvenance: Bool = true,
+ automaticallyQualifyRuntime: Bool = true,
+ trustedRuntimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification]? = nil
+ ) -> DoryVirtualMachineBackendPlanResult {
+ let resolvedArtifactSHA256 = mediaArtifactSHA256
+ ?? defaultArtifactSHA256(family: family, media: media)
+ let resolvedInspection = trustedBootMediaInspection
+ ?? (automaticallyTrustBootMedia
+ ? makeBootMediaInspection(
+ family: family,
+ media: media,
+ artifactSHA256: resolvedArtifactSHA256
+ )
+ : nil)
+ let mutableProvenance = automaticallyResolveMutableProvenance && media == .virtualDisk
+ ? DoryMutableBootMediaProvenanceReference(
+ repositoryIdentity: "dory-test-store",
+ mediaIdentity: "\(family.rawValue)-system-disk",
+ revision: 7
+ )
+ : nil
+ let trustedMutableProvenance = makeTrustedMutableProvenance(mutableProvenance)
+ let planRequest = DoryVirtualMachineBackendPlanRequest(
+ guest: DoryGuestPlatform(family: family, architecture: .arm64),
+ bootMedia: DoryBootMedia(
+ kind: media,
+ source: source,
+ artifactSHA256: resolvedArtifactSHA256,
+ mutableProvenance: mutableProvenance
+ ),
+ acceptableGraphics: graphics,
+ devices: devices,
+ backendPreferences: backends,
+ backendPreferencePolicy: backendPolicy,
+ allowsExperimentalBackends: allowsExperimental
+ )
+ let resolvedRuntimeQualifications = trustedRuntimeQualifications
+ ?? (automaticallyQualifyRuntime
+ ? makeRuntimeQualifications(request: planRequest, host: host)
+ : [])
+ return DoryAppleSiliconVirtualMachineBackendPlanner.plan(
+ planRequest,
+ host: host,
+ trustedGuestImageGraphicsQualification: trustedGuestImageGraphicsQualification,
+ trustedBootMediaInspection: resolvedInspection,
+ trustedMutableBootMediaProvenance: trustedMutableProvenance,
+ trustedRuntimeQualifications: resolvedRuntimeQualifications
+ )
+ }
+
+ private func defaultArtifactSHA256(
+ family: DoryGuestFamily,
+ media: DoryBootMediaKind
+ ) -> String? {
+ switch (family, media) {
+ case (.linux, .installerISO):
+ Self.linuxISOArtifactSHA256
+ case (.windows, .installerISO):
+ Self.windowsISOArtifactSHA256
+ case (.macOS, .macOSRestoreImage):
+ Self.macOSRestoreArtifactSHA256
+ default:
+ nil
+ }
+ }
+
+ private func makeBootMediaInspection(
+ family: DoryGuestFamily,
+ media: DoryBootMediaKind,
+ artifactSHA256: String?
+ ) -> DoryTrustedBootMediaInspection? {
+ guard media == .installerISO || media == .macOSRestoreImage,
+ let artifactSHA256 else {
+ return nil
+ }
+ return DoryTrustedBootMediaInspection(
+ auditEvidence: DoryBootMediaInspectionAuditEvidence(
+ inspectionIdentity: "inspection-\(family.rawValue)-\(media.rawValue)",
+ artifactSHA256: artifactSHA256,
+ inspectionReportSHA256: Self.inspectionReportSHA256,
+ inspectorID: "dory-media-inspector",
+ inspectorVersion: 1
+ ),
+ detectedKind: media,
+ detectedGuestFamily: family,
+ detectedArchitecture: .arm64,
+ isEFIBootable: true,
+ macOSBuildIdentifier: family == .macOS ? "25A123" : nil,
+ macOSHardwareModelCompatible: family == .macOS,
+ macOSAuxiliaryStorageCompatible: family == .macOS
+ )
+ }
+
+ private func makeTrustedMutableProvenance(
+ _ provenance: DoryMutableBootMediaProvenanceReference?
+ ) -> DoryTrustedMutableBootMediaProvenance? {
+ guard let provenance else { return nil }
+ return DoryTrustedMutableBootMediaProvenance(
+ auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence(
+ receiptIdentity: "mutable-disk-receipt-\(provenance.mediaIdentity)",
+ provenance: provenance,
+ receiptSHA256: Self.provenanceReceiptSHA256,
+ resolverID: "dory-artifact-resolver",
+ resolverVersion: 1
+ )
+ )
+ }
+
+ private func makeRuntimeQualifications(
+ request: DoryVirtualMachineBackendPlanRequest,
+ host: DoryAppleSiliconHostFacts
+ ) -> [DoryTrustedVirtualMachineRuntimeQualification] {
+ guard request.guest.family == .linux,
+ let hostContext = host.runtimeQualificationContext else {
+ return []
+ }
+ return request.acceptableGraphics.flatMap { graphics in
+ DoryVirtualizationBackendIdentity.allCases.compactMap { backend in
+ let immutableArtifactSHA256 = request.bootMedia.kind == .virtualDisk
+ ? nil : request.bootMedia.artifactSHA256
+ guard immutableArtifactSHA256 != nil
+ || request.bootMedia.mutableProvenance != nil else {
+ return nil
+ }
+ return DoryTrustedVirtualMachineRuntimeQualification(
+ auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "linux-runtime-qualification-v1",
+ qualificationReportSHA256: Self.runtimeQualificationReportSHA256,
+ signingKeyID: "dory-runtime-qualification-2026",
+ qualificationFormatVersion: 1,
+ guest: request.guest,
+ bootMediaKind: request.bootMedia.kind,
+ immutableArtifactSHA256: immutableArtifactSHA256,
+ mutableProvenance: request.bootMedia.kind == .virtualDisk
+ ? request.bootMedia.mutableProvenance : nil,
+ backend: backend,
+ backendRuntimeBuildID: hostContext.runtimeBuildID(for: backend),
+ virtualHardwareABIVersion: request.virtualHardwareABIVersion,
+ graphics: graphics,
+ devices: request.devices
+ ),
+ runtimeQualified: true
+ )
+ }
+ }
+ }
+
+ private func runtimeQualification(
+ media: DoryBootMediaKind,
+ immutableArtifactSHA256: String? = nil,
+ mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil,
+ backend: DoryVirtualizationBackendIdentity,
+ runtimeBuildID: String
+ ) -> DoryTrustedVirtualMachineRuntimeQualification {
+ DoryTrustedVirtualMachineRuntimeQualification(
+ auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "linux-runtime-qualification-v1",
+ qualificationReportSHA256: Self.runtimeQualificationReportSHA256,
+ signingKeyID: "dory-runtime-qualification-2026",
+ qualificationFormatVersion: 1,
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ bootMediaKind: media,
+ immutableArtifactSHA256: immutableArtifactSHA256,
+ mutableProvenance: mutableProvenance,
+ backend: backend,
+ backendRuntimeBuildID: runtimeBuildID,
+ virtualHardwareABIVersion: 1,
+ graphics: .software,
+ devices: .minimumBootable
+ ),
+ runtimeQualified: true
+ )
+ }
+}
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
new file mode 100644
index 00000000..c8d9c0d7
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
@@ -0,0 +1,1064 @@
+import Foundation
+import Testing
+@testable import DoryOperations
+
+@Suite("Virtual machine capability negotiation")
+struct VirtualMachineCapabilitiesTests {
+ private static let guestArtifactSHA256 = String(repeating: "a", count: 64)
+ private static let linuxISOArtifactSHA256 = String(repeating: "b", count: 64)
+ private static let windowsISOArtifactSHA256 = String(repeating: "c", count: 64)
+ private static let macOSRestoreArtifactSHA256 = String(repeating: "d", count: 64)
+ private static let manifestSHA256 = String(repeating: "e", count: 64)
+ private static let inspectionReportSHA256 = String(repeating: "f", count: 64)
+ private static let provenanceReceiptSHA256 = String(repeating: "1", count: 64)
+ private static let runtimeQualificationReportSHA256 = String(repeating: "2", count: 64)
+ private static let qualifiedLinuxGraphics = DoryTrustedGuestImageGraphicsQualification(
+ auditEvidence: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: "dory-linux-desktop-arm64-gpu-v1",
+ artifactSHA256: guestArtifactSHA256,
+ manifestSHA256: manifestSHA256,
+ signingKeyID: "dory-release-2026",
+ manifestFormatVersion: 1
+ ),
+ virtioGPUKernelAndDeviceSupportQualified: true,
+ venusVulkanGuestRuntimeQualified: true
+ )
+
+ private static let provisionedHost = DoryAppleSiliconHostFacts(
+ macOSMajorVersion: 15,
+ virtualizationFrameworkAvailable: true,
+ hypervisorFrameworkAvailable: true,
+ doryHypervisorAvailable: true,
+ qemuHypervisorFrameworkAvailable: true,
+ windowsUEFIFirmwareAvailable: true,
+ windowsSecureBootAvailable: true,
+ windowsSBSADeviceModelAvailable: true,
+ virtualTPM20Available: true,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: true,
+ networkAvailable: true,
+ displayAvailable: true,
+ inputAvailable: true
+ ),
+ macOSGuestVirtualizationSupported: true,
+ macOSRestoreImageInstallationSupported: true,
+ doryMacOSBackendAvailable: true,
+ doryMacOSBackendQualified: true,
+ metalAvailable: true,
+ doryAcceleratedRendererAvailable: true,
+ runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: "dory-hv-2026.8",
+ virtualizationFrameworkAdapterBuildID: "dory-vz-2026.8",
+ qemuRuntimeBuildID: "qemu-hvf-2026.8"
+ )
+ )
+
+ @Test("installed Linux ARM64 with native 3D is supported when all components are present")
+ func linuxNative3DIsSupported() {
+ let descriptor = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .hardwareAccelerated3D,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics
+ )
+
+ #expect(descriptor.schemaVersion == 2)
+ #expect(descriptor.evaluatorVersion == 2)
+ #expect(descriptor.availability.supportTier == .supported)
+ #expect(descriptor.availability.state == .available)
+ #expect(descriptor.availability.reason == nil)
+ #expect(descriptor.availability.isUsable)
+ #expect(descriptor.graphicsQualificationEvidence
+ == Self.qualifiedLinuxGraphics.auditEvidence)
+ #expect(descriptor.runtimeQualificationEvidence?.backend == .doryHypervisor)
+ }
+
+ @Test("host renderer facts cannot authorize an unqualified Linux guest image")
+ func linux3DRequiresExactSignedGuestQualification() {
+ let missing = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .hardwareAccelerated3D
+ )
+ let invalidArtifactIdentity = evaluateLinux3D(
+ mediaArtifactSHA256: "not-a-sha256",
+ trustedQualification: Self.qualifiedLinuxGraphics
+ )
+ let missingManifestIdentity = trustedQualification(signedManifestIdentity: "")
+ let invalidManifestDigest = trustedQualification(manifestSHA256: "invalid")
+ let mismatched = trustedQualification(
+ qualifiedArtifactSHA256: String(repeating: "b", count: 64)
+ )
+ let noVirtioGPU = trustedQualification(virtioGPUQualified: false)
+ let noVenus = trustedQualification(venusVulkanQualified: false)
+
+ #expect(missing.availability.reason?.code == .guestImageArtifactDigestUnavailable)
+ #expect(invalidArtifactIdentity.availability.reason?.code
+ == .bootMediaArtifactDigestInvalid)
+ #expect(evaluateLinux3D(trustedQualification: missingManifestIdentity).availability.reason?.code
+ == .guestImageQualificationAuditEvidenceInvalid)
+ #expect(evaluateLinux3D(trustedQualification: invalidManifestDigest).availability.reason?.code
+ == .guestImageQualificationAuditEvidenceInvalid)
+ #expect(evaluateLinux3D(trustedQualification: mismatched).availability.reason?.code
+ == .guestImageArtifactQualificationMismatch)
+ #expect(evaluateLinux3D(trustedQualification: noVirtioGPU).availability.reason?.code
+ == .linuxVirtioGPUKernelDeviceUnqualified)
+ #expect(evaluateLinux3D(trustedQualification: noVenus).availability.reason?.code
+ == .linuxVenusVulkanRuntimeUnqualified)
+ #expect(evaluateLinux3D(
+ trustedQualification: Self.qualifiedLinuxGraphics
+ ).availability.isUsable)
+ }
+
+ @Test("caller-authored qualification fields cannot cross the request trust boundary")
+ func spoofedRequestQualificationIsIgnored() throws {
+ let request = DoryVirtualMachineCapabilityRequest(
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ bootMedia: DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .userProvided,
+ artifactSHA256: Self.guestArtifactSHA256
+ ),
+ backend: .doryHypervisor,
+ graphics: .hardwareAccelerated3D
+ )
+ let encoded = try JSONEncoder().encode(request)
+ var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+ object["guestImageGraphicsQualification"] = [
+ "manifestSignatureVerified": true,
+ "qualifiedArtifactSHA256": Self.guestArtifactSHA256,
+ "virtioGPUKernelAndDeviceSupportQualified": true,
+ "venusVulkanGuestRuntimeQualified": true,
+ ]
+ let spoofed = try JSONSerialization.data(withJSONObject: object)
+ let decoded = try JSONDecoder().decode(
+ DoryVirtualMachineCapabilityRequest.self,
+ from: spoofed
+ )
+ let descriptor = DoryAppleSiliconCapabilityEvaluator.evaluate(
+ decoded,
+ host: Self.provisionedHost
+ )
+
+ #expect(descriptor.availability.reason?.code
+ == .trustedGuestImageGraphicsQualificationUnavailable)
+ }
+
+ @Test("native hypervisor only accepts the post-install direct-boot bundle")
+ func nativeHypervisorRejectsInstallerISO() {
+ let native = evaluate(
+ family: .linux,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .software
+ )
+ let installer = evaluate(
+ family: .linux,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software
+ )
+
+ #expect(native.availability.reason?.code == .bootMediaDoesNotSupportBackend)
+ #expect(!native.availability.isUsable)
+ #expect(installer.availability.isUsable)
+ }
+
+ @Test("Apple Silicon never labels x86_64 execution as virtualization")
+ func x86RequiresEmulation() {
+ let descriptor = evaluate(
+ family: .linux,
+ architecture: .x86_64,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .qemuHypervisorFramework,
+ graphics: .software
+ )
+
+ #expect(descriptor.availability.supportTier == .unsupported)
+ #expect(descriptor.availability.state == .unavailable)
+ #expect(descriptor.availability.reason?.code == .guestArchitectureRequiresEmulation)
+ #expect(!descriptor.availability.isUsable)
+ }
+
+ @Test("Windows ARM64 is explicitly experimental and never promises 3D")
+ func windowsSupportBoundaries() {
+ let experimental = evaluate(
+ family: .windows,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .qemuHypervisorFramework,
+ graphics: .hostAcceleratedDisplay
+ )
+ let threeD = evaluate(
+ family: .windows,
+ media: .virtualDisk,
+ source: .userProvided,
+ backend: .qemuHypervisorFramework,
+ graphics: .hardwareAccelerated3D
+ )
+
+ #expect(experimental.availability.supportTier == .experimental)
+ #expect(experimental.availability.state == .available)
+ #expect(experimental.availability.reason?.code == .windowsSupportIsExperimental)
+ #expect(experimental.availability.isUsable)
+ #expect(threeD.availability.supportTier == .unsupported)
+ #expect(threeD.availability.reason?.code == .windows3DAccelerationUnsupported)
+ #expect(!threeD.availability.isUsable)
+ }
+
+ @Test("Windows support does not leak onto backends that cannot boot it")
+ func windowsRequiresQEMUBackend() {
+ let descriptor = evaluate(
+ family: .windows,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software
+ )
+
+ #expect(descriptor.availability.supportTier == .unsupported)
+ #expect(descriptor.availability.reason?.code == .backendDoesNotSupportGuest)
+ }
+
+ @Test("macOS media is vendor or user supplied, never Dory redistributed")
+ func macOSMediaProvenance() {
+ let bundled = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .bundledByDory,
+ backend: .appleVirtualizationFramework,
+ graphics: .hardwareAccelerated3D
+ )
+ let vendor = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .vendorDownload,
+ backend: .appleVirtualizationFramework,
+ graphics: .hardwareAccelerated3D
+ )
+
+ #expect(bundled.availability.supportTier == .unsupported)
+ #expect(bundled.availability.reason?.code == .guestMediaRedistributionUnavailable)
+ #expect(vendor.availability.supportTier == .experimental)
+ #expect(vendor.availability.state == .available)
+ }
+
+ @Test("Windows installation media is never represented as Dory redistributable")
+ func windowsMediaProvenance() {
+ let descriptor = evaluate(
+ family: .windows,
+ media: .installerISO,
+ source: .bundledByDory,
+ backend: .qemuHypervisorFramework,
+ graphics: .software
+ )
+
+ #expect(descriptor.availability.supportTier == .unsupported)
+ #expect(descriptor.availability.reason?.code == .guestMediaRedistributionUnavailable)
+ }
+
+ @Test("guest-specific boot formats are rejected before host probing")
+ func bootMediaCompatibility() {
+ let windowsKernel = evaluate(
+ family: .windows,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .qemuHypervisorFramework,
+ graphics: .software
+ )
+ let linuxRestore = evaluate(
+ family: .linux,
+ media: .macOSRestoreImage,
+ source: .vendorDownload,
+ backend: .doryHypervisor,
+ graphics: .software
+ )
+
+ #expect(windowsKernel.availability.reason?.code == .bootMediaDoesNotSupportGuest)
+ #expect(linuxRestore.availability.reason?.code == .bootMediaDoesNotSupportGuest)
+ }
+
+ @Test("missing renderer blocks accelerated native graphics but not software graphics")
+ func rendererAvailabilityIsExact() {
+ var host = Self.provisionedHost
+ host.doryAcceleratedRendererAvailable = false
+
+ let accelerated = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .hardwareAccelerated3D,
+ host: host,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics
+ )
+ let software = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .software,
+ host: host,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics
+ )
+
+ #expect(accelerated.availability.supportTier == .supported)
+ #expect(accelerated.availability.state == .unavailable)
+ #expect(accelerated.availability.reason?.code == .acceleratedRendererUnavailable)
+ #expect(software.availability.isUsable)
+ }
+
+ @Test("host facts distinguish product support from local component availability")
+ func backendComponentAvailability() {
+ var host = Self.provisionedHost
+ host.qemuHypervisorFrameworkAvailable = false
+
+ let descriptor = evaluate(
+ family: .windows,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .qemuHypervisorFramework,
+ graphics: .software,
+ host: host
+ )
+
+ #expect(descriptor.availability.supportTier == .experimental)
+ #expect(descriptor.availability.state == .unavailable)
+ #expect(descriptor.availability.reason?.code == .backendComponentUnavailable)
+ }
+
+ @Test("Windows requires a complete qualified boot and driver stack")
+ func windowsRequiresCompleteRuntime() {
+ var noFirmware = Self.provisionedHost
+ noFirmware.windowsUEFIFirmwareAvailable = false
+ var noDeviceModel = Self.provisionedHost
+ noDeviceModel.windowsSBSADeviceModelAvailable = false
+ var noSecureBoot = Self.provisionedHost
+ noSecureBoot.windowsSecureBootAvailable = false
+ var noTPM = Self.provisionedHost
+ noTPM.virtualTPM20Available = false
+ var noStorage = Self.provisionedHost
+ noStorage.windowsGuestDrivers.storageAvailable = false
+ var noNetwork = Self.provisionedHost
+ noNetwork.windowsGuestDrivers.networkAvailable = false
+ var noDisplay = Self.provisionedHost
+ noDisplay.windowsGuestDrivers.displayAvailable = false
+ var noInput = Self.provisionedHost
+ noInput.windowsGuestDrivers.inputAvailable = false
+
+ #expect(windows(host: noFirmware).availability.reason?.code == .windowsUEFIFirmwareUnavailable)
+ #expect(windows(host: noSecureBoot).availability.reason?.code == .windowsSecureBootUnavailable)
+ #expect(windows(host: noDeviceModel).availability.reason?.code == .windowsSBSADeviceModelUnavailable)
+ #expect(windows(host: noTPM).availability.reason?.code == .virtualTPM20Unavailable)
+ #expect(windows(host: noStorage).availability.reason?.code == .windowsStorageDriverUnavailable)
+ #expect(windows(host: noNetwork).availability.reason?.code == .windowsNetworkDriverUnavailable)
+ #expect(windows(host: noDisplay).availability.reason?.code == .windowsDisplayDriverUnavailable)
+ #expect(windows(host: noInput).availability.reason?.code == .windowsInputDriverUnavailable)
+ }
+
+ @Test("macOS availability requires host-qualified VZMac and restore-image support")
+ func macOSHostQualification() {
+ var noPlatform = Self.provisionedHost
+ noPlatform.macOSGuestVirtualizationSupported = false
+ var noRestore = Self.provisionedHost
+ noRestore.macOSRestoreImageInstallationSupported = false
+
+ let platform = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ host: noPlatform
+ )
+ let restore = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ host: noRestore
+ )
+
+ #expect(platform.availability.reason?.code == .macOSGuestVirtualizationUnavailable)
+ #expect(restore.availability.reason?.code == .macOSRestoreImageUnsupported)
+ }
+
+ @Test("Apple host support cannot substitute for Dory's qualified VZMac adapter")
+ func macOSRequiresQualifiedDoryAdapter() {
+ var noAdapter = Self.provisionedHost
+ noAdapter.doryMacOSBackendAvailable = false
+ var unqualified = Self.provisionedHost
+ unqualified.doryMacOSBackendQualified = false
+
+ let absent = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ host: noAdapter
+ )
+ let notQualified = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ host: unqualified
+ )
+
+ #expect(absent.availability.supportTier == .experimental)
+ #expect(absent.availability.state == .unavailable)
+ #expect(absent.availability.reason?.code == .backendComponentUnavailable)
+ #expect(notQualified.availability.reason?.code == .backendComponentUnqualified)
+ }
+
+ @Test("native backend host requirements are deterministic")
+ func nativeBackendHostRequirements() {
+ var oldHost = Self.provisionedHost
+ oldHost.macOSMajorVersion = 14
+ let oldOS = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .software,
+ host: oldHost,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics
+ )
+
+ var noHypervisor = Self.provisionedHost
+ noHypervisor.hypervisorFrameworkAvailable = false
+ let noFramework = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .software,
+ host: noHypervisor,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: Self.qualifiedLinuxGraphics
+ )
+
+ #expect(oldOS.availability.reason?.code == .hostOperatingSystemUnsupported)
+ #expect(noFramework.availability.reason?.code == .hypervisorFrameworkUnavailable)
+ }
+
+ @Test("capability descriptors have stable Codable round trips")
+ func codableRoundTrip() throws {
+ let descriptor = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .vendorDownload,
+ backend: .appleVirtualizationFramework,
+ graphics: .hardwareAccelerated3D
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let encoded = try encoder.encode(descriptor)
+ let decoded = try JSONDecoder().decode(
+ DoryVirtualMachineCapabilityDescriptor.self,
+ from: encoded
+ )
+ let json = try #require(String(data: encoded, encoding: .utf8))
+
+ #expect(decoded == descriptor)
+ #expect(json.contains(#""schemaVersion":2"#))
+ #expect(json.contains(#""family":"macos""#))
+ #expect(json.contains(#""kind":"macos-restore-image""#))
+ #expect(json.contains(#""backend":"apple-virtualization-framework""#))
+ #expect(json.contains(#""graphics":"hardware-accelerated-3d""#))
+ #expect(decoded.bootMediaInspectionEvidence?.artifactSHA256
+ == Self.macOSRestoreArtifactSHA256)
+ }
+
+ @Test("qualified graphics audit evidence survives descriptor persistence")
+ func graphicsAuditEvidenceRoundTrip() throws {
+ let descriptor = evaluateLinux3D(
+ trustedQualification: Self.qualifiedLinuxGraphics
+ )
+ let encoded = try JSONEncoder().encode(descriptor)
+ let decoded = try JSONDecoder().decode(
+ DoryVirtualMachineCapabilityDescriptor.self,
+ from: encoded
+ )
+
+ #expect(decoded.graphicsQualificationEvidence
+ == Self.qualifiedLinuxGraphics.auditEvidence)
+ #expect(decoded.graphicsQualificationEvidence?.artifactSHA256
+ == Self.guestArtifactSHA256)
+ #expect(decoded.graphicsQualificationEvidence?.manifestSHA256
+ == Self.manifestSHA256)
+ #expect(decoded.graphicsQualificationEvidence?.signingKeyID == "dory-release-2026")
+ #expect(decoded.graphicsQualificationEvidence?.manifestFormatVersion == 1)
+ #expect(decoded.runtimeQualificationEvidence?.backendRuntimeBuildID
+ == "dory-hv-2026.8")
+ #expect(decoded.runtimeQualificationEvidence?.virtualHardwareABIVersion == 1)
+ }
+
+ @Test("raw-HV graphical modes require digest-bound virtio-gpu qualification")
+ func everyRawGraphicalModeRequiresGuestDriverQualification() {
+ for graphics in [
+ DoryGraphicsAccelerationLevel.software,
+ .hostAcceleratedDisplay,
+ .hardwareAccelerated3D,
+ ] {
+ let descriptor = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: graphics,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ automaticallyTrustGuestGraphics: false
+ )
+ #expect(descriptor.availability.reason?.code
+ == .trustedGuestImageGraphicsQualificationUnavailable)
+ }
+
+ let noVirtio = trustedQualification(virtioGPUQualified: false)
+ let software = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .software,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: noVirtio,
+ automaticallyTrustGuestGraphics: false
+ )
+ let noVenus = trustedQualification(venusVulkanQualified: false)
+ let hostDisplay = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .hostAcceleratedDisplay,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ trustedGuestImageGraphicsQualification: noVenus,
+ automaticallyTrustGuestGraphics: false
+ )
+
+ #expect(software.availability.reason?.code == .linuxVirtioGPUKernelDeviceUnqualified)
+ #expect(hostDisplay.availability.isUsable)
+ }
+
+ @Test("all supplied media digests are validated before backend availability")
+ func malformedDigestFailsForVZSoftware() {
+ let descriptor = evaluate(
+ family: .linux,
+ media: .virtualDisk,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ mediaArtifactSHA256: "spoof"
+ )
+
+ #expect(descriptor.availability.reason?.code == .bootMediaArtifactDigestInvalid)
+ #expect(!descriptor.availability.isUsable)
+ }
+
+ @Test("structural ISO inspection never implies supported runtime compatibility")
+ func structuralISOWithoutRuntimeQualificationIsExperimental() {
+ let descriptor = evaluate(
+ family: .linux,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ automaticallyQualifyRuntime: false
+ )
+
+ #expect(descriptor.availability.supportTier == .experimental)
+ #expect(descriptor.availability.state == .available)
+ #expect(descriptor.availability.reason?.code == .runtimeQualificationUnavailable)
+ #expect(descriptor.bootMediaInspectionEvidence?.artifactSHA256
+ == Self.linuxISOArtifactSHA256)
+ #expect(descriptor.runtimeQualificationEvidence == nil)
+ }
+
+ @Test("runtime qualification is exact to backend build and virtual-device ABI")
+ func runtimeQualificationCannotCrossRuntimeContexts() throws {
+ let request = DoryVirtualMachineCapabilityRequest(
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ bootMedia: DoryBootMedia(
+ kind: .installerISO,
+ source: .userProvided,
+ artifactSHA256: Self.linuxISOArtifactSHA256
+ ),
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ devices: .minimumBootable,
+ virtualHardwareABIVersion: 1
+ )
+ let valid = try #require(makeRuntimeQualification(
+ request: request,
+ host: Self.provisionedHost
+ ))
+ var wrongBuildEvidence = valid.auditEvidence
+ wrongBuildEvidence.backendRuntimeBuildID = "other-vz-build"
+ var wrongABIEvidence = valid.auditEvidence
+ wrongABIEvidence.virtualHardwareABIVersion = 2
+ let inspection = makeBootMediaInspection(
+ family: .linux,
+ architecture: .arm64,
+ media: .installerISO,
+ artifactSHA256: Self.linuxISOArtifactSHA256
+ )
+
+ let wrongBuild = DoryAppleSiliconCapabilityEvaluator.evaluate(
+ request,
+ host: Self.provisionedHost,
+ trustedBootMediaInspection: inspection,
+ trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification(
+ auditEvidence: wrongBuildEvidence,
+ runtimeQualified: true
+ )
+ )
+ let wrongABI = DoryAppleSiliconCapabilityEvaluator.evaluate(
+ request,
+ host: Self.provisionedHost,
+ trustedBootMediaInspection: inspection,
+ trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification(
+ auditEvidence: wrongABIEvidence,
+ runtimeQualified: true
+ )
+ )
+
+ #expect(wrongBuild.availability.reason?.code == .runtimeQualificationHostMismatch)
+ #expect(wrongABI.availability.reason?.code == .runtimeQualificationRequestMismatch)
+ #expect(!wrongBuild.availability.isUsable)
+ #expect(!wrongABI.availability.isUsable)
+ }
+
+ @Test("a content digest cannot replace daemon provenance for a mutable disk")
+ func mutableDiskRequiresTrustedRevisionProvenance() {
+ let digestOnly = evaluate(
+ family: .linux,
+ media: .virtualDisk,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ mediaArtifactSHA256: String(repeating: "8", count: 64),
+ automaticallyResolveMutableProvenance: false,
+ automaticallyQualifyRuntime: false
+ )
+ let provenanceButUnqualified = evaluate(
+ family: .linux,
+ media: .virtualDisk,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ automaticallyQualifyRuntime: false
+ )
+
+ #expect(digestOnly.availability.reason?.code
+ == .mutableBootMediaProvenanceUnavailable)
+ #expect(!digestOnly.availability.isUsable)
+ #expect(provenanceButUnqualified.availability.supportTier == .experimental)
+ #expect(provenanceButUnqualified.availability.reason?.code
+ == .runtimeQualificationUnavailable)
+ #expect(provenanceButUnqualified.mutableBootMediaProvenanceEvidence?.provenance.revision
+ == 7)
+ }
+
+ @Test("installer media architecture and EFI bootability come from trusted inspection")
+ func installerInspectionIsAuthoritative() {
+ let x86Inspection = makeBootMediaInspection(
+ family: .linux,
+ architecture: .x86_64,
+ media: .installerISO,
+ artifactSHA256: Self.linuxISOArtifactSHA256
+ )
+ var nonEFI = try! #require(makeBootMediaInspection(
+ family: .linux,
+ architecture: .arm64,
+ media: .installerISO,
+ artifactSHA256: Self.linuxISOArtifactSHA256
+ ))
+ nonEFI = DoryTrustedBootMediaInspection(
+ auditEvidence: nonEFI.auditEvidence,
+ detectedKind: nonEFI.detectedKind,
+ detectedGuestFamily: nonEFI.detectedGuestFamily,
+ detectedArchitecture: nonEFI.detectedArchitecture,
+ isEFIBootable: false
+ )
+
+ let mislabeled = evaluate(
+ family: .linux,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ automaticallyTrustBootMedia: false,
+ trustedBootMediaInspection: x86Inspection
+ )
+ let unbootable = evaluate(
+ family: .linux,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ automaticallyTrustBootMedia: false,
+ trustedBootMediaInspection: nonEFI
+ )
+ let uninspected = evaluate(
+ family: .linux,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ automaticallyTrustBootMedia: false
+ )
+
+ #expect(mislabeled.availability.reason?.code
+ == .bootMediaArchitectureInspectionMismatch)
+ #expect(unbootable.availability.reason?.code == .bootMediaNotEFIBootable)
+ #expect(uninspected.availability.reason?.code == .trustedBootMediaInspectionUnavailable)
+ }
+
+ @Test("macOS restore compatibility is bound to the exact inspected artifact")
+ func macOSRestoreInspectionIsExact() {
+ let mismatched = makeBootMediaInspection(
+ family: .macOS,
+ architecture: .arm64,
+ media: .macOSRestoreImage,
+ artifactSHA256: String(repeating: "9", count: 64)
+ )
+ let incompatible = DoryTrustedBootMediaInspection(
+ auditEvidence: DoryBootMediaInspectionAuditEvidence(
+ inspectionIdentity: "macos-restore-inspection",
+ artifactSHA256: Self.macOSRestoreArtifactSHA256,
+ inspectionReportSHA256: Self.inspectionReportSHA256,
+ inspectorID: "dory-media-inspector",
+ inspectorVersion: 1
+ ),
+ detectedKind: .macOSRestoreImage,
+ detectedGuestFamily: .macOS,
+ detectedArchitecture: .arm64,
+ isEFIBootable: true,
+ macOSBuildIdentifier: "25A123",
+ macOSHardwareModelCompatible: false,
+ macOSAuxiliaryStorageCompatible: true
+ )
+
+ let mismatchResult = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .vendorDownload,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ automaticallyTrustBootMedia: false,
+ trustedBootMediaInspection: mismatched
+ )
+ let incompatibleResult = evaluate(
+ family: .macOS,
+ media: .macOSRestoreImage,
+ source: .vendorDownload,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ automaticallyTrustBootMedia: false,
+ trustedBootMediaInspection: incompatible
+ )
+
+ #expect(mismatchResult.availability.reason?.code
+ == .bootMediaArtifactInspectionMismatch)
+ #expect(incompatibleResult.availability.reason?.code
+ == .macOSRestoreHardwareModelIncompatible)
+ }
+
+ @Test("device contract is exact and unsupported modes fail closed")
+ func deviceCapabilitiesAreNegotiated() {
+ let resolved = evaluate(
+ family: .linux,
+ media: .virtualDisk,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ devices: .minimumBootable
+ )
+ let disconnected = evaluate(
+ family: .linux,
+ media: .virtualDisk,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ devices: DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .disconnected)
+ )
+ let spoofedGuestTools = evaluate(
+ family: .linux,
+ media: .virtualDisk,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ devices: DoryVirtualMachineDeviceCapabilityRequest(directorySharing: true)
+ )
+
+ #expect(resolved.resolvedDevices == .minimumBootable)
+ #expect(disconnected.availability.reason?.code == .networkAttachmentUnsupported)
+ #expect(spoofedGuestTools.availability.reason?.code == .directorySharingUnsupported)
+ }
+
+ @Test("version-one descriptor JSON remains readable with conservative device defaults")
+ func versionOneDescriptorDecoding() throws {
+ let data = Data(#"""
+ {
+ "schemaVersion":1,
+ "evaluatorVersion":1,
+ "request":{
+ "guest":{"family":"linux","architecture":"arm64"},
+ "bootMedia":{"kind":"virtual-disk","source":"user-provided"},
+ "backend":"apple-virtualization-framework",
+ "graphics":"software"
+ },
+ "availability":{"supportTier":"supported","state":"available"}
+ }
+ """#.utf8)
+ let descriptor = try JSONDecoder().decode(
+ DoryVirtualMachineCapabilityDescriptor.self,
+ from: data
+ )
+
+ #expect(descriptor.schemaVersion == 1)
+ #expect(descriptor.request.devices == .minimumBootable)
+ #expect(descriptor.resolvedDevices == nil)
+ #expect(descriptor.graphicsQualificationEvidence == nil)
+ #expect(descriptor.bootMediaInspectionEvidence == nil)
+ }
+
+ private func evaluate(
+ family: DoryGuestFamily,
+ architecture: DoryGuestArchitecture = .arm64,
+ media: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ backend: DoryVirtualizationBackendIdentity,
+ graphics: DoryGraphicsAccelerationLevel,
+ host: DoryAppleSiliconHostFacts = provisionedHost,
+ mediaArtifactSHA256: String? = nil,
+ trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil,
+ automaticallyTrustGuestGraphics: Bool = true,
+ devices: DoryVirtualMachineDeviceCapabilityRequest = .minimumBootable,
+ automaticallyTrustBootMedia: Bool = true,
+ trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil,
+ automaticallyResolveMutableProvenance: Bool = true,
+ mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil,
+ trustedMutableProvenance: DoryTrustedMutableBootMediaProvenance? = nil,
+ automaticallyQualifyRuntime: Bool = true,
+ trustedRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? = nil
+ ) -> DoryVirtualMachineCapabilityDescriptor {
+ let resolvedArtifactSHA256 = mediaArtifactSHA256
+ ?? defaultArtifactSHA256(family: family, media: media)
+ let resolvedInspection = trustedBootMediaInspection
+ ?? (automaticallyTrustBootMedia
+ ? makeBootMediaInspection(
+ family: family,
+ architecture: architecture,
+ media: media,
+ artifactSHA256: resolvedArtifactSHA256
+ )
+ : nil)
+ let resolvedGraphicsQualification = trustedGuestImageGraphicsQualification
+ ?? (automaticallyTrustGuestGraphics
+ && family == .linux
+ && backend == .doryHypervisor
+ && graphics != .none
+ && resolvedArtifactSHA256 == Self.guestArtifactSHA256
+ ? Self.qualifiedLinuxGraphics
+ : nil)
+ let resolvedMutableProvenance = mutableProvenance
+ ?? (automaticallyResolveMutableProvenance && media == .virtualDisk
+ ? DoryMutableBootMediaProvenanceReference(
+ repositoryIdentity: "dory-test-store",
+ mediaIdentity: "\(family.rawValue)-system-disk",
+ revision: 7
+ )
+ : nil)
+ let resolvedTrustedMutableProvenance = trustedMutableProvenance
+ ?? (automaticallyResolveMutableProvenance
+ ? makeTrustedMutableProvenance(resolvedMutableProvenance)
+ : nil)
+ let request = DoryVirtualMachineCapabilityRequest(
+ guest: DoryGuestPlatform(family: family, architecture: architecture),
+ bootMedia: DoryBootMedia(
+ kind: media,
+ source: source,
+ artifactSHA256: resolvedArtifactSHA256,
+ mutableProvenance: resolvedMutableProvenance
+ ),
+ backend: backend,
+ graphics: graphics,
+ devices: devices
+ )
+ let resolvedRuntimeQualification = trustedRuntimeQualification
+ ?? (automaticallyQualifyRuntime
+ ? makeRuntimeQualification(request: request, host: host)
+ : nil)
+ return DoryAppleSiliconCapabilityEvaluator.evaluate(
+ request,
+ host: host,
+ trustedGuestImageGraphicsQualification: resolvedGraphicsQualification,
+ trustedBootMediaInspection: resolvedInspection,
+ trustedMutableBootMediaProvenance: resolvedTrustedMutableProvenance,
+ trustedRuntimeQualification: resolvedRuntimeQualification
+ )
+ }
+
+ private func windows(
+ host: DoryAppleSiliconHostFacts
+ ) -> DoryVirtualMachineCapabilityDescriptor {
+ evaluate(
+ family: .windows,
+ media: .installerISO,
+ source: .userProvided,
+ backend: .qemuHypervisorFramework,
+ graphics: .software,
+ host: host
+ )
+ }
+
+ private func evaluateLinux3D(
+ mediaArtifactSHA256: String = guestArtifactSHA256,
+ trustedQualification: DoryTrustedGuestImageGraphicsQualification?
+ ) -> DoryVirtualMachineCapabilityDescriptor {
+ evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .hardwareAccelerated3D,
+ mediaArtifactSHA256: mediaArtifactSHA256,
+ trustedGuestImageGraphicsQualification: trustedQualification,
+ automaticallyTrustGuestGraphics: false
+ )
+ }
+
+ private func trustedQualification(
+ signedManifestIdentity: String = "dory-linux-desktop-arm64-gpu-v1",
+ qualifiedArtifactSHA256: String = guestArtifactSHA256,
+ manifestSHA256: String = manifestSHA256,
+ signingKeyID: String = "dory-release-2026",
+ manifestFormatVersion: UInt16 = 1,
+ virtioGPUQualified: Bool = true,
+ venusVulkanQualified: Bool = true
+ ) -> DoryTrustedGuestImageGraphicsQualification {
+ DoryTrustedGuestImageGraphicsQualification(
+ auditEvidence: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: signedManifestIdentity,
+ artifactSHA256: qualifiedArtifactSHA256,
+ manifestSHA256: manifestSHA256,
+ signingKeyID: signingKeyID,
+ manifestFormatVersion: manifestFormatVersion
+ ),
+ virtioGPUKernelAndDeviceSupportQualified: virtioGPUQualified,
+ venusVulkanGuestRuntimeQualified: venusVulkanQualified
+ )
+ }
+
+ private func defaultArtifactSHA256(
+ family: DoryGuestFamily,
+ media: DoryBootMediaKind
+ ) -> String? {
+ switch (family, media) {
+ case (.linux, .installerISO):
+ Self.linuxISOArtifactSHA256
+ case (.windows, .installerISO):
+ Self.windowsISOArtifactSHA256
+ case (.macOS, .macOSRestoreImage):
+ Self.macOSRestoreArtifactSHA256
+ default:
+ nil
+ }
+ }
+
+ private func makeBootMediaInspection(
+ family: DoryGuestFamily,
+ architecture: DoryGuestArchitecture,
+ media: DoryBootMediaKind,
+ artifactSHA256: String?
+ ) -> DoryTrustedBootMediaInspection? {
+ guard media == .installerISO || media == .macOSRestoreImage,
+ let artifactSHA256 else {
+ return nil
+ }
+ return DoryTrustedBootMediaInspection(
+ auditEvidence: DoryBootMediaInspectionAuditEvidence(
+ inspectionIdentity: "inspection-\(family.rawValue)-\(media.rawValue)",
+ artifactSHA256: artifactSHA256,
+ inspectionReportSHA256: Self.inspectionReportSHA256,
+ inspectorID: "dory-media-inspector",
+ inspectorVersion: 1
+ ),
+ detectedKind: media,
+ detectedGuestFamily: family,
+ detectedArchitecture: architecture,
+ isEFIBootable: true,
+ macOSBuildIdentifier: family == .macOS ? "25A123" : nil,
+ macOSHardwareModelCompatible: family == .macOS,
+ macOSAuxiliaryStorageCompatible: family == .macOS
+ )
+ }
+
+ private func makeTrustedMutableProvenance(
+ _ provenance: DoryMutableBootMediaProvenanceReference?
+ ) -> DoryTrustedMutableBootMediaProvenance? {
+ guard let provenance else { return nil }
+ return DoryTrustedMutableBootMediaProvenance(
+ auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence(
+ receiptIdentity: "mutable-disk-receipt-\(provenance.mediaIdentity)",
+ provenance: provenance,
+ receiptSHA256: Self.provenanceReceiptSHA256,
+ resolverID: "dory-artifact-resolver",
+ resolverVersion: 1
+ )
+ )
+ }
+
+ private func makeRuntimeQualification(
+ request: DoryVirtualMachineCapabilityRequest,
+ host: DoryAppleSiliconHostFacts
+ ) -> DoryTrustedVirtualMachineRuntimeQualification? {
+ guard request.guest.family == .linux,
+ request.backend == .doryHypervisor
+ || request.backend == .appleVirtualizationFramework,
+ let hostContext = host.runtimeQualificationContext else {
+ return nil
+ }
+ let immutableArtifactSHA256 = request.bootMedia.kind == .virtualDisk
+ ? nil : request.bootMedia.artifactSHA256
+ guard immutableArtifactSHA256 != nil || request.bootMedia.mutableProvenance != nil else {
+ return nil
+ }
+ return DoryTrustedVirtualMachineRuntimeQualification(
+ auditEvidence: DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "linux-runtime-qualification-v1",
+ qualificationReportSHA256: Self.runtimeQualificationReportSHA256,
+ signingKeyID: "dory-runtime-qualification-2026",
+ qualificationFormatVersion: 1,
+ guest: request.guest,
+ bootMediaKind: request.bootMedia.kind,
+ immutableArtifactSHA256: immutableArtifactSHA256,
+ mutableProvenance: request.bootMedia.kind == .virtualDisk
+ ? request.bootMedia.mutableProvenance : nil,
+ backend: request.backend,
+ backendRuntimeBuildID: hostContext.runtimeBuildID(for: request.backend),
+ virtualHardwareABIVersion: request.virtualHardwareABIVersion,
+ graphics: request.graphics,
+ devices: request.devices
+ ),
+ runtimeQualified: true
+ )
+ }
+}
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
new file mode 100644
index 00000000..37625101
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
@@ -0,0 +1,556 @@
+import Foundation
+import Testing
+@testable import DoryOperations
+
+@Suite("Virtual machine definition")
+struct DoryVirtualMachineDefinitionTests {
+ private let gibibyte: UInt64 = 1_073_741_824
+ private let nowMilliseconds: Int64 = 1_787_200_000_000
+
+ @Test("schema 2 round trips with stable resolver and timestamp representations")
+ func currentRoundTrip() throws {
+ let original = linuxDefinition()
+ #expect(original.isValid)
+
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = try encoder.encode(original)
+ let decoded = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: data)
+ #expect(decoded == original)
+
+ let json = try #require(String(data: data, encoding: .utf8))
+ #expect(json.contains("\"schemaVersion\":2"))
+ #expect(json.contains("\"virtualHardwareABIVersion\":1"))
+ #expect(json.contains("\"createdAtUnixMilliseconds\":1787200000000"))
+ #expect(json.contains("\"namespace\":\"boot\""))
+ #expect(!json.contains("\"bootMedia\""))
+ #expect(!json.contains("artifactID"))
+ #expect(!json.contains("hostLocationID"))
+ }
+
+ @Test("oldest schema 1 golden JSON migrates deterministically")
+ func oldestSchemaMigration() throws {
+ let golden = Data(#"""
+ {
+ "schemaVersion": 1,
+ "identity": {"id":"legacy-vm","name":"Legacy Installer"},
+ "guest": {"family":"linux","architecture":"arm64"},
+ "workload": "installer",
+ "bootMedia": {
+ "role":"installer","kind":"installer-iso","source":"user-provided",
+ "artifactID":"install:ubuntu-24.04"
+ },
+ "backendPreference": {"mode":"automatic"},
+ "graphics": {"desiredLevel":"host-accelerated-display","allowsFallback":true},
+ "resources": {"virtualCPUCount":4,"memoryBytes":8589934592,"diskBytes":68719476736},
+ "storage": [{
+ "id":"system","role":"system","artifactID":"disk:legacy-system",
+ "capacityBytes":68719476736,"readOnly":false
+ }],
+ "networkMode":"shared-nat",
+ "display":{"enabled":true,"widthPixels":1920,"heightPixels":1080,"pixelsPerInch":110},
+ "audio":{"inputEnabled":false,"outputEnabled":true},
+ "input":{"keyboardEnabled":true,"pointerEnabled":true},
+ "shares":[],
+ "integrations":["dynamic-display","graceful-shutdown"],
+ "lifecycle":{"revision":1,"createdAt":721692800,"updatedAt":721692800}
+ }
+ """#.utf8)
+
+ let migrated = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: golden)
+ #expect(migrated.schemaVersion == 2)
+ #expect(migrated.virtualHardwareABIVersion == 1)
+ #expect(migrated.workload == .desktop)
+ #expect(migrated.boot.phase == .install)
+ #expect(migrated.boot.order == ["installer"])
+ #expect(migrated.boot.devices[0].artifact == reference("install", "ubuntu-24.04"))
+ #expect(migrated.graphics.acceptableLevels == [.hostAcceleratedDisplay, .software])
+ #expect(migrated.lifecycle.createdAtUnixMilliseconds == 1_700_000_000_000)
+ #expect(migrated.isValid)
+
+ let upgraded = try JSONEncoder().encode(migrated)
+ let upgradedJSON = try #require(String(data: upgraded, encoding: .utf8))
+ #expect(upgradedJSON.contains("\"schemaVersion\":2"))
+ #expect(upgradedJSON.contains("createdAtUnixMilliseconds"))
+ #expect(!upgradedJSON.contains("\"bootMedia\""))
+ }
+
+ @Test("install and live phases are independent from desktop or server workload")
+ func bootPhaseIndependentFromWorkload() {
+ let desktopInstaller = installerDefinition(family: .linux, workload: .desktop)
+ let serverInstaller = installerDefinition(family: .windows, workload: .server)
+ var liveWorkspace = installerDefinition(family: .linux, workload: .desktop)
+ liveWorkspace.boot.phase = .live
+ liveWorkspace.storage = []
+
+ #expect(desktopInstaller.isValid)
+ #expect(serverInstaller.isValid)
+ #expect(liveWorkspace.isValid)
+
+ var legacy = desktopInstaller
+ legacy.workload = .installer
+ #expect(has(.legacyInstallerWorkload, "workload", in: legacy.validate()))
+ }
+
+ @Test("recovery and boot order are explicit")
+ func bootOrderAndRecovery() {
+ var definition = recoveryDefinition()
+ #expect(definition.isValid)
+
+ definition.boot.order = ["system", "recovery"]
+ #expect(has(.invalidBootPhase, "boot.phase", in: definition.validate()))
+
+ definition.boot.order = ["recovery", "missing"]
+ #expect(has(.invalidBootOrder, "boot.order", in: definition.validate()))
+
+ definition.boot.order = ["recovery", "system"]
+ definition.boot.devices[1].id = "recovery"
+ #expect(has(
+ .duplicateBootDeviceIdentifier,
+ "boot.devices[1].id",
+ in: definition.validate()
+ ))
+ }
+
+ @Test("boot attachment role and removability are validated")
+ func bootAttachmentInvariants() {
+ var definition = installerDefinition(family: .linux)
+ definition.boot.devices[0].removable = false
+ #expect(has(
+ .invalidBootAttachment,
+ "boot.devices[0].removable",
+ in: definition.validate()
+ ))
+
+ definition.boot.devices[0].removable = true
+ definition.boot.devices[0].role = .system
+ #expect(has(.bootMediaRoleMismatch, "boot.devices[0].role", in: definition.validate()))
+ #expect(has(.invalidBootPhase, "boot.phase", in: definition.validate()))
+ }
+
+ @Test("guest families reject incompatible media", arguments: [
+ (DoryGuestFamily.linux, DoryBootMediaKind.macOSRestoreImage),
+ (DoryGuestFamily.windows, DoryBootMediaKind.installedLinuxBootBundle),
+ (DoryGuestFamily.macOS, DoryBootMediaKind.installerISO),
+ ])
+ func guestMediaCompatibility(family: DoryGuestFamily, kind: DoryBootMediaKind) {
+ var definition = linuxDefinition()
+ definition.guest.family = family
+ definition.boot.devices[0].kind = kind
+ #expect(has(
+ .bootMediaIncompatibleWithGuest,
+ "boot.devices[0].kind",
+ in: definition.validate()
+ ))
+ }
+
+ @Test("Windows and macOS media cannot claim Dory redistribution")
+ func proprietaryMediaProvenance() {
+ var windows = installerDefinition(family: .windows)
+ windows.boot.devices[0].source = .bundledByDory
+ var macOS = installerDefinition(family: .macOS)
+ macOS.boot.devices[0].source = .bundledByDory
+
+ #expect(has(.guestMediaCannotBeBundled, "boot.devices[0].source", in: windows.validate()))
+ #expect(has(.guestMediaCannotBeBundled, "boot.devices[0].source", in: macOS.validate()))
+ }
+
+ @Test("schema identity lifecycle and positive resources are validated")
+ func foundationalInvariants() {
+ var definition = linuxDefinition()
+ definition.schemaVersion = 99
+ definition.virtualHardwareABIVersion = 2
+ definition.identity = DoryVirtualMachineIdentity(id: " ", name: "\n")
+ definition.resources = DoryVMResourceRequest(
+ virtualCPUCount: 0,
+ memoryBytes: 0,
+ diskBytes: 0
+ )
+ definition.lifecycle = DoryVMLifecycleMetadata(
+ revision: 0,
+ createdAtUnixMilliseconds: 0,
+ updatedAtUnixMilliseconds: -1
+ )
+
+ let issues = definition.validate()
+ #expect(has(.unsupportedSchemaVersion, "schemaVersion", in: issues))
+ #expect(has(.unsupportedVirtualHardwareABIVersion, "virtualHardwareABIVersion", in: issues))
+ #expect(has(.emptyIdentifier, "identity.id", in: issues))
+ #expect(has(.emptyName, "identity.name", in: issues))
+ #expect(has(.nonPositiveResource, "resources.virtualCPUCount", in: issues))
+ #expect(has(.nonPositiveResource, "resources.memoryBytes", in: issues))
+ #expect(has(.nonPositiveResource, "resources.diskBytes", in: issues))
+ #expect(has(.invalidLifecycleMetadata, "lifecycle", in: issues))
+ }
+
+ @Test("machine identifiers match the launch-safe MachineManager contract")
+ func machineIdentifierContract() {
+ for invalidID in [
+ "-starts-with-dash", "contains+plus", "contains space", "é",
+ String(repeating: "a", count: 64),
+ ] {
+ var definition = linuxDefinition()
+ definition.identity.id = invalidID
+ #expect(has(.invalidIdentifier, "identity.id", in: definition.validate()))
+ }
+ }
+
+ @Test("resolver references reject paths URLs controls and token-like values")
+ func hostileResolverReferences() {
+ let hostile = [
+ reference("artifact", "/Users/me/image.iso"),
+ reference("artifact", "https://example.com/image.iso"),
+ reference("artifact", "C:\\images\\disk.raw"),
+ reference("artifact", "name\u{001F}control"),
+ reference("artifact", "sk-proj-123456"),
+ reference("artifact", "ghp_123456"),
+ reference("artifact", "eyJhbGciOiJIUzI1NiJ9"),
+ reference("Bad-Namespace", "image"),
+ reference("artifact", String(repeating: "a", count: 65)),
+ ]
+
+ for hostileReference in hostile {
+ var boot = linuxDefinition()
+ boot.boot.devices[0].artifact = hostileReference
+ #expect(has(
+ .invalidResolverReference,
+ "boot.devices[0].artifact",
+ in: boot.validate()
+ ))
+
+ var share = linuxDefinition()
+ share.shares[0].hostLocation = hostileReference
+ #expect(has(
+ .invalidResolverReference,
+ "shares[0].hostLocation",
+ in: share.validate()
+ ))
+ }
+ }
+
+ @Test("storage identifiers capacities and one system disk are enforced")
+ func storageInvariants() {
+ var definition = linuxDefinition()
+ definition.storage.append(DoryVMStorageAttachment(
+ id: "system",
+ role: .system,
+ artifact: reference("disk", "duplicate"),
+ capacityBytes: 0,
+ readOnly: true
+ ))
+ let issues = definition.validate()
+ #expect(has(.duplicateAttachmentIdentifier, "storage[1].id", in: issues))
+ #expect(has(.nonPositiveAttachmentCapacity, "storage[1].capacityBytes", in: issues))
+ #expect(has(.multipleSystemDisks, "storage", in: issues))
+
+ definition.storage = []
+ #expect(has(.missingSystemDisk, "storage", in: definition.validate()))
+ }
+
+ @Test("artifact reuse is allowed only when every occurrence is read-only")
+ func storageArtifactAliasing() {
+ let shared = reference("disk", "shared-data")
+
+ var readWriteThenReadOnly = linuxDefinition()
+ readWriteThenReadOnly.storage.append(storage("data-rw", artifact: shared, readOnly: false))
+ readWriteThenReadOnly.storage.append(storage("data-ro", artifact: shared, readOnly: true))
+ #expect(has(
+ .duplicateWritableStorageArtifactIdentity,
+ "storage[2].artifact",
+ in: readWriteThenReadOnly.validate()
+ ))
+
+ var readOnlyThenReadWrite = linuxDefinition()
+ readOnlyThenReadWrite.storage.append(storage("data-ro", artifact: shared, readOnly: true))
+ readOnlyThenReadWrite.storage.append(storage("data-rw", artifact: shared, readOnly: false))
+ #expect(has(
+ .duplicateWritableStorageArtifactIdentity,
+ "storage[2].artifact",
+ in: readOnlyThenReadWrite.validate()
+ ))
+
+ var allReadOnly = linuxDefinition()
+ allReadOnly.storage.append(storage("data-ro-1", artifact: shared, readOnly: true))
+ allReadOnly.storage.append(storage("data-ro-2", artifact: shared, readOnly: true))
+ #expect(!allReadOnly.validate().contains {
+ $0.code == .duplicateWritableStorageArtifactIdentity
+ })
+ #expect(allReadOnly.isValid)
+ }
+
+ @Test("system disk agrees with resource and virtual-disk boot identity")
+ func systemDiskAgreement() {
+ var definition = linuxDefinition(kind: .virtualDisk)
+ definition.storage[0].readOnly = true
+ definition.storage[0].capacityBytes = 63 * gibibyte
+ definition.storage[0].artifact = reference("disk", "other")
+
+ let issues = definition.validate()
+ #expect(has(.readOnlySystemDisk, "storage[0].readOnly", in: issues))
+ #expect(has(.systemDiskCapacityMismatch, "storage[0].capacityBytes", in: issues))
+ #expect(has(.bootDiskArtifactMismatch, "boot.devices", in: issues))
+ }
+
+ @Test("system boot validation follows boot order and rejects ambiguous system devices")
+ func systemBootResolutionUsesPersistedOrder() {
+ let systemA = DoryVMBootMediaReference(
+ id: "system-a",
+ role: .system,
+ kind: .virtualDisk,
+ source: .userProvided,
+ artifact: reference("disk", "system-a"),
+ removable: false
+ )
+ let systemB = DoryVMBootMediaReference(
+ id: "system-b",
+ role: .system,
+ kind: .virtualDisk,
+ source: .userProvided,
+ artifact: reference("disk", "system-b"),
+ removable: false
+ )
+
+ var arrayAOrderB = linuxDefinition(kind: .virtualDisk)
+ arrayAOrderB.storage[0].artifact = systemA.artifact
+ arrayAOrderB.boot.devices = [systemA, systemB]
+ arrayAOrderB.boot.order = [systemB.id, systemA.id]
+ let arrayAOrderBIssues = arrayAOrderB.validate()
+ #expect(has(.multipleSystemBootDevices, "boot.devices", in: arrayAOrderBIssues))
+ #expect(has(.bootDiskArtifactMismatch, "boot.devices", in: arrayAOrderBIssues))
+
+ var arrayBOrderA = arrayAOrderB
+ arrayBOrderA.boot.devices = [systemB, systemA]
+ arrayBOrderA.boot.order = [systemA.id, systemB.id]
+ let arrayBOrderAIssues = arrayBOrderA.validate()
+ #expect(has(.multipleSystemBootDevices, "boot.devices", in: arrayBOrderAIssues))
+ #expect(!has(.bootDiskArtifactMismatch, "boot.devices", in: arrayBOrderAIssues))
+ }
+
+ @Test("Unix mount paths are canonical and unique")
+ func unixSharePaths() {
+ for unsafePath in [
+ "relative/path", "/srv/../secret", "/srv//source", "/srv/source/", "//srv/source",
+ ] {
+ var definition = linuxDefinition()
+ definition.shares[0].guestMountPath = unsafePath
+ #expect(has(.unsafeGuestMountPath, "shares[0].guestMountPath", in: definition.validate()))
+ }
+
+ var duplicate = linuxDefinition()
+ duplicate.shares.append(share("source-copy", path: "/workspace/source"))
+ #expect(has(.duplicateGuestMountPath, "shares[1].guestMountPath", in: duplicate.validate()))
+ }
+
+ @Test("Windows paths reject aliases and unsafe device components")
+ func windowsSharePaths() {
+ var definition = installerDefinition(family: .windows)
+ definition.shares = [share("source", path: "C:\\Workspace\\Source")]
+ #expect(definition.isValid)
+
+ definition.shares.append(share("source-copy", path: "c:\\workspace\\source"))
+ #expect(has(.duplicateGuestMountPath, "shares[1].guestMountPath", in: definition.validate()))
+ definition.shares.removeLast()
+
+ for unsafePath in [
+ "Workspace\\Source", "C:\\Workspace\\..\\Secret", "C:/Workspace/Source",
+ "\\\\server\\share", "C:\\Workspace\\CON.txt", "C:\\Workspace\\CON .txt",
+ "C:\\Workspace\\PRN.data", "C:\\Workspace\\AUX.log", "C:\\Workspace\\NUL",
+ "C:\\Workspace\\COM1.txt", "C:\\Workspace\\LPT9.log",
+ "C:\\Workspace\\trailing.", "C:\\Workspace\\trailing ",
+ "C:\\Workspace\\control\u{001F}char",
+ ] {
+ definition.shares[0].guestMountPath = unsafePath
+ #expect(has(.unsafeGuestMountPath, "shares[0].guestMountPath", in: definition.validate()))
+ }
+ }
+
+ @Test("graphics and backend preferences are explicit structural contracts")
+ func graphicsAndBackendPolicy() {
+ var definition = linuxDefinition()
+ definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [])
+ #expect(has(.emptyGraphicsPolicy, "graphics.acceptableLevels", in: definition.validate()))
+
+ definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.software, .software])
+ #expect(has(
+ .duplicateGraphicsLevel,
+ "graphics.acceptableLevels[1]",
+ in: definition.validate()
+ ))
+
+ definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.hardwareAccelerated3D])
+ definition.backendPreference = DoryVMBackendPreference(
+ mode: .required,
+ backend: .doryHypervisor
+ )
+ #expect(definition.isValid)
+
+ definition.backendPreference = DoryVMBackendPreference(mode: .required, backend: nil)
+ #expect(has(.backendPreferenceMalformed, "backendPreference", in: definition.validate()))
+ }
+
+ @Test("dynamic display integration requires an enabled display")
+ func dynamicDisplayRequiresDisplay() {
+ var definition = linuxDefinition()
+ definition.display = .disabled
+ #expect(has(.integrationRequiresDisplay, "integrations", in: definition.validate()))
+
+ definition.integrations.removeAll { $0 == .dynamicDisplay }
+ #expect(definition.isValid)
+ }
+
+ private func linuxDefinition(
+ kind: DoryBootMediaKind = .installedLinuxBootBundle
+ ) -> DoryVirtualMachineDefinition {
+ let bootArtifact = kind == .virtualDisk
+ ? reference("disk", "system")
+ : reference("boot", "ubuntu-24.04")
+ return DoryVirtualMachineDefinition(
+ identity: DoryVirtualMachineIdentity(id: "vm-ubuntu-dev", name: "Ubuntu Development"),
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ workload: .desktop,
+ boot: DoryVMBootConfiguration(
+ phase: .normal,
+ devices: [DoryVMBootMediaReference(
+ id: "system",
+ role: .system,
+ kind: kind,
+ source: .userProvided,
+ artifact: bootArtifact,
+ removable: false
+ )],
+ order: ["system"]
+ ),
+ backendPreference: DoryVMBackendPreference(
+ mode: .preferred,
+ backend: .doryHypervisor
+ ),
+ graphics: DoryVMGraphicsPolicy(acceptableLevels: [.hardwareAccelerated3D]),
+ resources: resources(),
+ storage: [DoryVMStorageAttachment(
+ id: "system",
+ role: .system,
+ artifact: reference("disk", "system"),
+ capacityBytes: 64 * gibibyte
+ )],
+ shares: [share("source", path: "/workspace/source")],
+ integrations: [.clipboard, .clockSynchronization, .dynamicDisplay, .gracefulShutdown],
+ lifecycle: lifecycle()
+ )
+ }
+
+ private func installerDefinition(
+ family: DoryGuestFamily,
+ workload: DoryVMWorkloadProfile = .desktop
+ ) -> DoryVirtualMachineDefinition {
+ let kind: DoryBootMediaKind = family == .macOS ? .macOSRestoreImage : .installerISO
+ let source: DoryBootMediaSource = family == .macOS ? .vendorDownload : .userProvided
+ return DoryVirtualMachineDefinition(
+ identity: DoryVirtualMachineIdentity(
+ id: "vm-\(family.rawValue)-installer",
+ name: "\(family.rawValue) Installer"
+ ),
+ guest: DoryGuestPlatform(family: family, architecture: .arm64),
+ workload: workload,
+ boot: DoryVMBootConfiguration(
+ phase: .install,
+ devices: [DoryVMBootMediaReference(
+ id: "installer",
+ role: .installer,
+ kind: kind,
+ source: source,
+ artifact: reference("install", family.rawValue),
+ removable: true
+ )],
+ order: ["installer"]
+ ),
+ graphics: DoryVMGraphicsPolicy(
+ acceptableLevels: [.hostAcceleratedDisplay, .software]
+ ),
+ resources: resources(),
+ storage: [DoryVMStorageAttachment(
+ id: "system",
+ role: .system,
+ artifact: reference("disk", family.rawValue),
+ capacityBytes: 64 * gibibyte
+ )],
+ lifecycle: lifecycle()
+ )
+ }
+
+ private func recoveryDefinition() -> DoryVirtualMachineDefinition {
+ var definition = linuxDefinition(kind: .virtualDisk)
+ definition.boot = DoryVMBootConfiguration(
+ phase: .recovery,
+ devices: [
+ DoryVMBootMediaReference(
+ id: "recovery",
+ role: .recovery,
+ kind: .installerISO,
+ source: .userProvided,
+ artifact: reference("recovery", "ubuntu-rescue"),
+ removable: true
+ ),
+ DoryVMBootMediaReference(
+ id: "system",
+ role: .system,
+ kind: .virtualDisk,
+ source: .userProvided,
+ artifact: reference("disk", "system"),
+ removable: false
+ ),
+ ],
+ order: ["recovery", "system"]
+ )
+ return definition
+ }
+
+ private func resources() -> DoryVMResourceRequest {
+ DoryVMResourceRequest(
+ virtualCPUCount: 4,
+ memoryBytes: 8 * gibibyte,
+ diskBytes: 64 * gibibyte
+ )
+ }
+
+ private func storage(
+ _ id: String,
+ artifact: DoryVMResolverReference,
+ readOnly: Bool
+ ) -> DoryVMStorageAttachment {
+ DoryVMStorageAttachment(
+ id: id,
+ role: .data,
+ artifact: artifact,
+ capacityBytes: 10 * gibibyte,
+ readOnly: readOnly
+ )
+ }
+
+ private func share(_ id: String, path: String) -> DoryVMShare {
+ DoryVMShare(
+ id: id,
+ hostLocation: reference("bookmark", id),
+ guestMountPath: path
+ )
+ }
+
+ private func reference(_ namespace: String, _ identifier: String) -> DoryVMResolverReference {
+ DoryVMResolverReference(namespace: namespace, identifier: identifier)
+ }
+
+ private func lifecycle() -> DoryVMLifecycleMetadata {
+ DoryVMLifecycleMetadata(
+ revision: 1,
+ createdAtUnixMilliseconds: nowMilliseconds,
+ updatedAtUnixMilliseconds: nowMilliseconds
+ )
+ }
+
+ private func has(
+ _ code: DoryVMDefinitionValidationCode,
+ _ field: String,
+ in issues: [DoryVMDefinitionValidationIssue]
+ ) -> Bool {
+ issues.contains(DoryVMDefinitionValidationIssue(code: code, field: field))
+ }
+}
From c42bca09cb7b42181ca18ecc2dd3838814d15e5f Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 11:34:12 +0000
Subject: [PATCH 008/338] ci(release): qualify accelerated Linux desktops
---
.../verify-release-workflow-contract.py | 62 +++
.github/workflows/release.yml | 49 +-
.gitignore | 3 +
scripts/build-components.py | 10 +-
scripts/build.sh | 89 +++-
scripts/bundle-engine.sh | 130 ++++-
scripts/desktop-linux-live-gate.sh | 492 ++++++++++++++++++
scripts/test-build-components.sh | 9 +-
8 files changed, 812 insertions(+), 32 deletions(-)
create mode 100755 scripts/desktop-linux-live-gate.sh
diff --git a/.github/scripts/verify-release-workflow-contract.py b/.github/scripts/verify-release-workflow-contract.py
index 03ef1f25..17a6a036 100755
--- a/.github/scripts/verify-release-workflow-contract.py
+++ b/.github/scripts/verify-release-workflow-contract.py
@@ -20,6 +20,68 @@ def require(text: str, value: str, message: str) -> None:
bump = workflow.split(" bump-cask:", 1)[1].split("\n verify-public-release:", 1)[0]
final = workflow.split(" verify-public-release:", 1)[1]
+for distro in ("debian", "ubuntu", "kali"):
+ require(
+ workflow,
+ f'guest/desktop/build.sh arm64 "$distro"',
+ f"release workflow does not build the {distro} desktop from its commit",
+ )
+ require(
+ workflow,
+ f"guest/out/dory-desktop-{distro}-rootfs-arm64.ext4.zst",
+ f"same-commit guest artifact omits the {distro} desktop",
+ )
+ require(
+ workflow,
+ f'guest/desktop/verify-build.sh arm64 "$distro"',
+ f"release candidate does not verify the {distro} desktop",
+ )
+require(workflow, "guest/out/Image-desktop.zst", "same-commit guest artifact omits the desktop kernel")
+require(
+ workflow,
+ "DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64",
+ "release candidate does not verify its desktop kernel",
+)
+require(
+ workflow,
+ "DORY_RELEASE_DESKTOP_KERNEL: ${{ github.workspace }}/guest/out/Image-desktop",
+ "physical release gate does not receive its same-commit desktop kernel",
+)
+live_smoke = Path("scripts/release-candidate-live-smoke.sh").read_text(encoding="utf-8")
+desktop_gate_path = Path("scripts/desktop-linux-live-gate.sh")
+if not desktop_gate_path.stat().st_mode & 0o100:
+ raise SystemExit("release workflow contract: desktop live gate is not executable")
+desktop_gate = desktop_gate_path.read_text(encoding="utf-8")
+require(
+ live_smoke,
+ "scripts/desktop-linux-live-gate.sh",
+ "physical release smoke does not boot and exercise the managed desktops",
+)
+for distro in ("debian", "ubuntu", "kali"):
+ require(
+ live_smoke,
+ f'--{distro}-rootfs "$DESKTOP_{distro.upper()}_ROOTFS"',
+ f"physical desktop gate omits {distro}",
+ )
+ require(
+ live_smoke,
+ f'--{distro}-update "$DESKTOP_{distro.upper()}_UPDATE"',
+ f"physical desktop gate omits the {distro} in-place update payload",
+ )
+for proof in (
+ "browser-running",
+ "grep -q 'capture' /proc/asound/pcm",
+ "grep -q 'playback' /proc/asound/pcm",
+ "persistence-pass",
+ "Dory Wired",
+ "com.apple.security.device.audio-input",
+ 'grep -F "$VMM"',
+ "machine desktop-update",
+ "rollback-pass",
+ "last-good snapshot",
+):
+ require(desktop_gate, proof, f"desktop live gate omits required proof: {proof}")
+
require(candidate, "release-build/components/arm64/*", "candidate omits component payloads")
require(publication, "release-build/components/arm64/*", "GitHub release omits component payloads")
for name in ("catalog.json", "catalog.json.sha256", "catalog.json.sig"):
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9cadde46..944c8bbd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -270,12 +270,23 @@ jobs:
DORY_EXPERIMENTAL_GPU=0 guest/kernel/verify-build.sh arm64
DORY_EXPERIMENTAL_GPU=1 guest/kernel/verify-build.sh arm64
guest/initfs/verify-build.sh arm64
+ - name: Build and verify every Linux desktop payload from this commit
+ run: |
+ DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/build.sh arm64
+ for distro in debian ubuntu kali; do
+ guest/desktop/build.sh arm64 "$distro"
+ done
+ DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64
+ for distro in debian ubuntu kali; do
+ guest/desktop/verify-build.sh arm64 "$distro"
+ done
- name: Upload same-commit arm64 guest payload
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: dory-guest-arm64-${{ github.sha }}
retention-days: 30
if-no-files-found: error
+ compression-level: 0
path: |
guest/out/Image
guest/out/Image.zst
@@ -288,6 +299,21 @@ jobs:
guest/out/initfs-arm64.ext4
guest/out/dory-agent-arm64
guest/out/initfs-build-arm64.stamp
+ guest/out/Image-desktop.zst
+ guest/out/config-arm64-desktop
+ guest/out/kernel-build-arm64-desktop.stamp
+ guest/out/dory-desktop-debian-rootfs-arm64.ext4.zst
+ guest/out/dory-desktop-debian-packages-arm64.txt
+ guest/out/dory-desktop-debian-update-arm64.tar
+ guest/out/dory-desktop-debian-build-arm64.stamp
+ guest/out/dory-desktop-ubuntu-rootfs-arm64.ext4.zst
+ guest/out/dory-desktop-ubuntu-packages-arm64.txt
+ guest/out/dory-desktop-ubuntu-update-arm64.tar
+ guest/out/dory-desktop-ubuntu-build-arm64.stamp
+ guest/out/dory-desktop-kali-rootfs-arm64.ext4.zst
+ guest/out/dory-desktop-kali-packages-arm64.txt
+ guest/out/dory-desktop-kali-update-arm64.tar
+ guest/out/dory-desktop-kali-build-arm64.stamp
prepublication-quality:
name: macOS pre-publication quality gate
@@ -375,9 +401,19 @@ jobs:
path: guest/out
- name: Independently verify every downloaded guest payload
run: |
+ zstd -q -d --sparse -f guest/out/Image-desktop.zst -o guest/out/Image-desktop
+ for distro in debian ubuntu kali; do
+ zstd -q -d --sparse -f \
+ "guest/out/dory-desktop-$distro-rootfs-arm64.ext4.zst" \
+ -o "guest/out/dory-desktop-$distro-rootfs-arm64.ext4"
+ done
DORY_EXPERIMENTAL_GPU=0 guest/kernel/verify-build.sh arm64
DORY_EXPERIMENTAL_GPU=1 guest/kernel/verify-build.sh arm64
guest/initfs/verify-build.sh arm64
+ DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64
+ for distro in debian ubuntu kali; do
+ guest/desktop/verify-build.sh arm64 "$distro"
+ done
- name: Prove the tracked release source exactly matches the commit
run: |
@@ -432,6 +468,9 @@ jobs:
- name: Ensure Metal toolchain (SwiftTerm ships Metal shaders)
run: xcodebuild -downloadComponent MetalToolchain || true
+ - name: Install pinned GPU renderer build prerequisites
+ run: brew install meson ninja pkgconf molten-vk libepoxy
+
- name: Preserve previous released appcast history
run: |
previous="$RUNNER_TEMP/previous-appcast.xml"
@@ -591,7 +630,7 @@ jobs:
printf 'path=%s\n' "$sparkle_source" >> "$GITHUB_OUTPUT"
- name: Exercise the notarized direct-download candidate on a clean physical Mac
- timeout-minutes: 30
+ timeout-minutes: 60
env:
DORY_RELEASE_CLEAN_USER: '1'
DORY_RELEASE_EXTERNAL_VOLUME_ROOT: ${{ vars.DORY_EXTERNAL_VOLUME_TEST_ROOT }}
@@ -604,6 +643,14 @@ jobs:
DORY_RELEASE_CORPORATE_VPN_PROBE_HOST: ${{ vars.DORY_CORPORATE_VPN_PROBE_HOST }}
DORY_RELEASE_CORPORATE_VPN_PROBE_URL: ${{ vars.DORY_CORPORATE_VPN_PROBE_URL }}
DORY_RELEASE_TAILSCALE_EXIT_NODE: ${{ vars.DORY_TAILSCALE_EXIT_NODE }}
+ DORY_RELEASE_DESKTOP_KERNEL: ${{ github.workspace }}/guest/out/Image-desktop
+ DORY_RELEASE_DESKTOP_DEBIAN_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-debian-rootfs-arm64.ext4
+ DORY_RELEASE_DESKTOP_UBUNTU_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-ubuntu-rootfs-arm64.ext4
+ DORY_RELEASE_DESKTOP_KALI_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-kali-rootfs-arm64.ext4
+ DORY_RELEASE_DESKTOP_DEBIAN_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-debian-update-arm64.tar
+ DORY_RELEASE_DESKTOP_UBUNTU_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-ubuntu-update-arm64.tar
+ DORY_RELEASE_DESKTOP_KALI_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-kali-update-arm64.tar
+ DORY_RELEASE_DESKTOP_VERSION: ${{ steps.ver.outputs.version }}
run: |
scripts/direct-dmg-install-gate.sh \
--dmg "${{ steps.build.outputs.dmg }}" \
diff --git a/.gitignore b/.gitignore
index 28db686b..876491ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,6 +59,9 @@ secrets*
ExportOptions.plist
# Release pipeline output
+# Exact-candidate release gates are source, even when a checkout's private exclude file hides
+# untracked scripts by default.
+!/scripts/desktop-linux-live-gate.sh
release-build*/
/release/
/dist/
diff --git a/scripts/build-components.py b/scripts/build-components.py
index 90eca6db..ac6c7694 100755
--- a/scripts/build-components.py
+++ b/scripts/build-components.py
@@ -104,7 +104,7 @@ def validate_sources(repo: pathlib.Path, source_root: pathlib.Path, kubectl: pat
run([str(repo / "guest/kernel/verify-build.sh"), "arm64"], cwd=repo)
run([str(repo / "guest/initfs/verify-build.sh"), "arm64"], cwd=repo)
desktop_env = dict(os.environ)
- desktop_env["DORY_KERNEL_PROFILE"] = "desktop"
+ desktop_env["DORY_KERNEL_PROFILE"] = "accelerated-desktop"
run([str(repo / "guest/kernel/verify-build.sh"), "arm64"], cwd=repo, env=desktop_env)
for distro in ("debian", "ubuntu", "kali"):
run([str(repo / "guest/desktop/verify-build.sh"), "arm64", distro], cwd=repo)
@@ -137,7 +137,7 @@ def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[di
(
"ubuntu",
"Ubuntu 24.04 LTS Desktop",
- "An Ubuntu 24.04 LTS Xfce desktop with its own packages and official repositories.",
+ "Canonical's Ubuntu 24.04 LTS GNOME desktop with its own packages and official repositories.",
),
(
"kali",
@@ -170,6 +170,12 @@ def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[di
"delivery": "none",
"executable": False,
},
+ {
+ "path": f"dory-desktop-{distro}-update-arm64.tar",
+ "source": source_root / f"dory-desktop-{distro}-update-arm64.tar",
+ "delivery": "none",
+ "executable": False,
+ },
],
}
)
diff --git a/scripts/build.sh b/scripts/build.sh
index 1eba9656..1ba75d05 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -47,6 +47,16 @@ if [ "$DESKTOP_BUNDLE_MODE" = all ] && [ "${DORY_BUILD_DEBUG_HELPERS:-1}" != 1 ]
exit 64
fi
+# Replacing or re-signing the same DerivedData bundle while its app, daemon, or helpers are
+# executing can leave macOS provenance locks behind and corrupt the bundle's CodeResources.
+# Fail before deleting any product so the caller can stop that runtime cleanly first.
+running_product_pattern="$HOME/Library/Developer/Xcode/DerivedData/Dory-.*/Build/Products/$XCODE_CONFIGURATION/Dory\.app/Contents/"
+if pgrep -f "$running_product_pattern" >/dev/null 2>&1; then
+ echo "error: a $XCODE_CONFIGURATION Dory build product is running; quit Dory and unload its development daemon before rebuilding" >&2
+ pgrep -alf "$running_product_pattern" >&2 || true
+ exit 1
+fi
+
# shellcheck source=gvproxy-payload.sh
source scripts/gvproxy-payload.sh
@@ -185,7 +195,7 @@ bundle_debug_desktop_assets() {
[ "$(uname -m)" = "arm64" ] || return 0
kernel="guest/out/Image-desktop"
[ -f "$kernel" ] || { echo "error: all-inclusive build is missing $kernel" >&2; return 1; }
- guest/kernel/verify-build.sh arm64 desktop >/dev/null || return 1
+ DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64 >/dev/null || return 1
kernel_out="$app/Contents/Resources/dory-desktop-kernel-arm64.lzfse"
if [ ! -f "$kernel_out" ] || [ "$kernel" -nt "$kernel_out" ]; then
"$compressor" lzfse compress "$kernel" "$kernel_out" || return 1
@@ -278,6 +288,12 @@ bundle_debug_hv_helper() {
com.apple.security.hypervisor
PLIST
+ /usr/libexec/PlistBuddy \
+ -c 'Add :com.apple.security.cs.disable-library-validation bool true' \
+ "$entitlements" >/dev/null || return 1
+ /usr/libexec/PlistBuddy \
+ -c 'Add :com.apple.security.device.audio-input bool true' \
+ "$entitlements" >/dev/null || return 1
for app in "$HOME"/Library/Developer/Xcode/DerivedData/Dory-*/Build/Products/"$XCODE_CONFIGURATION"/Dory.app; do
[ -d "$app" ] || continue
@@ -334,6 +350,67 @@ PLIST
rm -f "$gvproxy_tmp" "$gvproxy_tmp.provenance"
}
+bundle_debug_gpu_renderer() {
+ local renderer provenance molten_prefix molten molten_provenance epoxy icd app framework resources dylib dep
+ [ "${DORY_BUILD_DEBUG_HELPERS:-1}" = "1" ] || return 0
+ renderer="$PWD/.build/virglrenderer/libvirglrenderer.dylib"
+ provenance="$PWD/.build/virglrenderer/provenance.txt"
+ molten_prefix="$PWD/.build/moltenvk"
+ molten_provenance="$molten_prefix/provenance.txt"
+ echo "note: building and bundling Dory's patched GPU renderer" >&2
+ if [ ! -f "$molten_prefix/lib/libMoltenVK.dylib" ] \
+ || ! nm -gU "$molten_prefix/lib/libMoltenVK.dylib" \
+ | grep '_dory_moltenvk_spirv_native_array_fix$' >/dev/null; then
+ scripts/build-moltenvk.sh --prefix "$molten_prefix" --provenance "$molten_provenance" || return 1
+ fi
+ DORY_MOLTENVK_PREFIX="$molten_prefix" scripts/build-virglrenderer.sh \
+ --output "$renderer" --provenance "$provenance" || return 1
+ molten="$molten_prefix/lib/libMoltenVK.dylib"
+ epoxy="$(brew --prefix libepoxy 2>/dev/null)/lib/libepoxy.0.dylib"
+ icd="$molten_prefix/share/vulkan/icd.d/MoltenVK_icd.json"
+ for dylib in "$renderer" "$molten" "$epoxy"; do
+ [ -f "$dylib" ] || { echo "error: missing GPU renderer dependency $dylib" >&2; return 1; }
+ done
+ [ -f "$icd" ] || { echo "error: missing MoltenVK_icd.json" >&2; return 1; }
+
+ for app in "$HOME"/Library/Developer/Xcode/DerivedData/Dory-*/Build/Products/"$XCODE_CONFIGURATION"/Dory.app; do
+ [ -d "$app" ] || continue
+ framework="$app/Contents/Frameworks"
+ resources="$app/Contents/Resources"
+ mkdir -p "$framework" "$resources/vulkan/icd.d"
+ install -m0755 "$renderer" "$framework/libvirglrenderer.dylib"
+ install -m0755 "$molten" "$framework/libMoltenVK.dylib"
+ install -m0755 "$epoxy" "$framework/libepoxy.0.dylib"
+ for dylib in "$framework/libvirglrenderer.dylib" \
+ "$framework/libMoltenVK.dylib" \
+ "$framework/libepoxy.0.dylib"; do
+ install_name_tool -id "@rpath/$(basename "$dylib")" "$dylib" 2>/dev/null || true
+ for dep in $(otool -L "$dylib" | awk 'NR > 1 {print $1}'); do
+ case "$dep" in
+ /opt/homebrew/*|/usr/local/*)
+ install_name_tool -change "$dep" "@rpath/$(basename "$dep")" "$dylib" 2>/dev/null || true ;;
+ esac
+ done
+ otool -l "$dylib" | grep -q 'path @loader_path ' \
+ || install_name_tool -add_rpath @loader_path "$dylib" 2>/dev/null \
+ || true
+ codesign --force -s - "$dylib" >/dev/null || return 1
+ done
+ sed -E 's#"library_path"[[:space:]]*:[[:space:]]*"[^"]+"#"library_path": "@executable_path/../Frameworks/libMoltenVK.dylib"#' \
+ "$icd" > "$resources/vulkan/icd.d/MoltenVK_icd.json"
+ install -m0644 "$provenance" "$resources/virglrenderer-provenance.txt"
+ install -m0644 "$molten_provenance" "$resources/moltenvk-provenance.txt"
+ echo "bundled_sha256=$(shasum -a 256 "$framework/libvirglrenderer.dylib" | awk '{print $1}')" \
+ >> "$resources/virglrenderer-provenance.txt"
+ nm -gU "$framework/libvirglrenderer.dylib" \
+ | grep '_dory_virglrenderer_macos_fragment_coord_fix$' >/dev/null || return 1
+ nm -gU "$framework/libvirglrenderer.dylib" \
+ | grep '_dory_virglrenderer_macos_venus_fence_fix$' >/dev/null || return 1
+ nm -gU "$framework/libMoltenVK.dylib" \
+ | grep '_dory_moltenvk_spirv_native_array_fix$' >/dev/null || return 1
+ done
+}
+
bundle_doryd_swiftpm_helpers() {
local configuration bin_path entitlements app product helper vmm_app vmm_executable
[ "${DORY_BUILD_DORYD_HELPERS:-1}" = "1" ] || return 0
@@ -352,7 +429,11 @@ bundle_doryd_swiftpm_helpers() {
done
bin_path="$(swift build --package-path dory-core-swift -c "$configuration" --show-bin-path 2>/dev/null)"
- entitlements="dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements"
+ entitlements="$(mktemp "${TMPDIR:-/tmp}/dory-vmm-entitlements.XXXXXX")" || return 1
+ cp dory-core-swift/Sources/dory-vmm/dory-vmm.entitlements "$entitlements" || return 1
+ /usr/libexec/PlistBuddy \
+ -c 'Add :com.apple.security.cs.disable-library-validation bool true' \
+ "$entitlements" >/dev/null || return 1
for app in "$HOME"/Library/Developer/Xcode/DerivedData/Dory-*/Build/Products/"$XCODE_CONFIGURATION"/Dory.app; do
[ -d "$app" ] || continue
@@ -382,6 +463,7 @@ bundle_doryd_swiftpm_helpers() {
"$app/Contents/Library/LaunchDaemons/dev.dory.network-helper.plist"
plutil -lint "$app/Contents/Library/LaunchDaemons/dev.dory.network-helper.plist" >/dev/null
done
+ rm -f "$entitlements"
}
bundle_debug_transfer_helper() {
@@ -697,6 +779,9 @@ PLIST
if [ "$status" -eq 0 ]; then
bundle_debug_hv_helper || status=$?
fi
+if [ "$status" -eq 0 ]; then
+ bundle_debug_gpu_renderer || status=$?
+fi
if [ "$status" -eq 0 ]; then
bundle_doryd_swiftpm_helpers || status=$?
fi
diff --git a/scripts/bundle-engine.sh b/scripts/bundle-engine.sh
index 6225865b..33c75c61 100755
--- a/scripts/bundle-engine.sh
+++ b/scripts/bundle-engine.sh
@@ -15,8 +15,8 @@
# * Contents/Helpers/gvproxy — userspace networking (Apache-2.0) for the dory-hv engine.
# * Contents/Helpers/docker, docker-buildx, docker-compose — Docker Core host CLIs.
# * kubectl is exported as the independently installable Kubernetes component in Core builds.
-# * Contents/Frameworks/libvirglrenderer.dylib, libMoltenVK.dylib — optional experimental
-# Venus/Vulkan renderer payload for in-guest GPU acceleration.
+# * Contents/Frameworks/libvirglrenderer.dylib, libMoltenVK.dylib — pinned VirGL/Venus renderer
+# payload for in-guest GPU acceleration.
# * Contents/Resources/dory-hv-kernel- — legacy raw kernel payload.
# * Contents/Resources/dory-machine-rootfs-.ext4 — legacy raw machine payload.
# * Contents/Resources/dory-hv-kernel-.lzfse — LZFSE PVH/Image kernel for dory-hv.
@@ -300,13 +300,27 @@ fetch_url_stdout() {
dylib_has_symbol() {
local dylib="$1" symbol="$2"
[ -f "$dylib" ] || return 1
- nm -gU "$dylib" 2>/dev/null | grep -q "_$symbol"
+ nm -gU "$dylib" 2>/dev/null | grep "_$symbol" >/dev/null
+}
+
+virglrenderer_is_dory_compatible() {
+ local dylib="$1"
+ dylib_has_symbol "$dylib" dory_virglrenderer_macos_fragment_coord_fix || return 1
+ dylib_has_symbol "$dylib" dory_virglrenderer_macos_venus_fence_fix || return 1
+ otool -L "$dylib" 2>/dev/null | grep -q '@loader_path/libMoltenVK\.dylib' || return 1
+ dylib_has_symbol "$dylib" virgl_renderer_resource_get_map_ptr \
+ || dylib_has_symbol "$dylib" virgl_renderer_resource_map
+}
+
+moltenvk_is_dory_compatible() {
+ dylib_has_symbol "$1" dory_moltenvk_spirv_native_array_fix
}
find_compatible_virglrenderer() {
local cand
for cand in "${DORY_VIRGLRENDERER_PATH:-}" \
"${DORY_VIRGLRENDERER:-}" \
+ "$REPO_ROOT/release-build/virglrenderer/libvirglrenderer.dylib" \
"$FRAMEWORKS/libvirglrenderer.dylib" \
/opt/homebrew/lib/libvirglrenderer.dylib \
/opt/homebrew/opt/virglrenderer/lib/libvirglrenderer.dylib \
@@ -315,14 +329,13 @@ find_compatible_virglrenderer() {
/usr/local/opt/virglrenderer/lib/libvirglrenderer.dylib \
/usr/local/opt/virglrenderer/lib/libvirglrenderer.1.dylib; do
[ -n "$cand" ] && [ -f "$cand" ] || continue
- # The Venus path maps host-visible blobs via virgl_renderer_resource_get_map_ptr (the
- # libkrun/krunkit model); the slp/krunkit build exports it. resource_map is the fallback.
- if dylib_has_symbol "$cand" virgl_renderer_resource_get_map_ptr \
- || dylib_has_symbol "$cand" virgl_renderer_resource_map; then
+ # Dory's markers prove that the macOS shader and async Venus fence fixes are present. The map
+ # entry point is also mandatory because Venus blobs must be exposed as host-visible memory.
+ if virglrenderer_is_dory_compatible "$cand"; then
printf '%s\n' "$cand"
return 0
fi
- echo " WARNING: $cand exports no virgl_renderer_resource_get_map_ptr/resource_map; skipping for Venus GPU bundling" >&2
+ echo " WARNING: $cand is not Dory's patched VirGL/Venus renderer; skipping it" >&2
done
return 1
}
@@ -356,20 +369,21 @@ find_moltenvk_icd() {
find_moltenvk_dylib() {
local icd="${1:-}" library_path cand base_dir relative_dir
if [ -n "${DORY_MOLTENVK_DYLIB:-}" ] && [ -f "$DORY_MOLTENVK_DYLIB" ]; then
- printf '%s\n' "$DORY_MOLTENVK_DYLIB"
- return 0
+ moltenvk_is_dory_compatible "$DORY_MOLTENVK_DYLIB" \
+ && { printf '%s\n' "$DORY_MOLTENVK_DYLIB"; return 0; }
fi
if [ -n "$icd" ] && [ -f "$icd" ]; then
library_path="$(sed -nE 's/.*"library_path"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$icd" | head -1)"
if [ -n "$library_path" ]; then
if [ "${library_path#/}" != "$library_path" ] && [ -f "$library_path" ]; then
- printf '%s\n' "$library_path"
- return 0
+ moltenvk_is_dory_compatible "$library_path" \
+ && { printf '%s\n' "$library_path"; return 0; }
fi
base_dir="$(cd "$(dirname "$icd")" && pwd)"
relative_dir="$(dirname "$library_path")"
cand="$(cd "$base_dir/$relative_dir" 2>/dev/null && pwd)/$(basename "$library_path")"
- [ -f "$cand" ] && { printf '%s\n' "$cand"; return 0; }
+ [ -f "$cand" ] && moltenvk_is_dory_compatible "$cand" \
+ && { printf '%s\n' "$cand"; return 0; }
fi
fi
for cand in "$FRAMEWORKS/libMoltenVK.dylib" \
@@ -377,7 +391,8 @@ find_moltenvk_dylib() {
/opt/homebrew/opt/molten-vk/lib/libMoltenVK.dylib \
/usr/local/lib/libMoltenVK.dylib \
/usr/local/opt/molten-vk/lib/libMoltenVK.dylib; do
- [ -n "$cand" ] && [ -f "$cand" ] && { printf '%s\n' "$cand"; return 0; }
+ [ -n "$cand" ] && [ -f "$cand" ] || continue
+ moltenvk_is_dory_compatible "$cand" && { printf '%s\n' "$cand"; return 0; }
done
return 1
}
@@ -436,18 +451,53 @@ warn_or_fail_missing_bundle_asset() {
}
bundle_venus_renderer() {
- local virgl icd molten bundled_icd dylib
- echo "==> Bundling experimental Venus GPU renderer (virglrenderer + MoltenVK)…"
- if ! virgl="$(find_compatible_virglrenderer)"; then
- warn_or_fail_optional_venus "no compatible libvirglrenderer.dylib found; install the slp/krunkit tap (brew install slp/krunkit/virglrenderer molten-vk libepoxy) or set DORY_VIRGLRENDERER_PATH to a Venus build that exports virgl_renderer_resource_get_map_ptr"
+ local virgl icd molten bundled_icd dylib provenance managed_virgl managed_provenance explicit_virgl sibling_provenance
+ local molten_prefix managed_molten_prefix molten_provenance
+ echo "==> Bundling Dory's pinned VirGL/Venus GPU renderer (virglrenderer + MoltenVK)…"
+
+ managed_virgl="$REPO_ROOT/release-build/virglrenderer/libvirglrenderer.dylib"
+ managed_provenance="$REPO_ROOT/release-build/virglrenderer/provenance.txt"
+ managed_molten_prefix="$REPO_ROOT/release-build/moltenvk"
+ molten_prefix="${DORY_MOLTENVK_PREFIX:-$managed_molten_prefix}"
+ molten_provenance="$molten_prefix/provenance.txt"
+ if [ ! -f "$molten_prefix/lib/libMoltenVK.dylib" ] \
+ || [ ! -f "$molten_prefix/share/vulkan/icd.d/MoltenVK_icd.json" ] \
+ || ! moltenvk_is_dory_compatible "$molten_prefix/lib/libMoltenVK.dylib"; then
+ if ! "$REPO_ROOT/scripts/build-moltenvk.sh" \
+ --prefix "$molten_prefix" \
+ --provenance "$molten_provenance"; then
+ warn_or_fail_optional_venus "failed to build Dory's pinned MoltenVK runtime; install full Xcode and retry"
+ return 0
+ fi
+ fi
+ molten="$molten_prefix/lib/libMoltenVK.dylib"
+ icd="$molten_prefix/share/vulkan/icd.d/MoltenVK_icd.json"
+ provenance=""
+ explicit_virgl="${DORY_VIRGLRENDERER_PATH:-${DORY_VIRGLRENDERER:-}}"
+ if [ -z "$explicit_virgl" ]; then
+ if ! DORY_MOLTENVK_PREFIX="$molten_prefix" "$REPO_ROOT/scripts/build-virglrenderer.sh" \
+ --output "$managed_virgl" \
+ --provenance "$managed_provenance"; then
+ warn_or_fail_optional_venus "failed to build Dory's pinned VirGL renderer; install meson, ninja, pkg-config, and libepoxy"
+ return 0
+ fi
+ virgl="$managed_virgl"
+ provenance="$managed_provenance"
+ elif virglrenderer_is_dory_compatible "$explicit_virgl"; then
+ virgl="$explicit_virgl"
+ sibling_provenance="$(dirname "$explicit_virgl")/provenance.txt"
+ [ -f "$sibling_provenance" ] && provenance="$sibling_provenance"
+ else
+ warn_or_fail_optional_venus "explicit VirGL renderer $explicit_virgl is missing Dory's shader/fence fixes, bundled-MoltenVK linkage, or a required Venus map entry point"
return 0
fi
- if ! icd="$(find_moltenvk_icd)"; then
- warn_or_fail_optional_venus "MoltenVK_icd.json not found; install/provide MoltenVK or set DORY_MOLTENVK_ICD"
+
+ if ! virgl="$(find_compatible_virglrenderer)"; then
+ warn_or_fail_optional_venus "Dory's patched libvirglrenderer.dylib could not be built or found"
return 0
fi
- if ! molten="$(find_moltenvk_dylib "$icd")"; then
- warn_or_fail_optional_venus "libMoltenVK.dylib not found; install/provide MoltenVK or set DORY_MOLTENVK_DYLIB"
+ if ! moltenvk_is_dory_compatible "$molten"; then
+ warn_or_fail_optional_venus "MoltenVK is missing Dory's SPIRV native-array compatibility fix"
return 0
fi
@@ -456,12 +506,32 @@ bundle_venus_renderer() {
copy_dylib_dependency_closure "$virgl" libvirglrenderer.dylib
copy_dylib_dependency_closure "$molten" libMoltenVK.dylib
- if ! dylib_has_symbol "$FRAMEWORKS/libvirglrenderer.dylib" virgl_renderer_resource_get_map_ptr \
- && ! dylib_has_symbol "$FRAMEWORKS/libvirglrenderer.dylib" virgl_renderer_resource_map; then
- warn_or_fail_optional_venus "bundled libvirglrenderer.dylib exports no virgl_renderer_resource_get_map_ptr/resource_map"
+ if ! virglrenderer_is_dory_compatible "$FRAMEWORKS/libvirglrenderer.dylib"; then
+ warn_or_fail_optional_venus "bundled libvirglrenderer.dylib lost Dory's compatibility markers or required Venus map entry point"
return 0
fi
+ if [ -n "$provenance" ] && [ -f "$provenance" ]; then
+ install -m0644 "$provenance" "$RESOURCES/virglrenderer-provenance.txt"
+ else
+ {
+ echo "source=explicit"
+ echo "path=$virgl"
+ echo "verified_sha256=$(shasum -a 256 "$virgl" | awk '{print $1}')"
+ echo "features=venus,macos-core-shader-compat-v2,macos-venus-fence-callback-v1,moltenvk-native-array-v1"
+ } > "$RESOURCES/virglrenderer-provenance.txt"
+ fi
+ if [ -f "$molten_provenance" ]; then
+ install -m0644 "$molten_provenance" "$RESOURCES/moltenvk-provenance.txt"
+ else
+ {
+ echo "source=explicit"
+ echo "path=$molten"
+ echo "features=spirv-native-function-arrays-v1"
+ echo "verified_sha256=$(shasum -a 256 "$molten" | awk '{print $1}')"
+ } > "$RESOURCES/moltenvk-provenance.txt"
+ fi
+
bundled_icd="$RESOURCES/vulkan/icd.d/MoltenVK_icd.json"
if grep -q '"library_path"' "$icd"; then
sed -E 's#"library_path"[[:space:]]*:[[:space:]]*"[^"]+"#"library_path": "@executable_path/../Frameworks/libMoltenVK.dylib"#' "$icd" > "$bundled_icd"
@@ -485,9 +555,14 @@ bundle_venus_renderer() {
sign_runtime_payload "$dylib"
done < <(find "$FRAMEWORKS" -maxdepth 1 -type f -name '*.dylib' -print)
+ echo "bundled_sha256=$(shasum -a 256 "$FRAMEWORKS/libvirglrenderer.dylib" | awk '{print $1}')" \
+ >> "$RESOURCES/virglrenderer-provenance.txt"
+
echo " bundled Frameworks/libvirglrenderer.dylib (from $virgl)"
echo " bundled Frameworks/libMoltenVK.dylib (from $molten)"
echo " bundled Resources/vulkan/icd.d/MoltenVK_icd.json"
+ echo " bundled Resources/virglrenderer-provenance.txt"
+ echo " bundled Resources/moltenvk-provenance.txt"
}
find_debugfs() {
@@ -750,7 +825,10 @@ if [ -d "$PKG" ]; then
cat > "$DORY_HV_ENTITLEMENTS" <<'PLIST'
-com.apple.security.hypervisor
+
+ com.apple.security.hypervisor
+ com.apple.security.device.audio-input
+
PLIST
bundle_swiftpm_executable "$PKG" release dory-hv "$HELPERS/dory-hv" "$DORY_HV_ENTITLEMENTS"
rm -f "$DORY_HV_ENTITLEMENTS"
diff --git a/scripts/desktop-linux-live-gate.sh b/scripts/desktop-linux-live-gate.sh
new file mode 100755
index 00000000..1add0c89
--- /dev/null
+++ b/scripts/desktop-linux-live-gate.sh
@@ -0,0 +1,492 @@
+#!/bin/bash
+# Boot and exercise every managed desktop with the exact signed release candidate. This gate is
+# intentionally destructive only to its uniquely named temporary machines and work directory.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+CTL=""
+KERNEL=""
+DEBIAN_ROOTFS=""
+UBUNTU_ROOTFS=""
+KALI_ROOTFS=""
+DEBIAN_UPDATE=""
+UBUNTU_UPDATE=""
+KALI_UPDATE=""
+DESKTOP_VERSION=""
+SELECTED_DISTRO="all"
+WORKROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dory-desktop-linux-live"
+CONFIRM=""
+
+usage() {
+ echo "usage: desktop-linux-live-gate.sh --ctl PATH --kernel PATH [--distro all|debian|ubuntu|kali] [desktop assets] --version VERSION --workroot PATH --confirm EXACT-CANDIDATE-DESKTOPS" >&2
+ exit 64
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --ctl) CTL="${2:?missing path}"; shift 2 ;;
+ --kernel) KERNEL="${2:?missing path}"; shift 2 ;;
+ --debian-rootfs) DEBIAN_ROOTFS="${2:?missing path}"; shift 2 ;;
+ --ubuntu-rootfs) UBUNTU_ROOTFS="${2:?missing path}"; shift 2 ;;
+ --kali-rootfs) KALI_ROOTFS="${2:?missing path}"; shift 2 ;;
+ --debian-update) DEBIAN_UPDATE="${2:?missing path}"; shift 2 ;;
+ --ubuntu-update) UBUNTU_UPDATE="${2:?missing path}"; shift 2 ;;
+ --kali-update) KALI_UPDATE="${2:?missing path}"; shift 2 ;;
+ --distro) SELECTED_DISTRO="${2:?missing distro}"; shift 2 ;;
+ --version) DESKTOP_VERSION="${2:?missing version}"; shift 2 ;;
+ --workroot) WORKROOT="${2:?missing path}"; shift 2 ;;
+ --confirm) CONFIRM="${2:?missing confirmation}"; shift 2 ;;
+ *) usage ;;
+ esac
+done
+
+[ "$CONFIRM" = EXACT-CANDIDATE-DESKTOPS ] || usage
+case "$SELECTED_DISTRO" in
+ all|debian|ubuntu|kali) ;;
+ *) echo "desktop live gate: unsupported distro selection: $SELECTED_DISTRO" >&2; exit 64 ;;
+esac
+[ -x "$CTL" ] || { echo "desktop live gate: missing dorydctl: $CTL" >&2; exit 66; }
+HELPERS="$(cd "$(dirname "$CTL")" && pwd)"
+VMM="$HELPERS/dory-hv"
+VZ_VMM="$HELPERS/dory-vmm"
+[ -x "$VMM" ] || { echo "desktop live gate: accelerated candidate dory-hv is missing: $VMM" >&2; exit 66; }
+[ -x "$VZ_VMM" ] || { echo "desktop live gate: fallback candidate dory-vmm is missing: $VZ_VMM" >&2; exit 66; }
+assets=("$KERNEL")
+case "$SELECTED_DISTRO" in
+ all) assets+=("$DEBIAN_ROOTFS" "$UBUNTU_ROOTFS" "$KALI_ROOTFS" "$DEBIAN_UPDATE" "$UBUNTU_UPDATE" "$KALI_UPDATE") ;;
+ debian) assets+=("$DEBIAN_ROOTFS" "$DEBIAN_UPDATE") ;;
+ ubuntu) assets+=("$UBUNTU_ROOTFS" "$UBUNTU_UPDATE") ;;
+ kali) assets+=("$KALI_ROOTFS" "$KALI_UPDATE") ;;
+esac
+for asset in "${assets[@]}"; do
+ [ -s "$asset" ] || { echo "desktop live gate: missing asset: $asset" >&2; exit 66; }
+done
+absolute_asset() {
+ local asset_input="$1"
+ local asset_directory
+ asset_directory="$(cd "$(dirname "$asset_input")" && pwd -P)"
+ printf '%s/%s\n' "$asset_directory" "$(basename "$asset_input")"
+}
+KERNEL="$(absolute_asset "$KERNEL")"
+case "$SELECTED_DISTRO" in
+ all)
+ DEBIAN_ROOTFS="$(absolute_asset "$DEBIAN_ROOTFS")"
+ UBUNTU_ROOTFS="$(absolute_asset "$UBUNTU_ROOTFS")"
+ KALI_ROOTFS="$(absolute_asset "$KALI_ROOTFS")"
+ DEBIAN_UPDATE="$(absolute_asset "$DEBIAN_UPDATE")"
+ UBUNTU_UPDATE="$(absolute_asset "$UBUNTU_UPDATE")"
+ KALI_UPDATE="$(absolute_asset "$KALI_UPDATE")"
+ ;;
+ debian)
+ DEBIAN_ROOTFS="$(absolute_asset "$DEBIAN_ROOTFS")"
+ DEBIAN_UPDATE="$(absolute_asset "$DEBIAN_UPDATE")"
+ ;;
+ ubuntu)
+ UBUNTU_ROOTFS="$(absolute_asset "$UBUNTU_ROOTFS")"
+ UBUNTU_UPDATE="$(absolute_asset "$UBUNTU_UPDATE")"
+ ;;
+ kali)
+ KALI_ROOTFS="$(absolute_asset "$KALI_ROOTFS")"
+ KALI_UPDATE="$(absolute_asset "$KALI_UPDATE")"
+ ;;
+esac
+printf '%s\n' "$DESKTOP_VERSION" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$' \
+ || { echo "desktop live gate: invalid desktop version: $DESKTOP_VERSION" >&2; exit 64; }
+case "$WORKROOT" in
+ ""|/|"$HOME"|"$ROOT"|"${RUNNER_TEMP:-/definitely-not-this-path}")
+ echo "desktop live gate: unsafe workroot: $WORKROOT" >&2
+ exit 64
+ ;;
+esac
+case "$WORKROOT" in
+ *dory*desktop*) ;;
+ *) echo "desktop live gate: workroot must identify the Dory desktop gate: $WORKROOT" >&2; exit 64 ;;
+esac
+
+rm -rf "$WORKROOT"
+mkdir -p "$WORKROOT/share" "$WORKROOT/evidence"
+printf 'Dory desktop release gate\n' > "$WORKROOT/share/host-marker.txt"
+codesign --verify --strict "$VMM"
+codesign -d --entitlements :- "$VMM" > "$WORKROOT/evidence/dory-hv-entitlements.plist" 2>&1
+grep -q 'com.apple.security.hypervisor' "$WORKROOT/evidence/dory-hv-entitlements.plist"
+grep -q 'com.apple.security.device.audio-input' "$WORKROOT/evidence/dory-hv-entitlements.plist"
+grep -q 'com.apple.security.cs.disable-library-validation' "$WORKROOT/evidence/dory-hv-entitlements.plist"
+codesign --verify --strict "$VZ_VMM"
+codesign -d --entitlements :- "$VZ_VMM" > "$WORKROOT/evidence/dory-vmm-entitlements.plist" 2>&1
+grep -q 'com.apple.security.virtualization' "$WORKROOT/evidence/dory-vmm-entitlements.plist"
+grep -q 'com.apple.security.device.audio-input' "$WORKROOT/evidence/dory-vmm-entitlements.plist"
+grep -q 'NSMicrophoneUsageDescription' "$HELPERS/../Info.plist"
+ACTIVE_MACHINE=""
+
+cleanup() {
+ result=$?
+ set +e
+ if [ -n "$ACTIVE_MACHINE" ]; then
+ "$CTL" machine stop "$ACTIVE_MACHINE" >/dev/null 2>&1 || true
+ "$CTL" machine delete "$ACTIVE_MACHINE" >/dev/null 2>&1 || true
+ fi
+ trap - EXIT INT TERM
+ exit "$result"
+}
+trap cleanup EXIT INT TERM
+
+exec_json() {
+ machine="$1"
+ shift
+ "$CTL" machine exec "$machine" --json --timeout-ms 120000 \
+ --output-limit-bytes 262144 -- "$@"
+}
+
+assert_exec_token() {
+ machine="$1"
+ token="$2"
+ shift 2
+ output="$(exec_json "$machine" "$@")"
+ if ! printf '%s\n' "$output" | python3 -c '
+import json, sys
+token = sys.argv[1]
+body = json.load(sys.stdin)
+assert body["exitCode"] == 0 and not body["timedOut"], body
+assert token in body["stdout"], body
+' "$token"; then
+ printf '%s\n' "$output" >&2
+ return 1
+ fi
+ printf '%s\n' "$output"
+}
+
+wait_for_exec_token() {
+ machine="$1"
+ token="$2"
+ shift 2
+ for attempt in $(seq 1 60); do
+ if output="$(assert_exec_token "$machine" "$token" "$@" 2>/dev/null)"; then
+ printf '%s\n' "$output"
+ return 0
+ fi
+ sleep 1
+ done
+ echo "desktop live gate: $machine did not satisfy $token readiness" >&2
+ assert_exec_token "$machine" "$token" "$@"
+}
+
+wait_for_desktop() {
+ machine="$1"
+ manager="$2"
+ session="$3"
+ for attempt in $(seq 1 120); do
+ if assert_exec_token "$machine" desktop-ready sh -lc \
+ "systemctl is-active '$manager' >/dev/null && pgrep -u dorygate -x '$session' >/dev/null && echo desktop-ready" \
+ >/dev/null 2>&1; then
+ return 0
+ fi
+ sleep 1
+ done
+ "$CTL" machine status "$machine" >&2 || true
+ echo "desktop live gate: $machine did not reach its graphical session" >&2
+ return 1
+}
+
+wait_for_running() {
+ machine="$1"
+ for attempt in $(seq 1 240); do
+ if "$CTL" machine status "$machine" 2>/dev/null | python3 -c '
+import json, sys
+body = json.load(sys.stdin)
+raise SystemExit(0 if body.get("state") == "running" else 1)
+'; then
+ return 0
+ fi
+ sleep 0.25
+ done
+ "$CTL" machine status "$machine" >&2 || true
+ echo "desktop live gate: $machine did not complete its ready handoff" >&2
+ return 1
+}
+
+run_desktop() {
+ distro="$1"
+ rootfs="$2"
+ manager="$3"
+ session="$4"
+ browser="$5"
+ browser_pattern="$6"
+ browser_desktop="$7"
+ update_bundle="$8"
+ shift 8
+ expected_apps="$*"
+ machine="dory-release-desktop-${distro}-$$"
+ ACTIVE_MACHINE="$machine"
+
+ if "$CTL" machine status "$machine" >/dev/null 2>&1; then
+ echo "desktop live gate: refusing to overwrite existing machine $machine" >&2
+ exit 1
+ fi
+
+ created="$WORKROOT/evidence/$distro-create.json"
+ "$CTL" machine create "$machine" \
+ --kernel "$KERNEL" --rootfs "$rootfs" --memory-mb 4096 --cpus 4 \
+ --display-mode desktop \
+ --share "releasegate=$WORKROOT/share:/home/dorygate/Mac:ro" \
+ --env "DORY_DESKTOP_DISTRO=$distro" \
+ --env DORY_DESKTOP_VMM=accelerated \
+ --env DORY_DESKTOP_GRAPHICS=virgl \
+ --env DORY_GUEST_USER=dorygate --env DORY_GUEST_UID=1550 > "$created"
+ python3 - "$created" <<'PY'
+import json, sys
+with open(sys.argv[1], encoding="utf-8") as handle:
+ body = json.load(handle)
+assert body["state"] == "created", body
+assert body["displayMode"] == "desktop", body
+PY
+
+ "$CTL" machine start "$machine" > "$WORKROOT/evidence/$distro-start.json"
+ wait_for_desktop "$machine" "$manager" "$session"
+ wait_for_running "$machine"
+ "$CTL" machine status "$machine" > "$WORKROOT/evidence/$distro-running.json"
+ machine_pid="$(python3 - "$WORKROOT/evidence/$distro-running.json" <<'PY'
+import json, sys
+with open(sys.argv[1], encoding="utf-8") as handle:
+ body = json.load(handle)
+assert body["state"] == "running" and body["displayMode"] == "desktop", body
+print(body["pid"])
+PY
+)"
+ ps -ww -p "$machine_pid" -o command= | grep -F "$VMM" \
+ > "$WORKROOT/evidence/$distro-vmm-command.txt"
+
+ app_checks=""
+ for app in $expected_apps; do
+ app_checks="$app_checks command -v '$app' >/dev/null;"
+ done
+ wait_for_exec_token "$machine" system-pass sh -lc "
+ set -eu
+ systemctl is-active '$manager' >/dev/null
+ systemctl is-active dory-zram.service >/dev/null
+ test \"\$(cat /run/dory/graphics-backend)\" = virgl2
+ grep -q '^/dev/zram0 ' /proc/swaps
+ pgrep -u dorygate -x '$session' >/dev/null
+ desktop_uid=\$(id -u dorygate)
+ desktop_environment=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"/run/user/\$desktop_uid\" \
+ DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/\$desktop_uid/bus\" \
+ systemctl --user show-environment)
+ printf '%s\n' \"\$desktop_environment\" | grep -Fqx 'MOZ_ENABLE_WAYLAND=0'
+ printf '%s\n' \"\$desktop_environment\" | grep -Fqx 'GSK_RENDERER=gl'
+ ethernet=\$(nmcli -t -f DEVICE,TYPE,STATE device status | awk -F: '\$2 ~ /^ethernet\$/ && \$3 ~ /^connected\$/ { print \$1; exit }')
+ test -n \"\$ethernet\"
+ ip -4 -o addr show dev \"\$ethernet\" | grep -q ' inet '
+ test \"\$(nmcli -g GENERAL.CONNECTION device show \"\$ethernet\")\" = 'Dory Wired'
+ test \"\$(readlink /etc/resolv.conf)\" = ../run/NetworkManager/resolv.conf
+ getent ahostsv4 example.com >/dev/null
+ test \"\$(curl -4 -fsS --max-time 30 -o /dev/null -w '%{http_code}' https://example.com)\" = 200
+ command -v '$browser' >/dev/null
+ command -v gio >/dev/null
+ test -f '$browser_desktop'
+ command -v xwininfo >/dev/null
+ $app_checks
+ grep -q 'virtio-snd' /proc/asound/cards
+ grep -q 'playback' /proc/asound/pcm
+ grep -q 'capture' /proc/asound/pcm
+ audio_status=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"/run/user/\$desktop_uid\" \
+ wpctl status)
+ test \"\$(printf '%s\\n' \"\$audio_status\" | grep -Fc 'Dory Audio Pro')\" -ge 2
+ runuser -u dorygate -- env XDG_RUNTIME_DIR=\"/run/user/\$desktop_uid\" \
+ DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/\$desktop_uid/bus\" \
+ timeout 15 aplay -D pipewire -q -t raw -f S16_LE -r 48000 -c 2 -d 1 /dev/zero
+ runuser -u dorygate -- env XDG_RUNTIME_DIR=\"/run/user/\$desktop_uid\" \
+ DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/\$desktop_uid/bus\" \
+ timeout 15 arecord -D pipewire -q -t raw -f S16_LE -r 48000 -c 2 -d 1 /dev/null
+ mountpoint -q /home/dorygate/Mac
+ grep -qx 'Dory desktop release gate' /home/dorygate/Mac/host-marker.txt
+ ! touch /home/dorygate/Mac/write-must-fail 2>/dev/null
+ printf persistence-pass > /home/dorygate/.dory-release-marker
+ echo system-pass
+ " > "$WORKROOT/evidence/$distro-system.json"
+
+ assert_exec_token "$machine" browser-window-mapped sh -lc "
+ set -eu
+ uid=\$(id -u dorygate)
+ runtime=/run/user/\$uid
+ session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \
+ DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \
+ | grep -E '^(DISPLAY|DBUS_SESSION_BUS_ADDRESS|XAUTHORITY|XDG_RUNTIME_DIR)=')
+ display=\$(printf '%s\\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p')
+ dbus=\$(printf '%s\\n' \"\$session_env\" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
+ xauth=\$(printf '%s\\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p')
+ configured_runtime=\$(printf '%s\\n' \"\$session_env\" | sed -n 's/^XDG_RUNTIME_DIR=//p')
+ test -n \"\$display\"
+ test -n \"\$xauth\"
+ test \"\$configured_runtime\" = \"\$runtime\"
+ if [ '$distro' = ubuntu ]; then
+ favorites=\$(runuser -u dorygate -- env DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" \
+ XDG_RUNTIME_DIR=\"\$runtime\" gsettings get org.gnome.shell favorite-apps)
+ printf '%s\\n' \"\$favorites\" | grep -Fq \"'firefox.desktop'\"
+ ! printf '%s\\n' \"\$favorites\" | grep -Fq 'firefox_firefox.desktop'
+ fi
+ runuser -u dorygate -- env DISPLAY=\"\$display\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" \
+ XAUTHORITY=\"\$xauth\" XDG_RUNTIME_DIR=\"\$runtime\" MOZ_ENABLE_WAYLAND=0 \
+ gio launch '$browser_desktop' https://example.com >/tmp/dory-release-browser.log 2>&1
+ for _ in \$(seq 1 30); do
+ if pgrep -u dorygate -f '$browser_pattern' >/dev/null \
+ && runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \
+ xwininfo -root -tree 2>/dev/null \
+ | grep -Eq '\(\"Navigator\" \"firefox(-esr)?\"\)'; then
+ echo browser-running
+ echo browser-window-mapped
+ exit 0
+ fi
+ sleep 1
+ done
+ cat /tmp/dory-release-browser.log >&2
+ exit 1
+ " > "$WORKROOT/evidence/$distro-browser.json"
+
+ if [ "$distro" = ubuntu ]; then
+ assert_exec_token "$machine" gtk-windows-mapped sh -lc "
+ set -eu
+ uid=\$(id -u dorygate)
+ runtime=/run/user/\$uid
+ session_env=\$(runuser -u dorygate -- env XDG_RUNTIME_DIR=\"\$runtime\" \
+ DBUS_SESSION_BUS_ADDRESS=\"unix:path=\$runtime/bus\" systemctl --user show-environment \
+ | grep -E '^(DISPLAY|DBUS_SESSION_BUS_ADDRESS|XAUTHORITY|XDG_RUNTIME_DIR|GSK_RENDERER)=')
+ display=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DISPLAY=//p')
+ dbus=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
+ xauth=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^XAUTHORITY=//p')
+ renderer=\$(printf '%s\n' \"\$session_env\" | sed -n 's/^GSK_RENDERER=//p')
+ test \"\$renderer\" = gl
+ runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \
+ XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" GSK_RENDERER=\"\$renderer\" \
+ XDG_CURRENT_DESKTOP=ubuntu:GNOME DESKTOP_SESSION=ubuntu GDMSESSION=ubuntu \
+ gio launch /usr/share/applications/org.gnome.Nautilus.desktop \
+ >/tmp/dory-release-files.log 2>&1
+ runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \
+ XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" GSK_RENDERER=\"\$renderer\" \
+ XDG_CURRENT_DESKTOP=ubuntu:GNOME DESKTOP_SESSION=ubuntu GDMSESSION=ubuntu \
+ gio launch /usr/share/applications/org.gnome.Calculator.desktop \
+ >/tmp/dory-release-calculator.log 2>&1
+ runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \
+ XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" GSK_RENDERER=\"\$renderer\" \
+ XDG_CURRENT_DESKTOP=ubuntu:GNOME DESKTOP_SESSION=ubuntu GDMSESSION=ubuntu \
+ gio launch /usr/share/applications/org.gnome.Settings.desktop \
+ >/tmp/dory-release-settings.log 2>&1
+ runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \
+ XDG_RUNTIME_DIR=\"\$runtime\" DBUS_SESSION_BUS_ADDRESS=\"\$dbus\" GSK_RENDERER=\"\$renderer\" \
+ XDG_CURRENT_DESKTOP=ubuntu:GNOME DESKTOP_SESSION=ubuntu GDMSESSION=ubuntu \
+ gnome-terminal >/tmp/dory-release-terminal.log 2>&1
+ for _ in \$(seq 1 30); do
+ windows=\$(runuser -u dorygate -- env DISPLAY=\"\$display\" XAUTHORITY=\"\$xauth\" \
+ xwininfo -root -tree 2>/dev/null)
+ if printf '%s\n' \"\$windows\" | grep -Fq '(\"org.gnome.Nautilus\" \"org.gnome.Nautilus\")' \
+ && printf '%s\n' \"\$windows\" | grep -Fq '(\"gnome-calculator\" \"gnome-calculator\")' \
+ && printf '%s\n' \"\$windows\" | grep -Fq '(\"gnome-control-center\" \"gnome-control-center\")' \
+ && printf '%s\n' \"\$windows\" | grep -Eq '\(\"gnome-terminal-server\" \"Gnome-terminal(-server)?\"\)'; then
+ for process_pattern in '/usr/bin/nautilus' '/usr/bin/gnome-calculator' 'gnome-control-center'; do
+ process_id=\$(pgrep -n -u dorygate -f \"\$process_pattern\")
+ tr '\\0' '\\n' <\"/proc/\$process_id/environ\" | grep -Fqx 'GSK_RENDERER=gl'
+ done
+ echo gtk-windows-mapped
+ exit 0
+ fi
+ sleep 1
+ done
+ cat /tmp/dory-release-files.log /tmp/dory-release-calculator.log \
+ /tmp/dory-release-settings.log /tmp/dory-release-terminal.log >&2
+ exit 1
+ " > "$WORKROOT/evidence/$distro-gtk-windows.json"
+ fi
+
+ "$CTL" machine stop "$machine" > "$WORKROOT/evidence/$distro-stop.json"
+ "$CTL" machine start "$machine" > "$WORKROOT/evidence/$distro-restart.json"
+ wait_for_desktop "$machine" "$manager" "$session"
+ wait_for_running "$machine"
+ assert_exec_token "$machine" persistence-pass sh -lc \
+ "cat /home/dorygate/.dory-release-marker; mountpoint -q /home/dorygate/Mac" \
+ > "$WORKROOT/evidence/$distro-persistence.json"
+
+ "$CTL" machine desktop-update "$machine" \
+ --distro "$distro" --version "$DESKTOP_VERSION" \
+ --bundle "$update_bundle" --kernel "$KERNEL" \
+ > "$WORKROOT/evidence/$distro-desktop-update.json"
+ python3 - "$WORKROOT/evidence/$distro-desktop-update.json" "$DESKTOP_VERSION" <<'PY'
+import json, sys
+with open(sys.argv[1], encoding="utf-8") as handle:
+ body = json.load(handle)
+assert body["version"] == sys.argv[2], body
+assert body["status"]["state"] == "running", body
+assert len(body["inputSHA256"]) == 64, body
+assert len(body["bundleSHA256"]) == 64, body
+assert body["snapshotID"], body
+PY
+ wait_for_desktop "$machine" "$manager" "$session"
+ wait_for_running "$machine"
+ assert_exec_token "$machine" update-pass sh -lc \
+ "grep -Fqx 'version=$DESKTOP_VERSION' /var/lib/dory/desktop-update.env; cat /home/dorygate/.dory-release-marker; echo update-pass" \
+ > "$WORKROOT/evidence/$distro-update-qualified.json"
+
+ bad_update="$WORKROOT/$distro-corrupt-update.tar"
+ printf 'not a tar archive\n' > "$bad_update"
+ if "$CTL" machine desktop-update "$machine" \
+ --distro "$distro" --version "$DESKTOP_VERSION-fault" \
+ --bundle "$bad_update" --kernel "$KERNEL" \
+ > "$WORKROOT/evidence/$distro-rollback-stdout.json" \
+ 2> "$WORKROOT/evidence/$distro-rollback-stderr.txt"; then
+ echo "desktop live gate: corrupt $distro update unexpectedly succeeded" >&2
+ exit 1
+ fi
+ grep -q 'last-good snapshot' "$WORKROOT/evidence/$distro-rollback-stderr.txt"
+ grep -q 'was restored' "$WORKROOT/evidence/$distro-rollback-stderr.txt"
+ wait_for_desktop "$machine" "$manager" "$session"
+ wait_for_running "$machine"
+ assert_exec_token "$machine" rollback-pass sh -lc \
+ "grep -Fqx 'version=$DESKTOP_VERSION' /var/lib/dory/desktop-update.env; cat /home/dorygate/.dory-release-marker; echo rollback-pass" \
+ > "$WORKROOT/evidence/$distro-rollback-qualified.json"
+
+ "$CTL" machine stop "$machine" >/dev/null
+ "$CTL" machine delete "$machine" >/dev/null
+ if "$CTL" machine status "$machine" >/dev/null 2>&1; then
+ echo "desktop live gate: temporary machine survived deletion: $machine" >&2
+ exit 1
+ fi
+ ACTIVE_MACHINE=""
+ printf '%s=PASS\n' "$distro" >> "$WORKROOT/evidence/desktop-results.txt"
+}
+
+if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then
+ run_desktop debian "$DEBIAN_ROOTFS" lightdm xfce4-session firefox-esr \
+ 'firefox-esr|/firefox' /usr/share/applications/firefox-esr.desktop "$DEBIAN_UPDATE" \
+ xfce4-terminal thunar mousepad ristretto file-roller evince galculator
+fi
+if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = ubuntu ]; then
+ run_desktop ubuntu "$UBUNTU_ROOTFS" gdm3 gnome-shell firefox \
+ 'firefox' /usr/share/applications/firefox.desktop "$UBUNTU_UPDATE" \
+ gnome-terminal nautilus gnome-text-editor eog file-roller evince \
+ gnome-calculator gnome-control-center
+fi
+if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = kali ]; then
+ run_desktop kali "$KALI_ROOTFS" lightdm xfce4-session firefox-esr \
+ 'firefox-esr|/firefox' /usr/share/applications/firefox-esr.desktop "$KALI_UPDATE" \
+ xfce4-terminal thunar mousepad ristretto file-roller atril
+fi
+
+{
+ printf 'source_commit=%s\n' "${GITHUB_SHA:-local}"
+ printf 'distros=%s\n' "$SELECTED_DISTRO"
+ printf 'kernel_sha256=%s\n' "$(shasum -a 256 "$KERNEL" | awk '{print $1}')"
+ if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = debian ]; then
+ printf 'debian_rootfs_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_ROOTFS" | awk '{print $1}')"
+ printf 'debian_update_sha256=%s\n' "$(shasum -a 256 "$DEBIAN_UPDATE" | awk '{print $1}')"
+ fi
+ if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = ubuntu ]; then
+ printf 'ubuntu_rootfs_sha256=%s\n' "$(shasum -a 256 "$UBUNTU_ROOTFS" | awk '{print $1}')"
+ printf 'ubuntu_update_sha256=%s\n' "$(shasum -a 256 "$UBUNTU_UPDATE" | awk '{print $1}')"
+ fi
+ if [ "$SELECTED_DISTRO" = all ] || [ "$SELECTED_DISTRO" = kali ]; then
+ printf 'kali_rootfs_sha256=%s\n' "$(shasum -a 256 "$KALI_ROOTFS" | awk '{print $1}')"
+ printf 'kali_update_sha256=%s\n' "$(shasum -a 256 "$KALI_UPDATE" | awk '{print $1}')"
+ fi
+ printf 'status=PASS\n'
+} > "$WORKROOT/evidence/manifest.txt"
+
+echo "Desktop Linux exact-candidate live gate: PASS ($WORKROOT/evidence/manifest.txt)"
diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh
index f7e6cb71..5ae3265c 100755
--- a/scripts/test-build-components.sh
+++ b/scripts/test-build-components.sh
@@ -27,7 +27,14 @@ write_fixture "$SOURCE/Image-desktop" 196608
for distro in debian ubuntu kali; do
write_fixture "$SOURCE/dory-desktop-$distro-rootfs-arm64.ext4" 327680
printf 'schema=fixture\n' > "$SOURCE/dory-desktop-$distro-build-arm64.stamp"
- printf 'xfce4\tfixture\n' > "$SOURCE/dory-desktop-$distro-packages-arm64.txt"
+ if [ "$distro" = ubuntu ]; then
+ printf 'ubuntu-desktop-minimal\tfixture\nubuntu-session\tfixture\ngdm3\tfixture\n' \
+ > "$SOURCE/dory-desktop-$distro-packages-arm64.txt"
+ else
+ printf 'xfce4\tfixture\nlightdm\tfixture\n' \
+ > "$SOURCE/dory-desktop-$distro-packages-arm64.txt"
+ fi
+ write_fixture "$SOURCE/dory-desktop-$distro-update-arm64.tar" 4096
done
printf 'schema=fixture\n' > "$SOURCE/kernel-build-arm64-desktop.stamp"
From 9ea0b6819b215e5c9dbddad8d26d05c1152e4812 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 11:34:48 +0000
Subject: [PATCH 009/338] docs: define Linux desktop parity and support status
---
CHANGELOG.md | 41 ++++++++++++++
COMPATIBILITY.md | 25 +++++----
LINUX_DESKTOP_PARITY.md | 81 ++++++++++++++++++++++++++++
README.md | 44 +++++++++++----
website/index.html | 2 +-
website/public/agent-guide.json | 2 +-
website/public/docs/agents.md | 2 +-
website/public/docs/architecture.md | 7 +--
website/public/docs/compatibility.md | 14 +++--
website/public/docs/operations.md | 2 +-
website/public/llms-full.txt | 6 +--
website/public/llms.txt | 2 +-
website/src/App.tsx | 26 ++++-----
13 files changed, 205 insertions(+), 49 deletions(-)
create mode 100644 LINUX_DESKTOP_PARITY.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aef2d159..88958c32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,47 @@
## Unreleased
+### Changed
+
+- Added custom arm64 Linux installation from a user-selected ISO. Dory now imports the installer
+ into private managed storage, creates a configurable thin-provisioned disk, boots through native
+ EFI with a stable VM identity and persistent NVRAM, and lets users eject or reattach installer
+ media from the machine menu.
+- Extended custom EFI snapshots to capture and transactionally restore the VM disk, machine
+ identifier, and NVRAM together. EFI snapshot exports use an integrity-checked portable bundle,
+ while clones retain EFI boot variables and receive a new hardware identity.
+- Enabled full-screen participation and system-key capture in the native Linux display window.
+
+- Rebuilt the Ubuntu 24.04 LTS desktop component around Canonical's GNOME session, Yaru themes,
+ Ubuntu Dock, Files, Settings, standard desktop utilities, and a working native-package browser
+ instead of presenting the shared Xfce profile as Ubuntu Desktop.
+- Completed Debian's everyday desktop application set with Firefox ESR, Evince, and Galculator.
+- Enabled explicit bidirectional SPICE clipboard transport and added a VirtIO microphone input
+ stream alongside desktop speaker output.
+- Made release candidates rebuild and verify the desktop kernel and all three desktop root filesystems
+ from the release commit instead of accepting pre-existing local guest artifacts.
+- Added a physical-Mac release gate that boots every managed desktop with the signed candidate
+ helper, launches its browser, and verifies applications, networking, audio devices, persistence,
+ read-only sharing, entitlements, and exact process provenance.
+- Added signed, versioned in-place updates for existing Debian, Ubuntu, and Kali desktops. Dory now
+ preserves the persistent guest disk and account, creates a last-good snapshot, updates packages,
+ browser and guest integration, boots the new desktop kernel, qualifies the graphical session,
+ and automatically rolls back any failed or interrupted update.
+- Extended the physical-Mac release gate to apply each exact desktop update payload and prove that
+ a corrupt payload restores the last-good snapshot without losing guest data or running state.
+
+### Fixed
+
+- Stopped Firefox and Firefox ESR from producing blank, black, or duplicated desktop frames on
+ Dory's current macOS VirGL renderer. Managed desktops now apply Mozilla's supported
+ per-browser software-compositing fallback while leaving the Linux desktop and other compatible
+ applications on the accelerated graphics path.
+- Made the desktop VM's virtio Ethernet adapter an explicit NetworkManager connection. Ubuntu's
+ vendor policy no longer leaves the guest offline when no Netplan-generated profile exists.
+- Made that managed connection independent of the guest's interface name, covering both Debian and
+ Ubuntu's `enp0s1` and Kali's `eth0` without falling back to an auto-generated profile.
+- Fixed `machine create` reporting a newly created desktop as `headless` until its first status refresh.
+
## 0.4.5 - 2026-08-13
### Fixed
diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index d4f78702..152189ea 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -68,20 +68,26 @@ engine resources.
| Capability | Status |
|---|---|
-| Guest OS | Managed Debian 13, Ubuntu 24.04 LTS, or Kali rolling Xfce desktop; lightweight Alpine headless Linux on native arm64 |
+| Guest OS | Managed Ubuntu 24.04 LTS GNOME, Debian 13 Xfce, or Kali rolling Xfce desktop; lightweight Alpine headless Linux on native arm64 |
| Access | Configurable desktop user, graphical session, embedded or selected external terminal, `dory machine shell`, and command execution |
| Resources | CPU and memory configuration with guest-reported statistics |
| Snapshots and export/import | Supported |
| Scheduled local recovery bundles | Supported; owner-only durable schedules, archive re-import verification on every run, periodic disposable boot verification, and scheduler-owned retention |
| Managed remote/offsite machine backup | **Unavailable**; Dory does not claim an S3 or hosted backup service |
| Development recipes | Curated Node, Python, Go, Rust, Java, Ruby, and DevOps toolsets for Debian and Alpine |
-| Graphical Linux sessions | Supported with managed Debian, Ubuntu, and Kali Xfce profiles on Apple Silicon |
-| Desktop display | Retina-sharp 2x framebuffer, dynamic window resizing, and matching Xfce scaling |
-
-Desktop machines run normal graphical and command-line applications with glibc and systemd. Their
-disk is thin-provisioned to 64 GiB in the selected Dory data drive. Headless machines use Alpine,
-musl, `root`, and `/bin/sh`. Arbitrary desktop images and guest kernel modules are not part of the
-current contract.
+| Graphical Linux sessions | Supported with managed Ubuntu GNOME and Debian/Kali Xfce profiles on Apple Silicon |
+| Desktop display | Retina-sharp 2x framebuffer, dynamic window resizing, and matching GNOME or Xfce scaling |
+| Desktop Vulkan | Supported through Dory's isolated, capability-probed Venus runtime on Apple-silicon raw-HV; Automatic falls back to classic VirGL and software when unavailable |
+| Existing desktop updates | Signed in-place package, browser, guest-integration, and kernel updates with a retained last-good snapshot and automatic failure/interruption rollback |
+| Custom arm64 installer ISO | Preview: architecture preflight, exact-media SHA-256/runtime evidence, native EFI boot, private managed ISO copy and recovery console, thin-provisioned disk, persistent machine identity/NVRAM, and attach/eject lifecycle |
+
+Managed desktop machines run normal graphical and command-line applications with glibc and systemd.
+Their disk is thin-provisioned to 64 GiB in the selected Dory data drive. Headless machines use
+Alpine, musl, `root`, and `/bin/sh`. Custom arm64 ISO installation is preview until real-distribution
+installation, reboot, media-ejection, device, and guest-tools qualification passes on physical Macs.
+Architecture compatibility does not imply runtime qualification: Dory records the exact ISO hash,
+host model, and macOS build, blocks known-unstable tuples, and labels unseen combinations as
+unqualified.
## Networking
@@ -127,7 +133,7 @@ grants; `full` is an explicit unrestricted choice. See the
## Preview
-- In-guest Venus/Vulkan acceleration is preview on the Apple-silicon raw-HV tier.
+- Custom arm64 Linux installation from ISO through EFI is preview pending exact-candidate physical-Mac qualification. Ubuntu 24.04.3 and 24.04.4 ARM64 are known unstable on Mac14,10/macOS build 26A5406e with Dory's retired VirtIO-block EFI profile. Under the current native-NVMe/fsync profile, Ubuntu 24.04.3 completed installation, package updates, ISO ejection, and persistent GNOME/Chrome boot, but a later whole-guest stall during Chromium snap installation keeps that exact tuple unqualified.
- Remote SSH workspace foundations and custom machine kernel/rootfs inputs remain preview with the
exact limits reported by `dory agent guide --json`.
@@ -137,7 +143,6 @@ grants; `full` is an explicit unrestricted choice. See the
engine fails closed until a complete guest USB/IP RPC and physical qualification exist.
- Audio passthrough is unavailable.
- Intel-host public builds are unavailable before dedicated physical qualification.
-- Desktop images beyond the managed Debian, Ubuntu, and Kali Xfce profiles are unavailable.
- Managed image update discovery/replacement, mDNS/multicast relay, and general L2 bridging are
unavailable in 0.4.
diff --git a/LINUX_DESKTOP_PARITY.md b/LINUX_DESKTOP_PARITY.md
new file mode 100644
index 00000000..db4d97a1
--- /dev/null
+++ b/LINUX_DESKTOP_PARITY.md
@@ -0,0 +1,81 @@
+# Linux desktop parity contract
+
+This document defines Dory's release bar for a Parallels-class Linux experience on Apple Silicon.
+The comparison target is the Linux feature set in Parallels Desktop 26, not its Windows-only
+features. A capability is not considered shipped because code or a package exists: it must pass an
+automated test in the signed release candidate and a live test on physical supported Macs.
+
+Primary references:
+
+- [Parallels Tools overview](https://docs.parallels.com/landing/pdfm-ug/parallels-desktop-for-mac-26-users-guide/advanced-topics/installing-and-updating-parallels-tools/parallels-tools-overview)
+- [Parallels Apple-silicon limitations](https://kb.parallels.com/en/128914)
+- [Parallels Linux OpenGL support](https://kb.parallels.com/124138)
+- [Parallels Virtio GPU and VirGL](https://kb.parallels.com/en/128518)
+- [Apple GUI Linux VM reference](https://developer.apple.com/documentation/virtualization/running-gui-linux-in-a-virtual-machine-on-a-mac)
+- [Apple shared directories](https://developer.apple.com/documentation/virtualization/shared-directories)
+- [Apple clipboard sharing](https://developer.apple.com/documentation/virtualization/clipboard-sharing)
+- [Apple VM audio](https://developer.apple.com/documentation/virtualization/audio)
+- [Apple Intel-binary translation in Linux](https://developer.apple.com/documentation/virtualization/running-intel-binaries-in-linux-vms)
+
+## Non-negotiable release contract
+
+| Area | Required behavior | Current state |
+|---|---|---|
+| Official desktops | Ubuntu uses Canonical GNOME and Yaru; Debian and Kali use complete, distro-native Xfce sessions | Ubuntu's same-commit image and signed-app candidate passed the physical-Mac live gate, including restart, persistence, guest-tools update, corrupt-update rejection, rollback, and post-rollback qualification; Debian and Kali still need the same candidate-bound run |
+| Applications | Each managed desktop includes a browser, terminal, files, settings, editor, archive viewer, image viewer, PDF viewer, package sources, and can install and launch more apps | Ubuntu's signed candidate mapped and rendered Firefox, Files, Settings, Calculator, and Terminal correctly; Debian and Kali plus the broader app set still need candidate-bound evidence |
+| Custom Linux | Create an ARM64 Linux VM from a user-selected ISO through EFI, with persistent NVRAM and install media lifecycle | The signed app securely stages protected user-selected media, fingerprints the exact bytes, reports architecture compatibility separately from runtime qualification, imports accepted media into private daemon-managed storage, creates a thin disk, and boots through persistent EFI. Desktop ISO machines use balanced 4-vCPU/4-GB resource defaults rather than treating a CPU count as a compatibility workaround. A private bidirectional serial console gives every EFI boot durable diagnostics and recovery input. The EFI root disk is now native NVMe with fsync semantics; Dory-owned direct-kernel guests retain their VirtIO-block contract. Ubuntu 24.04.3 ARM64 completed installation and booted its persistent desktop after ISO ejection, but a later whole-guest stall during a Chromium snap installation keeps the exact runtime unqualified |
+| Native and Intel apps | Native ARM64 apps work normally; supported x86_64 Linux applications use Apple's Linux translation runtime with guided setup and clear compatibility reporting | Missing for desktop machines |
+| Display | Retina rendering, dynamic resolution during resize, full screen, correct scaling, cursor integration, and multi-display support | Single-display Retina, resize, native full-screen participation, and system-key capture exist; full qualification and multi-display are missing |
+| Graphics | Hardware-accelerated Linux graphics meeting Parallels' advertised OpenGL level, with a software fallback and an application compatibility suite | Raw-HV desktop VirGL2/Venus rendering and Metal scanout are implemented; GTK4 uses its qualified accelerated GL renderer to avoid newer-renderer texture corruption, Firefox uses its reliable XWayland presentation path, and Files/Settings/Calculator/Firefox are locally normal. The broader OpenGL suite and rebuilt exact-candidate gate remain |
+| Clipboard | Bidirectional text and image clipboard with an explicit off/host-to-guest/guest-to-host/bidirectional policy | Binary-safe host/guest transport, native shortcuts, focus synchronization, Wayland/X11 adapters, and policy UI are implemented; all-distro live and exact-candidate qualification remain |
+| Drag and drop | Bidirectional file drag and drop between Finder and the Linux desktop with conflict, cancellation, and progress handling | Missing |
+| Shared folders | Add/remove read-only or read-write Mac folders, stable guest paths, permissions, large-file tests, file watching, and safe runtime updates | Scoped VirtioFS shares exist; runtime mutation and complete compatibility qualification are missing |
+| Audio | Speaker output and microphone input, device/permission handling, mute, reconnect, and host sleep/wake recovery | Output and microphone devices plus signing/privacy declarations added; exact-candidate capture, controls, and recovery qualification pending |
+| Devices | USB storage plus qualified physical USB attachment/detachment and remembered routing; camera and removable-media behavior is explicit | Host discovery only; passthrough missing |
+| Networking | NAT/shared networking, bridged networking, host-only networking, stable addressing, DNS/VPN behavior, port forwarding, and offline recovery | NAT and Dory addressing exist; per-machine bridged and host-only modes are missing |
+| Lifecycle | Graceful stop, pause/resume, durable suspend-to-disk, host sleep/wake, crash recovery, and resource reconfiguration | Managed guests shut down through the Dory agent; arbitrary EFI guests now fall back to Virtualization.framework's native virtual power-button request so filesystems can flush without guest tools. Pause foundations and disk persistence exist; saved machine state is missing |
+| Data safety | Consistent snapshots, restore, clone, linked clone, export/import, and corruption/interruption recovery | Snapshot, restore, clone, and export/import include EFI machine identity/NVRAM with transactional rollback and bundle integrity checks; linked clones and live-state consistency need work |
+| Guest tools | Versioned Dory guest tools update automatically and provide clipboard, resize, shares, time sync, drag/drop, telemetry, and compatibility health | Agent and Dory-owned integration files, including clipboard adapters, ship in one offline, deterministic desktop-update transaction. The signed package list is provenance and a compatibility preflight, not permission to run a hidden distribution upgrade; drag/drop and a unified compatibility-health surface remain missing |
+| Updates | Existing desktops receive tested guest-tools and integration updates in place with rollback, while normal distro package and application updates remain available inside the guest; recreating the VM is never the upgrade path | Durable daemon journals, retained last-good snapshots, automatic failure/interruption rollback, and UI activation exist. The Ubuntu signed candidate passed the schema-v2 offline guest-tools update, corrupt-bundle rejection, last-good rollback, and post-rollback requalification; distro package-update UX and the remaining distros still need release evidence |
+| Release provenance | Desktop kernel and every rootfs are built and verified from the release commit, signed into the component catalog, then boot-tested from that exact catalog | Same-commit build plus signed-helper physical-Mac boot gate added; the workflow must still pass before release |
+
+## Current installer-media evidence
+
+- Ubuntu 24.04.4 Desktop ARM64 (`c2610520bf582976839a1724c669e1cfed0547427be5a0ad12d457b92b46ffbe`) is architecture-compatible but runtime-known-unstable on Mac14,10 with macOS build 26A5406e and Dory's retired `vz-efi-virtio-blk-v1` profile. This exact media/host/profile tuple panicked or froze at one, two, and six vCPUs, so Dory blocks that tuple instead of presenting a resource-count workaround. It has not yet been qualified with the materially different native-NVMe profile.
+- Ubuntu 24.04.3 Desktop ARM64 (`cdbf0f83ab4f7d46be767e73c59b5cbca9743dd5fb887142c96f4b2df38fa5ad`) also froze during installation with the retired VirtIO-block EFI profile. With `vz-efi-nvme-fsync-v1`, its 6.14.0-27 installer completed `curtin_install`, post-install configuration, and unattended package updates against a 64-GB NVMe disk. After ejecting the ISO, the installed system cold-booted, reached GNOME, retained Chrome, and browsed HTTPS over the VirtIO network. A later whole-guest stall was observed while App Center installed the Chromium snap, so this exact native-NVMe tuple remains unqualified pending a reproduced kernel/runtime diagnosis and sustained application stress pass.
+- The user-supplied Omarchy 4.0.0 ISO (`9224fab3720560f771969a99a499e5f7e0f8e2d6a0681d872d52f05fb5003da4`) is x86_64 EFI-only. Dory rejects it before disk allocation or ISO staging with: `This ISO is Intel x86_64-only. Apple Silicon requires an arm64 EFI ISO.` This is the same whole-guest architecture limit documented for hardware-virtualized Linux on Apple silicon; native Omarchy qualification requires upstream ARM64 EFI media.
+
+## Qualification matrix
+
+Every supported managed desktop and every custom-ISO path must pass the following on each supported
+macOS major version and qualified Apple-silicon generation:
+
+1. Cold boot, login, shutdown, restart, host sleep/wake, pause/resume, and durable suspend/restore.
+2. DHCP, DNS, IPv4/IPv6, VPN route changes, host-only connectivity, bridged connectivity, and port forwarding.
+3. Browser HTTPS navigation, package update, GUI package installation, application launch, file open/save, and reboot persistence.
+4. Window resize, full screen, Retina scaling, cursor capture/release, keyboard shortcuts, clipboard text/image, and drag/drop in both directions.
+5. Speaker playback, microphone recording, shared-folder read/write and read-only enforcement, USB attach/detach, and removable media.
+6. OpenGL compatibility, accelerated rendering correctness, software fallback, and representative developer/creative GUI workloads.
+7. Snapshot, restore, clone, export/import, interrupted-operation recovery, and low-disk behavior.
+8. Native ARM64 and supported x86_64 application execution with architecture and failure diagnostics.
+9. Upgrade from every supported prior Dory desktop image without losing user accounts, applications,
+ settings, shared-folder configuration, or workload data; failed updates must roll back cleanly.
+
+Evidence must identify the release commit, app digest, component catalog digest, desktop image digest,
+Mac model, macOS version, guest distribution, guest package manifest, and pass/fail result.
+
+## Platform-equivalent scope
+
+The contract does not require a capability that Parallels itself marks unavailable for Linux on
+Apple Silicon, such as nested virtualization or booting a complete Intel Linux distribution through
+hardware virtualization. It does require supported Intel Linux *applications* inside ARM Linux.
+Windows-only Coherence is not a Linux parity requirement. A beta-only host API does not count as a
+shipping capability; Dory must either provide a qualified fallback or mark that host version
+unsupported for the parity release.
+
+## Release rule
+
+Dory must not describe its Linux experience as Parallels-equivalent while any required row is
+missing or only preview. The next public desktop release may ship when every required capability is
+either `PASS` in candidate-bound evidence or explicitly outside Parallels' own Apple-silicon Linux
+contract.
diff --git a/README.md b/README.md
index b5b5648e..845f8bcc 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ or commercial-use tier. Dory is GPL-3.0 software and stores workload data on you
|---|---|
| Docker | Docker 29 API and CLI, Buildx, BuildKit, Compose v2, registries, bind mounts, volumes, and custom networks |
| Native app | Containers, images, volumes, networks, Compose projects, Kubernetes, Linux machines, health, migration, and settings |
-| Linux machines | Full Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops plus lightweight Alpine headless VMs, with configurable resources, scoped mounts, networking, recipes, snapshots, clone, import, and export |
+| Linux machines | Full Ubuntu 24.04 LTS GNOME, Debian 13 Xfce, and Kali rolling Xfce desktops plus lightweight Alpine headless VMs, with configurable resources, scoped mounts, networking, recipes, snapshots, clone, import, and export |
| Kubernetes | One-click k3s with selectable v1.34, v1.35, and v1.36 presets plus a native resource browser |
| Migration | Transactional full or exact-selection import from Docker Desktop, OrbStack, Colima, Rancher Desktop, Podman, or another Docker-compatible socket, with a selected/verified/omitted completeness report |
| Storage | One managed `.dorydrive`, external APFS drive support, sparse growth, verified backup, restore, and safe selection |
@@ -71,6 +71,16 @@ or commercial-use tier. Dory is GPL-3.0 software and stores workload data on you
configurable, and free guest pages can be returned to macOS.
- **Linux machines beside containers.** Machines are separate VMs with their own disk, address,
resources, shell, shares, and snapshots. They are not disguised containers.
+- **Install custom arm64 Linux.** Choose an installer ISO and virtual-disk size in the desktop
+ creation flow. Dory fingerprints the exact media, reports architecture compatibility separately
+ from runtime qualification, and privately imports accepted bytes. It preserves the VM's EFI
+ identity and NVRAM; eject or reattach the ISO from the machine menu after installation. A private
+ bidirectional serial console supplies durable boot diagnostics and recovery input. Snapshots
+ preserve the disk and EFI firmware state together, including across export/import.
+- **Desktops update without recreation.** Signed Debian, Ubuntu, Kali, and graphical-kernel
+ component updates are applied to existing persistent guests. Dory creates a last-good snapshot,
+ preserves installed applications and user data, reboots and qualifies the desktop, and restores
+ the snapshot automatically if installation, boot, or qualification fails.
- **Every important setting is in the app.** Engine resources, storage, migration, automatic and custom domains,
low ports, LAN access, Auto-Idle, machine environment policy, USB, and managed defaults all have a
graphical path.
@@ -114,7 +124,7 @@ migration, diagnostics, and recovery. The signed component catalog offers:
| Linux Machines | Headless VPS-style Linux guests | Docker Core |
| Linux Desktop Runtime | Shared graphical VM kernel | Docker Core |
| Debian 13 Desktop | Debian 13 Xfce image | Linux Desktop Runtime |
-| Ubuntu 24.04 LTS Desktop | Ubuntu Xfce image | Linux Desktop Runtime |
+| Ubuntu 24.04 LTS Desktop | Canonical Ubuntu GNOME image | Linux Desktop Runtime |
| Kali Linux Desktop | Kali rolling Xfce image | Linux Desktop Runtime |
Component download and installed sizes come from the signed release catalog, not estimates. Dory
@@ -286,9 +296,9 @@ store, so push a built image to a registry or import it into the cluster before
Install Linux Machines for headless guests. Graphical guests additionally need the Linux Desktop
Runtime and the selected Debian, Ubuntu, or Kali distribution component.
-Dory Linux machines are persistent, separate VMs rather than containers. The app offers full Xfce
-desktops based on Debian 13, Ubuntu 24.04 LTS, or Kali Linux rolling for graphical and command-line
-applications. A lightweight Alpine-based headless profile remains available for services,
+Dory Linux machines are persistent, separate VMs rather than containers. The app offers Canonical's
+Ubuntu 24.04 LTS GNOME desktop plus Debian 13 and Kali Linux rolling Xfce desktops for graphical and
+command-line applications. A lightweight Alpine-based headless profile remains available for services,
terminals, test environments, and agent work. Each machine has its own disk, address, resources,
shares, and snapshots.
@@ -297,7 +307,7 @@ From the app or CLI you can:
- create, start, stop, delete, and inspect machines;
- choose Desktop Linux or Headless Linux when creating a machine in the app;
- choose 1 to 8 CPUs and 1 to 16 GiB of memory per machine;
-- configure the desktop Linux username, then use its Xfce session, embedded terminal, or an external
+- configure the desktop Linux username, then use its GNOME or Xfce session, embedded terminal, or an external
terminal selected in Settings;
- use a root shell for lightweight headless machines or `dory machine shell NAME`;
- execute structured commands with `dory machine exec NAME --json -- COMMAND`;
@@ -336,13 +346,24 @@ deletes only scheduler-owned archives and snapshots; manual snapshots are never
local backup contract, not an S3 or managed offsite service, so copy important verified archives to
independent storage as part of your normal backup policy.
-Desktop machines use native arm64 Debian 13, Ubuntu 24.04 LTS, or Kali rolling with systemd, Xfce,
-Bash, a configurable login user, and a 64 GiB thin-provisioned disk stored in the selected
+Desktop machines use native arm64 Ubuntu 24.04 LTS with GNOME or Debian 13 and Kali rolling with
+Xfce, plus systemd, Bash, a configurable login user, and a 64 GiB thin-provisioned disk stored in the selected
`.dorydrive`. Their window follows the Mac display at a true 2x framebuffer, resizes dynamically,
-and configures Xfce for Retina-sharp text and controls. They run normal graphical and command-line
+and configures the selected desktop for Retina-sharp text and controls. They run normal graphical and command-line
Linux applications and can mount the Mac home at `~/Mac` only when the user enables that share.
+Installing a newer desktop component updates matching existing guests in place; their automatic
+last-good snapshot remains available in the normal snapshot list for manual rollback.
Headless machines retain the smaller Alpine, `root`, and `/bin/sh` contract.
+The custom ISO path accepts arm64 Linux installation media on Apple Silicon. It verifies the media
+architecture before allocation, computes an SHA-256 identity, and evaluates exact media/host
+runtime evidence separately; a known-unstable tuple is blocked while unknown media is clearly
+reported as unqualified. It uses native EFI and an NVMe root disk with fsync semantics, plus VirtIO
+display, network, audio, pointer, keyboard, clipboard, and memory-balloon devices; the distribution must include drivers for the
+devices it needs. Desktop ISO machines default to four vCPUs and 4 GB of memory as balanced resource
+defaults, not as compatibility claims. This path remains preview until the real-installer
+physical-Mac release matrix passes.
+
### Machine secrets and host access
New machines do not receive arbitrary host environment variables. Settings contains an allow-list.
@@ -686,9 +707,10 @@ docker run --rm \
- **Unavailable — Intel hosts:** Apple Silicon is the only qualified host architecture. Intel support is planned for a later
release after dedicated hardware validation.
-- **Supported — Desktop Linux:** managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce arm64 profiles.
+- **Supported — Desktop Linux:** managed Ubuntu 24.04 LTS GNOME plus Debian 13 and Kali rolling Xfce arm64 profiles.
- **Supported — Headless Linux:** Alpine-based arm64 guests with an initial root `/bin/sh` login.
-- **Preview — Venus/Vulkan:** opt-in on the Apple-silicon raw-HV path. Host AI services work without it.
+- **Supported — Desktop Venus/Vulkan:** managed arm64 desktops automatically use the isolated,
+ capability-probed Venus path for Vulkan apps, with ordered fallback to classic VirGL and software.
- **Supported discovery / unavailable passthrough — USB:** host discovery is available. Attach, detach, and remembered replay are disabled
until the engine has a complete guest USB/IP RPC and verified guest-kernel support.
- **Unavailable — audio passthrough:** not part of the current release.
diff --git a/website/index.html b/website/index.html
index 1bff779e..3eb5f506 100644
--- a/website/index.html
+++ b/website/index.html
@@ -51,7 +51,7 @@
"Docker 29 engine and command line tools",
"Docker Compose v2 and Buildx",
"Local k3s Kubernetes",
- "Managed Debian, Ubuntu, and Kali Xfce desktops",
+ "Managed Ubuntu GNOME and Debian/Kali Xfce desktops",
"Retina-sharp dynamically resizing Linux display",
"Persistent headless Dory Linux servers",
"Docker runtime migration",
diff --git a/website/public/agent-guide.json b/website/public/agent-guide.json
index d91563f1..e5a57c56 100644
--- a/website/public/agent-guide.json
+++ b/website/public/agent-guide.json
@@ -194,7 +194,7 @@
"safeForAutomation": true,
"json": true,
"destructiveRequires": ["delete requires --confirm NAME", "restore requires --confirm NAME and creates a retained pre-restore snapshot", "delete-snapshot requires --confirm SNAPSHOT_ID"],
- "notes": "Requires dorydctl and Dory's daemon-managed VM machine runtime; use `dory machine exec NAME --json -- CMD` for schema dev.dory.machine.exec v1. Managed Debian, Ubuntu, and Kali Xfce machines require their matching desktop component; Alpine headless machines require linux-machines. Set DORY_LEGACY_DOCKER_MACHINES=1 only for old container-backed machine inspection."
+ "notes": "Requires dorydctl and Dory's daemon-managed VM machine runtime; use `dory machine exec NAME --json -- CMD` for schema dev.dory.machine.exec v1. Managed Ubuntu GNOME and Debian/Kali Xfce machines require their matching desktop component; Alpine headless machines require linux-machines. Set DORY_LEGACY_DOCKER_MACHINES=1 only for old container-backed machine inspection."
},
{
"id": "machine-backup",
diff --git a/website/public/docs/agents.md b/website/public/docs/agents.md
index b82f1e27..ddf4fe27 100644
--- a/website/public/docs/agents.md
+++ b/website/public/docs/agents.md
@@ -65,7 +65,7 @@ servers and should not be described as an MCP catalog or gateway.
dory machine exec dev --json -- /bin/sh -lc 'uname -a'
```
-The result uses `dev.dory.machine.exec v1` and includes command status and bounded output. Inspect each machine before choosing commands: desktop machines use the selected Debian, Ubuntu, or Kali profile with systemd, Xfce, Bash, and a configured user, while headless machines use Alpine with an initial root `/bin/sh` login.
+The result uses `dev.dory.machine.exec v1` and includes command status and bounded output. Inspect each machine before choosing commands: Ubuntu desktop machines use GNOME, Debian and Kali desktop machines use Xfce, and all three use systemd, Bash, and a configured user; headless machines use Alpine with an initial root `/bin/sh` login.
Agents may inspect verified local backup schedules without changing them:
diff --git a/website/public/docs/architecture.md b/website/public/docs/architecture.md
index 9b67a21b..443b46a0 100644
--- a/website/public/docs/architecture.md
+++ b/website/public/docs/architecture.md
@@ -29,9 +29,10 @@ Docker dataplane, selected `.dorydrive`, and userspace gvproxy network contract.
- **Supported:** Apple-silicon Docker/Compose/Buildx on macOS 14+, raw-HV on macOS 15+, Sonoma VZ
fallback, explicit external Docker sockets, dedicated agent sandbox VMs, Build Activity for
Dory-launched work, exact-selection transactional migration, verified scheduled local machine
- backups, Dory's control MCP, and host USB discovery.
-- **Preview:** Venus/Vulkan on the arm64 raw-HV path, remote SSH workspace foundations, and custom
- machine kernel/rootfs inputs within the published image contract.
+ backups, Dory's control MCP, host USB discovery, and capability-probed Venus/Vulkan for managed
+ arm64 desktop applications.
+- **Preview:** Remote SSH workspace foundations and custom machine kernel/rootfs inputs within the
+ published image contract.
- **Unavailable:** USB attach/detach/replay, audio passthrough, managed remote/offsite machine
backup, third-party MCP catalog/gateway, image-update orchestration, mDNS/multicast relay or
general L2 bridging, Intel public releases, and Windows/Linux host apps.
diff --git a/website/public/docs/compatibility.md b/website/public/docs/compatibility.md
index 156acf08..78f917ba 100644
--- a/website/public/docs/compatibility.md
+++ b/website/public/docs/compatibility.md
@@ -18,7 +18,8 @@
and a default container `nofile` limit of 65536
- Common linux/amd64 images on Apple Silicon through FEX
- k3s v1.34, v1.35, and v1.36 presets
-- Persistent arm64 Linux machines: managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops plus lightweight Alpine headless VMs, with resources, scoped mounts, network addresses, recipes, snapshots, clone, import, export, and verified scheduled local recovery bundles
+- Persistent arm64 Linux machines: managed Ubuntu 24.04 LTS GNOME and Debian 13/Kali rolling Xfce desktops plus lightweight Alpine headless VMs, with resources, scoped mounts, network addresses, recipes, snapshots, clone, import, export, verified scheduled local recovery bundles, and signed in-place desktop updates with automatic rollback
+- Hardware Vulkan in managed arm64 desktops through Dory's isolated, capability-probed Venus runtime, with automatic fallback to classic VirGL and software
- Managed `.dorydrive` storage, sparse growth, verified backup, restore, and external local APFS drives
- Transactional full or exact-selection migration from detected Docker-compatible engines, with dependency closure, rollback, source-drift rejection, and selected/verified/omitted completeness evidence
- Localhost ports, optional local domains and HTTPS, built-in low-port forwarding, custom resolver and proxy ports, and opt-in LAN access
@@ -41,7 +42,7 @@ Compatibility can vary when a client depends on another product's private paths,
## Current machine boundary
-Desktop machines run normal graphical and command-line applications with Debian 13, Ubuntu 24.04 LTS, or Kali rolling, plus glibc, systemd, Xfce, Bash, and a configurable login user. Their window uses a true 2x guest framebuffer, dynamically follows its Mac window, and applies matching Xfce scaling. Headless machines use Alpine, musl, `root`, and `/bin/sh`. Arbitrary desktop images and guest kernel modules are outside the current contract.
+Desktop machines run normal graphical and command-line applications with Ubuntu 24.04 LTS GNOME or Debian 13/Kali rolling Xfce, plus glibc, systemd, Bash, and a configurable login user. Their window uses a true 2x guest framebuffer, dynamically follows its Mac window, and applies matching desktop scaling. Headless machines use Alpine, musl, `root`, and `/bin/sh`. User-selected arm64 EFI installer ISOs are preview: Dory separates architecture preflight from exact-media/host runtime evidence, privately stages accepted media, and preserves disk, NVRAM, and install-media lifecycle. Other arbitrary desktop images and guest kernel modules are outside the current contract.
## Supported
@@ -56,7 +57,12 @@ Desktop machines run normal graphical and command-line applications with Debian
## Preview
-- In-guest Venus/Vulkan acceleration on the Apple-silicon raw-HV tier.
+- Custom arm64 Linux installation from ISO through EFI until installation, reboot, media ejection,
+ devices, and guest integration pass the exact-candidate physical-Mac matrix.
+ Ubuntu 24.04.3 and 24.04.4 are known unstable on Mac14,10/macOS build 26A5406e with the retired
+ VirtIO-block EFI profile. The current native-NVMe/fsync profile completed Ubuntu 24.04.3 install,
+ updates, ISO ejection, and persistent GNOME/Chrome boot, but a later Chromium-install guest stall
+ keeps that exact tuple unqualified.
- Remote SSH workspace foundations and custom machine kernel/rootfs inputs within their published
image and trust limits.
@@ -64,7 +70,7 @@ Desktop machines run normal graphical and command-line applications with Debian
- USB attach, detach, and replay until the guest USB/IP RPC and physical qualification exist
- Intel host builds before dedicated physical qualification
-- Desktop images beyond the managed Debian, Ubuntu, and Kali Xfce profiles
+- Complete x86_64 guest operating systems on Apple Silicon hardware virtualization
- Audio passthrough
- Managed remote/offsite machine backup or S3 backup
- Third-party MCP catalog/gateway; Dory's local MCP controls Dory only
diff --git a/website/public/docs/operations.md b/website/public/docs/operations.md
index 63ef9fa9..3165101d 100644
--- a/website/public/docs/operations.md
+++ b/website/public/docs/operations.md
@@ -39,7 +39,7 @@ Desktop distributions add the shared Linux Desktop Runtime automatically. Removi
## Linux desktops and servers
-The app separates graphical Linux Desktops from lightweight Linux Servers. A new desktop can use Debian 13, Ubuntu 24.04 LTS, or Kali rolling with Xfce, systemd, Bash, and a configurable login user. Its display uses a true 2x guest framebuffer with matching Xfce scaling and follows the Mac window as it resizes.
+The app separates graphical Linux Desktops from lightweight Linux Servers. A new desktop can use Ubuntu 24.04 LTS with Canonical's GNOME session, or Debian 13 and Kali rolling with Xfce. All three use systemd, Bash, and a configurable login user. The display uses a true 2x guest framebuffer with matching desktop scaling and follows the Mac window as it resizes.
Desktop creation also controls CPU, memory, development recipe, Mac home sharing, and scoped folders. Each desktop has a thin-provisioned 64 GiB disk in the selected Dory data drive. Headless servers use Alpine with an initial root `/bin/sh` login. Install `linux-machines` for headless servers or a matching `desktop-*` component for graphical machines.
diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt
index ad788aac..e267d10d 100644
--- a/website/public/llms-full.txt
+++ b/website/public/llms-full.txt
@@ -12,10 +12,10 @@ Telemetry: none
Dory has three distinct execution surfaces:
1. The shared container engine is one persistent Linux VM that serves the Docker API. Docker containers, images, volumes, networks, Compose, BuildKit, and k3s use this engine.
-2. Dory Linux machines are separate persistent arm64 VMs. Managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops have configurable users and Retina-sharp resizable displays; lightweight Alpine headless machines retain root /bin/sh. Each has its own disk, address, resources, scoped mounts, and snapshots.
+2. Dory Linux machines are separate persistent arm64 VMs. Managed Ubuntu 24.04 LTS GNOME and Debian 13/Kali rolling Xfce desktops have configurable users, Retina-sharp resizable displays, and signed in-place updates with automatic last-good snapshot rollback; lightweight Alpine headless machines retain root /bin/sh. Each has its own disk, address, resources, scoped mounts, and snapshots.
3. Agent sandboxes are supported, dedicated Dory Linux VMs created for bounded non-root commands. They have no ambient host files, network, or credentials and are deleted by default.
-Do not describe a Dory Linux machine as a container. Inspect its display mode and identity: desktop machines use the selected managed Debian, Ubuntu, or Kali profile with systemd, Xfce, Bash, and a configurable user; headless machines are Alpine-based, root, and /bin/sh.
+Do not describe a Dory Linux machine as a container. Inspect its display mode and identity: Ubuntu desktop machines use GNOME, Debian and Kali desktop machines use Xfce, and all three use systemd, Bash, and a configurable user; headless machines are Alpine-based, root, and /bin/sh.
## Installation and host tools
@@ -219,7 +219,7 @@ and deliberately omitted objects. Dory never deletes source data.
dory machine backup run NAME
dory machine backup remove NAME
-The app has separate Linux Desktops and Linux Servers areas. Desktop creation selects Debian 13, Ubuntu 24.04 LTS, or Kali rolling, a login user, CPU, memory, optional development recipe, Mac home sharing, and scoped mounts. Desktop windows render at a true 2x guest framebuffer, follow window resizing, and configure matching Xfce scaling. A new graphical machine requires its matching `desktop-*` component; a headless machine requires `linux-machines`.
+The app has separate Linux Desktops and Linux Servers areas. Desktop creation selects Debian 13 Xfce, Ubuntu 24.04 LTS GNOME, or Kali rolling Xfce, a login user, CPU, memory, optional development recipe, Mac home sharing, and scoped mounts. Desktop windows render at a true 2x guest framebuffer, follow window resizing, and configure matching desktop scaling. A new graphical machine requires its matching `desktop-*` component; a headless machine requires `linux-machines`.
The machine command also supports clone, import, export, and snapshot deletion. Destructive Dory
commands repeat the exact target with `--confirm`; restore first creates and reports a retained
diff --git a/website/public/llms.txt b/website/public/llms.txt
index c632d80a..95bc4b6f 100644
--- a/website/public/llms.txt
+++ b/website/public/llms.txt
@@ -2,7 +2,7 @@
> Dory is a native, open-source macOS runtime for Docker, Compose, Kubernetes, full Linux desktops, persistent servers, local networking, migration, diagnostics, and coding-agent automation.
-Dory 0.4.5 supports Apple Silicon on macOS 14 or later and bundles its own Docker engine and command-line tools. Its optional Linux-machine profiles are managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops with configurable users plus lightweight Alpine headless VMs. Intel hosts and desktop distributions beyond those three are not part of the current release.
+Dory supports Apple Silicon on macOS 14 or later and bundles its own Docker engine and command-line tools. Its optional Linux-machine profiles are managed Ubuntu 24.04 LTS GNOME and Debian 13/Kali rolling Xfce desktops with configurable users, signed in-place updates, and automatic last-good snapshot rollback, plus lightweight Alpine headless VMs. Intel hosts and desktop distributions beyond those three are not part of the current release.
Dory ships one Docker Core app. A signed catalog adds removable Kubernetes, Linux Machines, Linux Desktop Runtime, Debian, Ubuntu, and Kali components to the selected Dory data drive. The website verifies the catalog and shows the exact total before download. Removing a component reclaims its payload without deleting workload data.
diff --git a/website/src/App.tsx b/website/src/App.tsx
index b83595b4..4fc49350 100644
--- a/website/src/App.tsx
+++ b/website/src/App.tsx
@@ -287,21 +287,21 @@ const surfaces: Array<{
icon: ServerStackIcon,
label: 'Real Linux machines',
title: 'A full Linux desktop beside your containers.',
- copy: 'Create a managed Debian, Ubuntu, or Kali Xfce desktop for graphical and command-line apps, or choose lightweight headless Linux for services and agents.',
+ copy: 'Create a managed Ubuntu GNOME or Debian/Kali Xfce desktop for graphical and command-line apps, or choose lightweight headless Linux for services and agents.',
facts: ['Three desktop distributions', 'Scoped Mac folders', 'Snapshot and clone'],
command: 'dory machine shell dev',
},
]
const desktopDistros = [
- ['Debian', '13', 'Stable, clean desktop for everyday Linux and development', '#a80030'],
- ['Ubuntu', '24.04 LTS', 'A familiar long-term-support base for development and daily work', '#e95420'],
- ['Kali Linux', 'Rolling', 'A focused security lab backed by Kali\'s official rolling repository', '#367bf0'],
+ ['Debian', '13', 'Xfce', 'Stable, clean desktop for everyday Linux and development', '#a80030'],
+ ['Ubuntu', '24.04 LTS', 'GNOME', 'Canonical\'s familiar long-term-support desktop for development and daily work', '#e95420'],
+ ['Kali Linux', 'Rolling', 'Xfce', 'A focused security lab backed by Kali\'s official rolling repository', '#367bf0'],
]
const desktopCapabilities = [
- ['Real Linux guest', 'Native arm64, systemd, Xfce, Bash, apt, and normal graphical or command-line applications'],
- ['Retina display', 'A true 2x guest framebuffer follows the window and keeps the Xfce desktop sharp as it resizes'],
+ ['Real Linux guest', 'Native arm64, systemd, GNOME or Xfce, Bash, apt, and normal graphical or command-line applications'],
+ ['Retina display', 'A true 2x guest framebuffer follows the window and keeps the selected desktop sharp as it resizes'],
['Your identity', 'Choose the Linux username, CPU, memory, development recipe, and only the Mac folders to share'],
['Persistent and recoverable', 'A thin 64 GiB disk lives in the selected .dorydrive with snapshots, clone, import, and export'],
]
@@ -382,7 +382,7 @@ const faqs = [
},
{
question: 'Are Linux machines full desktop VMs?',
- answer: 'Yes on Apple Silicon. Choose managed Debian 13, Ubuntu 24.04 LTS, or Kali rolling with systemd, Xfce, Bash, a configurable user, a Retina-sharp resizable display, and a 64 GiB thin-provisioned disk. Lightweight Alpine headless machines remain available for terminal and service workloads.',
+ answer: 'Yes on Apple Silicon. Choose Ubuntu 24.04 LTS with Canonical\'s GNOME session, or Debian 13 and Kali rolling with Xfce. Each has systemd, Bash, a configurable user, a Retina-sharp resizable display, and a 64 GiB thin-provisioned disk. Signed component updates preserve the guest and keep a last-good snapshot for automatic rollback. Lightweight Alpine headless machines remain available for terminal and service workloads.',
},
{
question: 'Which Dory components should I install?',
@@ -399,9 +399,9 @@ const faqs = [
]
const releaseStates = [
- ['Supported', 'Docker, Compose, Kubernetes, Linux desktops and servers, migration, storage, networking, health, MCP, policy-enforced sandbox VMs, and USB discovery'],
- ['Preview', 'In-guest Venus/Vulkan, remote SSH workspace foundations, and bounded custom machine images'],
- ['Unavailable', 'USB passthrough, Intel host releases, desktop images beyond Debian/Ubuntu/Kali, and audio passthrough'],
+ ['Supported', 'Docker, Compose, Kubernetes, hardware-accelerated Linux desktops and servers, migration, storage, networking, health, MCP, policy-enforced sandbox VMs, and USB discovery'],
+ ['Preview', 'Remote SSH workspace foundations and bounded custom machine images'],
+ ['Unavailable', 'USB passthrough, Intel host releases, and desktop images beyond Debian/Ubuntu/Kali'],
]
type DemoView = 'containers' | 'kubernetes' | 'machines'
@@ -528,7 +528,7 @@ function KubernetesDemo() {
function MachinesDemo({ tick }: { tick: number }) {
const machines = [
- { name: 'workbench', status: 'Running', cpu: 8.6 + (tick % 4), memory: '2.4 GB', address: '192.168.127.11', shell: 'augustus · /bin/bash', distro: 'Ubuntu 24.04 LTS · Xfce · arm64' },
+ { name: 'workbench', status: 'Running', cpu: 8.6 + (tick % 4), memory: '2.4 GB', address: '192.168.127.11', shell: 'augustus · /bin/bash', distro: 'Ubuntu 24.04 LTS · GNOME · arm64' },
{ name: 'security-lab', status: 'Stopped', cpu: 0, memory: 'Not running', address: 'security-lab.dory.local', shell: 'analyst · /bin/bash', distro: 'Kali Linux Rolling · Xfce · arm64' },
]
return (
@@ -853,10 +853,10 @@ function App() {
distribution, login user, resources, development tools, and only the Mac folders it
should see. Then open its desktop or terminal without leaving Dory.
- {desktopDistros.map(([name, version, copy, color]) => (
+ {desktopDistros.map(([name, version, desktop, copy, color]) => (
{name.slice(0, 1)}
- {name}
{version} · Xfce · arm64{copy}
+ {name}
{version} · {desktop} · arm64{copy}
))}
From 7467ecd46aa67ee1915df4850785bf4a793c2f7a Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 11:42:04 +0000
Subject: [PATCH 010/338] feat(workspace): add durable definition repository
---
.../DorydKit/DoryWorkspaceRepository.swift | 464 ++++++++++++++++++
.../DoryWorkspaceRepositoryTests.swift | 215 ++++++++
2 files changed, 679 insertions(+)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
new file mode 100644
index 00000000..56d64cce
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
@@ -0,0 +1,464 @@
+import CryptoKit
+import Darwin
+import DoryOperations
+import Foundation
+
+public enum DoryWorkspaceRepositoryError: Error, Sendable, Equatable, CustomStringConvertible {
+ case invalidIdentifier(String)
+ case invalidDefinition([DoryVMDefinitionValidationIssue])
+ case workspaceExists(String)
+ case workspaceNotFound(String)
+ case staleRevision(expected: UInt64, actual: UInt64)
+ case invalidRevision(expected: UInt64, actual: UInt64)
+ case identityChanged(String)
+ case legacyAuthorityRequired(String)
+ case staleLegacyProjection(String)
+ case invalidRecord(String)
+ case filesystem(String)
+
+ public var description: String {
+ switch self {
+ case let .invalidIdentifier(id):
+ return "invalid workspace identifier: \(id)"
+ case let .invalidDefinition(issues):
+ return "invalid workspace definition: "
+ + issues.map { "\($0.code.rawValue) at \($0.field)" }.joined(separator: ", ")
+ case let .workspaceExists(id):
+ return "workspace already exists: \(id)"
+ case let .workspaceNotFound(id):
+ return "workspace does not exist: \(id)"
+ case let .staleRevision(expected, actual):
+ return "stale workspace revision: expected \(expected), found \(actual)"
+ case let .invalidRevision(expected, actual):
+ return "invalid replacement workspace revision: expected \(expected), found \(actual)"
+ case let .identityChanged(id):
+ return "workspace identity cannot change during replacement: \(id)"
+ case let .legacyAuthorityRequired(id):
+ return "workspace \(id) is a legacy projection and requires authoritative legacy bytes"
+ case let .staleLegacyProjection(id):
+ return "workspace \(id) does not match the authoritative legacy configuration"
+ case let .invalidRecord(path):
+ return "invalid workspace repository record: \(path)"
+ case let .filesystem(message):
+ return message
+ }
+ }
+}
+
+/// One durable desired-state record.
+///
+/// During migration, `legacyConfigurationSHA256` binds the v2 definition to the exact canonical
+/// `DoryMachineConfiguration` bytes that remain authoritative. Once all consumers use the v2
+/// contract, authoritative records omit this digest and use optimistic lifecycle revisions.
+public struct DoryWorkspaceRepositoryRecord: Codable, Sendable, Equatable {
+ public static let schemaVersion: UInt16 = 1
+
+ public let schemaVersion: UInt16
+ public let legacyConfigurationSHA256: String?
+ public let definition: DoryVirtualMachineDefinition
+
+ public init(
+ definition: DoryVirtualMachineDefinition,
+ legacyConfigurationSHA256: String? = nil
+ ) {
+ schemaVersion = Self.schemaVersion
+ self.legacyConfigurationSHA256 = legacyConfigurationSHA256
+ self.definition = definition
+ }
+}
+
+/// Crash-safe persistence for versioned workspace desired state.
+///
+/// Records live beside the existing machine artifacts, use owner-only files, reject links, and
+/// publish through a fully written and fsynced temporary file. The repository never persists host
+/// paths or credentials because `DoryVirtualMachineDefinition` contains resolver references only.
+public final class DoryWorkspaceRepository: @unchecked Sendable {
+ public static let recordFileName = "workspace-v2.json"
+
+ private static let temporaryPrefix = ".workspace-v2."
+ private static let maximumRecordBytes = 16 * 1_024 * 1_024
+
+ public let root: String
+ private let lock = NSLock()
+
+ public init(root: String) {
+ self.root = URL(fileURLWithPath: root).standardizedFileURL.path
+ }
+
+ public func create(_ definition: DoryVirtualMachineDefinition) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try Self.validate(definition)
+ guard definition.lifecycle.revision == 1 else {
+ throw DoryWorkspaceRepositoryError.invalidRevision(
+ expected: 1,
+ actual: definition.lifecycle.revision
+ )
+ }
+ let path = try prepareWorkspaceDirectory(id: definition.identity.id)
+ + "/\(Self.recordFileName)"
+ guard !Self.pathExists(path) else {
+ throw DoryWorkspaceRepositoryError.workspaceExists(definition.identity.id)
+ }
+ try publish(
+ DoryWorkspaceRepositoryRecord(definition: definition),
+ path: path,
+ replacing: false
+ )
+ }
+
+ public func replace(
+ _ definition: DoryVirtualMachineDefinition,
+ expectedRevision: UInt64
+ ) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try Self.validate(definition)
+ let current = try readRecordUnlocked(id: definition.identity.id)
+ guard current.legacyConfigurationSHA256 == nil else {
+ throw DoryWorkspaceRepositoryError.legacyAuthorityRequired(definition.identity.id)
+ }
+ guard current.definition.lifecycle.revision == expectedRevision else {
+ throw DoryWorkspaceRepositoryError.staleRevision(
+ expected: expectedRevision,
+ actual: current.definition.lifecycle.revision
+ )
+ }
+ guard expectedRevision < UInt64.max,
+ definition.lifecycle.revision == expectedRevision + 1 else {
+ throw DoryWorkspaceRepositoryError.invalidRevision(
+ expected: expectedRevision == UInt64.max ? UInt64.max : expectedRevision + 1,
+ actual: definition.lifecycle.revision
+ )
+ }
+ guard definition.identity.id == current.definition.identity.id,
+ definition.lifecycle.createdAtUnixMilliseconds
+ == current.definition.lifecycle.createdAtUnixMilliseconds else {
+ throw DoryWorkspaceRepositoryError.identityChanged(definition.identity.id)
+ }
+ let path = recordPath(id: definition.identity.id)
+ try publish(
+ DoryWorkspaceRepositoryRecord(definition: definition),
+ path: path,
+ replacing: true
+ )
+ }
+
+ /// Publish a deterministic v2 projection while legacy machine metadata remains authoritative.
+ /// A crash on either side of the two-file migration is detected by the bound digest and can be
+ /// repaired by projecting the authoritative legacy record again.
+ public func publishLegacyProjection(
+ _ definition: DoryVirtualMachineDefinition,
+ authoritativeLegacyData: Data
+ ) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try Self.validate(definition)
+ guard !authoritativeLegacyData.isEmpty else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(definition.identity.id)
+ }
+ let directory = try prepareWorkspaceDirectory(id: definition.identity.id)
+ let record = DoryWorkspaceRepositoryRecord(
+ definition: definition,
+ legacyConfigurationSHA256: Self.sha256(authoritativeLegacyData)
+ )
+ try publish(
+ record,
+ path: directory + "/\(Self.recordFileName)",
+ replacing: Self.pathExists(directory + "/\(Self.recordFileName)")
+ )
+ }
+
+ public func read(id: String) throws -> DoryVirtualMachineDefinition {
+ lock.lock()
+ defer { lock.unlock() }
+ let record = try readRecordUnlocked(id: id)
+ guard record.legacyConfigurationSHA256 == nil else {
+ throw DoryWorkspaceRepositoryError.legacyAuthorityRequired(id)
+ }
+ return record.definition
+ }
+
+ public func readLegacyProjection(
+ id: String,
+ authoritativeLegacyData: Data
+ ) throws -> DoryVirtualMachineDefinition {
+ lock.lock()
+ defer { lock.unlock() }
+ let record = try readRecordUnlocked(id: id)
+ guard let expectedDigest = record.legacyConfigurationSHA256 else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(recordPath(id: id))
+ }
+ guard expectedDigest == Self.sha256(authoritativeLegacyData) else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(id)
+ }
+ return record.definition
+ }
+
+ public func remove(id: String) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try Self.validateIdentifier(id)
+ let path = recordPath(id: id)
+ guard Self.pathExists(path) else {
+ throw DoryWorkspaceRepositoryError.workspaceNotFound(id)
+ }
+ _ = try Self.secureRead(path: path)
+ guard unlink(path) == 0 else {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "remove workspace record at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ try Self.fsyncDirectory(workspaceDirectory(id: id))
+ }
+
+ private func readRecordUnlocked(id: String) throws -> DoryWorkspaceRepositoryRecord {
+ try Self.validateIdentifier(id)
+ let path = recordPath(id: id)
+ guard Self.pathExists(path) else {
+ throw DoryWorkspaceRepositoryError.workspaceNotFound(id)
+ }
+ let data = try Self.secureRead(path: path)
+ let decoder = JSONDecoder()
+ guard let record = try? decoder.decode(DoryWorkspaceRepositoryRecord.self, from: data),
+ record.schemaVersion == DoryWorkspaceRepositoryRecord.schemaVersion,
+ record.definition.identity.id == id,
+ record.definition.validate().isEmpty,
+ record.legacyConfigurationSHA256.map(Self.isSHA256) ?? true else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(path)
+ }
+ return record
+ }
+
+ private func prepareWorkspaceDirectory(id: String) throws -> String {
+ try Self.validateIdentifier(id)
+ try Self.preparePrivateDirectory(root)
+ let directory = workspaceDirectory(id: id)
+ try Self.preparePrivateDirectory(directory)
+ return directory
+ }
+
+ private func workspaceDirectory(id: String) -> String {
+ root + "/" + id
+ }
+
+ private func recordPath(id: String) -> String {
+ workspaceDirectory(id: id) + "/" + Self.recordFileName
+ }
+
+ private func publish(
+ _ record: DoryWorkspaceRepositoryRecord,
+ path: String,
+ replacing: Bool
+ ) throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data: Data
+ do {
+ data = try encoder.encode(record)
+ } catch {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "encode workspace record for \(record.definition.identity.id): \(error)"
+ )
+ }
+ guard !data.isEmpty, data.count <= Self.maximumRecordBytes else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(path)
+ }
+
+ let directory = URL(fileURLWithPath: path).deletingLastPathComponent().path
+ let temporaryPath = directory + "/\(Self.temporaryPrefix)\(UUID().uuidString.lowercased())"
+ let descriptor = open(
+ temporaryPath,
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
+ mode_t(0o600)
+ )
+ guard descriptor >= 0 else {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "create workspace temporary record at \(temporaryPath): "
+ + String(cString: strerror(errno))
+ )
+ }
+ var shouldRemoveTemporary = true
+ defer {
+ _ = close(descriptor)
+ if shouldRemoveTemporary { _ = unlink(temporaryPath) }
+ }
+ do {
+ try data.withUnsafeBytes { bytes in
+ guard let base = bytes.baseAddress else { return }
+ var written = 0
+ while written < data.count {
+ let count = Darwin.write(
+ descriptor,
+ base.advanced(by: written),
+ data.count - written
+ )
+ if count < 0, errno == EINTR { continue }
+ guard count > 0 else {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "write workspace temporary record at \(temporaryPath): "
+ + String(cString: strerror(errno))
+ )
+ }
+ written += count
+ }
+ }
+ guard fsync(descriptor) == 0 else {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "fsync workspace temporary record at \(temporaryPath): "
+ + String(cString: strerror(errno))
+ )
+ }
+ if replacing {
+ guard rename(temporaryPath, path) == 0 else {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "publish workspace record at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ } else {
+ guard link(temporaryPath, path) == 0 else {
+ if errno == EEXIST {
+ throw DoryWorkspaceRepositoryError.workspaceExists(
+ record.definition.identity.id
+ )
+ }
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "publish workspace record at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ guard unlink(temporaryPath) == 0 else {
+ _ = unlink(path)
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "remove workspace temporary record at \(temporaryPath): "
+ + String(cString: strerror(errno))
+ )
+ }
+ }
+ shouldRemoveTemporary = false
+ try Self.fsyncDirectory(directory)
+ } catch let error as DoryWorkspaceRepositoryError {
+ throw error
+ } catch {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "publish workspace record at \(path): \(error)"
+ )
+ }
+ }
+
+ private static func validate(_ definition: DoryVirtualMachineDefinition) throws {
+ let issues = definition.validate()
+ guard issues.isEmpty else {
+ throw DoryWorkspaceRepositoryError.invalidDefinition(issues)
+ }
+ }
+
+ private static func validateIdentifier(_ id: String) throws {
+ let bytes = Array(id.utf8)
+ guard (1...63).contains(bytes.count), isAlphaNumeric(bytes[0]),
+ bytes.dropFirst().allSatisfy({ byte in
+ isAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45
+ }) else {
+ throw DoryWorkspaceRepositoryError.invalidIdentifier(id)
+ }
+ }
+
+ private static func isAlphaNumeric(_ byte: UInt8) -> Bool {
+ (byte >= 48 && byte <= 57)
+ || (byte >= 65 && byte <= 90)
+ || (byte >= 97 && byte <= 122)
+ }
+
+ private static func preparePrivateDirectory(_ path: String) throws {
+ if !pathExists(path) {
+ do {
+ try FileManager.default.createDirectory(
+ atPath: path,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ } catch {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "create workspace directory at \(path): \(error)"
+ )
+ }
+ _ = chmod(path, mode_t(0o700))
+ }
+ var info = stat()
+ guard lstat(path, &info) == 0,
+ (info.st_mode & S_IFMT) == S_IFDIR,
+ info.st_uid == getuid(),
+ (info.st_mode & 0o077) == 0 else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(path)
+ }
+ }
+
+ private static func secureRead(path: String) throws -> Data {
+ var before = stat()
+ guard lstat(path, &before) == 0,
+ (before.st_mode & S_IFMT) == S_IFREG,
+ before.st_uid == getuid(),
+ before.st_nlink == 1,
+ (before.st_mode & 0o077) == 0,
+ before.st_size > 0,
+ before.st_size <= maximumRecordBytes else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(path)
+ }
+ let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(path)
+ }
+ defer { _ = close(descriptor) }
+ var after = stat()
+ guard fstat(descriptor, &after) == 0,
+ before.st_dev == after.st_dev,
+ before.st_ino == after.st_ino,
+ after.st_size == before.st_size else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(path)
+ }
+ let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false)
+ do {
+ let data = try handle.read(upToCount: maximumRecordBytes + 1) ?? Data()
+ guard !data.isEmpty, data.count <= maximumRecordBytes,
+ data.count == Int(after.st_size) else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(path)
+ }
+ return data
+ } catch let error as DoryWorkspaceRepositoryError {
+ throw error
+ } catch {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "read workspace record at \(path): \(error)"
+ )
+ }
+ }
+
+ private static func fsyncDirectory(_ path: String) throws {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC)
+ guard descriptor >= 0 else {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "open workspace directory at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ defer { _ = close(descriptor) }
+ guard fsync(descriptor) == 0 else {
+ throw DoryWorkspaceRepositoryError.filesystem(
+ "fsync workspace directory at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ }
+
+ private static func pathExists(_ path: String) -> Bool {
+ var info = stat()
+ return lstat(path, &info) == 0
+ }
+
+ private static func sha256(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy { byte in
+ (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
+ }
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift
new file mode 100644
index 00000000..403f3120
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift
@@ -0,0 +1,215 @@
+import Darwin
+import DoryOperations
+@testable import DorydKit
+import Foundation
+import Testing
+
+@Suite("Workspace repository")
+struct DoryWorkspaceRepositoryTests {
+ @Test("authoritative records create read and replace with optimistic revisions")
+ func authoritativeLifecycle() throws {
+ try withRepository { repository, _ in
+ let initial = definition()
+ try repository.create(initial)
+ #expect(try repository.read(id: initial.identity.id) == initial)
+
+ var replacement = initial
+ replacement.identity.name = "Renamed workspace"
+ replacement.lifecycle.revision = 2
+ replacement.lifecycle.updatedAtUnixMilliseconds += 1
+ try repository.replace(replacement, expectedRevision: 1)
+ #expect(try repository.read(id: initial.identity.id) == replacement)
+
+ #expect(throws: DoryWorkspaceRepositoryError.staleRevision(expected: 1, actual: 2)) {
+ try repository.replace(replacement, expectedRevision: 1)
+ }
+ }
+ }
+
+ @Test("legacy projections are bound to exact authoritative bytes and can be reconciled")
+ func legacyProjectionBinding() throws {
+ try withRepository { repository, _ in
+ let firstLegacy = Data("legacy-v1".utf8)
+ let secondLegacy = Data("legacy-v2".utf8)
+ let workspace = definition()
+
+ try repository.publishLegacyProjection(
+ workspace,
+ authoritativeLegacyData: firstLegacy
+ )
+ #expect(
+ try repository.readLegacyProjection(
+ id: workspace.identity.id,
+ authoritativeLegacyData: firstLegacy
+ ) == workspace
+ )
+ #expect(throws: DoryWorkspaceRepositoryError.staleLegacyProjection(workspace.identity.id)) {
+ _ = try repository.readLegacyProjection(
+ id: workspace.identity.id,
+ authoritativeLegacyData: secondLegacy
+ )
+ }
+ #expect(throws: DoryWorkspaceRepositoryError.legacyAuthorityRequired(workspace.identity.id)) {
+ _ = try repository.read(id: workspace.identity.id)
+ }
+
+ var reconciled = workspace
+ reconciled.lifecycle.revision = 2
+ reconciled.lifecycle.updatedAtUnixMilliseconds += 1
+ try repository.publishLegacyProjection(
+ reconciled,
+ authoritativeLegacyData: secondLegacy
+ )
+ #expect(
+ try repository.readLegacyProjection(
+ id: workspace.identity.id,
+ authoritativeLegacyData: secondLegacy
+ ) == reconciled
+ )
+ }
+ }
+
+ @Test("invalid definitions and revision or identity changes fail before publication")
+ func validationAndRevisionSafety() throws {
+ try withRepository { repository, root in
+ var invalid = definition()
+ invalid.identity.id = "../escape"
+ #expect(throws: DoryWorkspaceRepositoryError.self) {
+ try repository.create(invalid)
+ }
+ #expect(!FileManager.default.fileExists(atPath: root + "/escape"))
+
+ let initial = definition()
+ try repository.create(initial)
+ var skippedRevision = initial
+ skippedRevision.lifecycle.revision = 3
+ skippedRevision.lifecycle.updatedAtUnixMilliseconds += 1
+ #expect(throws: DoryWorkspaceRepositoryError.invalidRevision(expected: 2, actual: 3)) {
+ try repository.replace(skippedRevision, expectedRevision: 1)
+ }
+
+ var changedCreation = initial
+ changedCreation.lifecycle.revision = 2
+ changedCreation.lifecycle.createdAtUnixMilliseconds -= 1
+ changedCreation.lifecycle.updatedAtUnixMilliseconds += 1
+ #expect(throws: DoryWorkspaceRepositoryError.identityChanged(initial.identity.id)) {
+ try repository.replace(changedCreation, expectedRevision: 1)
+ }
+ }
+ }
+
+ @Test("repository rejects symlink hard-link public and oversized records")
+ func hostileRecordTypes() throws {
+ try withRepository { repository, root in
+ let workspace = definition()
+ try repository.create(workspace)
+ let directory = root + "/" + workspace.identity.id
+ let record = directory + "/" + DoryWorkspaceRepository.recordFileName
+ let saved = directory + "/saved"
+ try FileManager.default.moveItem(atPath: record, toPath: saved)
+
+ #expect(symlink(saved, record) == 0)
+ #expect(throws: DoryWorkspaceRepositoryError.self) {
+ _ = try repository.read(id: workspace.identity.id)
+ }
+ #expect(unlink(record) == 0)
+
+ #expect(link(saved, record) == 0)
+ #expect(throws: DoryWorkspaceRepositoryError.self) {
+ _ = try repository.read(id: workspace.identity.id)
+ }
+ #expect(unlink(record) == 0)
+
+ try FileManager.default.copyItem(atPath: saved, toPath: record)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o644],
+ ofItemAtPath: record
+ )
+ #expect(throws: DoryWorkspaceRepositoryError.self) {
+ _ = try repository.read(id: workspace.identity.id)
+ }
+ try FileManager.default.removeItem(atPath: record)
+
+ #expect(FileManager.default.createFile(atPath: record, contents: Data([0])))
+ let descriptor = open(record, O_WRONLY | O_CLOEXEC)
+ #expect(descriptor >= 0)
+ #expect(ftruncate(descriptor, off_t(17 * 1_024 * 1_024)) == 0)
+ _ = close(descriptor)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: record
+ )
+ #expect(throws: DoryWorkspaceRepositoryError.self) {
+ _ = try repository.read(id: workspace.identity.id)
+ }
+ }
+ }
+
+ @Test("remove rejects unsafe identifiers and durably removes one record")
+ func removeSafety() throws {
+ try withRepository { repository, root in
+ let workspace = definition()
+ try repository.create(workspace)
+ try repository.remove(id: workspace.identity.id)
+ #expect(
+ !FileManager.default.fileExists(
+ atPath: root + "/" + workspace.identity.id + "/"
+ + DoryWorkspaceRepository.recordFileName
+ )
+ )
+ #expect(throws: DoryWorkspaceRepositoryError.invalidIdentifier("../escape")) {
+ try repository.remove(id: "../escape")
+ }
+ }
+ }
+
+ private func withRepository(
+ _ body: (DoryWorkspaceRepository, String) throws -> Void
+ ) throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-workspace-repository-\(UUID().uuidString)")
+ .path
+ defer { try? FileManager.default.removeItem(atPath: root) }
+ try body(DoryWorkspaceRepository(root: root), root)
+ }
+
+ private func definition() -> DoryVirtualMachineDefinition {
+ let disk = DoryVMResolverReference(namespace: "machine", identifier: "disk-main")
+ return DoryVirtualMachineDefinition(
+ identity: DoryVirtualMachineIdentity(id: "workspace-one", name: "Workspace One"),
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ workload: .desktop,
+ boot: DoryVMBootConfiguration(
+ phase: .normal,
+ devices: [DoryVMBootMediaReference(
+ id: "system",
+ role: .system,
+ kind: .virtualDisk,
+ source: .userProvided,
+ artifact: disk,
+ removable: false
+ )],
+ order: ["system"]
+ ),
+ graphics: DoryVMGraphicsPolicy(
+ acceptableLevels: [.hardwareAccelerated3D, .software]
+ ),
+ resources: DoryVMResourceRequest(
+ virtualCPUCount: 4,
+ memoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ diskBytes: 64 * 1_024 * 1_024 * 1_024
+ ),
+ storage: [DoryVMStorageAttachment(
+ id: "system",
+ role: .system,
+ artifact: disk,
+ capacityBytes: 64 * 1_024 * 1_024 * 1_024
+ )],
+ lifecycle: DoryVMLifecycleMetadata(
+ revision: 1,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000
+ )
+ )
+ }
+}
From c9af373ea83c5ed3712d9825fe87cc019295a51d Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 11:45:03 +0000
Subject: [PATCH 011/338] feat(vm): add backend registry and Linux adapters
---
.../LinuxMachineBackendAdapters.swift | 438 ++++++++++++++++++
.../Sources/DorydKit/MachineBackend.swift | 363 +++++++++++++++
.../DorydKitTests/MachineBackendTests.swift | 343 ++++++++++++++
3 files changed, 1144 insertions(+)
create mode 100644 dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
create mode 100644 dory-core-swift/Sources/DorydKit/MachineBackend.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
new file mode 100644
index 00000000..c01f4890
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
@@ -0,0 +1,438 @@
+import DoryOperations
+import Foundation
+
+public typealias MachineBackendLifecycleHandler = @Sendable (String) throws -> MachineBackendRuntimeObservation
+
+/// Hooks implemented by the existing machine launcher. Keeping them injectable lets the seam be
+/// qualified before MachineManager starts consuming BackendRegistry.
+public struct MachineBackendCompatibilityOperations: Sendable {
+ public var start: MachineBackendLifecycleHandler
+ public var stop: MachineBackendLifecycleHandler
+
+ public init(
+ start: @escaping MachineBackendLifecycleHandler,
+ stop: @escaping MachineBackendLifecycleHandler
+ ) {
+ self.start = start
+ self.stop = stop
+ }
+
+ /// Compatibility bridge to today's launch path. Both Linux adapters may share this bridge;
+ /// their validated backend plans determine which adapter is allowed to invoke it.
+ public init(machineManager: MachineManager) {
+ self.init(
+ start: { id in
+ MachineBackendRuntimeObservation(try machineManager.start(id: id))
+ },
+ stop: { id in
+ MachineBackendRuntimeObservation(try machineManager.stop(id: id))
+ }
+ )
+ }
+}
+
+public extension MachineBackendRuntimeObservation {
+ init(_ status: DoryMachineStatus) {
+ self.init(
+ machineID: status.id,
+ state: MachineBackendRuntimeState(status.state),
+ processIdentifier: status.pid,
+ failureMessage: status.lastError
+ )
+ }
+}
+
+private extension MachineBackendRuntimeState {
+ init(_ state: DoryMachineState) {
+ switch state {
+ case .created: self = .created
+ case .starting: self = .starting
+ case .running: self = .running
+ case .stopped: self = .stopped
+ case .failed: self = .failed
+ }
+ }
+}
+
+private final class LinuxMachineBackendAdapterCore: @unchecked Sendable {
+ typealias MachineValidator = @Sendable (
+ DoryMachineConfiguration,
+ DoryVirtualMachineCapabilityDescriptor
+ ) -> String?
+
+ let descriptor: MachineBackendDescriptor
+ private let executablePath: String?
+ private let executableIsAvailable: @Sendable (String) -> Bool
+ private let operations: MachineBackendCompatibilityOperations
+ private let validateMachine: MachineValidator
+
+ init(
+ descriptor: MachineBackendDescriptor,
+ executablePath: String?,
+ executableIsAvailable: @escaping @Sendable (String) -> Bool,
+ operations: MachineBackendCompatibilityOperations,
+ validateMachine: @escaping MachineValidator
+ ) {
+ self.descriptor = descriptor
+ self.executablePath = executablePath
+ self.executableIsAvailable = executableIsAvailable
+ self.operations = operations
+ self.validateMachine = validateMachine
+ }
+
+ func probe() -> MachineBackendProbeResult {
+ guard let executablePath, !executablePath.isEmpty else {
+ return unavailableProbe(
+ code: .componentNotConfigured,
+ message: "The backend helper path is not configured."
+ )
+ }
+ guard executableIsAvailable(executablePath) else {
+ return unavailableProbe(
+ code: .componentUnavailable,
+ message: "The configured backend helper is unavailable or not executable."
+ )
+ }
+ return MachineBackendProbeResult(descriptor: descriptor, state: .available)
+ }
+
+ func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult {
+ let currentProbe = probe()
+ guard currentProbe.isAvailable else {
+ return MachineBackendPlanResult(
+ plan: nil,
+ probe: currentProbe,
+ failure: currentProbe.failure
+ )
+ }
+ guard request.capabilityPlan.failure == nil,
+ let capability = request.capabilityPlan.selectedDescriptor else {
+ return failedPlan(
+ probe: currentProbe,
+ code: .capabilityPlanRejected,
+ message: "The product capability planner did not select a backend."
+ )
+ }
+ guard request.capabilityPlan.evaluatedDescriptors.contains(capability),
+ capability.schemaVersion == DoryVirtualMachineCapabilityDescriptor.currentSchemaVersion,
+ capability.evaluatorVersion == DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ capability.availability.isUsable,
+ capability.resolvedDevices == capability.request.devices else {
+ return failedPlan(
+ probe: currentProbe,
+ code: .capabilitySelectionInvalid,
+ message: "The selected capability is not a complete usable planner result."
+ )
+ }
+ guard capability.request.backend == descriptor.identity else {
+ return failedPlan(
+ probe: currentProbe,
+ code: .backendIdentityMismatch,
+ message: "The selected capability belongs to a different backend."
+ )
+ }
+ guard descriptor.guestFamilies.contains(capability.request.guest.family),
+ descriptor.guestArchitectures.contains(capability.request.guest.architecture) else {
+ return failedPlan(
+ probe: currentProbe,
+ code: .guestUnsupported,
+ message: "The selected guest is not implemented by this backend adapter."
+ )
+ }
+ guard descriptor.bootMediaKinds.contains(capability.request.bootMedia.kind) else {
+ return failedPlan(
+ probe: currentProbe,
+ code: .bootMediaUnsupported,
+ message: "The selected boot-media kind is not implemented by this backend adapter."
+ )
+ }
+ if let validationFailure = validateMachine(request.machine, capability) {
+ return failedPlan(
+ probe: currentProbe,
+ code: .machineConfigurationIncompatible,
+ message: validationFailure
+ )
+ }
+ return MachineBackendPlanResult(
+ plan: MachineBackendPlan(
+ backend: descriptor,
+ machine: request.machine,
+ capability: capability
+ ),
+ probe: currentProbe,
+ failure: nil
+ )
+ }
+
+ func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult {
+ guard plan.backend == descriptor,
+ plan.capability.request.backend == descriptor.identity else {
+ return failedOperation(
+ .start,
+ code: .backendIdentityMismatch,
+ message: "The launch plan belongs to a different backend adapter."
+ )
+ }
+ let revalidated = self.plan(MachineBackendPlanRequest(
+ machine: plan.machine,
+ capabilityPlan: DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: plan.capability,
+ evaluatedDescriptors: [plan.capability],
+ failure: nil
+ )
+ ))
+ guard revalidated.plan == plan else {
+ return failedOperation(
+ .start,
+ code: revalidated.failure?.code ?? .capabilitySelectionInvalid,
+ message: revalidated.failure?.message
+ ?? "The backend launch plan could not be revalidated."
+ )
+ }
+ guard descriptor.lifecycle.start else {
+ return unsupportedOperation(.start)
+ }
+ return perform(.start, machineID: plan.machine.id, operation: operations.start)
+ }
+
+ func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ guard request.backend == descriptor.identity else {
+ return failedOperation(
+ .stop,
+ code: .backendIdentityMismatch,
+ message: "The runtime request belongs to a different backend adapter."
+ )
+ }
+ guard descriptor.lifecycle.stop else {
+ return unsupportedOperation(.stop)
+ }
+ return perform(.stop, machineID: request.machineID, operation: operations.stop)
+ }
+
+ func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ guard request.backend == descriptor.identity else {
+ return failedOperation(
+ .pause,
+ code: .backendIdentityMismatch,
+ message: "The runtime request belongs to a different backend adapter."
+ )
+ }
+ return unsupportedOperation(.pause)
+ }
+
+ func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ guard request.backend == descriptor.identity else {
+ return failedOperation(
+ .resume,
+ code: .backendIdentityMismatch,
+ message: "The runtime request belongs to a different backend adapter."
+ )
+ }
+ return unsupportedOperation(.resume)
+ }
+
+ private func unavailableProbe(
+ code: MachineBackendFailureCode,
+ message: String
+ ) -> MachineBackendProbeResult {
+ MachineBackendProbeResult(
+ descriptor: descriptor,
+ state: .unavailable,
+ failure: MachineBackendFailure(
+ code: code,
+ backend: descriptor.identity,
+ message: message
+ )
+ )
+ }
+
+ private func failedPlan(
+ probe: MachineBackendProbeResult,
+ code: MachineBackendFailureCode,
+ message: String
+ ) -> MachineBackendPlanResult {
+ MachineBackendPlanResult(
+ plan: nil,
+ probe: probe,
+ failure: MachineBackendFailure(
+ code: code,
+ backend: descriptor.identity,
+ message: message
+ )
+ )
+ }
+
+ private func perform(
+ _ operation: MachineBackendLifecycleOperation,
+ machineID: String,
+ operation handler: MachineBackendLifecycleHandler
+ ) -> MachineBackendOperationResult {
+ do {
+ return MachineBackendOperationResult(
+ operation: operation,
+ backend: descriptor.identity,
+ observation: try handler(machineID),
+ failure: nil
+ )
+ } catch {
+ return failedOperation(
+ operation,
+ code: .lifecycleOperationFailed,
+ message: String(describing: error)
+ )
+ }
+ }
+
+ private func unsupportedOperation(
+ _ operation: MachineBackendLifecycleOperation
+ ) -> MachineBackendOperationResult {
+ failedOperation(
+ operation,
+ code: .lifecycleOperationUnsupported,
+ message: "The current backend adapter does not implement \(operation.rawValue)."
+ )
+ }
+
+ private func failedOperation(
+ _ operation: MachineBackendLifecycleOperation,
+ code: MachineBackendFailureCode,
+ message: String
+ ) -> MachineBackendOperationResult {
+ MachineBackendOperationResult(
+ operation: operation,
+ backend: descriptor.identity,
+ observation: nil,
+ failure: MachineBackendFailure(
+ code: code,
+ backend: descriptor.identity,
+ message: message
+ )
+ )
+ }
+}
+
+/// Compatibility adapter for Dory's current direct-kernel/raw-Hypervisor Linux desktop path.
+public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable {
+ public static let backendDescriptor = MachineBackendDescriptor(
+ identity: .doryHypervisor,
+ implementationIdentifier: "dory.raw-hv-linux.compatibility.v1",
+ guestFamilies: [.linux],
+ guestArchitectures: [.arm64],
+ bootMediaKinds: [.installedLinuxBootBundle],
+ lifecycle: .currentMachineManager
+ )
+
+ private let core: LinuxMachineBackendAdapterCore
+
+ public var descriptor: MachineBackendDescriptor { core.descriptor }
+
+ public init(
+ executablePath: String?,
+ operations: MachineBackendCompatibilityOperations,
+ executableIsAvailable: @escaping @Sendable (String) -> Bool = {
+ FileManager.default.isExecutableFile(atPath: $0)
+ }
+ ) {
+ core = LinuxMachineBackendAdapterCore(
+ descriptor: Self.backendDescriptor,
+ executablePath: executablePath,
+ executableIsAvailable: executableIsAvailable,
+ operations: operations,
+ validateMachine: { machine, _ in
+ guard machine.displayMode == .desktop else {
+ return "The current raw-HV machine path is implemented only for desktop Linux."
+ }
+ guard machine.installerISOPath == nil else {
+ return "The raw-HV machine path cannot boot attached installer media."
+ }
+ guard machine.bootMode == .linuxKernel || machine.bootMode == .efi else {
+ return "The machine boot mode is not implemented by the raw-HV adapter."
+ }
+ do {
+ guard try DoryDesktopVMMPreference(environment: machine.environment) != .compatible else {
+ return "The machine explicitly requests the Virtualization.framework compatibility path."
+ }
+ } catch {
+ return "The machine contains an invalid desktop backend preference."
+ }
+ if machine.bootMode == .efi,
+ !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) {
+ return "An EFI-installed raw-HV machine requires a verified installed-Linux boot bundle."
+ }
+ return nil
+ }
+ )
+ }
+
+ public func probe() -> MachineBackendProbeResult { core.probe() }
+ public func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { core.plan(request) }
+ public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { core.start(plan) }
+ public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.stop(request) }
+ public func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.pause(request) }
+ public func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.resume(request) }
+}
+
+/// Compatibility adapter for the current Virtualization.framework EFI Linux helper path.
+public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @unchecked Sendable {
+ public static let backendDescriptor = MachineBackendDescriptor(
+ identity: .appleVirtualizationFramework,
+ implementationIdentifier: "dory.vz-linux.compatibility.v1",
+ guestFamilies: [.linux],
+ guestArchitectures: [.arm64],
+ bootMediaKinds: [.installerISO, .virtualDisk],
+ lifecycle: .currentMachineManager
+ )
+
+ private let core: LinuxMachineBackendAdapterCore
+
+ public var descriptor: MachineBackendDescriptor { core.descriptor }
+
+ public init(
+ executablePath: String?,
+ operations: MachineBackendCompatibilityOperations,
+ executableIsAvailable: @escaping @Sendable (String) -> Bool = {
+ FileManager.default.isExecutableFile(atPath: $0)
+ }
+ ) {
+ core = LinuxMachineBackendAdapterCore(
+ descriptor: Self.backendDescriptor,
+ executablePath: executablePath,
+ executableIsAvailable: executableIsAvailable,
+ operations: operations,
+ validateMachine: { machine, capability in
+ guard machine.bootMode == .efi, machine.displayMode == .desktop else {
+ return "The current Virtualization.framework media path requires an EFI desktop machine."
+ }
+ switch capability.request.bootMedia.kind {
+ case .installerISO:
+ guard machine.installerISOPath?.isEmpty == false else {
+ return "An installer capability requires attached installer media."
+ }
+ case .virtualDisk:
+ guard machine.installerISOPath == nil else {
+ return "A virtual-disk capability cannot retain attached installer media."
+ }
+ if DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) {
+ do {
+ guard try DoryDesktopVMMPreference(environment: machine.environment) == .compatible else {
+ return "An installed-Linux boot bundle must explicitly select the compatibility path to use this adapter."
+ }
+ } catch {
+ return "The machine contains an invalid desktop backend preference."
+ }
+ }
+ default:
+ return "The selected media is not implemented by the Virtualization.framework Linux adapter."
+ }
+ return nil
+ }
+ )
+ }
+
+ public func probe() -> MachineBackendProbeResult { core.probe() }
+ public func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult { core.plan(request) }
+ public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult { core.start(plan) }
+ public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.stop(request) }
+ public func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.pause(request) }
+ public func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult { core.resume(request) }
+}
diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift
new file mode 100644
index 00000000..44c79856
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift
@@ -0,0 +1,363 @@
+import DoryOperations
+import Foundation
+
+/// Lifecycle operations implemented by a daemon backend adapter. This is intentionally separate
+/// from product support policy: it describes only what the concrete launch mechanism can do.
+public struct MachineBackendLifecycleCapabilities: Codable, Sendable, Equatable, Hashable {
+ public var start: Bool
+ public var stop: Bool
+ public var pause: Bool
+ public var resume: Bool
+
+ public init(start: Bool, stop: Bool, pause: Bool, resume: Bool) {
+ self.start = start
+ self.stop = stop
+ self.pause = pause
+ self.resume = resume
+ }
+
+ /// `MachineManager` currently exposes start and stop, but not pause and resume.
+ public static let currentMachineManager = MachineBackendLifecycleCapabilities(
+ start: true,
+ stop: true,
+ pause: false,
+ resume: false
+ )
+}
+
+public struct MachineBackendDescriptor: Codable, Sendable, Equatable, Hashable {
+ public static let currentSchemaVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var identity: DoryVirtualizationBackendIdentity
+ /// Stable identity for the daemon adapter implementation, independent of the helper build.
+ public var implementationIdentifier: String
+ public var guestFamilies: [DoryGuestFamily]
+ public var guestArchitectures: [DoryGuestArchitecture]
+ public var bootMediaKinds: [DoryBootMediaKind]
+ public var lifecycle: MachineBackendLifecycleCapabilities
+
+ public init(
+ schemaVersion: UInt16 = Self.currentSchemaVersion,
+ identity: DoryVirtualizationBackendIdentity,
+ implementationIdentifier: String,
+ guestFamilies: [DoryGuestFamily],
+ guestArchitectures: [DoryGuestArchitecture],
+ bootMediaKinds: [DoryBootMediaKind],
+ lifecycle: MachineBackendLifecycleCapabilities
+ ) {
+ self.schemaVersion = schemaVersion
+ self.identity = identity
+ self.implementationIdentifier = implementationIdentifier
+ self.guestFamilies = guestFamilies
+ self.guestArchitectures = guestArchitectures
+ self.bootMediaKinds = bootMediaKinds
+ self.lifecycle = lifecycle
+ }
+}
+
+public enum MachineBackendProbeState: String, Codable, Sendable, Equatable, Hashable {
+ case available
+ case unavailable
+}
+
+public enum MachineBackendFailureCode: String, Codable, Sendable, Equatable, Hashable {
+ case componentNotConfigured = "component-not-configured"
+ case componentUnavailable = "component-unavailable"
+ case duplicateRegistration = "duplicate-registration"
+ case backendNotRegistered = "backend-not-registered"
+ case capabilityPlanRejected = "capability-plan-rejected"
+ case capabilitySelectionInvalid = "capability-selection-invalid"
+ case backendIdentityMismatch = "backend-identity-mismatch"
+ case guestUnsupported = "guest-unsupported"
+ case bootMediaUnsupported = "boot-media-unsupported"
+ case machineConfigurationIncompatible = "machine-configuration-incompatible"
+ case lifecycleOperationUnsupported = "lifecycle-operation-unsupported"
+ case lifecycleOperationFailed = "lifecycle-operation-failed"
+}
+
+public struct MachineBackendFailure: Codable, Error, Sendable, Equatable, Hashable {
+ public var code: MachineBackendFailureCode
+ public var backend: DoryVirtualizationBackendIdentity?
+ public var message: String
+
+ public init(
+ code: MachineBackendFailureCode,
+ backend: DoryVirtualizationBackendIdentity? = nil,
+ message: String
+ ) {
+ self.code = code
+ self.backend = backend
+ self.message = message
+ }
+}
+
+public struct MachineBackendProbeResult: Codable, Sendable, Equatable, Hashable {
+ public var descriptor: MachineBackendDescriptor
+ public var state: MachineBackendProbeState
+ public var failure: MachineBackendFailure?
+
+ public var isAvailable: Bool { state == .available && failure == nil }
+
+ public init(
+ descriptor: MachineBackendDescriptor,
+ state: MachineBackendProbeState,
+ failure: MachineBackendFailure? = nil
+ ) {
+ self.descriptor = descriptor
+ self.state = state
+ self.failure = failure
+ }
+}
+
+/// Daemon-local bridge between a persisted machine and an already evaluated product capability.
+/// The adapter does not choose a support tier or downgrade the planner's requested devices.
+public struct MachineBackendPlanRequest: Codable, Sendable, Equatable, Hashable {
+ public var machine: DoryMachineConfiguration
+ public var capabilityPlan: DoryVirtualMachineBackendPlanResult
+
+ public init(
+ machine: DoryMachineConfiguration,
+ capabilityPlan: DoryVirtualMachineBackendPlanResult
+ ) {
+ self.machine = machine
+ self.capabilityPlan = capabilityPlan
+ }
+}
+
+public struct MachineBackendPlan: Codable, Sendable, Equatable, Hashable {
+ public static let currentSchemaVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var backend: MachineBackendDescriptor
+ public var machine: DoryMachineConfiguration
+ public var capability: DoryVirtualMachineCapabilityDescriptor
+
+ public init(
+ schemaVersion: UInt16 = Self.currentSchemaVersion,
+ backend: MachineBackendDescriptor,
+ machine: DoryMachineConfiguration,
+ capability: DoryVirtualMachineCapabilityDescriptor
+ ) {
+ self.schemaVersion = schemaVersion
+ self.backend = backend
+ self.machine = machine
+ self.capability = capability
+ }
+}
+
+public struct MachineBackendPlanResult: Codable, Sendable, Equatable, Hashable {
+ public var plan: MachineBackendPlan?
+ public var probe: MachineBackendProbeResult?
+ public var failure: MachineBackendFailure?
+
+ public var isSuccess: Bool { plan != nil && failure == nil }
+
+ public init(
+ plan: MachineBackendPlan?,
+ probe: MachineBackendProbeResult?,
+ failure: MachineBackendFailure?
+ ) {
+ self.plan = plan
+ self.probe = probe
+ self.failure = failure
+ }
+}
+
+public enum MachineBackendRuntimeState: String, Codable, Sendable, Equatable, Hashable {
+ case created
+ case starting
+ case running
+ case paused
+ case stopped
+ case failed
+}
+
+public struct MachineBackendRuntimeObservation: Codable, Sendable, Equatable, Hashable {
+ public var machineID: String
+ public var state: MachineBackendRuntimeState
+ public var processIdentifier: Int32?
+ public var failureMessage: String?
+
+ public init(
+ machineID: String,
+ state: MachineBackendRuntimeState,
+ processIdentifier: Int32? = nil,
+ failureMessage: String? = nil
+ ) {
+ self.machineID = machineID
+ self.state = state
+ self.processIdentifier = processIdentifier
+ self.failureMessage = failureMessage
+ }
+}
+
+public struct MachineBackendRuntimeRequest: Codable, Sendable, Equatable, Hashable {
+ public var machineID: String
+ public var backend: DoryVirtualizationBackendIdentity
+
+ public init(machineID: String, backend: DoryVirtualizationBackendIdentity) {
+ self.machineID = machineID
+ self.backend = backend
+ }
+}
+
+public enum MachineBackendLifecycleOperation: String, Codable, Sendable, Equatable, Hashable {
+ case start
+ case stop
+ case pause
+ case resume
+}
+
+public struct MachineBackendOperationResult: Codable, Sendable, Equatable, Hashable {
+ public var operation: MachineBackendLifecycleOperation
+ public var backend: DoryVirtualizationBackendIdentity
+ public var observation: MachineBackendRuntimeObservation?
+ public var failure: MachineBackendFailure?
+
+ public var isSuccess: Bool { observation != nil && failure == nil }
+
+ public init(
+ operation: MachineBackendLifecycleOperation,
+ backend: DoryVirtualizationBackendIdentity,
+ observation: MachineBackendRuntimeObservation?,
+ failure: MachineBackendFailure?
+ ) {
+ self.operation = operation
+ self.backend = backend
+ self.observation = observation
+ self.failure = failure
+ }
+}
+
+/// Mechanism boundary owned by doryd. Implementations validate a planner selection and adapt an
+/// existing launcher; they must not decide product support tiers or silently alter capabilities.
+public protocol MachineBackend: Sendable {
+ var descriptor: MachineBackendDescriptor { get }
+
+ func probe() -> MachineBackendProbeResult
+ func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult
+ func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult
+ func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult
+ func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult
+ func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult
+}
+
+/// Immutable registry for deterministic backend discovery and dispatch.
+public struct BackendRegistry: Sendable {
+ private let backends: [DoryVirtualizationBackendIdentity: any MachineBackend]
+
+ public init(backends: [any MachineBackend]) throws {
+ var indexed: [DoryVirtualizationBackendIdentity: any MachineBackend] = [:]
+ for backend in backends {
+ let identity = backend.descriptor.identity
+ guard indexed[identity] == nil else {
+ throw MachineBackendFailure(
+ code: .duplicateRegistration,
+ backend: identity,
+ message: "A backend is already registered for \(identity.rawValue)."
+ )
+ }
+ indexed[identity] = backend
+ }
+ self.backends = indexed
+ }
+
+ public var descriptors: [MachineBackendDescriptor] {
+ orderedBackends.map(\.descriptor)
+ }
+
+ public func backend(
+ for identity: DoryVirtualizationBackendIdentity
+ ) -> (any MachineBackend)? {
+ backends[identity]
+ }
+
+ public func probeAll() -> [MachineBackendProbeResult] {
+ orderedBackends.map { $0.probe() }
+ }
+
+ public func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult {
+ guard request.capabilityPlan.failure == nil,
+ let selected = request.capabilityPlan.selectedDescriptor else {
+ return MachineBackendPlanResult(
+ plan: nil,
+ probe: nil,
+ failure: MachineBackendFailure(
+ code: .capabilityPlanRejected,
+ message: "The product capability planner did not select a backend."
+ )
+ )
+ }
+ let identity = selected.request.backend
+ guard let backend = backends[identity] else {
+ return MachineBackendPlanResult(
+ plan: nil,
+ probe: nil,
+ failure: MachineBackendFailure(
+ code: .backendNotRegistered,
+ backend: identity,
+ message: "No daemon adapter is registered for \(identity.rawValue)."
+ )
+ )
+ }
+ return backend.plan(request)
+ }
+
+ public func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult {
+ guard let backend = backends[plan.backend.identity] else {
+ return missingBackendResult(operation: .start, identity: plan.backend.identity)
+ }
+ return backend.start(plan)
+ }
+
+ public func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ dispatch(.stop, request: request) { $0.stop(request) }
+ }
+
+ public func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ dispatch(.pause, request: request) { $0.pause(request) }
+ }
+
+ public func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ dispatch(.resume, request: request) { $0.resume(request) }
+ }
+
+ private var orderedBackends: [any MachineBackend] {
+ backends.values.sorted {
+ let left = $0.descriptor
+ let right = $1.descriptor
+ if left.identity.rawValue != right.identity.rawValue {
+ return left.identity.rawValue < right.identity.rawValue
+ }
+ return left.implementationIdentifier < right.implementationIdentifier
+ }
+ }
+
+ private func dispatch(
+ _ operation: MachineBackendLifecycleOperation,
+ request: MachineBackendRuntimeRequest,
+ action: (any MachineBackend) -> MachineBackendOperationResult
+ ) -> MachineBackendOperationResult {
+ guard let backend = backends[request.backend] else {
+ return missingBackendResult(operation: operation, identity: request.backend)
+ }
+ return action(backend)
+ }
+
+ private func missingBackendResult(
+ operation: MachineBackendLifecycleOperation,
+ identity: DoryVirtualizationBackendIdentity
+ ) -> MachineBackendOperationResult {
+ MachineBackendOperationResult(
+ operation: operation,
+ backend: identity,
+ observation: nil,
+ failure: MachineBackendFailure(
+ code: .backendNotRegistered,
+ backend: identity,
+ message: "No daemon adapter is registered for \(identity.rawValue)."
+ )
+ )
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
new file mode 100644
index 00000000..4b46ca3e
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
@@ -0,0 +1,343 @@
+@testable import DorydKit
+import DoryOperations
+import XCTest
+
+final class MachineBackendTests: XCTestCase {
+ func testConcreteDescriptorsDescribeOnlyCurrentLinuxMechanisms() {
+ let raw = RawHVLinuxMachineBackend.backendDescriptor
+ XCTAssertEqual(raw.identity, .doryHypervisor)
+ XCTAssertEqual(raw.guestFamilies, [.linux])
+ XCTAssertEqual(raw.guestArchitectures, [.arm64])
+ XCTAssertEqual(raw.bootMediaKinds, [.installedLinuxBootBundle])
+ XCTAssertEqual(raw.lifecycle, .currentMachineManager)
+
+ let vz = VirtualizationFrameworkLinuxMachineBackend.backendDescriptor
+ XCTAssertEqual(vz.identity, .appleVirtualizationFramework)
+ XCTAssertEqual(vz.guestFamilies, [.linux])
+ XCTAssertEqual(vz.guestArchitectures, [.arm64])
+ XCTAssertEqual(vz.bootMediaKinds, [.installerISO, .virtualDisk])
+ XCTAssertFalse(vz.lifecycle.pause)
+ XCTAssertFalse(vz.lifecycle.resume)
+ }
+
+ func testProbeFailsClosedForMissingAndUnavailableComponents() {
+ let operations = recordingOperations().operations
+ let missing = RawHVLinuxMachineBackend(
+ executablePath: nil,
+ operations: operations,
+ executableIsAvailable: { _ in true }
+ ).probe()
+ XCTAssertEqual(missing.state, .unavailable)
+ XCTAssertEqual(missing.failure?.code, .componentNotConfigured)
+
+ let unavailable = VirtualizationFrameworkLinuxMachineBackend(
+ executablePath: "/fixture/dory-vmm",
+ operations: operations,
+ executableIsAvailable: { _ in false }
+ ).probe()
+ XCTAssertEqual(unavailable.state, .unavailable)
+ XCTAssertEqual(unavailable.failure?.code, .componentUnavailable)
+
+ let available = RawHVLinuxMachineBackend(
+ executablePath: "/fixture/dory-hv",
+ operations: operations,
+ executableIsAvailable: { $0 == "/fixture/dory-hv" }
+ ).probe()
+ XCTAssertTrue(available.isAvailable)
+ XCTAssertNil(available.failure)
+ }
+
+ func testRegistryRejectsDuplicateBackendIdentity() {
+ let operations = recordingOperations().operations
+ let first = availableRawBackend(operations: operations)
+ let second = availableRawBackend(operations: operations)
+
+ XCTAssertThrowsError(try BackendRegistry(backends: [first, second])) { error in
+ let failure = error as? MachineBackendFailure
+ XCTAssertEqual(failure?.code, .duplicateRegistration)
+ XCTAssertEqual(failure?.backend, .doryHypervisor)
+ }
+ }
+
+ func testRegistryProbeOrderIsStableAcrossRegistrationOrder() throws {
+ let operations = recordingOperations().operations
+ let raw = availableRawBackend(operations: operations)
+ let vz = availableVZBackend(operations: operations)
+
+ let forward = try BackendRegistry(backends: [raw, vz]).probeAll()
+ let reverse = try BackendRegistry(backends: [vz, raw]).probeAll()
+ let expected: [DoryVirtualizationBackendIdentity] = [
+ .appleVirtualizationFramework,
+ .doryHypervisor,
+ ]
+ XCTAssertEqual(forward.map(\.descriptor.identity), expected)
+ XCTAssertEqual(reverse.map(\.descriptor.identity), expected)
+ }
+
+ func testRegistryPlansAndDispatchesRawHVLifecycleWithoutChangingOperations() throws {
+ let recorder = recordingOperations()
+ let registry = try BackendRegistry(backends: [
+ availableRawBackend(operations: recorder.operations),
+ ])
+ let request = MachineBackendPlanRequest(
+ machine: rawMachine(),
+ capabilityPlan: capabilityPlan(
+ backend: .doryHypervisor,
+ media: .installedLinuxBootBundle
+ )
+ )
+
+ let result = registry.plan(request)
+ let plan = try XCTUnwrap(result.plan)
+ XCTAssertTrue(result.isSuccess)
+ XCTAssertEqual(plan.backend.identity, .doryHypervisor)
+ XCTAssertEqual(plan.machine.id, "raw-linux")
+
+ let started = registry.start(plan)
+ XCTAssertTrue(started.isSuccess)
+ XCTAssertEqual(started.observation?.state, .running)
+
+ let stopped = registry.stop(MachineBackendRuntimeRequest(
+ machineID: plan.machine.id,
+ backend: .doryHypervisor
+ ))
+ XCTAssertTrue(stopped.isSuccess)
+ XCTAssertEqual(stopped.observation?.state, .stopped)
+ XCTAssertEqual(recorder.events, ["start:raw-linux", "stop:raw-linux"])
+ }
+
+ func testRegistryPlansVZInstallerWithPlannerSelection() throws {
+ let registry = try BackendRegistry(backends: [
+ availableVZBackend(operations: recordingOperations().operations),
+ ])
+ let result = registry.plan(MachineBackendPlanRequest(
+ machine: vzInstallerMachine(),
+ capabilityPlan: capabilityPlan(
+ backend: .appleVirtualizationFramework,
+ media: .installerISO
+ )
+ ))
+
+ XCTAssertTrue(result.isSuccess)
+ XCTAssertEqual(result.plan?.backend.identity, .appleVirtualizationFramework)
+ XCTAssertEqual(result.plan?.capability.request.bootMedia.kind, .installerISO)
+ }
+
+ func testPlanningFailsClosedWhenProductPlannerHasNoSelection() throws {
+ let registry = try BackendRegistry(backends: [
+ availableRawBackend(operations: recordingOperations().operations),
+ ])
+ let result = registry.plan(MachineBackendPlanRequest(
+ machine: rawMachine(),
+ capabilityPlan: DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: nil,
+ evaluatedDescriptors: [],
+ failure: DoryVirtualMachineBackendPlanningFailure(
+ code: .noCandidate,
+ message: "fixture"
+ )
+ )
+ ))
+
+ XCTAssertFalse(result.isSuccess)
+ XCTAssertEqual(result.failure?.code, .capabilityPlanRejected)
+ }
+
+ func testPlanningFailsClosedForUnregisteredSelectedBackend() throws {
+ let registry = try BackendRegistry(backends: [
+ availableRawBackend(operations: recordingOperations().operations),
+ ])
+ let result = registry.plan(MachineBackendPlanRequest(
+ machine: vzInstallerMachine(),
+ capabilityPlan: capabilityPlan(
+ backend: .appleVirtualizationFramework,
+ media: .installerISO
+ )
+ ))
+
+ XCTAssertEqual(result.failure?.code, .backendNotRegistered)
+ XCTAssertEqual(result.failure?.backend, .appleVirtualizationFramework)
+ }
+
+ func testAdapterRejectsSelectionMissingFromPlannerEvaluation() {
+ let backend = availableRawBackend(operations: recordingOperations().operations)
+ let descriptor = capabilityDescriptor(
+ backend: .doryHypervisor,
+ media: .installedLinuxBootBundle
+ )
+ let result = backend.plan(MachineBackendPlanRequest(
+ machine: rawMachine(),
+ capabilityPlan: DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: descriptor,
+ evaluatedDescriptors: [],
+ failure: nil
+ )
+ ))
+
+ XCTAssertEqual(result.failure?.code, .capabilitySelectionInvalid)
+ }
+
+ func testRawAdapterRejectsConfigurationThatWouldRouteToCompatibilityLauncher() {
+ let backend = availableRawBackend(operations: recordingOperations().operations)
+ var machine = rawMachine()
+ machine.environment[DoryDesktopVMMPreference.environmentKey] = "compatible"
+ let result = backend.plan(MachineBackendPlanRequest(
+ machine: machine,
+ capabilityPlan: capabilityPlan(
+ backend: .doryHypervisor,
+ media: .installedLinuxBootBundle
+ )
+ ))
+
+ XCTAssertEqual(result.failure?.code, .machineConfigurationIncompatible)
+ }
+
+ func testVZInstallerPlanRequiresAttachedInstallerMedia() {
+ let backend = availableVZBackend(operations: recordingOperations().operations)
+ var machine = vzInstallerMachine()
+ machine.installerISOPath = nil
+ let result = backend.plan(MachineBackendPlanRequest(
+ machine: machine,
+ capabilityPlan: capabilityPlan(
+ backend: .appleVirtualizationFramework,
+ media: .installerISO
+ )
+ ))
+
+ XCTAssertEqual(result.failure?.code, .machineConfigurationIncompatible)
+ }
+
+ func testPauseAndResumeAreExplicitlyUnsupportedAndDoNotInvokeLauncher() {
+ let recorder = recordingOperations()
+ let backend = availableRawBackend(operations: recorder.operations)
+ let request = MachineBackendRuntimeRequest(
+ machineID: "raw-linux",
+ backend: .doryHypervisor
+ )
+
+ XCTAssertEqual(backend.pause(request).failure?.code, .lifecycleOperationUnsupported)
+ XCTAssertEqual(backend.resume(request).failure?.code, .lifecycleOperationUnsupported)
+ XCTAssertEqual(recorder.events, [])
+ }
+
+ func testBackendPlanRoundTripsWithPlannerCapabilityEvidence() throws {
+ let backend = availableVZBackend(operations: recordingOperations().operations)
+ let plan = try XCTUnwrap(backend.plan(MachineBackendPlanRequest(
+ machine: vzInstallerMachine(),
+ capabilityPlan: capabilityPlan(
+ backend: .appleVirtualizationFramework,
+ media: .installerISO
+ )
+ )).plan)
+
+ let data = try JSONEncoder().encode(plan)
+ XCTAssertEqual(try JSONDecoder().decode(MachineBackendPlan.self, from: data), plan)
+ }
+
+ private func availableRawBackend(
+ operations: MachineBackendCompatibilityOperations
+ ) -> RawHVLinuxMachineBackend {
+ RawHVLinuxMachineBackend(
+ executablePath: "/fixture/dory-hv",
+ operations: operations,
+ executableIsAvailable: { _ in true }
+ )
+ }
+
+ private func availableVZBackend(
+ operations: MachineBackendCompatibilityOperations
+ ) -> VirtualizationFrameworkLinuxMachineBackend {
+ VirtualizationFrameworkLinuxMachineBackend(
+ executablePath: "/fixture/dory-vmm",
+ operations: operations,
+ executableIsAvailable: { _ in true }
+ )
+ }
+
+ private func rawMachine() -> DoryMachineConfiguration {
+ DoryMachineConfiguration(
+ id: "raw-linux",
+ kernelPath: "/fixture/direct-kernel",
+ rootfsPath: "/fixture/linux.raw",
+ bootMode: .linuxKernel,
+ displayMode: .desktop
+ )
+ }
+
+ private func vzInstallerMachine() -> DoryMachineConfiguration {
+ DoryMachineConfiguration(
+ id: "vz-linux",
+ kernelPath: "/fixture/nvram",
+ rootfsPath: "/fixture/linux.raw",
+ bootMode: .efi,
+ installerISOPath: "/fixture/ubuntu.iso",
+ displayMode: .desktop
+ )
+ }
+
+ private func capabilityPlan(
+ backend: DoryVirtualizationBackendIdentity,
+ media: DoryBootMediaKind
+ ) -> DoryVirtualMachineBackendPlanResult {
+ let descriptor = capabilityDescriptor(backend: backend, media: media)
+ return DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: descriptor,
+ evaluatedDescriptors: [descriptor],
+ failure: nil
+ )
+ }
+
+ private func capabilityDescriptor(
+ backend: DoryVirtualizationBackendIdentity,
+ media: DoryBootMediaKind
+ ) -> DoryVirtualMachineCapabilityDescriptor {
+ let request = DoryVirtualMachineCapabilityRequest(
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ bootMedia: DoryBootMedia(kind: media, source: .userProvided),
+ backend: backend,
+ graphics: .hostAcceleratedDisplay,
+ devices: .minimumBootable
+ )
+ return DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: request,
+ availability: DoryCapabilityAvailability(
+ supportTier: .supported,
+ state: .available
+ ),
+ resolvedDevices: request.devices
+ )
+ }
+
+ private func recordingOperations() -> OperationRecorder {
+ OperationRecorder()
+ }
+}
+
+private final class OperationRecorder: @unchecked Sendable {
+ private let lock = NSLock()
+ private var recordedEvents: [String] = []
+
+ var events: [String] {
+ lock.withLock { recordedEvents }
+ }
+
+ lazy var operations = MachineBackendCompatibilityOperations(
+ start: { [weak self] id in
+ self?.append("start:\(id)")
+ return MachineBackendRuntimeObservation(
+ machineID: id,
+ state: .running,
+ processIdentifier: 42
+ )
+ },
+ stop: { [weak self] id in
+ self?.append("stop:\(id)")
+ return MachineBackendRuntimeObservation(machineID: id, state: .stopped)
+ }
+ )
+
+ private func append(_ event: String) {
+ lock.withLock { recordedEvents.append(event) }
+ }
+}
From 033c70f9e0cd5590d1f73bb48c1976eb0b3cf9a0 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 11:47:03 +0000
Subject: [PATCH 012/338] feat(workspace): bridge legacy Linux configurations
---
.../DoryMachineConfigurationMigration.swift | 659 ++++++++++++++++++
...ryMachineConfigurationMigrationTests.swift | 296 ++++++++
2 files changed, 955 insertions(+)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
new file mode 100644
index 00000000..3df0347a
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
@@ -0,0 +1,659 @@
+import CryptoKit
+import DoryOperations
+import Foundation
+
+/// Explicit evidence needed to project legacy machine metadata without consulting host globals
+/// or guessing from mutable files while migration is in progress.
+public struct DoryMachineConfigurationMigrationFacts: Sendable, Equatable {
+ public var guestArchitecture: DoryGuestArchitecture
+ /// Logical capacity of the legacy root disk. Persisted configurations normally no longer
+ /// contain `diskSizeBytes`, so the repository or artifact inspector must supply this fact.
+ public var systemDiskCapacityBytes: UInt64?
+ /// Required only for an installed EFI machine whose installer is no longer attached.
+ public var installedEFIBoot: DoryMachineConfigurationInstalledEFIBoot?
+ public var lifecycle: DoryVMLifecycleMetadata
+
+ public init(
+ guestArchitecture: DoryGuestArchitecture,
+ systemDiskCapacityBytes: UInt64? = nil,
+ installedEFIBoot: DoryMachineConfigurationInstalledEFIBoot? = nil,
+ lifecycle: DoryVMLifecycleMetadata
+ ) {
+ self.guestArchitecture = guestArchitecture
+ self.systemDiskCapacityBytes = systemDiskCapacityBytes
+ self.installedEFIBoot = installedEFIBoot
+ self.lifecycle = lifecycle
+ }
+}
+
+/// The two installed-EFI arrangements supported by today's MachineManager.
+public enum DoryMachineConfigurationInstalledEFIBoot: String, Codable, Sendable, Equatable {
+ /// UEFI loads the installed system directly from its virtual disk.
+ case firmwareDisk = "firmware-disk"
+ /// Dory's verified kernel/initrd/root-device bundle launches the installed disk directly.
+ case installedLinuxBootBundle = "installed-linux-boot-bundle"
+}
+
+/// The exact legacy boot ABI represented by a migration result.
+public enum DoryMachineConfigurationLegacyBootContract: String, Codable, Sendable, Equatable {
+ case managedDirectKernel = "managed-direct-kernel"
+ case efiInstaller = "efi-installer"
+ case efiFirmwareDisk = "efi-firmware-disk"
+ case efiInstalledDirectBoot = "efi-installed-direct-boot"
+}
+
+public enum DoryMachineConfigurationArtifactRole: String, Codable, Sendable, Equatable, Hashable {
+ case kernel
+ case systemDisk = "system-disk"
+ case installerISO = "installer-iso"
+}
+
+/// Transitional resolver binding. It is intentionally not part of WorkspaceSpec persistence:
+/// paths remain under the legacy metadata authority until an artifact repository owns them.
+public struct DoryMachineConfigurationArtifactBinding: Sendable, Equatable {
+ public var role: DoryMachineConfigurationArtifactRole
+ public var reference: DoryVMResolverReference
+ public var path: String
+
+ public init(
+ role: DoryMachineConfigurationArtifactRole,
+ reference: DoryVMResolverReference,
+ path: String
+ ) {
+ self.role = role
+ self.reference = reference
+ self.path = path
+ }
+}
+
+/// Transitional host-share binding kept outside the path-hostile WorkspaceSpec.
+public struct DoryMachineConfigurationShareBinding: Sendable, Equatable {
+ public var reference: DoryVMResolverReference
+ public var hostPath: String
+
+ public init(reference: DoryVMResolverReference, hostPath: String) {
+ self.reference = reference
+ self.hostPath = hostPath
+ }
+}
+
+public enum DoryMachineConfigurationMigrationError: Error, Sendable, Equatable, LocalizedError {
+ case invalidLegacyPayload
+ case invalidLegacyPreference(String)
+ case invalidLegacyConfiguration(String)
+ case missingSystemDiskCapacity
+ case conflictingSystemDiskCapacity(configured: UInt64, inspected: UInt64)
+ case missingInstalledEFIBootFact
+ case invalidDefinition([DoryVMDefinitionValidationIssue])
+ case unsupportedDefinitionChange(String)
+ case resourceNotRepresentable(String)
+ case unresolvedArtifact(DoryVMResolverReference)
+ case unresolvedShare(DoryVMResolverReference)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidLegacyPayload:
+ "Legacy machine configuration JSON is invalid."
+ case let .invalidLegacyPreference(key):
+ "Legacy runtime preference \(key) is invalid."
+ case let .invalidLegacyConfiguration(reason):
+ "Legacy machine configuration is not migration-safe: \(reason)"
+ case .missingSystemDiskCapacity:
+ "The legacy root disk capacity must be supplied explicitly."
+ case let .conflictingSystemDiskCapacity(configured, inspected):
+ "Configured disk capacity \(configured) does not match supplied capacity \(inspected)."
+ case .missingInstalledEFIBootFact:
+ "Installed EFI boot requires an explicit firmware-disk or direct-boot-bundle fact."
+ case let .invalidDefinition(issues):
+ "Projected workspace definition is invalid: "
+ + issues.map { "\($0.code.rawValue) at \($0.field)" }.joined(separator: ", ")
+ case let .unsupportedDefinitionChange(field):
+ "The legacy machine format cannot represent the workspace change at \(field)."
+ case let .resourceNotRepresentable(field):
+ "The workspace resource cannot be represented by legacy field \(field)."
+ case let .unresolvedArtifact(reference):
+ "No legacy artifact binding exists for \(reference.namespace):\(reference.identifier)."
+ case let .unresolvedShare(reference):
+ "No legacy share binding exists for \(reference.namespace):\(reference.identifier)."
+ }
+ }
+}
+
+/// A lossless in-memory bridge while `machine.json` remains authoritative.
+///
+/// `definition` is safe for WorkspaceSpec persistence. The authoritative legacy configuration
+/// and path bindings are deliberately non-Codable so a workspace record cannot accidentally copy
+/// host paths, environment secrets, or other compatibility-only state.
+public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable {
+ public var definition: DoryVirtualMachineDefinition
+ public let bootContract: DoryMachineConfigurationLegacyBootContract
+ public let artifactBindings: [DoryMachineConfigurationArtifactBinding]
+ public let shareBindings: [DoryMachineConfigurationShareBinding]
+
+ private let authoritativeLegacyConfiguration: DoryMachineConfiguration
+ private let baselineDefinition: DoryVirtualMachineDefinition
+
+ fileprivate init(
+ definition: DoryVirtualMachineDefinition,
+ bootContract: DoryMachineConfigurationLegacyBootContract,
+ artifactBindings: [DoryMachineConfigurationArtifactBinding],
+ shareBindings: [DoryMachineConfigurationShareBinding],
+ authoritativeLegacyConfiguration: DoryMachineConfiguration
+ ) {
+ self.definition = definition
+ self.bootContract = bootContract
+ self.artifactBindings = artifactBindings
+ self.shareBindings = shareBindings
+ self.authoritativeLegacyConfiguration = authoritativeLegacyConfiguration
+ baselineDefinition = definition
+ }
+
+ public func artifactPath(for reference: DoryVMResolverReference) -> String? {
+ artifactBindings.first { $0.reference == reference }?.path
+ }
+
+ public func hostPath(for reference: DoryVMResolverReference) -> String? {
+ shareBindings.first { $0.reference == reference }?.hostPath
+ }
+
+ /// Reconstruct the legacy projection. Unsupported v2-only changes fail rather than being
+ /// silently dropped, while fields represented by both contracts are written from typed v2
+ /// values. An untouched definition reconstructs an equal legacy value and canonical bytes.
+ public func legacyConfiguration() throws -> DoryMachineConfiguration {
+ let issues = definition.validate()
+ guard issues.isEmpty else {
+ throw DoryMachineConfigurationMigrationError.invalidDefinition(issues)
+ }
+ guard definition.identity.id == baselineDefinition.identity.id else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("identity.id")
+ }
+ guard definition.identity.name == baselineDefinition.identity.name else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("identity.name")
+ }
+ guard definition.guest == baselineDefinition.guest else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("guest")
+ }
+ guard definition.workload == baselineDefinition.workload else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("workload")
+ }
+ guard definition.boot == baselineDefinition.boot else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("boot")
+ }
+ guard definition.networkMode == baselineDefinition.networkMode else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("networkMode")
+ }
+ guard definition.display == baselineDefinition.display else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("display")
+ }
+ guard definition.audio == baselineDefinition.audio else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("audio")
+ }
+ guard definition.input == baselineDefinition.input else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("input")
+ }
+ guard definition.integrations == baselineDefinition.integrations else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("integrations")
+ }
+ guard definition.storage.count == 1,
+ definition.storage[0].id == baselineDefinition.storage[0].id,
+ definition.storage[0].role == .system,
+ !definition.storage[0].readOnly,
+ artifactPath(for: definition.storage[0].artifact) != nil else {
+ if let attachment = definition.storage.first,
+ artifactPath(for: attachment.artifact) == nil {
+ throw DoryMachineConfigurationMigrationError.unresolvedArtifact(attachment.artifact)
+ }
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("storage")
+ }
+
+ guard definition.resources.virtualCPUCount <= UInt64(Int.max) else {
+ throw DoryMachineConfigurationMigrationError.resourceNotRepresentable("cpuCount")
+ }
+ let mebibyte: UInt64 = 1_048_576
+ guard definition.resources.memoryBytes > 0,
+ definition.resources.memoryBytes % mebibyte == 0 else {
+ throw DoryMachineConfigurationMigrationError.resourceNotRepresentable("memoryMB")
+ }
+ let memoryMB = definition.resources.memoryBytes / mebibyte
+ let cpuCount = Int(definition.resources.virtualCPUCount)
+
+ var diskSizeBytes = authoritativeLegacyConfiguration.diskSizeBytes
+ if diskSizeBytes != nil {
+ diskSizeBytes = definition.storage[0].capacityBytes
+ } else if definition.storage[0].capacityBytes != baselineDefinition.storage[0].capacityBytes
+ || definition.resources.diskBytes != baselineDefinition.resources.diskBytes {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange(
+ "resources.diskBytes"
+ )
+ }
+ guard definition.resources.diskBytes == definition.storage[0].capacityBytes else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange(
+ "storage[0].capacityBytes"
+ )
+ }
+
+ var environment = authoritativeLegacyConfiguration.environment
+ try applyBackendPreference(to: &environment)
+ try applyGraphicsPolicy(to: &environment)
+
+ let shares = try definition.shares.map { share -> DoryMachineShareConfiguration in
+ guard let hostPath = hostPath(for: share.hostLocation) else {
+ throw DoryMachineConfigurationMigrationError.unresolvedShare(share.hostLocation)
+ }
+ let migrated = DoryMachineShareConfiguration(
+ tag: share.id,
+ hostPath: hostPath,
+ guestPath: share.guestMountPath,
+ readOnly: share.readOnly
+ )
+ do {
+ try migrated.validate()
+ } catch {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("shares")
+ }
+ return migrated
+ }
+
+ return DoryMachineConfiguration(
+ id: definition.identity.id,
+ kernelPath: authoritativeLegacyConfiguration.kernelPath,
+ rootfsPath: authoritativeLegacyConfiguration.rootfsPath,
+ bootMode: authoritativeLegacyConfiguration.bootMode,
+ installerISOPath: authoritativeLegacyConfiguration.installerISOPath,
+ diskSizeBytes: diskSizeBytes,
+ memoryMB: memoryMB,
+ cpuCount: cpuCount,
+ address: authoritativeLegacyConfiguration.address,
+ displayMode: authoritativeLegacyConfiguration.displayMode,
+ shares: shares,
+ environment: environment
+ )
+ }
+
+ public func authoritativeLegacyData() throws -> Data {
+ try DoryMachineConfigurationMigrationBridge.encodeLegacy(legacyConfiguration())
+ }
+
+ private func applyBackendPreference(to environment: inout [String: String]) throws {
+ guard definition.backendPreference != baselineDefinition.backendPreference else { return }
+ guard bootContract == .managedDirectKernel || bootContract == .efiInstalledDirectBoot,
+ authoritativeLegacyConfiguration.displayMode == .desktop else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange(
+ "backendPreference"
+ )
+ }
+ let raw: String
+ switch (definition.backendPreference.mode, definition.backendPreference.backend) {
+ case (.automatic, nil):
+ raw = DoryDesktopVMMPreference.automatic.rawValue
+ case (.preferred, .doryHypervisor?):
+ raw = DoryDesktopVMMPreference.accelerated.rawValue
+ case (.preferred, .appleVirtualizationFramework?):
+ raw = DoryDesktopVMMPreference.compatible.rawValue
+ default:
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange(
+ "backendPreference"
+ )
+ }
+ environment[DoryDesktopVMMPreference.environmentKey] = raw
+ }
+
+ private func applyGraphicsPolicy(to environment: inout [String: String]) throws {
+ guard definition.graphics != baselineDefinition.graphics else { return }
+ guard bootContract == .managedDirectKernel || bootContract == .efiInstalledDirectBoot,
+ authoritativeLegacyConfiguration.displayMode == .desktop else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("graphics")
+ }
+ let preference: DoryDesktopGraphicsPreference
+ switch definition.graphics.acceptableLevels {
+ case [.hardwareAccelerated3D, .hostAcceleratedDisplay, .software]:
+ preference = .automatic
+ case [.hardwareAccelerated3D]:
+ preference = .virglVenus
+ case [.software]:
+ preference = .software
+ default:
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange("graphics")
+ }
+ environment[DoryDesktopGraphicsPreference.environmentKey] = preference.rawValue
+ environment.removeValue(forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey)
+ }
+}
+
+/// Pure migration entry points. MachineManager remains the legacy persistence owner; this bridge
+/// neither writes files nor reads ProcessInfo or filesystem state.
+public enum DoryMachineConfigurationMigrationBridge {
+ private static let mebibyte: UInt64 = 1_048_576
+
+ public static func decodeAndMigrate(
+ _ legacyData: Data,
+ facts: DoryMachineConfigurationMigrationFacts
+ ) throws -> DoryMachineConfigurationMigrationResult {
+ let configuration: DoryMachineConfiguration
+ do {
+ configuration = try JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: legacyData
+ )
+ } catch {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyPayload
+ }
+ return try migrate(configuration, facts: facts)
+ }
+
+ /// Canonical legacy bytes match MachineManager's existing persistence formatter and schema.
+ public static func encodeLegacy(_ configuration: DoryMachineConfiguration) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ do {
+ return try encoder.encode(configuration)
+ } catch {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyPayload
+ }
+ }
+
+ public static func migrate(
+ _ configuration: DoryMachineConfiguration,
+ facts: DoryMachineConfigurationMigrationFacts
+ ) throws -> DoryMachineConfigurationMigrationResult {
+ guard configuration.cpuCount > 0 else {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration(
+ "cpuCount must be positive"
+ )
+ }
+ let (memoryBytes, memoryOverflow) = configuration.memoryMB.multipliedReportingOverflow(
+ by: mebibyte
+ )
+ guard !memoryOverflow, memoryBytes > 0 else {
+ throw DoryMachineConfigurationMigrationError.resourceNotRepresentable("memoryMB")
+ }
+ let diskCapacity: UInt64
+ if let configured = configuration.diskSizeBytes,
+ let inspected = facts.systemDiskCapacityBytes,
+ configured != inspected {
+ throw DoryMachineConfigurationMigrationError.conflictingSystemDiskCapacity(
+ configured: configured,
+ inspected: inspected
+ )
+ } else if let supplied = facts.systemDiskCapacityBytes ?? configuration.diskSizeBytes,
+ supplied > 0 {
+ diskCapacity = supplied
+ } else {
+ throw DoryMachineConfigurationMigrationError.missingSystemDiskCapacity
+ }
+ guard !configuration.kernelPath.isEmpty else {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration(
+ "kernelPath is empty"
+ )
+ }
+ guard !configuration.rootfsPath.isEmpty else {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration(
+ "rootfsPath is empty"
+ )
+ }
+
+ let bootContract = try legacyBootContract(configuration, facts: facts)
+ let vmmPreference: DoryDesktopVMMPreference
+ let graphicsPreference: DoryDesktopGraphicsPreference
+ do {
+ vmmPreference = try DoryDesktopVMMPreference(environment: configuration.environment)
+ } catch {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyPreference(
+ DoryDesktopVMMPreference.environmentKey
+ )
+ }
+ do {
+ graphicsPreference = try DoryDesktopGraphicsPreference(
+ environment: configuration.environment
+ )
+ } catch {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyPreference(
+ DoryDesktopGraphicsPreference.environmentKey
+ )
+ }
+
+ let systemDiskReference = stableReference(
+ namespace: "legacy-artifact",
+ identity: configuration.id + "\u{0}system-disk\u{0}" + configuration.rootfsPath
+ )
+ let kernelReference = stableReference(
+ namespace: "legacy-artifact",
+ identity: configuration.id + "\u{0}kernel\u{0}" + configuration.kernelPath
+ )
+ var artifactBindings = [
+ DoryMachineConfigurationArtifactBinding(
+ role: .kernel,
+ reference: kernelReference,
+ path: configuration.kernelPath
+ ),
+ DoryMachineConfigurationArtifactBinding(
+ role: .systemDisk,
+ reference: systemDiskReference,
+ path: configuration.rootfsPath
+ ),
+ ]
+
+ let boot: DoryVMBootConfiguration
+ switch bootContract {
+ case .managedDirectKernel:
+ boot = normalBoot(
+ kind: .installedLinuxBootBundle,
+ source: configuration.environment["DORY_CUSTOM_LINUX"] == "1"
+ ? .userProvided : .bundledByDory,
+ artifact: kernelReference
+ )
+ case .efiInstaller:
+ guard let installerPath = configuration.installerISOPath,
+ !installerPath.isEmpty else {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration(
+ "installerISOPath is empty"
+ )
+ }
+ let installerReference = stableReference(
+ namespace: "legacy-artifact",
+ identity: configuration.id + "\u{0}installer-iso\u{0}" + installerPath
+ )
+ artifactBindings.append(DoryMachineConfigurationArtifactBinding(
+ role: .installerISO,
+ reference: installerReference,
+ path: installerPath
+ ))
+ boot = DoryVMBootConfiguration(
+ phase: .install,
+ devices: [DoryVMBootMediaReference(
+ id: "installer",
+ role: .installer,
+ kind: .installerISO,
+ source: .userProvided,
+ artifact: installerReference,
+ removable: true
+ )],
+ order: ["installer"]
+ )
+ case .efiFirmwareDisk:
+ boot = normalBoot(
+ kind: .virtualDisk,
+ source: .userProvided,
+ artifact: systemDiskReference
+ )
+ case .efiInstalledDirectBoot:
+ boot = normalBoot(
+ kind: .installedLinuxBootBundle,
+ source: .userProvided,
+ artifact: kernelReference
+ )
+ }
+
+ var shareBindings: [DoryMachineConfigurationShareBinding] = []
+ let shares = configuration.shares.enumerated().map { index, share in
+ let reference = stableReference(
+ namespace: "legacy-share",
+ identity: configuration.id + "\u{0}\(index)\u{0}" + share.tag + "\u{0}" + share.hostPath
+ )
+ shareBindings.append(DoryMachineConfigurationShareBinding(
+ reference: reference,
+ hostPath: share.hostPath
+ ))
+ return DoryVMShare(
+ id: share.tag,
+ hostLocation: reference,
+ guestMountPath: share.guestPath,
+ readOnly: share.readOnly
+ )
+ }
+
+ let isDesktop = configuration.displayMode == .desktop
+ let acceleratedBoot = bootContract == .managedDirectKernel
+ || bootContract == .efiInstalledDirectBoot
+ let backendPreference: DoryVMBackendPreference
+ if isDesktop, acceleratedBoot {
+ backendPreference = typedBackendPreference(vmmPreference)
+ } else {
+ backendPreference = DoryVMBackendPreference(
+ mode: .preferred,
+ backend: .appleVirtualizationFramework
+ )
+ }
+ let graphics: DoryVMGraphicsPolicy
+ if !isDesktop {
+ graphics = DoryVMGraphicsPolicy(acceptableLevels: [.none])
+ } else if acceleratedBoot {
+ graphics = typedGraphicsPolicy(graphicsPreference)
+ } else {
+ graphics = DoryVMGraphicsPolicy(
+ acceptableLevels: [.hostAcceleratedDisplay, .software]
+ )
+ }
+
+ let definition = DoryVirtualMachineDefinition(
+ identity: DoryVirtualMachineIdentity(id: configuration.id, name: configuration.id),
+ guest: DoryGuestPlatform(family: .linux, architecture: facts.guestArchitecture),
+ workload: isDesktop ? .desktop : .server,
+ boot: boot,
+ backendPreference: backendPreference,
+ graphics: graphics,
+ resources: DoryVMResourceRequest(
+ virtualCPUCount: UInt64(configuration.cpuCount),
+ memoryBytes: memoryBytes,
+ diskBytes: diskCapacity
+ ),
+ storage: [DoryVMStorageAttachment(
+ id: "system",
+ role: .system,
+ artifact: systemDiskReference,
+ capacityBytes: diskCapacity
+ )],
+ networkMode: .sharedNAT,
+ display: isDesktop ? DoryVMDisplayConfiguration() : .disabled,
+ audio: isDesktop
+ ? DoryVMAudioConfiguration()
+ : DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false),
+ input: isDesktop
+ ? DoryVMInputConfiguration()
+ : DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false),
+ shares: shares,
+ integrations: isDesktop
+ ? [.clipboard, .clockSynchronization, .dynamicDisplay, .gracefulShutdown]
+ : [.clockSynchronization, .gracefulShutdown],
+ lifecycle: facts.lifecycle
+ )
+ let issues = definition.validate()
+ guard issues.isEmpty else {
+ throw DoryMachineConfigurationMigrationError.invalidDefinition(issues)
+ }
+ return DoryMachineConfigurationMigrationResult(
+ definition: definition,
+ bootContract: bootContract,
+ artifactBindings: artifactBindings,
+ shareBindings: shareBindings,
+ authoritativeLegacyConfiguration: configuration
+ )
+ }
+
+ private static func legacyBootContract(
+ _ configuration: DoryMachineConfiguration,
+ facts: DoryMachineConfigurationMigrationFacts
+ ) throws -> DoryMachineConfigurationLegacyBootContract {
+ switch configuration.bootMode {
+ case .linuxKernel:
+ guard configuration.installerISOPath == nil else {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration(
+ "linux-kernel boot cannot attach installer media"
+ )
+ }
+ return .managedDirectKernel
+ case .efi:
+ guard configuration.displayMode == .desktop else {
+ throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration(
+ "EFI headless mode is not supported by MachineManager"
+ )
+ }
+ if configuration.installerISOPath != nil { return .efiInstaller }
+ switch facts.installedEFIBoot {
+ case .firmwareDisk?: return .efiFirmwareDisk
+ case .installedLinuxBootBundle?: return .efiInstalledDirectBoot
+ case nil: throw DoryMachineConfigurationMigrationError.missingInstalledEFIBootFact
+ }
+ }
+ }
+
+ private static func normalBoot(
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ artifact: DoryVMResolverReference
+ ) -> DoryVMBootConfiguration {
+ DoryVMBootConfiguration(
+ phase: .normal,
+ devices: [DoryVMBootMediaReference(
+ id: "system",
+ role: .system,
+ kind: kind,
+ source: source,
+ artifact: artifact,
+ removable: false
+ )],
+ order: ["system"]
+ )
+ }
+
+ private static func typedBackendPreference(
+ _ preference: DoryDesktopVMMPreference
+ ) -> DoryVMBackendPreference {
+ switch preference {
+ case .automatic:
+ DoryVMBackendPreference()
+ case .accelerated:
+ DoryVMBackendPreference(mode: .preferred, backend: .doryHypervisor)
+ case .compatible:
+ DoryVMBackendPreference(
+ mode: .preferred,
+ backend: .appleVirtualizationFramework
+ )
+ }
+ }
+
+ private static func typedGraphicsPolicy(
+ _ preference: DoryDesktopGraphicsPreference
+ ) -> DoryVMGraphicsPolicy {
+ switch preference {
+ case .automatic:
+ DoryVMGraphicsPolicy(
+ acceptableLevels: [.hardwareAccelerated3D, .hostAcceleratedDisplay, .software]
+ )
+ case .virgl, .virglVenus:
+ DoryVMGraphicsPolicy(acceptableLevels: [.hardwareAccelerated3D])
+ case .software:
+ DoryVMGraphicsPolicy(acceptableLevels: [.software])
+ }
+ }
+
+ private static func stableReference(
+ namespace: String,
+ identity: String
+ ) -> DoryVMResolverReference {
+ let digest = SHA256.hash(data: Data(identity.utf8))
+ .map { String(format: "%02x", $0) }
+ .joined()
+ return DoryVMResolverReference(namespace: namespace, identifier: digest)
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
new file mode 100644
index 00000000..177db8e6
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
@@ -0,0 +1,296 @@
+import DoryOperations
+import Foundation
+import Testing
+@testable import DorydKit
+
+@Suite("Legacy machine configuration migration")
+struct DoryMachineConfigurationMigrationTests {
+ private let gibibyte: UInt64 = 1_073_741_824
+
+ @Test("managed desktop projects typed policy and round trips canonical legacy bytes")
+ func managedDesktopRoundTrip() throws {
+ let legacy = DoryMachineConfiguration(
+ id: "ubuntu-dev",
+ kernelPath: "/managed/ubuntu-dev/kernel",
+ rootfsPath: "/managed/ubuntu-dev/rootfs.ext4",
+ memoryMB: 8_192,
+ cpuCount: 6,
+ address: "192.168.64.20",
+ displayMode: .desktop,
+ shares: [DoryMachineShareConfiguration(
+ tag: "source",
+ hostPath: "/Users/developer/Source",
+ guestPath: "/workspace/source",
+ readOnly: true
+ )],
+ environment: [
+ DoryDesktopVMMPreference.environmentKey: "accelerated",
+ DoryDesktopGraphicsPreference.environmentKey: "virgl",
+ "DORY_DESKTOP_DISTRO": "ubuntu",
+ "PRIVATE_RUNTIME_VALUE": "do-not-copy-into-workspace",
+ ]
+ )
+ let migrated = try migrate(legacy, capacity: 96 * gibibyte)
+
+ #expect(migrated.definition.isValid)
+ #expect(migrated.bootContract == .managedDirectKernel)
+ #expect(migrated.definition.workload == .desktop)
+ #expect(migrated.definition.boot.phase == .normal)
+ #expect(migrated.definition.boot.devices[0].kind == .installedLinuxBootBundle)
+ #expect(migrated.definition.boot.devices[0].source == .bundledByDory)
+ #expect(migrated.definition.backendPreference == DoryVMBackendPreference(
+ mode: .preferred,
+ backend: .doryHypervisor
+ ))
+ #expect(migrated.definition.graphics.acceptableLevels == [.hardwareAccelerated3D])
+ #expect(migrated.definition.resources.memoryBytes == 8 * gibibyte)
+ #expect(migrated.definition.resources.virtualCPUCount == 6)
+ #expect(migrated.definition.storage[0].capacityBytes == 96 * gibibyte)
+ #expect(migrated.hostPath(for: migrated.definition.shares[0].hostLocation)
+ == "/Users/developer/Source")
+ #expect(migrated.artifactPath(for: migrated.definition.storage[0].artifact)
+ == legacy.rootfsPath)
+
+ let definitionData = try JSONEncoder().encode(migrated.definition)
+ let definitionJSON = try #require(String(data: definitionData, encoding: .utf8))
+ #expect(!definitionJSON.contains("/Users/developer"))
+ #expect(!definitionJSON.contains("PRIVATE_RUNTIME_VALUE"))
+ #expect(!definitionJSON.contains("do-not-copy-into-workspace"))
+
+ #expect(try migrated.legacyConfiguration() == legacy)
+ #expect(try migrated.authoritativeLegacyData()
+ == DoryMachineConfigurationMigrationBridge.encodeLegacy(legacy))
+
+ let repeated = try migrate(legacy, capacity: 96 * gibibyte)
+ #expect(repeated.definition.storage[0].artifact == migrated.definition.storage[0].artifact)
+ #expect(repeated.definition.boot.devices[0].artifact
+ == migrated.definition.boot.devices[0].artifact)
+ #expect(repeated.definition.shares[0].hostLocation
+ == migrated.definition.shares[0].hostLocation)
+ }
+
+ @Test("headless Linux maps to an explicit non-graphical Virtualization.framework contract")
+ func headlessRoundTrip() throws {
+ let legacy = DoryMachineConfiguration(
+ id: "build-server",
+ kernelPath: "/managed/build-server/kernel",
+ rootfsPath: "/managed/build-server/rootfs.ext4",
+ memoryMB: 4_096,
+ cpuCount: 4,
+ displayMode: .headless,
+ environment: ["SERVICE_TOKEN": "legacy-only-value"]
+ )
+ let migrated = try migrate(legacy, capacity: 48 * gibibyte)
+
+ #expect(migrated.definition.workload == .server)
+ #expect(migrated.definition.display == .disabled)
+ #expect(migrated.definition.graphics.acceptableLevels == [.none])
+ #expect(migrated.definition.backendPreference.backend == .appleVirtualizationFramework)
+ #expect(!migrated.definition.audio.inputEnabled)
+ #expect(!migrated.definition.audio.outputEnabled)
+ #expect(!migrated.definition.input.keyboardEnabled)
+ #expect(!migrated.definition.input.pointerEnabled)
+ #expect(try migrated.legacyConfiguration() == legacy)
+ }
+
+ @Test("custom EFI ISO maps installer media and preserves blank-disk intent")
+ func customEFIInstallerRoundTrip() throws {
+ let legacy = DoryMachineConfiguration(
+ id: "omarchy",
+ kernelPath: "/managed/omarchy/kernel",
+ rootfsPath: "/managed/omarchy/rootfs.ext4",
+ bootMode: .efi,
+ installerISOPath: "/managed/omarchy/installer.iso",
+ diskSizeBytes: 80 * gibibyte,
+ memoryMB: 8_192,
+ cpuCount: 6,
+ displayMode: .desktop,
+ environment: ["DORY_CUSTOM_LINUX": "1"]
+ )
+ let migrated = try migrate(legacy, capacity: 80 * gibibyte)
+
+ #expect(migrated.bootContract == .efiInstaller)
+ #expect(migrated.definition.boot.phase == .install)
+ #expect(migrated.definition.boot.devices[0].kind == .installerISO)
+ #expect(migrated.definition.boot.devices[0].source == .userProvided)
+ #expect(migrated.definition.boot.devices[0].removable)
+ #expect(migrated.definition.backendPreference.backend == .appleVirtualizationFramework)
+ #expect(migrated.artifactPath(for: migrated.definition.boot.devices[0].artifact)
+ == legacy.installerISOPath)
+ #expect(try migrated.legacyConfiguration() == legacy)
+ }
+
+ @Test("installed EFI firmware and direct-boot bundle mappings remain distinct")
+ func installedEFIMappings() throws {
+ let legacy = DoryMachineConfiguration(
+ id: "installed-linux",
+ kernelPath: "/managed/installed-linux/kernel",
+ rootfsPath: "/managed/installed-linux/rootfs.ext4",
+ bootMode: .efi,
+ memoryMB: 4_096,
+ cpuCount: 4,
+ displayMode: .desktop,
+ environment: [DoryDesktopVMMPreference.environmentKey: "accelerated"]
+ )
+
+ let firmware = try migrate(
+ legacy,
+ capacity: 64 * gibibyte,
+ installedEFI: .firmwareDisk
+ )
+ #expect(firmware.bootContract == .efiFirmwareDisk)
+ #expect(firmware.definition.boot.devices[0].kind == .virtualDisk)
+ #expect(firmware.definition.boot.devices[0].artifact
+ == firmware.definition.storage[0].artifact)
+ #expect(firmware.definition.backendPreference.backend == .appleVirtualizationFramework)
+ #expect(try firmware.legacyConfiguration() == legacy)
+
+ let direct = try migrate(
+ legacy,
+ capacity: 64 * gibibyte,
+ installedEFI: .installedLinuxBootBundle
+ )
+ #expect(direct.bootContract == .efiInstalledDirectBoot)
+ #expect(direct.definition.boot.devices[0].kind == .installedLinuxBootBundle)
+ #expect(direct.definition.boot.devices[0].artifact
+ != direct.definition.storage[0].artifact)
+ #expect(direct.definition.backendPreference.backend == .doryHypervisor)
+ #expect(try direct.legacyConfiguration() == legacy)
+ }
+
+ @Test("typed policy edits use canonical legacy environment keys without losing other values")
+ func typedPolicyBackProjection() throws {
+ let legacy = DoryMachineConfiguration(
+ id: "desktop",
+ kernelPath: "/managed/desktop/kernel",
+ rootfsPath: "/managed/desktop/rootfs.ext4",
+ memoryMB: 4_096,
+ cpuCount: 4,
+ displayMode: .desktop,
+ environment: [
+ DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey: "1",
+ "PRESERVE": "yes",
+ ]
+ )
+ var migrated = try migrate(legacy, capacity: 64 * gibibyte)
+ migrated.definition.backendPreference = DoryVMBackendPreference(
+ mode: .preferred,
+ backend: .appleVirtualizationFramework
+ )
+ migrated.definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.software])
+ let projected = try migrated.legacyConfiguration()
+
+ #expect(projected.environment[DoryDesktopVMMPreference.environmentKey] == "compatible")
+ #expect(projected.environment[DoryDesktopGraphicsPreference.environmentKey] == "software")
+ #expect(projected.environment[
+ DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey
+ ] == nil)
+ #expect(projected.environment["PRESERVE"] == "yes")
+ }
+
+ @Test("unsupported and under-specified migrations fail explicitly")
+ func explicitMigrationFailures() throws {
+ let direct = DoryMachineConfiguration(
+ id: "direct",
+ kernelPath: "/managed/direct/kernel",
+ rootfsPath: "/managed/direct/rootfs.ext4"
+ )
+ #expect(throws: DoryMachineConfigurationMigrationError.missingSystemDiskCapacity) {
+ try DoryMachineConfigurationMigrationBridge.migrate(
+ direct,
+ facts: facts(capacity: nil)
+ )
+ }
+
+ let installedEFI = DoryMachineConfiguration(
+ id: "installed",
+ kernelPath: "/managed/installed/kernel",
+ rootfsPath: "/managed/installed/rootfs.ext4",
+ bootMode: .efi,
+ displayMode: .desktop
+ )
+ #expect(throws: DoryMachineConfigurationMigrationError.missingInstalledEFIBootFact) {
+ try DoryMachineConfigurationMigrationBridge.migrate(
+ installedEFI,
+ facts: facts(capacity: 64 * gibibyte)
+ )
+ }
+
+ var migrated = try migrate(direct, capacity: 64 * gibibyte)
+ migrated.definition.backendPreference = DoryVMBackendPreference(
+ mode: .required,
+ backend: .doryHypervisor
+ )
+ #expect(throws: DoryMachineConfigurationMigrationError.unsupportedDefinitionChange(
+ "backendPreference"
+ )) {
+ try migrated.legacyConfiguration()
+ }
+
+ migrated = try migrate(direct, capacity: 64 * gibibyte)
+ migrated.definition.storage[0].artifact = DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "unknown"
+ )
+ #expect(throws: DoryMachineConfigurationMigrationError.unresolvedArtifact(
+ DoryVMResolverReference(namespace: "artifact", identifier: "unknown")
+ )) {
+ try migrated.legacyConfiguration()
+ }
+ }
+
+ @Test("old legacy JSON defaults decode and retain the canonical machine.json wire shape")
+ func legacyGoldenWireCompatibility() throws {
+ let oldData = Data(#"{"id":"legacy","kernelPath":"/m/legacy/kernel","rootfsPath":"/m/legacy/rootfs.ext4"}"#.utf8)
+ let migrated = try DoryMachineConfigurationMigrationBridge.decodeAndMigrate(
+ oldData,
+ facts: facts(capacity: 32 * gibibyte)
+ )
+ let canonical = try migrated.authoritativeLegacyData()
+ let canonicalString = try #require(String(data: canonical, encoding: .utf8))
+ #expect(canonicalString == #"""
+ {
+ "bootMode" : "linux-kernel",
+ "cpuCount" : 2,
+ "displayMode" : "headless",
+ "environment" : {
+
+ },
+ "id" : "legacy",
+ "kernelPath" : "\/m\/legacy\/kernel",
+ "memoryMB" : 2048,
+ "rootfsPath" : "\/m\/legacy\/rootfs.ext4",
+ "shares" : [
+
+ ]
+ }
+ """#)
+ }
+
+ private func migrate(
+ _ configuration: DoryMachineConfiguration,
+ capacity: UInt64,
+ installedEFI: DoryMachineConfigurationInstalledEFIBoot? = nil
+ ) throws -> DoryMachineConfigurationMigrationResult {
+ try DoryMachineConfigurationMigrationBridge.migrate(
+ configuration,
+ facts: facts(capacity: capacity, installedEFI: installedEFI)
+ )
+ }
+
+ private func facts(
+ capacity: UInt64?,
+ installedEFI: DoryMachineConfigurationInstalledEFIBoot? = nil
+ ) -> DoryMachineConfigurationMigrationFacts {
+ DoryMachineConfigurationMigrationFacts(
+ guestArchitecture: .arm64,
+ systemDiskCapacityBytes: capacity,
+ installedEFIBoot: installedEFI,
+ lifecycle: DoryVMLifecycleMetadata(
+ revision: 1,
+ createdAtUnixMilliseconds: 1_787_200_000_000,
+ updatedAtUnixMilliseconds: 1_787_200_000_000
+ )
+ )
+ }
+}
From 95a548b5c25fad6812da1907ef1cc62abb3dd8d8 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 11:57:54 +0000
Subject: [PATCH 013/338] feat(workspace): journal lifecycle operations
---
.../DoryOperationJournalModels.swift | 17 +
.../DoryOperationJournalPublication.swift | 14 +-
.../DoryWorkspaceLifecycleOperation.swift | 559 ++++++++++++++++++
...DoryWorkspaceLifecycleOperationTests.swift | 261 ++++++++
4 files changed, 844 insertions(+), 7 deletions(-)
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalModels.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalModels.swift
index 2ffff482..e0245122 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalModels.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalModels.swift
@@ -6,6 +6,20 @@ public enum DoryOperationKind: String, Codable, CaseIterable, Sendable {
case driveRestore
case driveRelocation
case driveUpgrade
+ case workspaceImport
+ case workspaceProvision
+ case workspaceResolve
+ case workspaceStart
+ case workspaceStop
+ case workspacePause
+ case workspaceResume
+ case workspaceSuspend
+ case workspaceRestore
+ case workspaceSnapshot
+ case workspaceClone
+ case workspaceUpdate
+ case workspaceRepair
+ case workspaceDelete
}
public enum DoryOperationAuthorityKind: String, Codable, CaseIterable, Sendable {
@@ -13,6 +27,9 @@ public enum DoryOperationAuthorityKind: String, Codable, CaseIterable, Sendable
case dataDrive
case backupArchive
case filesystem
+ case workspace
+ case machineBackend
+ case componentSet
}
public enum DoryOperationPhase: String, Codable, CaseIterable, Sendable {
diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
index c27f4813..a419151d 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
@@ -25,7 +25,7 @@ extension DoryOperationJournalStore {
do {
try Self.createPrivateDirectory(partial)
try Self.createOperationDirectories(in: partial)
- if completenessPlanData != nil {
+ if completenessPlanData != nil || !specifications.isEmpty {
try Self.createPrivateDirectory(partial + "/specs/objects")
}
@@ -37,12 +37,12 @@ extension DoryOperationJournalStore {
completenessPlanData,
to: partial + "/specs/completeness-plan.json"
)
- for specification in specifications.sorted(by: { $0.digest < $1.digest }) {
- try Self.publish(
- specification.data,
- to: partial + "/specs/objects/" + specification.digest
- )
- }
+ }
+ for specification in specifications.sorted(by: { $0.digest < $1.digest }) {
+ try Self.publish(
+ specification.data,
+ to: partial + "/specs/objects/" + specification.digest
+ )
}
try Self.publish(try Self.encoded(initial.state, pretty: true), to: partial + "/state.json")
try Self.publish(try Self.encoded(initial.event, pretty: false), to: partial + "/events.ndjson")
diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
new file mode 100644
index 00000000..134d9874
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
@@ -0,0 +1,559 @@
+import Foundation
+
+public enum DoryWorkspaceLifecycleState: String, Codable, CaseIterable, Sendable {
+ case absent
+ case defined
+ case stopped
+ case running
+ case paused
+ case suspended
+ case failed
+ case deleting
+}
+
+public enum DoryWorkspaceMutationKind: String, Codable, CaseIterable, Sendable {
+ case importing
+ case provisioning
+ case resolving
+ case starting
+ case stopping
+ case pausing
+ case resuming
+ case suspending
+ case restoring
+ case snapshotting
+ case cloning
+ case updating
+ case repairing
+ case deleting
+
+ public var journalKind: DoryOperationKind {
+ switch self {
+ case .importing: .workspaceImport
+ case .provisioning: .workspaceProvision
+ case .resolving: .workspaceResolve
+ case .starting: .workspaceStart
+ case .stopping: .workspaceStop
+ case .pausing: .workspacePause
+ case .resuming: .workspaceResume
+ case .suspending: .workspaceSuspend
+ case .restoring: .workspaceRestore
+ case .snapshotting: .workspaceSnapshot
+ case .cloning: .workspaceClone
+ case .updating: .workspaceUpdate
+ case .repairing: .workspaceRepair
+ case .deleting: .workspaceDelete
+ }
+ }
+}
+
+public struct DoryWorkspaceResolvedCondition: Codable, Sendable, Equatable {
+ public var planRevision: UInt64
+ public var planDigest: String
+ public var backendID: String
+ public var backendRuntimeBuildID: String
+ public var virtualHardwareABIVersion: UInt16
+
+ public init(
+ planRevision: UInt64,
+ planDigest: String,
+ backendID: String,
+ backendRuntimeBuildID: String,
+ virtualHardwareABIVersion: UInt16
+ ) {
+ self.planRevision = planRevision
+ self.planDigest = planDigest
+ self.backendID = backendID
+ self.backendRuntimeBuildID = backendRuntimeBuildID
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ }
+
+ fileprivate var isValid: Bool {
+ planRevision > 0
+ && DoryOperationJournalStore.isDigest(planDigest)
+ && DoryOperationJournalStore.isToken(backendID)
+ && DoryOperationJournalStore.isToken(backendRuntimeBuildID)
+ && virtualHardwareABIVersion > 0
+ }
+}
+
+public struct DoryWorkspaceLifecycleCondition: Codable, Sendable, Equatable {
+ public var workspaceID: String
+ public var state: DoryWorkspaceLifecycleState
+ public var definitionRevision: UInt64?
+ public var resolved: DoryWorkspaceResolvedCondition?
+
+ public init(
+ workspaceID: String,
+ state: DoryWorkspaceLifecycleState,
+ definitionRevision: UInt64? = nil,
+ resolved: DoryWorkspaceResolvedCondition? = nil
+ ) {
+ self.workspaceID = workspaceID
+ self.state = state
+ self.definitionRevision = definitionRevision
+ self.resolved = resolved
+ }
+
+ fileprivate var isValid: Bool {
+ guard DoryOperationJournalStore.isToken(workspaceID) else { return false }
+ if state == .absent {
+ return definitionRevision == nil && resolved == nil
+ }
+ return definitionRevision.map { $0 > 0 } == true
+ && (resolved?.isValid ?? true)
+ && (!(state == .running || state == .paused || state == .suspended)
+ || resolved != nil)
+ }
+}
+
+public enum DoryWorkspaceOperationStage: String, Codable, CaseIterable, Sendable {
+ case resolve
+ case prepare
+ case quiesce
+ case mutate
+ case launch
+ case readiness
+ case publish
+ case cleanup
+ case rollback
+}
+
+public struct DoryWorkspaceOperationStep: Codable, Sendable, Equatable {
+ public var id: String
+ public var stage: DoryWorkspaceOperationStage
+ public var deadlineOffsetMilliseconds: UInt64
+
+ public init(
+ id: String,
+ stage: DoryWorkspaceOperationStage,
+ deadlineOffsetMilliseconds: UInt64
+ ) {
+ self.id = id
+ self.stage = stage
+ self.deadlineOffsetMilliseconds = deadlineOffsetMilliseconds
+ }
+
+ fileprivate var isValid: Bool {
+ DoryOperationJournalStore.isToken(id) && deadlineOffsetMilliseconds > 0
+ }
+}
+
+public enum DoryWorkspaceReadinessGateKind: String, Codable, CaseIterable, Sendable, Hashable {
+ case backendRunning
+ case firstDisplayFrame
+ case guestAgent
+ case network
+ case storageFlush
+}
+
+public struct DoryWorkspaceReadinessGate: Codable, Sendable, Equatable {
+ public var kind: DoryWorkspaceReadinessGateKind
+ public var required: Bool
+ public var deadlineOffsetMilliseconds: UInt64
+
+ public init(
+ kind: DoryWorkspaceReadinessGateKind,
+ required: Bool = true,
+ deadlineOffsetMilliseconds: UInt64
+ ) {
+ self.kind = kind
+ self.required = required
+ self.deadlineOffsetMilliseconds = deadlineOffsetMilliseconds
+ }
+}
+
+public struct DoryWorkspaceRetryBudget: Codable, Sendable, Equatable {
+ public var failureClass: String
+ public var maximumAttempts: UInt8
+
+ public init(failureClass: String, maximumAttempts: UInt8) {
+ self.failureClass = failureClass
+ self.maximumAttempts = maximumAttempts
+ }
+
+ fileprivate var isValid: Bool {
+ DoryOperationJournalStore.isToken(failureClass) && maximumAttempts > 0
+ }
+}
+
+public enum DoryWorkspaceCancellationPolicy: String, Codable, CaseIterable, Sendable {
+ case prohibited
+ case beforeGuestMutation
+ case beforePublish
+ case rollbackRequired
+}
+
+public enum DoryWorkspaceRecoveryDisposition: String, Codable, CaseIterable, Sendable {
+ case retry
+ case rollback
+ case repair
+ case manualIntervention
+}
+
+public struct DoryWorkspaceRecoveryRecipe: Codable, Sendable, Equatable {
+ public var disposition: DoryWorkspaceRecoveryDisposition
+ public var stepIDs: [String]
+
+ public init(disposition: DoryWorkspaceRecoveryDisposition, stepIDs: [String]) {
+ self.disposition = disposition
+ self.stepIDs = stepIDs
+ }
+
+ fileprivate var isValid: Bool {
+ !stepIDs.isEmpty
+ && Set(stepIDs).count == stepIDs.count
+ && stepIDs.allSatisfy(DoryOperationJournalStore.isToken)
+ }
+}
+
+public enum DoryWorkspaceOperationValidationCode: String, Codable, Sendable {
+ case invalidSchemaVersion
+ case invalidOperationID
+ case invalidCondition
+ case invalidTransition
+ case invalidTargetWorkspace
+ case invalidDeadline
+ case invalidSteps
+ case invalidReadinessGates
+ case invalidRetryBudgets
+ case invalidRecoveryRecipe
+}
+
+public struct DoryWorkspaceOperationValidationIssue: Codable, Sendable, Equatable {
+ public var code: DoryWorkspaceOperationValidationCode
+ public var field: String
+
+ public init(code: DoryWorkspaceOperationValidationCode, field: String) {
+ self.code = code
+ self.field = field
+ }
+}
+
+public struct DoryWorkspaceLifecycleJournalBinding: Sendable, Equatable {
+ public let plan: DoryOperationPlan
+ public let specification: DoryOperationSpecification
+
+ public init(plan: DoryOperationPlan, specification: DoryOperationSpecification) {
+ self.plan = plan
+ self.specification = specification
+ }
+}
+
+/// Immutable lifecycle intent bound into the existing crash-safe operation journal.
+///
+/// This object contains no host paths, credentials, or mutable runtime handles. Executors append
+/// actual progress to `DoryOperationJournalStore`; this specification is the exact transition,
+/// deadline, retry, readiness, cancellation, and recovery contract they must follow.
+public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
+ public static let schemaVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var operationID: UUID
+ public var kind: DoryWorkspaceMutationKind
+ public var source: DoryWorkspaceLifecycleCondition
+ public var target: DoryWorkspaceLifecycleCondition
+ public var targetWorkspaceID: String?
+ public var createdAtUnixMilliseconds: Int64
+ public var deadlineUnixMilliseconds: Int64
+ public var steps: [DoryWorkspaceOperationStep]
+ public var readinessGates: [DoryWorkspaceReadinessGate]
+ public var retryBudgets: [DoryWorkspaceRetryBudget]
+ public var cancellationPolicy: DoryWorkspaceCancellationPolicy
+ public var recovery: DoryWorkspaceRecoveryRecipe
+
+ public init(
+ operationID: UUID = UUID(),
+ kind: DoryWorkspaceMutationKind,
+ source: DoryWorkspaceLifecycleCondition,
+ target: DoryWorkspaceLifecycleCondition,
+ targetWorkspaceID: String? = nil,
+ createdAtUnixMilliseconds: Int64,
+ deadlineUnixMilliseconds: Int64,
+ steps: [DoryWorkspaceOperationStep],
+ readinessGates: [DoryWorkspaceReadinessGate] = [],
+ retryBudgets: [DoryWorkspaceRetryBudget] = [],
+ cancellationPolicy: DoryWorkspaceCancellationPolicy,
+ recovery: DoryWorkspaceRecoveryRecipe
+ ) {
+ schemaVersion = Self.schemaVersion
+ self.operationID = operationID
+ self.kind = kind
+ self.source = source
+ self.target = target
+ self.targetWorkspaceID = targetWorkspaceID
+ self.createdAtUnixMilliseconds = createdAtUnixMilliseconds
+ self.deadlineUnixMilliseconds = deadlineUnixMilliseconds
+ self.steps = steps
+ self.readinessGates = readinessGates
+ self.retryBudgets = retryBudgets
+ self.cancellationPolicy = cancellationPolicy
+ self.recovery = recovery
+ }
+
+ public func validate() -> [DoryWorkspaceOperationValidationIssue] {
+ var issues: [DoryWorkspaceOperationValidationIssue] = []
+ func add(_ code: DoryWorkspaceOperationValidationCode, _ field: String) {
+ issues.append(DoryWorkspaceOperationValidationIssue(code: code, field: field))
+ }
+
+ if schemaVersion != Self.schemaVersion { add(.invalidSchemaVersion, "schemaVersion") }
+ if operationID == UUID.zero { add(.invalidOperationID, "operationID") }
+ if !source.isValid { add(.invalidCondition, "source") }
+ if !target.isValid { add(.invalidCondition, "target") }
+ if kind == .cloning {
+ if targetWorkspaceID != target.workspaceID {
+ add(.invalidTargetWorkspace, "targetWorkspaceID")
+ }
+ } else if source.workspaceID != target.workspaceID {
+ add(.invalidCondition, "target.workspaceID")
+ }
+ if Self.requiresResolvedSource(kind), source.resolved == nil {
+ add(.invalidCondition, "source")
+ }
+ if Self.requiresResolvedTarget(kind), target.resolved == nil {
+ add(.invalidCondition, "target")
+ }
+ if Self.requiresUnchangedResolvedPlan(kind), source.resolved != target.resolved {
+ add(.invalidCondition, "target.resolved")
+ }
+ if !Self.allows(kind: kind, source: source.state, target: target.state) {
+ add(.invalidTransition, "target.state")
+ }
+ let validTargetWorkspace = targetWorkspaceID.map {
+ DoryOperationJournalStore.isToken($0) && $0 != source.workspaceID
+ }
+ if kind == .cloning {
+ if validTargetWorkspace != true { add(.invalidTargetWorkspace, "targetWorkspaceID") }
+ } else if targetWorkspaceID != nil {
+ add(.invalidTargetWorkspace, "targetWorkspaceID")
+ }
+ if createdAtUnixMilliseconds < 0
+ || deadlineUnixMilliseconds <= createdAtUnixMilliseconds {
+ add(.invalidDeadline, "deadlineUnixMilliseconds")
+ }
+
+ let stepIDs = steps.map(\.id)
+ let stepDeadlines = steps.map(\.deadlineOffsetMilliseconds)
+ if steps.isEmpty
+ || !steps.allSatisfy(\.isValid)
+ || Set(stepIDs).count != stepIDs.count
+ || stepDeadlines != stepDeadlines.sorted()
+ || stepDeadlines.last.map(deadlineContains(offset:)) != true {
+ add(.invalidSteps, "steps")
+ }
+
+ let gateKinds = readinessGates.map(\.kind)
+ if Set(gateKinds).count != gateKinds.count
+ || readinessGates.contains(where: {
+ $0.deadlineOffsetMilliseconds == 0
+ || !deadlineContains(offset: $0.deadlineOffsetMilliseconds)
+ })
+ || (Self.requiresBackendReadiness(kind, targetState: target.state)
+ && !readinessGates.contains(where: { $0.kind == .backendRunning && $0.required })) {
+ add(.invalidReadinessGates, "readinessGates")
+ }
+
+ let failureClasses = retryBudgets.map(\.failureClass)
+ if Set(failureClasses).count != failureClasses.count
+ || !retryBudgets.allSatisfy(\.isValid) {
+ add(.invalidRetryBudgets, "retryBudgets")
+ }
+ if !recovery.isValid || !Set(recovery.stepIDs).isSubset(of: Set(stepIDs)) {
+ add(.invalidRecoveryRecipe, "recovery")
+ }
+ return issues
+ }
+
+ public func journalSpecification() throws -> DoryOperationSpecification {
+ let issues = validate()
+ guard issues.isEmpty else {
+ let fields = issues.map { "\($0.code.rawValue):\($0.field)" }.joined(separator: ",")
+ throw DoryOperationJournalError.invalidPlan("workspace lifecycle contract \(fields)")
+ }
+ return try DoryOperationSpecification(canonical: self)
+ }
+
+ /// Creates the immutable journal plan and the exact specification bytes it selects.
+ /// `dependencyClosureDigest` binds the separately resolved component/media closure.
+ public func journalBinding(
+ dependencyClosureDigest: String
+ ) throws -> DoryWorkspaceLifecycleJournalBinding {
+ guard DoryOperationJournalStore.isDigest(dependencyClosureDigest) else {
+ throw DoryOperationJournalError.invalidPlan(
+ "workspace lifecycle dependency closure digest"
+ )
+ }
+ let specification = try journalSpecification()
+ let sourceFingerprint = DoryOperationJournalStore.digest(
+ try DoryOperationJournalStore.encoded(source, pretty: false)
+ )
+ let targetFingerprint = DoryOperationJournalStore.digest(
+ try DoryOperationJournalStore.encoded(target, pretty: false)
+ )
+ let plan = DoryOperationPlan(
+ id: operationID,
+ kind: kind.journalKind,
+ createdAt: Date(timeIntervalSince1970: Double(createdAtUnixMilliseconds) / 1_000),
+ source: DoryOperationAuthority(
+ kind: .workspace,
+ id: source.workspaceID,
+ fingerprint: sourceFingerprint
+ ),
+ target: DoryOperationAuthority(
+ kind: .workspace,
+ id: target.workspaceID,
+ fingerprint: targetFingerprint
+ ),
+ selectionDigest: specification.digest,
+ dependencyClosureDigest: dependencyClosureDigest,
+ successCriteriaDigest: targetFingerprint
+ )
+ return DoryWorkspaceLifecycleJournalBinding(
+ plan: plan,
+ specification: specification
+ )
+ }
+
+ private static func requiresBackendReadiness(
+ _ kind: DoryWorkspaceMutationKind,
+ targetState: DoryWorkspaceLifecycleState
+ ) -> Bool {
+ kind == .starting || kind == .resuming
+ || (kind == .restoring && targetState == .running)
+ }
+
+ private static func requiresResolvedSource(_ kind: DoryWorkspaceMutationKind) -> Bool {
+ switch kind {
+ case .starting, .stopping, .pausing, .resuming, .suspending, .restoring,
+ .snapshotting, .cloning, .updating:
+ true
+ case .importing, .provisioning, .resolving, .repairing, .deleting:
+ false
+ }
+ }
+
+ private static func requiresResolvedTarget(_ kind: DoryWorkspaceMutationKind) -> Bool {
+ switch kind {
+ case .provisioning, .resolving, .starting, .stopping, .pausing, .resuming,
+ .suspending, .restoring, .snapshotting, .cloning, .updating, .repairing:
+ true
+ case .importing, .deleting:
+ false
+ }
+ }
+
+ private static func requiresUnchangedResolvedPlan(_ kind: DoryWorkspaceMutationKind) -> Bool {
+ switch kind {
+ case .starting, .stopping, .pausing, .resuming, .suspending, .snapshotting:
+ true
+ case .importing, .provisioning, .resolving, .restoring, .cloning, .updating,
+ .repairing, .deleting:
+ false
+ }
+ }
+
+ private func deadlineContains(offset: UInt64) -> Bool {
+ guard createdAtUnixMilliseconds >= 0,
+ deadlineUnixMilliseconds > createdAtUnixMilliseconds,
+ offset <= UInt64(Int64.max) else {
+ return false
+ }
+ let signedOffset = Int64(offset)
+ guard createdAtUnixMilliseconds <= Int64.max - signedOffset else { return false }
+ return createdAtUnixMilliseconds + signedOffset <= deadlineUnixMilliseconds
+ }
+
+ private static func allows(
+ kind: DoryWorkspaceMutationKind,
+ source: DoryWorkspaceLifecycleState,
+ target: DoryWorkspaceLifecycleState
+ ) -> Bool {
+ switch kind {
+ case .importing: source == .absent && target == .defined
+ case .provisioning: source == .defined && target == .stopped
+ case .resolving:
+ source != .absent && source != .deleting && target == source
+ case .starting: source == .stopped && target == .running
+ case .stopping: (source == .running || source == .paused) && target == .stopped
+ case .pausing: source == .running && target == .paused
+ case .resuming: source == .paused && target == .running
+ case .suspending:
+ (source == .running || source == .paused) && target == .suspended
+ case .restoring:
+ (source == .stopped || source == .suspended || source == .failed)
+ && (target == .stopped || target == .running)
+ case .snapshotting:
+ (source == .stopped || source == .running || source == .paused)
+ && target == source
+ case .cloning:
+ (source == .stopped || source == .suspended) && target == .stopped
+ case .updating:
+ (source == .stopped || source == .running) && target == source
+ case .repairing: source == .failed && target == .stopped
+ case .deleting:
+ source != .absent && source != .deleting && target == .deleting
+ }
+ }
+}
+
+extension DoryOperationJournalStore {
+ /// Atomically publishes a workspace lifecycle journal and its exact immutable contract.
+ public func begin(
+ _ binding: DoryWorkspaceLifecycleJournalBinding,
+ fileManager: FileManager = .default
+ ) throws -> DoryOperationLease {
+ let operation: DoryWorkspaceLifecycleOperation
+ do {
+ operation = try JSONDecoder().decode(
+ DoryWorkspaceLifecycleOperation.self,
+ from: binding.specification.data
+ )
+ } catch {
+ throw DoryOperationJournalError.invalidPlan(
+ "workspace lifecycle specification decoding"
+ )
+ }
+ let expected = try operation.journalBinding(
+ dependencyClosureDigest: binding.plan.dependencyClosureDigest
+ )
+ guard expected == binding else {
+ throw DoryOperationJournalError.invalidPlan(
+ "workspace lifecycle journal binding mismatch"
+ )
+ }
+ return try begin(
+ binding.plan,
+ completenessPlanData: nil,
+ specifications: [binding.specification],
+ at: Date(
+ timeIntervalSince1970: Double(operation.createdAtUnixMilliseconds) / 1_000
+ ),
+ fileManager: fileManager
+ )
+ }
+}
+
+extension DoryOperationLease {
+ /// Reloads and rebinds the immutable lifecycle contract after restart.
+ public func readWorkspaceLifecycleOperation() throws -> DoryWorkspaceLifecycleOperation {
+ let record = try read()
+ let data = try readSpecification(digest: record.plan.selectionDigest)
+ let operation: DoryWorkspaceLifecycleOperation
+ do {
+ operation = try JSONDecoder().decode(DoryWorkspaceLifecycleOperation.self, from: data)
+ } catch {
+ throw DoryOperationJournalError.invalidRecord(operationDirectory)
+ }
+ guard let expected = try? operation.journalBinding(
+ dependencyClosureDigest: record.plan.dependencyClosureDigest
+ ), expected.plan == record.plan, expected.specification.data == data else {
+ throw DoryOperationJournalError.invalidRecord(operationDirectory)
+ }
+ return operation
+ }
+}
+
+private extension UUID {
+ static let zero = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
+}
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
new file mode 100644
index 00000000..26dd0117
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
@@ -0,0 +1,261 @@
+import Foundation
+import Testing
+@testable import DoryOperations
+
+@Suite("Workspace lifecycle operation contract")
+struct DoryWorkspaceLifecycleOperationTests {
+ @Test("start binds exact state plan ABI deadlines readiness and recovery into journal bytes")
+ func validStartContract() throws {
+ let operation = makeOperation()
+ #expect(operation.validate().isEmpty)
+ #expect(operation.kind.journalKind == .workspaceStart)
+
+ let specification = try operation.journalSpecification()
+ #expect(DoryOperationJournalStore.isDigest(specification.digest))
+ let decoded = try JSONDecoder().decode(
+ DoryWorkspaceLifecycleOperation.self,
+ from: specification.data
+ )
+ #expect(decoded == operation)
+ #expect(decoded.target.resolved?.backendRuntimeBuildID == "dory-hv-1.0.0")
+ #expect(decoded.target.resolved?.virtualHardwareABIVersion == 1)
+
+ let binding = try operation.journalBinding(
+ dependencyClosureDigest: String(repeating: "b", count: 64)
+ )
+ #expect(binding.plan.id == operation.operationID)
+ #expect(binding.plan.kind == .workspaceStart)
+ #expect(binding.plan.selectionDigest == binding.specification.digest)
+ #expect(binding.plan.source.id == "workspace-one")
+ #expect(binding.plan.target.id == "workspace-one")
+ #expect(binding.plan.dependencyClosureDigest == String(repeating: "b", count: 64))
+ #expect(binding.plan.successCriteriaDigest == binding.plan.target.fingerprint)
+ }
+
+ @Test("journal atomically persists and restart rebinds the exact lifecycle contract")
+ func journalPersistenceAndTamperDetection() throws {
+ let home = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-workspace-lifecycle-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: home, withIntermediateDirectories: false)
+ defer { try? FileManager.default.removeItem(at: home) }
+
+ let operation = makeOperation()
+ let binding = try operation.journalBinding(
+ dependencyClosureDigest: String(repeating: "b", count: 64)
+ )
+ let store = try DoryOperationJournalStore(home: home.path)
+ var lease: DoryOperationLease? = try store.begin(binding)
+ #expect(try lease?.readWorkspaceLifecycleOperation() == operation)
+ lease = nil
+
+ let acquired = try store.acquire(operation.operationID)
+ #expect(try acquired.readWorkspaceLifecycleOperation() == operation)
+ let specificationPath = store.operationDirectory(for: operation.operationID)
+ + "/specs/objects/" + binding.specification.digest
+ var tampered = binding.specification.data
+ tampered.append(Data("tampered".utf8))
+ try tampered.write(to: URL(fileURLWithPath: specificationPath))
+ #expect(throws: DoryOperationJournalError.self) {
+ _ = try acquired.readWorkspaceLifecycleOperation()
+ }
+ }
+
+ @Test("all lifecycle mutation kinds have stable journal kinds")
+ func journalKinds() {
+ #expect(Set(DoryWorkspaceMutationKind.allCases.map(\.journalKind.rawValue)).count
+ == DoryWorkspaceMutationKind.allCases.count)
+ #expect(DoryWorkspaceMutationKind.deleting.journalKind == .workspaceDelete)
+ #expect(DoryWorkspaceMutationKind.snapshotting.journalKind == .workspaceSnapshot)
+ }
+
+ @Test("start requires exact resolved source target and backend readiness")
+ func startRequirements() {
+ var operation = makeOperation()
+ operation.source.resolved = nil
+ #expect(has(.invalidCondition, "source", operation))
+
+ operation = makeOperation()
+ operation.readinessGates = []
+ #expect(has(.invalidReadinessGates, "readinessGates", operation))
+
+ operation = makeOperation()
+ operation.target.state = .paused
+ #expect(has(.invalidTransition, "target.state", operation))
+ }
+
+ @Test("deadlines steps retry budgets and recovery are deterministic and bounded")
+ func executionContractValidation() {
+ var operation = makeOperation()
+ operation.steps.swapAt(0, 1)
+ #expect(has(.invalidSteps, "steps", operation))
+
+ operation = makeOperation()
+ operation.steps[1].id = operation.steps[0].id
+ #expect(has(.invalidSteps, "steps", operation))
+
+ operation = makeOperation()
+ operation.retryBudgets.append(operation.retryBudgets[0])
+ #expect(has(.invalidRetryBudgets, "retryBudgets", operation))
+
+ operation = makeOperation()
+ operation.recovery.stepIDs = ["not-in-plan"]
+ #expect(has(.invalidRecoveryRecipe, "recovery", operation))
+
+ operation = makeOperation()
+ operation.deadlineUnixMilliseconds = operation.createdAtUnixMilliseconds
+ #expect(has(.invalidDeadline, "deadlineUnixMilliseconds", operation))
+ }
+
+ @Test("clone requires a distinct target workspace and delete enters tombstone state")
+ func targetRules() {
+ let resolved = resolvedCondition()
+ var clone = DoryWorkspaceLifecycleOperation(
+ kind: .cloning,
+ source: condition(.stopped, resolved: resolved),
+ target: condition(.stopped, workspaceID: "workspace-copy", resolved: resolved),
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ deadlineUnixMilliseconds: 1_700_000_060_000,
+ steps: [step("clone", 30_000)],
+ cancellationPolicy: .beforePublish,
+ recovery: .init(disposition: .rollback, stepIDs: ["clone"])
+ )
+ #expect(has(.invalidTargetWorkspace, "targetWorkspaceID", clone))
+ clone.targetWorkspaceID = "workspace-copy"
+ #expect(clone.validate().isEmpty)
+
+ let deletion = DoryWorkspaceLifecycleOperation(
+ kind: .deleting,
+ source: condition(.stopped, resolved: resolved),
+ target: condition(.deleting, resolved: resolved),
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ deadlineUnixMilliseconds: 1_700_000_060_000,
+ steps: [step("tombstone", 10_000), step("release-artifacts", 50_000)],
+ cancellationPolicy: .prohibited,
+ recovery: .init(disposition: .repair, stepIDs: ["tombstone"])
+ )
+ #expect(deletion.validate().isEmpty)
+ }
+
+ @Test("restore readiness follows the target and an unresolved definition can be deleted")
+ func targetSensitiveRequirements() {
+ let resolved = resolvedCondition()
+ let restoreStopped = DoryWorkspaceLifecycleOperation(
+ kind: .restoring,
+ source: condition(.stopped, resolved: resolved),
+ target: condition(.stopped, resolved: resolved),
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ deadlineUnixMilliseconds: 1_700_000_060_000,
+ steps: [step("restore", 50_000)],
+ cancellationPolicy: .rollbackRequired,
+ recovery: .init(disposition: .rollback, stepIDs: ["restore"])
+ )
+ #expect(restoreStopped.validate().isEmpty)
+
+ var restoreRunning = restoreStopped
+ restoreRunning.target.state = .running
+ #expect(has(.invalidReadinessGates, "readinessGates", restoreRunning))
+
+ let deleteDefined = DoryWorkspaceLifecycleOperation(
+ kind: .deleting,
+ source: condition(.defined, resolved: nil),
+ target: condition(.deleting, resolved: nil),
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ deadlineUnixMilliseconds: 1_700_000_060_000,
+ steps: [step("tombstone", 10_000)],
+ cancellationPolicy: .prohibited,
+ recovery: .init(disposition: .repair, stepIDs: ["tombstone"])
+ )
+ #expect(deleteDefined.validate().isEmpty)
+ }
+
+ @Test("operation identifiers reject path secret and control-like values")
+ func identifierSafety() {
+ var operation = makeOperation()
+ operation.source.workspaceID = "../workspace"
+ operation.target.workspaceID = "../workspace"
+ #expect(has(.invalidCondition, "source", operation))
+ #expect(has(.invalidCondition, "target", operation))
+
+ operation = makeOperation()
+ operation.retryBudgets[0].failureClass = "token=secret"
+ #expect(has(.invalidRetryBudgets, "retryBudgets", operation))
+ }
+
+ @Test("zero UUID and absent condition payloads fail closed")
+ func identityAndAbsentCondition() {
+ var operation = makeOperation()
+ operation.operationID = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
+ #expect(has(.invalidOperationID, "operationID", operation))
+
+ operation = makeOperation()
+ operation.source = DoryWorkspaceLifecycleCondition(
+ workspaceID: "workspace-one",
+ state: .absent,
+ definitionRevision: 1
+ )
+ #expect(has(.invalidCondition, "source", operation))
+ }
+
+ private func makeOperation() -> DoryWorkspaceLifecycleOperation {
+ let resolved = resolvedCondition()
+ return DoryWorkspaceLifecycleOperation(
+ operationID: UUID(uuidString: "8f9f5b13-77ee-4f59-8a13-1704147c7f00")!,
+ kind: .starting,
+ source: condition(.stopped, resolved: resolved),
+ target: condition(.running, resolved: resolved),
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ deadlineUnixMilliseconds: 1_700_000_060_000,
+ steps: [step("validate-plan", 5_000), step("launch", 20_000), step("ready", 55_000)],
+ readinessGates: [
+ .init(kind: .backendRunning, deadlineOffsetMilliseconds: 20_000),
+ .init(kind: .firstDisplayFrame, deadlineOffsetMilliseconds: 40_000),
+ .init(kind: .guestAgent, deadlineOffsetMilliseconds: 55_000)
+ ],
+ retryBudgets: [
+ .init(failureClass: "helper-crash", maximumAttempts: 2),
+ .init(failureClass: "agent-timeout", maximumAttempts: 1)
+ ],
+ cancellationPolicy: .beforeGuestMutation,
+ recovery: .init(disposition: .rollback, stepIDs: ["validate-plan", "launch"])
+ )
+ }
+
+ private func condition(
+ _ state: DoryWorkspaceLifecycleState,
+ workspaceID: String = "workspace-one",
+ resolved: DoryWorkspaceResolvedCondition?
+ ) -> DoryWorkspaceLifecycleCondition {
+ DoryWorkspaceLifecycleCondition(
+ workspaceID: workspaceID,
+ state: state,
+ definitionRevision: 3,
+ resolved: resolved
+ )
+ }
+
+ private func resolvedCondition() -> DoryWorkspaceResolvedCondition {
+ DoryWorkspaceResolvedCondition(
+ planRevision: 4,
+ planDigest: String(repeating: "a", count: 64),
+ backendID: "dory-hv-linux",
+ backendRuntimeBuildID: "dory-hv-1.0.0",
+ virtualHardwareABIVersion: 1
+ )
+ }
+
+ private func step(_ id: String, _ deadline: UInt64) -> DoryWorkspaceOperationStep {
+ DoryWorkspaceOperationStep(
+ id: id,
+ stage: id == "ready" ? .readiness : .mutate,
+ deadlineOffsetMilliseconds: deadline
+ )
+ }
+
+ private func has(
+ _ code: DoryWorkspaceOperationValidationCode,
+ _ field: String,
+ _ operation: DoryWorkspaceLifecycleOperation
+ ) -> Bool {
+ operation.validate().contains { $0.code == code && $0.field == field }
+ }
+}
From 304542a4cd21246f91687eed137e788f1c52ffbf Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 12:06:06 +0000
Subject: [PATCH 014/338] feat(workspace): reconcile legacy projections
---
.../DorydKit/DoryWorkspaceRepository.swift | 244 ++++++++++++-
.../Sources/DorydKit/MachineManager.swift | 238 +++++++++++-
.../DoryWorkspaceRepositoryTests.swift | 119 ++++++
...chineManagerWorkspaceProjectionTests.swift | 341 ++++++++++++++++++
4 files changed, 934 insertions(+), 8 deletions(-)
create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerWorkspaceProjectionTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
index 56d64cce..4bdb1307 100644
--- a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
@@ -47,7 +47,7 @@ public enum DoryWorkspaceRepositoryError: Error, Sendable, Equatable, CustomStri
/// One durable desired-state record.
///
-/// During migration, `legacyConfigurationSHA256` binds the v2 definition to the exact canonical
+/// During migration, `legacyConfigurationSHA256` binds the v2 definition to the exact raw
/// `DoryMachineConfiguration` bytes that remain authoritative. Once all consumers use the v2
/// contract, authoritative records omit this digest and use optimistic lifecycle revisions.
public struct DoryWorkspaceRepositoryRecord: Codable, Sendable, Equatable {
@@ -55,14 +55,76 @@ public struct DoryWorkspaceRepositoryRecord: Codable, Sendable, Equatable {
public let schemaVersion: UInt16
public let legacyConfigurationSHA256: String?
+ /// Digest of canonical non-lifecycle facts used by the legacy migration bridge. Raw metadata
+ /// alone is insufficient because artifact inspection can change the boot contract in place.
+ public let legacyMigrationFactsSHA256: String?
public let definition: DoryVirtualMachineDefinition
public init(
definition: DoryVirtualMachineDefinition,
- legacyConfigurationSHA256: String? = nil
+ legacyConfigurationSHA256: String? = nil,
+ legacyMigrationFactsSHA256: String? = nil
) {
schemaVersion = Self.schemaVersion
self.legacyConfigurationSHA256 = legacyConfigurationSHA256
+ self.legacyMigrationFactsSHA256 = legacyMigrationFactsSHA256
+ self.definition = definition
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaVersion
+ case legacyConfigurationSHA256
+ case legacyMigrationFactsSHA256
+ case definition
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ schemaVersion = try container.decode(UInt16.self, forKey: .schemaVersion)
+ legacyConfigurationSHA256 = try container.decodeIfPresent(
+ String.self,
+ forKey: .legacyConfigurationSHA256
+ )
+ // Records written before facts became part of legacy authority do not contain this key.
+ legacyMigrationFactsSHA256 = try container.decodeIfPresent(
+ String.self,
+ forKey: .legacyMigrationFactsSHA256
+ )
+ definition = try container.decode(
+ DoryVirtualMachineDefinition.self,
+ forKey: .definition
+ )
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(schemaVersion, forKey: .schemaVersion)
+ try container.encodeIfPresent(
+ legacyConfigurationSHA256,
+ forKey: .legacyConfigurationSHA256
+ )
+ try container.encodeIfPresent(
+ legacyMigrationFactsSHA256,
+ forKey: .legacyMigrationFactsSHA256
+ )
+ try container.encode(definition, forKey: .definition)
+ }
+}
+
+public enum DoryWorkspaceLegacyProjectionReconcileState: String, Sendable, Equatable {
+ case unchanged
+ case published
+}
+
+public struct DoryWorkspaceLegacyProjectionReconcileResult: Sendable, Equatable {
+ public let state: DoryWorkspaceLegacyProjectionReconcileState
+ public let definition: DoryVirtualMachineDefinition
+
+ public init(
+ state: DoryWorkspaceLegacyProjectionReconcileState,
+ definition: DoryVirtualMachineDefinition
+ ) {
+ self.state = state
self.definition = definition
}
}
@@ -158,14 +220,146 @@ public final class DoryWorkspaceRepository: @unchecked Sendable {
throw DoryWorkspaceRepositoryError.staleLegacyProjection(definition.identity.id)
}
let directory = try prepareWorkspaceDirectory(id: definition.identity.id)
+ let path = directory + "/\(Self.recordFileName)"
+ let replacing = Self.pathExists(path)
+ if replacing {
+ let current = try readRecordUnlocked(id: definition.identity.id)
+ guard current.legacyConfigurationSHA256 != nil else {
+ throw DoryWorkspaceRepositoryError.legacyAuthorityRequired(definition.identity.id)
+ }
+ // This compatibility API cannot validate facts-bound projections. Require callers to
+ // use reconcileLegacyProjection rather than silently dropping half the authority.
+ guard current.legacyMigrationFactsSHA256 == nil else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(definition.identity.id)
+ }
+ guard current.definition.lifecycle.revision < UInt64.max,
+ definition.lifecycle.revision
+ == current.definition.lifecycle.revision + 1 else {
+ throw DoryWorkspaceRepositoryError.invalidRevision(
+ expected: current.definition.lifecycle.revision == UInt64.max
+ ? UInt64.max : current.definition.lifecycle.revision + 1,
+ actual: definition.lifecycle.revision
+ )
+ }
+ guard definition.identity.id == current.definition.identity.id,
+ definition.lifecycle.createdAtUnixMilliseconds
+ == current.definition.lifecycle.createdAtUnixMilliseconds else {
+ throw DoryWorkspaceRepositoryError.identityChanged(definition.identity.id)
+ }
+ } else {
+ guard definition.lifecycle.revision == 1 else {
+ throw DoryWorkspaceRepositoryError.invalidRevision(
+ expected: 1,
+ actual: definition.lifecycle.revision
+ )
+ }
+ }
let record = DoryWorkspaceRepositoryRecord(
definition: definition,
legacyConfigurationSHA256: Self.sha256(authoritativeLegacyData)
)
try publish(
record,
- path: directory + "/\(Self.recordFileName)",
- replacing: Self.pathExists(directory + "/\(Self.recordFileName)")
+ path: path,
+ replacing: replacing
+ )
+ }
+
+ /// Atomically reconcile a derived legacy projection and its two-part authority tuple.
+ ///
+ /// An exact metadata+facts restart does not rewrite or bump. Any changed bytes, changed facts,
+ /// or changed derived definition advances the revision while retaining `createdAt`. Invalid
+ /// projections can be regenerated, but a native v2 record is never overwritten by legacy.
+ public func reconcileLegacyProjection(
+ _ candidate: DoryVirtualMachineDefinition,
+ authoritativeLegacyData: Data,
+ authoritativeMigrationFactsData: Data
+ ) throws -> DoryWorkspaceLegacyProjectionReconcileResult {
+ lock.lock()
+ defer { lock.unlock() }
+ try Self.validate(candidate)
+ guard !authoritativeLegacyData.isEmpty, !authoritativeMigrationFactsData.isEmpty else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(candidate.identity.id)
+ }
+
+ let directory = try prepareWorkspaceDirectory(id: candidate.identity.id)
+ let path = directory + "/\(Self.recordFileName)"
+ let legacyDigest = Self.sha256(authoritativeLegacyData)
+ let factsDigest = Self.sha256(authoritativeMigrationFactsData)
+ let pathExists = Self.pathExists(path)
+ let current: DoryWorkspaceRepositoryRecord?
+ if pathExists {
+ do {
+ current = try readRecordUnlocked(id: candidate.identity.id)
+ } catch {
+ // A damaged legacy-derived projection is disposable and can be recreated from
+ // authority. Native v2 state and ambiguous damage must never be overwritten.
+ guard try Self.hasRecoverableLegacyAuthorityMarker(path: path) else {
+ throw error
+ }
+ current = nil
+ }
+ } else {
+ current = nil
+ }
+
+ if let current {
+ guard current.legacyConfigurationSHA256 != nil else {
+ throw DoryWorkspaceRepositoryError.legacyAuthorityRequired(candidate.identity.id)
+ }
+ var sameLifecycleCandidate = candidate
+ sameLifecycleCandidate.lifecycle = current.definition.lifecycle
+ if current.legacyConfigurationSHA256 == legacyDigest,
+ current.legacyMigrationFactsSHA256 == factsDigest,
+ current.definition == sameLifecycleCandidate {
+ return DoryWorkspaceLegacyProjectionReconcileResult(
+ state: .unchanged,
+ definition: current.definition
+ )
+ }
+ guard current.definition.lifecycle.revision < UInt64.max else {
+ throw DoryWorkspaceRepositoryError.invalidRevision(
+ expected: UInt64.max,
+ actual: UInt64.max
+ )
+ }
+ var replacement = candidate
+ replacement.lifecycle = DoryVMLifecycleMetadata(
+ revision: current.definition.lifecycle.revision + 1,
+ createdAtUnixMilliseconds: current.definition.lifecycle.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: Self.nextUpdatedTimestamp(
+ previous: current.definition.lifecycle.updatedAtUnixMilliseconds,
+ proposed: candidate.lifecycle.updatedAtUnixMilliseconds
+ )
+ )
+ try Self.validate(replacement)
+ let record = DoryWorkspaceRepositoryRecord(
+ definition: replacement,
+ legacyConfigurationSHA256: legacyDigest,
+ legacyMigrationFactsSHA256: factsDigest
+ )
+ try publish(record, path: path, replacing: true)
+ return DoryWorkspaceLegacyProjectionReconcileResult(
+ state: .published,
+ definition: replacement
+ )
+ }
+
+ guard candidate.lifecycle.revision == 1 else {
+ throw DoryWorkspaceRepositoryError.invalidRevision(
+ expected: 1,
+ actual: candidate.lifecycle.revision
+ )
+ }
+ let record = DoryWorkspaceRepositoryRecord(
+ definition: candidate,
+ legacyConfigurationSHA256: legacyDigest,
+ legacyMigrationFactsSHA256: factsDigest
+ )
+ try publish(record, path: path, replacing: pathExists)
+ return DoryWorkspaceLegacyProjectionReconcileResult(
+ state: .published,
+ definition: candidate
)
}
@@ -189,12 +383,34 @@ public final class DoryWorkspaceRepository: @unchecked Sendable {
guard let expectedDigest = record.legacyConfigurationSHA256 else {
throw DoryWorkspaceRepositoryError.invalidRecord(recordPath(id: id))
}
+ guard record.legacyMigrationFactsSHA256 == nil else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(id)
+ }
guard expectedDigest == Self.sha256(authoritativeLegacyData) else {
throw DoryWorkspaceRepositoryError.staleLegacyProjection(id)
}
return record.definition
}
+ public func readLegacyProjection(
+ id: String,
+ authoritativeLegacyData: Data,
+ authoritativeMigrationFactsData: Data
+ ) throws -> DoryVirtualMachineDefinition {
+ lock.lock()
+ defer { lock.unlock() }
+ let record = try readRecordUnlocked(id: id)
+ guard let expectedLegacyDigest = record.legacyConfigurationSHA256,
+ let expectedFactsDigest = record.legacyMigrationFactsSHA256 else {
+ throw DoryWorkspaceRepositoryError.invalidRecord(recordPath(id: id))
+ }
+ guard expectedLegacyDigest == Self.sha256(authoritativeLegacyData),
+ expectedFactsDigest == Self.sha256(authoritativeMigrationFactsData) else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(id)
+ }
+ return record.definition
+ }
+
public func remove(id: String) throws {
lock.lock()
defer { lock.unlock() }
@@ -224,7 +440,10 @@ public final class DoryWorkspaceRepository: @unchecked Sendable {
record.schemaVersion == DoryWorkspaceRepositoryRecord.schemaVersion,
record.definition.identity.id == id,
record.definition.validate().isEmpty,
- record.legacyConfigurationSHA256.map(Self.isSHA256) ?? true else {
+ record.legacyConfigurationSHA256.map(Self.isSHA256) ?? true,
+ record.legacyMigrationFactsSHA256.map(Self.isSHA256) ?? true,
+ record.legacyMigrationFactsSHA256 == nil
+ || record.legacyConfigurationSHA256 != nil else {
throw DoryWorkspaceRepositoryError.invalidRecord(path)
}
return record
@@ -461,4 +680,19 @@ public final class DoryWorkspaceRepository: @unchecked Sendable {
(byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
}
}
+
+ private static func hasRecoverableLegacyAuthorityMarker(path: String) throws -> Bool {
+ let data = try secureRead(path: path)
+ guard let object = try? JSONSerialization.jsonObject(with: data),
+ let dictionary = object as? [String: Any],
+ let digest = dictionary["legacyConfigurationSHA256"] as? String else {
+ return false
+ }
+ return isSHA256(digest)
+ }
+
+ private static func nextUpdatedTimestamp(previous: Int64, proposed: Int64) -> Int64 {
+ guard previous < Int64.max else { return Int64.max }
+ return max(previous + 1, proposed)
+ }
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index c1972d9f..d53c847f 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -561,6 +561,35 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert
}
}
+public enum DoryWorkspaceProjectionState: String, Sendable, Equatable {
+ case current
+ case regenerated
+ case unavailable
+}
+
+public enum DoryWorkspaceProjectionFailureCode: String, Sendable, Equatable {
+ case unsupportedLegacyConfiguration = "unsupported-legacy-configuration"
+ case repositoryFailure = "repository-failure"
+}
+
+/// Non-fatal migration status. Legacy `machine.json` remains runnable when a projection cannot
+/// be produced; callers can surface this diagnostic without treating the VM itself as corrupt.
+public struct DoryWorkspaceProjectionDiagnostic: Sendable, Equatable {
+ public var state: DoryWorkspaceProjectionState
+ public var failureCode: DoryWorkspaceProjectionFailureCode?
+ public var message: String?
+
+ public init(
+ state: DoryWorkspaceProjectionState,
+ failureCode: DoryWorkspaceProjectionFailureCode? = nil,
+ message: String? = nil
+ ) {
+ self.state = state
+ self.failureCode = failureCode
+ self.message = message
+ }
+}
+
public final class MachineManager: @unchecked Sendable {
public typealias AgentConnector = @Sendable (String) throws -> any AgentControlClient
public typealias ProcessStarter = @Sendable (HvProcess) throws -> Void
@@ -594,10 +623,12 @@ public final class MachineManager: @unchecked Sendable {
private let agentConnector: AgentConnector
private let balloonController: any MachineBalloonControlling
private let processStarter: ProcessStarter
+ private let workspaceRepository: DoryWorkspaceRepository
private let operationLock = NSRecursiveLock()
private let lock = NSLock()
private var machines: [String: MachineEntry] = [:]
private var deletingMachineIDs: Set = []
+ private var workspaceProjectionDiagnostics: [String: DoryWorkspaceProjectionDiagnostic] = [:]
public init(
configuration: MachineManagerConfiguration,
@@ -611,6 +642,7 @@ public final class MachineManager: @unchecked Sendable {
self.balloonController = balloonController
self.agentConnector = agentConnector
self.processStarter = processStarter
+ self.workspaceRepository = DoryWorkspaceRepository(root: configuration.stateDirectory)
_ = HelperProcessJanitor.terminateStaleHelpers(
executablePath: configuration.vmmExecutablePath,
stateDirectory: configuration.stateDirectory,
@@ -627,7 +659,9 @@ public final class MachineManager: @unchecked Sendable {
Self.removeStaleDeletionQuarantines(stateDirectory: configuration.stateDirectory)
Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory)
Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory)
+ Self.restrictWorkspaceProjectionRootIfOwned(configuration.stateDirectory)
self.machines = Self.loadPersistedMachines(configuration: configuration)
+ reconcileLoadedWorkspaceProjections()
recoverInterruptedDesktopUpdates()
}
@@ -686,6 +720,7 @@ public final class MachineManager: @unchecked Sendable {
let fileManager = FileManager.default
do {
try fileManager.createDirectory(atPath: configuration.stateDirectory, withIntermediateDirectories: true)
+ Self.restrictWorkspaceProjectionRootIfOwned(configuration.stateDirectory)
} catch {
throw MachineManagerError.persistence("could not create machine state root: \(error)")
}
@@ -744,6 +779,16 @@ public final class MachineManager: @unchecked Sendable {
do {
try ensureInstalledLinuxBootBundleIfNeeded(entry.configuration)
try materializeInstalledLinuxBootRuntimeIfNeeded(entry.configuration)
+ if let authoritativeLegacyData = Self.readPrivateMetadata(
+ path: machineConfigPath(id: entry.configuration.id)
+ ) {
+ // Boot-bundle materialization changes authoritative inspection facts without
+ // changing machine.json. Bind that fact change and advance the projection here.
+ reconcileWorkspaceProjection(
+ machine: entry.configuration,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ }
try Self.validateLaunchConfiguration(entry.configuration)
try validateManagedMachineArtifacts(entry.configuration)
try validateRuntimeAvailability(entry.configuration)
@@ -986,6 +1031,7 @@ public final class MachineManager: @unchecked Sendable {
lock.lock()
deletingMachineIDs.remove(id)
+ workspaceProjectionDiagnostics.removeValue(forKey: id)
lock.unlock()
try? FileManager.default.removeItem(atPath: machineRuntimeDirectory(id: id))
@@ -1718,6 +1764,15 @@ public final class MachineManager: @unchecked Sendable {
return statuses
}
+ public func workspaceProjectionDiagnostic(
+ id: String
+ ) -> DoryWorkspaceProjectionDiagnostic? {
+ lock.lock()
+ defer { lock.unlock() }
+ guard machines[id] != nil else { return nil }
+ return workspaceProjectionDiagnostics[id]
+ }
+
private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus {
if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true {
return DoryMachineStatus(
@@ -2629,14 +2684,13 @@ public final class MachineManager: @unchecked Sendable {
let fileManager = FileManager.default
let directory = machineStateDirectory(id: machine.id)
let temporaryPath = "\(directory)/\(Self.machineMetadataTemporaryPrefix)\(UUID().uuidString)"
+ var authoritativeLegacyData: Data?
do {
try fileManager.createDirectory(
atPath: directory,
withIntermediateDirectories: true
)
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- let data = try encoder.encode(machine)
+ let data = try DoryMachineConfigurationMigrationBridge.encodeLegacy(machine)
let path = machineConfigPath(id: machine.id)
try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic)
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath)
@@ -2645,6 +2699,7 @@ public final class MachineManager: @unchecked Sendable {
"could not publish machine metadata: \(String(cString: strerror(errno)))"
)
}
+ authoritativeLegacyData = data
} catch let error as MachineManagerError {
try? fileManager.removeItem(atPath: temporaryPath)
throw error
@@ -2652,6 +2707,164 @@ public final class MachineManager: @unchecked Sendable {
try? fileManager.removeItem(atPath: temporaryPath)
throw MachineManagerError.persistence("\(error)")
}
+ // The rename above is the commit point. Projection is intentionally best-effort and
+ // ordered afterwards: a v2 failure must never roll back or hide working legacy metadata.
+ if let authoritativeLegacyData {
+ reconcileWorkspaceProjection(
+ machine: machine,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ }
+ }
+
+ private func reconcileLoadedWorkspaceProjections() {
+ let configurations = machines.values.map(\.configuration)
+ .sorted { $0.id < $1.id }
+ for machine in configurations {
+ guard let data = Self.readPrivateMetadata(path: machineConfigPath(id: machine.id)) else {
+ setWorkspaceProjectionDiagnostic(
+ DoryWorkspaceProjectionDiagnostic(
+ state: .unavailable,
+ failureCode: .repositoryFailure,
+ message: "authoritative legacy metadata could not be reread"
+ ),
+ id: machine.id
+ )
+ continue
+ }
+ reconcileWorkspaceProjection(machine: machine, authoritativeLegacyData: data)
+ }
+ }
+
+ private func reconcileWorkspaceProjection(
+ machine: DoryMachineConfiguration,
+ authoritativeLegacyData: Data
+ ) {
+ do {
+ let facts = try workspaceMigrationFacts(for: machine)
+ let migration = try DoryMachineConfigurationMigrationBridge.migrate(
+ machine,
+ facts: facts
+ )
+ let result = try workspaceRepository.reconcileLegacyProjection(
+ migration.definition,
+ authoritativeLegacyData: authoritativeLegacyData,
+ authoritativeMigrationFactsData: try Self.workspaceMigrationAuthorityData(facts)
+ )
+ setWorkspaceProjectionDiagnostic(
+ DoryWorkspaceProjectionDiagnostic(
+ state: result.state == .unchanged ? .current : .regenerated
+ ),
+ id: machine.id
+ )
+ } catch let error as DoryMachineConfigurationMigrationError {
+ setWorkspaceProjectionDiagnostic(
+ DoryWorkspaceProjectionDiagnostic(
+ state: .unavailable,
+ failureCode: .unsupportedLegacyConfiguration,
+ message: error.localizedDescription
+ ),
+ id: machine.id
+ )
+ } catch let error as DoryWorkspaceRepositoryError {
+ setWorkspaceProjectionDiagnostic(
+ DoryWorkspaceProjectionDiagnostic(
+ state: .unavailable,
+ failureCode: .repositoryFailure,
+ message: error.description
+ ),
+ id: machine.id
+ )
+ } catch {
+ setWorkspaceProjectionDiagnostic(
+ DoryWorkspaceProjectionDiagnostic(
+ state: .unavailable,
+ failureCode: .repositoryFailure,
+ message: String(describing: error)
+ ),
+ id: machine.id
+ )
+ }
+ }
+
+ private func workspaceMigrationFacts(
+ for machine: DoryMachineConfiguration
+ ) throws -> DoryMachineConfigurationMigrationFacts {
+ let architecture: DoryGuestArchitecture
+ switch configuration.guestArchitecture.lowercased() {
+ case "arm64", "aarch64":
+ architecture = .arm64
+ case "amd64", "x86_64":
+ architecture = .x86_64
+ default:
+ throw DoryMachineConfigurationMigrationError.invalidLegacyConfiguration(
+ "unsupported guest architecture \(configuration.guestArchitecture)"
+ )
+ }
+
+ var diskInfo = stat()
+ guard lstat(machine.rootfsPath, &diskInfo) == 0,
+ (diskInfo.st_mode & S_IFMT) == S_IFREG,
+ diskInfo.st_size > 0 else {
+ throw DoryMachineConfigurationMigrationError.missingSystemDiskCapacity
+ }
+ let diskCapacity = UInt64(diskInfo.st_size)
+
+ let installedEFIBoot: DoryMachineConfigurationInstalledEFIBoot?
+ if machine.bootMode == .efi, machine.installerISOPath == nil {
+ installedEFIBoot = DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath)
+ ? .installedLinuxBootBundle : .firmwareDisk
+ } else {
+ installedEFIBoot = nil
+ }
+
+ return DoryMachineConfigurationMigrationFacts(
+ guestArchitecture: architecture,
+ systemDiskCapacityBytes: diskCapacity,
+ installedEFIBoot: installedEFIBoot,
+ lifecycle: DoryVMLifecycleMetadata(
+ revision: 1,
+ createdAtUnixMilliseconds: workspaceCreationTimestamp(id: machine.id),
+ updatedAtUnixMilliseconds: workspaceCreationTimestamp(id: machine.id)
+ )
+ )
+ }
+
+ private func workspaceCreationTimestamp(id: String) -> Int64 {
+ var info = stat()
+ guard lstat(machineStateDirectory(id: id), &info) == 0 else { return 1 }
+ let time = info.st_birthtimespec.tv_sec > 0
+ ? info.st_birthtimespec : info.st_ctimespec
+ guard time.tv_sec > 0 else { return 1 }
+ let seconds = Int64(time.tv_sec)
+ let (milliseconds, overflow) = seconds.multipliedReportingOverflow(by: 1_000)
+ guard !overflow else { return Int64.max }
+ let nanos = max(Int64(0), Int64(time.tv_nsec)) / 1_000_000
+ let (result, additionOverflow) = milliseconds.addingReportingOverflow(nanos)
+ return additionOverflow ? Int64.max : max(1, result)
+ }
+
+ private static func workspaceMigrationAuthorityData(
+ _ facts: DoryMachineConfigurationMigrationFacts
+ ) throws -> Data {
+ let authority = WorkspaceMigrationAuthorityFacts(
+ guestArchitecture: facts.guestArchitecture,
+ systemDiskCapacityBytes: facts.systemDiskCapacityBytes,
+ installedEFIBoot: facts.installedEFIBoot,
+ lifecycle: facts.lifecycle
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ return try encoder.encode(authority)
+ }
+
+ private func setWorkspaceProjectionDiagnostic(
+ _ diagnostic: DoryWorkspaceProjectionDiagnostic,
+ id: String
+ ) {
+ lock.lock()
+ workspaceProjectionDiagnostics[id] = diagnostic
+ lock.unlock()
}
private func persistSnapshot(_ snapshot: DoryMachineSnapshot) throws {
@@ -3185,6 +3398,18 @@ public final class MachineManager: @unchecked Sendable {
return lstat(path, &info) == 0 && isPrivateDirectory(info: info)
}
+ /// Workspace records share MachineManager's state root. Tighten an existing owned directory
+ /// without following links; failure merely leaves projection publishing unavailable.
+ private static func restrictWorkspaceProjectionRootIfOwned(_ path: String) {
+ var info = stat()
+ guard lstat(path, &info) == 0,
+ (info.st_mode & S_IFMT) == S_IFDIR,
+ info.st_uid == getuid() else {
+ return
+ }
+ _ = chmod(path, mode_t(0o700))
+ }
+
private static func isDirectory(path: String) -> Bool {
var info = stat()
return lstat(path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFDIR
@@ -3838,6 +4063,13 @@ private enum MachineSnapshotBundle {
}
}
+private struct WorkspaceMigrationAuthorityFacts: Codable {
+ var guestArchitecture: DoryGuestArchitecture
+ var systemDiskCapacityBytes: UInt64?
+ var installedEFIBoot: DoryMachineConfigurationInstalledEFIBoot?
+ var lifecycle: DoryVMLifecycleMetadata
+}
+
private struct MachineEntry {
var configuration: DoryMachineConfiguration
var state: DoryMachineState
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift
index 403f3120..b6fba079 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryWorkspaceRepositoryTests.swift
@@ -66,9 +66,124 @@ struct DoryWorkspaceRepositoryTests {
authoritativeLegacyData: secondLegacy
) == reconciled
)
+ #expect(throws: DoryWorkspaceRepositoryError.invalidRevision(expected: 3, actual: 1)) {
+ try repository.publishLegacyProjection(
+ workspace,
+ authoritativeLegacyData: firstLegacy
+ )
+ }
+ #expect(
+ try repository.readLegacyProjection(
+ id: workspace.identity.id,
+ authoritativeLegacyData: secondLegacy
+ ) == reconciled
+ )
+ }
+ }
+
+ @Test("facts-bound projections require both exact authorities")
+ func factsBoundProjectionAuthority() throws {
+ try withRepository { repository, _ in
+ let legacy = Data("legacy-v1".utf8)
+ let factsA = Data("facts-a".utf8)
+ let factsB = Data("facts-b".utf8)
+ let workspace = definition()
+
+ let result = try repository.reconcileLegacyProjection(
+ workspace,
+ authoritativeLegacyData: legacy,
+ authoritativeMigrationFactsData: factsA
+ )
+ #expect(result.state == .published)
+ #expect(
+ try repository.readLegacyProjection(
+ id: workspace.identity.id,
+ authoritativeLegacyData: legacy,
+ authoritativeMigrationFactsData: factsA
+ ) == workspace
+ )
+ #expect(throws: DoryWorkspaceRepositoryError.staleLegacyProjection(workspace.identity.id)) {
+ _ = try repository.readLegacyProjection(
+ id: workspace.identity.id,
+ authoritativeLegacyData: legacy,
+ authoritativeMigrationFactsData: factsB
+ )
+ }
+ #expect(throws: DoryWorkspaceRepositoryError.staleLegacyProjection(workspace.identity.id)) {
+ _ = try repository.readLegacyProjection(
+ id: workspace.identity.id,
+ authoritativeLegacyData: legacy
+ )
+ }
+ #expect(throws: DoryWorkspaceRepositoryError.staleLegacyProjection(workspace.identity.id)) {
+ try repository.publishLegacyProjection(
+ workspace,
+ authoritativeLegacyData: legacy
+ )
+ }
+ }
+ }
+
+ @Test("legacy publication cannot overwrite native or ambiguously corrupt v2 state")
+ func legacyCannotOverwriteNativeState() throws {
+ try withRepository { repository, root in
+ let workspace = definition()
+ try repository.create(workspace)
+ let path = recordPath(root: root, id: workspace.identity.id)
+ let nativeBytes = try Data(contentsOf: URL(fileURLWithPath: path))
+
+ #expect(throws: DoryWorkspaceRepositoryError.legacyAuthorityRequired(workspace.identity.id)) {
+ try repository.publishLegacyProjection(
+ workspace,
+ authoritativeLegacyData: Data("legacy".utf8)
+ )
+ }
+ #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == nativeBytes)
+
+ let corruptNative = Data("{\"schemaVersion\":1,\"definition\":{}}".utf8)
+ try corruptNative.write(to: URL(fileURLWithPath: path), options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ #expect(throws: DoryWorkspaceRepositoryError.self) {
+ _ = try repository.reconcileLegacyProjection(
+ workspace,
+ authoritativeLegacyData: Data("legacy".utf8),
+ authoritativeMigrationFactsData: Data("facts".utf8)
+ )
+ }
+ #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == corruptNative)
}
}
+ @Test("golden prior repository record decodes without migration facts digest")
+ func priorRecordCompatibility() throws {
+ let workspace = definition()
+ let definitionJSON = try #require(String(
+ data: JSONEncoder().encode(workspace),
+ encoding: .utf8
+ ))
+ let legacyDigest = String(repeating: "a", count: 64)
+ let priorRecord = Data(
+ """
+ {
+ "definition": \(definitionJSON),
+ "legacyConfigurationSHA256": "\(legacyDigest)",
+ "schemaVersion": 1
+ }
+ """.utf8
+ )
+
+ let decoded = try JSONDecoder().decode(
+ DoryWorkspaceRepositoryRecord.self,
+ from: priorRecord
+ )
+ #expect(decoded.definition == workspace)
+ #expect(decoded.legacyConfigurationSHA256 == legacyDigest)
+ #expect(decoded.legacyMigrationFactsSHA256 == nil)
+ }
+
@Test("invalid definitions and revision or identity changes fail before publication")
func validationAndRevisionSafety() throws {
try withRepository { repository, root in
@@ -212,4 +327,8 @@ struct DoryWorkspaceRepositoryTests {
)
)
}
+
+ private func recordPath(root: String, id: String) -> String {
+ root + "/" + id + "/" + DoryWorkspaceRepository.recordFileName
+ }
}
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerWorkspaceProjectionTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerWorkspaceProjectionTests.swift
new file mode 100644
index 00000000..77257069
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerWorkspaceProjectionTests.swift
@@ -0,0 +1,341 @@
+import CryptoKit
+import DoryOperations
+import Foundation
+import Testing
+@testable import DorydKit
+
+@Suite("MachineManager workspace projection integration")
+struct MachineManagerWorkspaceProjectionTests {
+ private let gibibyte: UInt64 = 1_073_741_824
+
+ @Test("create update reload and revert preserve authority with monotonic revisions")
+ func createUpdateReloadAndRevert() throws {
+ try withStateRoot("lifecycle") { base, state in
+ let hostShare = base + "/host-secret"
+ try FileManager.default.createDirectory(
+ atPath: hostShare,
+ withIntermediateDirectories: false
+ )
+ let manager = makeManager(state: state)
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ memoryMB: 2_048,
+ cpuCount: 2,
+ displayMode: .desktop,
+ shares: [DoryMachineShareConfiguration(
+ tag: "source",
+ hostPath: hostShare,
+ guestPath: "/workspace/source"
+ )],
+ environment: ["PRIVATE_TOKEN": "projection-must-not-persist-this"]
+ ))
+
+ let firstLegacy = try legacyData(state: state, id: "dev")
+ let firstRecord = try record(state: state, id: "dev")
+ #expect(firstRecord.definition.lifecycle.revision == 1)
+ #expect(firstRecord.legacyConfigurationSHA256 == sha256(firstLegacy))
+ #expect(firstRecord.legacyMigrationFactsSHA256 != nil)
+ #expect(manager.workspaceProjectionDiagnostic(id: "dev")?.state == .regenerated)
+
+ let workspaceJSON = try #require(String(
+ data: Data(contentsOf: URL(fileURLWithPath: recordPath(state: state, id: "dev"))),
+ encoding: .utf8
+ ))
+ #expect(!workspaceJSON.contains(hostShare))
+ #expect(!workspaceJSON.contains("projection-must-not-persist-this"))
+ #expect(!workspaceJSON.contains(state + "/dev/rootfs.ext4"))
+ let machineJSON = try #require(String(data: firstLegacy, encoding: .utf8))
+ #expect(machineJSON.contains("projection-must-not-persist-this"))
+
+ _ = try manager.update(id: "dev", memoryMB: 4_096)
+ let secondRecord = try record(state: state, id: "dev")
+ #expect(secondRecord.definition.lifecycle.revision == 2)
+ #expect(secondRecord.definition.lifecycle.createdAtUnixMilliseconds
+ == firstRecord.definition.lifecycle.createdAtUnixMilliseconds)
+ #expect(secondRecord.definition.lifecycle.updatedAtUnixMilliseconds
+ > firstRecord.definition.lifecycle.updatedAtUnixMilliseconds)
+ #expect(secondRecord.definition.resources.memoryBytes == 4 * gibibyte)
+ #expect(secondRecord.legacyConfigurationSHA256
+ == sha256(try legacyData(state: state, id: "dev")))
+
+ let recordBytesBeforeReload = try Data(
+ contentsOf: URL(fileURLWithPath: recordPath(state: state, id: "dev"))
+ )
+ let reloaded = makeManager(state: state)
+ #expect(reloaded.status(id: "dev")?.memoryMB == 4_096)
+ #expect(reloaded.workspaceProjectionDiagnostic(id: "dev")?.state == .current)
+ #expect(try Data(contentsOf: URL(
+ fileURLWithPath: recordPath(state: state, id: "dev")
+ )) == recordBytesBeforeReload)
+
+ _ = try reloaded.update(id: "dev", memoryMB: 2_048)
+ let reverted = try record(state: state, id: "dev")
+ #expect(reverted.definition.lifecycle.revision == 3)
+ #expect(reverted.definition.lifecycle.createdAtUnixMilliseconds
+ == firstRecord.definition.lifecycle.createdAtUnixMilliseconds)
+ #expect(reverted.definition.resources.memoryBytes == 2 * gibibyte)
+ }
+ }
+
+ @Test("noncanonical authoritative bytes remain untouched and missing projection is repaired")
+ func noncanonicalLegacyBytesRemainAuthoritative() throws {
+ try withStateRoot("noncanonical") { _, state in
+ let directory = state + "/legacy"
+ try FileManager.default.createDirectory(
+ atPath: directory,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ _ = chmod(directory, mode_t(0o700))
+ let kernel = directory + "/kernel"
+ let rootfs = directory + "/rootfs.ext4"
+ try writePrivate(Data("kernel".utf8), path: kernel)
+ try writePrivate(Data("rootfs".utf8), path: rootfs)
+ let raw = Data(
+ "{\"rootfsPath\":\"\(rootfs)\",\"id\":\"legacy\","
+ .appending("\"kernelPath\":\"\(kernel)\"}").utf8
+ )
+ try writePrivate(raw, path: directory + "/machine.json")
+
+ let manager = makeManager(state: state)
+ #expect(manager.status(id: "legacy")?.memoryMB == 2_048)
+ #expect(manager.workspaceProjectionDiagnostic(id: "legacy")?.state == .regenerated)
+ #expect(try legacyData(state: state, id: "legacy") == raw)
+ let projected = try record(state: state, id: "legacy")
+ #expect(projected.legacyConfigurationSHA256 == sha256(raw))
+
+ let projectionBytes = try Data(
+ contentsOf: URL(fileURLWithPath: recordPath(state: state, id: "legacy"))
+ )
+ let restarted = makeManager(state: state)
+ #expect(restarted.workspaceProjectionDiagnostic(id: "legacy")?.state == .current)
+ #expect(try legacyData(state: state, id: "legacy") == raw)
+ #expect(try Data(contentsOf: URL(
+ fileURLWithPath: recordPath(state: state, id: "legacy")
+ )) == projectionBytes)
+
+ try FileManager.default.removeItem(atPath: recordPath(state: state, id: "legacy"))
+ let repaired = makeManager(state: state)
+ #expect(repaired.workspaceProjectionDiagnostic(id: "legacy")?.state == .regenerated)
+ #expect(FileManager.default.fileExists(
+ atPath: recordPath(state: state, id: "legacy")
+ ))
+ #expect(try legacyData(state: state, id: "legacy") == raw)
+ }
+ }
+
+ @Test("stale projection is repaired from crash-ordered legacy metadata")
+ func staleProjectionRepair() throws {
+ try withStateRoot("stale") { _, state in
+ let manager = makeManager(state: state)
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ memoryMB: 2_048
+ ))
+ let first = try record(state: state, id: "dev")
+
+ let path = state + "/dev/machine.json"
+ var authoritative = try JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: try legacyData(state: state, id: "dev")
+ )
+ authoritative.memoryMB = 6_144
+ let changedBytes = try DoryMachineConfigurationMigrationBridge.encodeLegacy(authoritative)
+ try writePrivate(changedBytes, path: path)
+
+ let recovered = makeManager(state: state)
+ #expect(recovered.status(id: "dev")?.memoryMB == 6_144)
+ #expect(recovered.workspaceProjectionDiagnostic(id: "dev")?.state == .regenerated)
+ let repaired = try record(state: state, id: "dev")
+ #expect(repaired.definition.lifecycle.revision == first.definition.lifecycle.revision + 1)
+ #expect(repaired.definition.resources.memoryBytes == 6 * gibibyte)
+ #expect(repaired.legacyConfigurationSHA256 == sha256(changedBytes))
+ }
+ }
+
+ @Test("inspection fact changes advance projection without rewriting machine metadata")
+ func inspectionFactsAreAuthority() throws {
+ try withStateRoot("facts") { _, state in
+ let manager = makeManager(state: state)
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "installed",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ bootMode: .efi,
+ memoryMB: 4_096,
+ cpuCount: 4,
+ displayMode: .desktop
+ ))
+ let rawBefore = try legacyData(state: state, id: "installed")
+ let firmware = try record(state: state, id: "installed")
+ #expect(firmware.definition.boot.devices[0].kind == .virtualDisk)
+
+ try DoryInstalledLinuxBootBundle.write(
+ assets: DoryLinuxInstallerBootAssets(
+ kernel: Data(repeating: 0x41, count: 4_096),
+ initrd: Data(repeating: 0x42, count: 8_192),
+ kernelISOPath: "casper/vmlinuz",
+ initrdISOPath: "casper/initrd"
+ ),
+ rootDevice: "/dev/vda2",
+ toPath: state + "/installed/kernel"
+ )
+
+ let reconciled = makeManager(state: state)
+ #expect(reconciled.workspaceProjectionDiagnostic(id: "installed")?.state == .regenerated)
+ #expect(try legacyData(state: state, id: "installed") == rawBefore)
+ let direct = try record(state: state, id: "installed")
+ #expect(direct.definition.lifecycle.revision == firmware.definition.lifecycle.revision + 1)
+ #expect(direct.definition.lifecycle.createdAtUnixMilliseconds
+ == firmware.definition.lifecycle.createdAtUnixMilliseconds)
+ #expect(direct.definition.boot.devices[0].kind == .installedLinuxBootBundle)
+ #expect(direct.legacyConfigurationSHA256 == firmware.legacyConfigurationSHA256)
+ #expect(direct.legacyMigrationFactsSHA256 != firmware.legacyMigrationFactsSHA256)
+ }
+ }
+
+ @Test("projection failure keeps disk and in-memory legacy configuration consistent")
+ func projectionFailureAfterLegacyCommitIsNonfatal() throws {
+ try withStateRoot("projection-failure") { _, state in
+ let manager = makeManager(state: state)
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ memoryMB: 2_048
+ ))
+ let projectionPath = recordPath(state: state, id: "dev")
+ try FileManager.default.removeItem(atPath: projectionPath)
+ try FileManager.default.createDirectory(
+ atPath: projectionPath,
+ withIntermediateDirectories: false,
+ attributes: [.posixPermissions: 0o700]
+ )
+
+ let status = try manager.update(id: "dev", memoryMB: 4_096)
+ #expect(status.memoryMB == 4_096)
+ let persisted = try JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: try legacyData(state: state, id: "dev")
+ )
+ #expect(persisted.memoryMB == 4_096)
+ let diagnostic = try #require(manager.workspaceProjectionDiagnostic(id: "dev"))
+ #expect(diagnostic.state == .unavailable)
+ #expect(diagnostic.failureCode == .repositoryFailure)
+
+ try FileManager.default.removeItem(atPath: projectionPath)
+ let repaired = makeManager(state: state)
+ #expect(repaired.status(id: "dev")?.memoryMB == 4_096)
+ #expect(repaired.workspaceProjectionDiagnostic(id: "dev")?.state == .regenerated)
+ }
+ }
+
+ @Test("unsupported migration facts do not prevent legacy create or load")
+ func unsupportedFactsRemainDiagnosticOnly() throws {
+ try withStateRoot("unsupported") { _, state in
+ let manager = makeManager(state: state, architecture: "mips64")
+ let created = try manager.create(DoryMachineConfiguration(
+ id: "legacy",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath
+ ))
+ #expect(created.state == .created)
+ let diagnostic = try #require(manager.workspaceProjectionDiagnostic(id: "legacy"))
+ #expect(diagnostic.state == .unavailable)
+ #expect(diagnostic.failureCode == .unsupportedLegacyConfiguration)
+ #expect(FileManager.default.fileExists(atPath: state + "/legacy/machine.json"))
+ #expect(!FileManager.default.fileExists(
+ atPath: recordPath(state: state, id: "legacy")
+ ))
+
+ let reloaded = makeManager(state: state, architecture: "mips64")
+ #expect(reloaded.status(id: "legacy")?.state == .stopped)
+ #expect(reloaded.workspaceProjectionDiagnostic(id: "legacy")?.failureCode
+ == .unsupportedLegacyConfiguration)
+ }
+ }
+
+ @Test("snapshot restore publishes the restored authoritative configuration")
+ func snapshotRestoreProjection() throws {
+ try withStateRoot("restore") { _, state in
+ let manager = makeManager(state: state)
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ memoryMB: 2_048
+ ))
+ _ = try manager.snapshot(id: "dev", snapshotID: "before")
+ _ = try manager.update(id: "dev", memoryMB: 4_096)
+ #expect(try record(state: state, id: "dev").definition.lifecycle.revision == 2)
+
+ let restored = try manager.restoreSnapshot(machineID: "dev", snapshotID: "before")
+ #expect(restored.memoryMB == 2_048)
+ let projection = try record(state: state, id: "dev")
+ #expect(projection.definition.lifecycle.revision == 3)
+ #expect(projection.definition.resources.memoryBytes == 2 * gibibyte)
+ #expect(projection.legacyConfigurationSHA256
+ == sha256(try legacyData(state: state, id: "dev")))
+ }
+ }
+
+ private func makeManager(
+ state: String,
+ architecture: String = "arm64"
+ ) -> MachineManager {
+ MachineManager(configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ stateDirectory: state,
+ passMachineArguments: false,
+ requiresReadyHandoff: false,
+ guestArchitecture: architecture
+ ))
+ }
+
+ private func withStateRoot(
+ _ label: String,
+ _ body: (String, String) throws -> Void
+ ) throws {
+ let base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-projection-\(label)-\(UUID().uuidString.lowercased())")
+ .path
+ try FileManager.default.createDirectory(
+ atPath: base,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ defer { try? FileManager.default.removeItem(atPath: base) }
+ try body(base, base + "/machines")
+ }
+
+ private func legacyData(state: String, id: String) throws -> Data {
+ try Data(contentsOf: URL(fileURLWithPath: state + "/\(id)/machine.json"))
+ }
+
+ private func record(state: String, id: String) throws -> DoryWorkspaceRepositoryRecord {
+ try JSONDecoder().decode(
+ DoryWorkspaceRepositoryRecord.self,
+ from: Data(contentsOf: URL(fileURLWithPath: recordPath(state: state, id: id)))
+ )
+ }
+
+ private func recordPath(state: String, id: String) -> String {
+ state + "/\(id)/" + DoryWorkspaceRepository.recordFileName
+ }
+
+ private func writePrivate(_ data: Data, path: String) throws {
+ try data.write(to: URL(fileURLWithPath: path), options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ }
+
+ private func sha256(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
From cf826ab3e458f9ccd81ad3fd8dc0f5129addb1ce Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 12:14:52 +0000
Subject: [PATCH 015/338] feat(vm): persist resolved launch plans
---
.../DorydKit/DoryResolvedMachinePlan.swift | 1526 +++++++++++++++++
.../DoryResolvedMachinePlanRepository.swift | 423 +++++
.../DoryResolvedMachinePlanTests.swift | 927 ++++++++++
3 files changed, 2876 insertions(+)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
create mode 100644 dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlanRepository.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
new file mode 100644
index 00000000..365c8039
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
@@ -0,0 +1,1526 @@
+import DoryOperations
+import Foundation
+
+public enum DoryResolvedMachinePlanMigrationDisposition: String, Codable, Sendable, Hashable {
+ case current
+ /// The record can be decoded for migration, but cannot authorize a launch.
+ case requiresReplanning = "requires-replanning"
+}
+
+public struct DoryResolvedBackendComponentEvidence: Codable, Sendable, Equatable, Hashable {
+ public var componentIdentifier: String
+ public var buildIdentifier: String
+ public var artifactSHA256: String
+
+ public init(
+ componentIdentifier: String,
+ buildIdentifier: String,
+ artifactSHA256: String
+ ) {
+ self.componentIdentifier = componentIdentifier
+ self.buildIdentifier = buildIdentifier
+ self.artifactSHA256 = artifactSHA256
+ }
+}
+
+/// Exact resolved media identity. Immutable media is content-addressed; mutable disks use a
+/// daemon-issued provenance revision and receipt instead of pretending their bytes never change.
+public struct DoryResolvedMachineBootMedia: Codable, Sendable, Equatable, Hashable {
+ public var resolverReference: DoryVMResolverReference?
+ public var media: DoryBootMedia
+ public var inspectionEvidence: DoryBootMediaInspectionAuditEvidence?
+ public var mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence?
+
+ public init(
+ resolverReference: DoryVMResolverReference?,
+ media: DoryBootMedia,
+ inspectionEvidence: DoryBootMediaInspectionAuditEvidence? = nil,
+ mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? = nil
+ ) {
+ self.resolverReference = resolverReference
+ self.media = media
+ self.inspectionEvidence = inspectionEvidence
+ self.mutableProvenanceEvidence = mutableProvenanceEvidence
+ }
+}
+
+/// Persisted audit references only. Signature verification and qualification decisions remain
+/// daemon-owned; these references let the daemon resolve and verify the exact evidence again.
+public struct DoryResolvedMachineQualificationEvidence: Codable, Sendable, Equatable, Hashable {
+ public var graphics: DorySignedArtifactQualificationEvidence?
+ public var runtime: DoryVirtualMachineRuntimeQualificationEvidence?
+
+ public init(
+ graphics: DorySignedArtifactQualificationEvidence? = nil,
+ runtime: DoryVirtualMachineRuntimeQualificationEvidence? = nil
+ ) {
+ self.graphics = graphics
+ self.runtime = runtime
+ }
+}
+
+/// Exact resource admission captured by the daemon policy immediately before plan publication.
+/// The snapshot includes existing commitments and the reserve retained for macOS.
+public struct DoryResolvedMachineResourceAdmissionEvidence: Codable, Sendable, Equatable, Hashable {
+ public var admittedVirtualCPUCount: UInt64
+ public var admittedMemoryBytes: UInt64
+ public var admittedStorageBytes: UInt64
+ public var hostLogicalCPUCount: UInt64
+ public var hostPhysicalMemoryBytes: UInt64
+ public var hostFreeStorageBytes: UInt64
+ public var existingVirtualCPUCommitment: UInt64
+ public var existingMemoryCommitmentBytes: UInt64
+ public var existingStorageReservationBytes: UInt64
+ public var hostReservedLogicalCPUCount: UInt64
+ public var hostReservedMemoryBytes: UInt64
+ public var hostReservedStorageBytes: UInt64
+ public var admissionIdentity: String
+ public var admissionReportSHA256: String
+ public var assessorIdentifier: String
+ public var assessorVersion: UInt16
+
+ public init(
+ admittedVirtualCPUCount: UInt64,
+ admittedMemoryBytes: UInt64,
+ admittedStorageBytes: UInt64,
+ hostLogicalCPUCount: UInt64,
+ hostPhysicalMemoryBytes: UInt64,
+ hostFreeStorageBytes: UInt64,
+ existingVirtualCPUCommitment: UInt64,
+ existingMemoryCommitmentBytes: UInt64,
+ existingStorageReservationBytes: UInt64,
+ hostReservedLogicalCPUCount: UInt64,
+ hostReservedMemoryBytes: UInt64,
+ hostReservedStorageBytes: UInt64,
+ admissionIdentity: String,
+ admissionReportSHA256: String,
+ assessorIdentifier: String,
+ assessorVersion: UInt16
+ ) {
+ self.admittedVirtualCPUCount = admittedVirtualCPUCount
+ self.admittedMemoryBytes = admittedMemoryBytes
+ self.admittedStorageBytes = admittedStorageBytes
+ self.hostLogicalCPUCount = hostLogicalCPUCount
+ self.hostPhysicalMemoryBytes = hostPhysicalMemoryBytes
+ self.hostFreeStorageBytes = hostFreeStorageBytes
+ self.existingVirtualCPUCommitment = existingVirtualCPUCommitment
+ self.existingMemoryCommitmentBytes = existingMemoryCommitmentBytes
+ self.existingStorageReservationBytes = existingStorageReservationBytes
+ self.hostReservedLogicalCPUCount = hostReservedLogicalCPUCount
+ self.hostReservedMemoryBytes = hostReservedMemoryBytes
+ self.hostReservedStorageBytes = hostReservedStorageBytes
+ self.admissionIdentity = admissionIdentity
+ self.admissionReportSHA256 = admissionReportSHA256
+ self.assessorIdentifier = assessorIdentifier
+ self.assessorVersion = assessorVersion
+ }
+}
+
+/// Explicit host qualification key. A generic runtime qualification identity is not sufficient:
+/// backend conformance also depends on the exact Mac model and operating-system build.
+public struct DoryResolvedHostQualificationEvidence: Codable, Sendable, Equatable, Hashable {
+ public var qualificationIdentity: String
+ public var qualificationReportSHA256: String
+ public var hostHardwareModelIdentifier: String
+ public var hostOperatingSystemBuild: String
+ public var backend: DoryVirtualizationBackendIdentity
+ public var backendRuntimeBuildIdentifier: String
+ public var virtualHardwareABIVersion: UInt16
+ public var qualifierIdentifier: String
+ public var qualifierVersion: UInt16
+
+ public init(
+ qualificationIdentity: String,
+ qualificationReportSHA256: String,
+ hostHardwareModelIdentifier: String,
+ hostOperatingSystemBuild: String,
+ backend: DoryVirtualizationBackendIdentity,
+ backendRuntimeBuildIdentifier: String,
+ virtualHardwareABIVersion: UInt16,
+ qualifierIdentifier: String,
+ qualifierVersion: UInt16
+ ) {
+ self.qualificationIdentity = qualificationIdentity
+ self.qualificationReportSHA256 = qualificationReportSHA256
+ self.hostHardwareModelIdentifier = hostHardwareModelIdentifier
+ self.hostOperatingSystemBuild = hostOperatingSystemBuild
+ self.backend = backend
+ self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.qualifierIdentifier = qualifierIdentifier
+ self.qualifierVersion = qualifierVersion
+ }
+}
+
+/// Explicit opt-in captured when an experimental product support tier is selected.
+public struct DoryResolvedExperimentalSupportAuthorization: Codable, Sendable, Equatable, Hashable {
+ public var authorizationIdentity: String
+ public var definitionRevision: UInt64
+ public var backend: DoryVirtualizationBackendIdentity
+ public var authorizedAtUnixMilliseconds: Int64
+
+ public init(
+ authorizationIdentity: String,
+ definitionRevision: UInt64,
+ backend: DoryVirtualizationBackendIdentity,
+ authorizedAtUnixMilliseconds: Int64
+ ) {
+ self.authorizationIdentity = authorizationIdentity
+ self.definitionRevision = definitionRevision
+ self.backend = backend
+ self.authorizedAtUnixMilliseconds = authorizedAtUnixMilliseconds
+ }
+}
+
+public enum DoryResolvedMachineCandidateRejection: String, Codable, Sendable, Hashable {
+ case unavailable
+ case experimentalNotAuthorized = "experimental-not-authorized"
+}
+
+public struct DoryResolvedMachineRejectedCandidate: Codable, Sendable, Equatable, Hashable {
+ public var backend: DoryVirtualizationBackendIdentity
+ public var graphics: DoryGraphicsAccelerationLevel
+ public var availability: DoryCapabilityAvailability
+ public var rejection: DoryResolvedMachineCandidateRejection
+
+ public init(
+ backend: DoryVirtualizationBackendIdentity,
+ graphics: DoryGraphicsAccelerationLevel,
+ availability: DoryCapabilityAvailability,
+ rejection: DoryResolvedMachineCandidateRejection
+ ) {
+ self.backend = backend
+ self.graphics = graphics
+ self.availability = availability
+ self.rejection = rejection
+ }
+}
+
+public struct DoryResolvedMachineFallbackAuthorization: Codable, Sendable, Equatable, Hashable {
+ public var authorizationIdentity: String
+ public var definitionRevision: UInt64
+ public var fromBackend: DoryVirtualizationBackendIdentity
+ public var fromGraphics: DoryGraphicsAccelerationLevel
+ public var toBackend: DoryVirtualizationBackendIdentity
+ public var toGraphics: DoryGraphicsAccelerationLevel
+ public var authorizedAtUnixMilliseconds: Int64
+
+ public init(
+ authorizationIdentity: String,
+ definitionRevision: UInt64,
+ fromBackend: DoryVirtualizationBackendIdentity,
+ fromGraphics: DoryGraphicsAccelerationLevel,
+ toBackend: DoryVirtualizationBackendIdentity,
+ toGraphics: DoryGraphicsAccelerationLevel,
+ authorizedAtUnixMilliseconds: Int64
+ ) {
+ self.authorizationIdentity = authorizationIdentity
+ self.definitionRevision = definitionRevision
+ self.fromBackend = fromBackend
+ self.fromGraphics = fromGraphics
+ self.toBackend = toBackend
+ self.toGraphics = toGraphics
+ self.authorizedAtUnixMilliseconds = authorizedAtUnixMilliseconds
+ }
+}
+
+public enum DoryResolvedMachineSelectionDisposition: String, Codable, Sendable, Hashable {
+ case primary
+ /// A later backend or graphics level explicitly listed by a required planner request.
+ case explicitAlternative = "explicit-alternative"
+ case approvedFallback = "approved-fallback"
+}
+
+/// Complete, non-secret selection trace. The original planner request is retained so diagnostics
+/// can distinguish an explicit primary choice from an approved fallback.
+public struct DoryResolvedMachineBackendSelectionEvidence: Codable, Sendable, Equatable, Hashable {
+ public var disposition: DoryResolvedMachineSelectionDisposition
+ public var plannerRequest: DoryVirtualMachineBackendPlanRequest
+ public var selectedEvaluationIndex: UInt32
+ public var rejectedCandidates: [DoryResolvedMachineRejectedCandidate]
+ public var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization?
+
+ public init(
+ disposition: DoryResolvedMachineSelectionDisposition,
+ plannerRequest: DoryVirtualMachineBackendPlanRequest,
+ selectedEvaluationIndex: UInt32,
+ rejectedCandidates: [DoryResolvedMachineRejectedCandidate],
+ fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? = nil
+ ) {
+ self.disposition = disposition
+ self.plannerRequest = plannerRequest
+ self.selectedEvaluationIndex = selectedEvaluationIndex
+ self.rejectedCandidates = rejectedCandidates
+ self.fallbackAuthorization = fallbackAuthorization
+ }
+
+ public static func resolving(
+ request: DoryVirtualMachineBackendPlanRequest,
+ result: DoryVirtualMachineBackendPlanResult,
+ definitionRevision: UInt64,
+ fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? = nil
+ ) throws -> DoryResolvedMachineBackendSelectionEvidence {
+ guard result.failure == nil,
+ let selected = result.selectedDescriptor,
+ let selectedIndex = result.evaluatedDescriptors.firstIndex(of: selected),
+ selectedIndex <= Int(UInt32.max),
+ selected.availability.isUsable,
+ selected.request.guest == request.guest,
+ selected.request.bootMedia == request.bootMedia,
+ selected.request.devices == request.devices,
+ selected.request.virtualHardwareABIVersion == request.virtualHardwareABIVersion,
+ request.acceptableGraphics.contains(selected.request.graphics) else {
+ throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid
+ }
+ guard let orderedCandidates = orderedCandidates(for: request),
+ orderedCandidates.indices.contains(selectedIndex),
+ orderedCandidates[selectedIndex].0 == selected.request.backend,
+ orderedCandidates[selectedIndex].1 == selected.request.graphics else {
+ throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid
+ }
+
+ var rejected: [DoryResolvedMachineRejectedCandidate] = []
+ for (index, descriptor) in result.evaluatedDescriptors.prefix(selectedIndex).enumerated() {
+ guard descriptor.request.guest == request.guest,
+ descriptor.request.bootMedia == request.bootMedia,
+ descriptor.request.devices == request.devices,
+ descriptor.request.virtualHardwareABIVersion == request.virtualHardwareABIVersion,
+ descriptor.request.backend == orderedCandidates[index].0,
+ descriptor.request.graphics == orderedCandidates[index].1 else {
+ throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid
+ }
+ let rejection: DoryResolvedMachineCandidateRejection
+ if !descriptor.availability.isUsable {
+ rejection = .unavailable
+ } else if descriptor.availability.supportTier == .experimental,
+ !request.allowsExperimentalBackends {
+ rejection = .experimentalNotAuthorized
+ } else {
+ throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid
+ }
+ rejected.append(DoryResolvedMachineRejectedCandidate(
+ backend: descriptor.request.backend,
+ graphics: descriptor.request.graphics,
+ availability: descriptor.availability,
+ rejection: rejection
+ ))
+ }
+
+ guard selectedIndex > 0 else {
+ guard fallbackAuthorization == nil else {
+ throw DoryResolvedMachinePlanConstructionError.fallbackAuthorizationInvalid
+ }
+ return DoryResolvedMachineBackendSelectionEvidence(
+ disposition: .primary,
+ plannerRequest: request,
+ selectedEvaluationIndex: 0,
+ rejectedCandidates: []
+ )
+ }
+ if request.backendPreferencePolicy == .required {
+ guard fallbackAuthorization == nil else {
+ throw DoryResolvedMachinePlanConstructionError.fallbackAuthorizationInvalid
+ }
+ return DoryResolvedMachineBackendSelectionEvidence(
+ disposition: .explicitAlternative,
+ plannerRequest: request,
+ selectedEvaluationIndex: UInt32(selectedIndex),
+ rejectedCandidates: rejected
+ )
+ }
+ guard let authorization = fallbackAuthorization else {
+ throw DoryResolvedMachinePlanConstructionError.fallbackAuthorizationRequired
+ }
+ guard let first = rejected.first,
+ authorization.definitionRevision == definitionRevision,
+ authorization.fromBackend == first.backend,
+ authorization.fromGraphics == first.graphics,
+ authorization.toBackend == selected.request.backend,
+ authorization.toGraphics == selected.request.graphics else {
+ throw DoryResolvedMachinePlanConstructionError.fallbackAuthorizationInvalid
+ }
+ return DoryResolvedMachineBackendSelectionEvidence(
+ disposition: .approvedFallback,
+ plannerRequest: request,
+ selectedEvaluationIndex: UInt32(selectedIndex),
+ rejectedCandidates: rejected,
+ fallbackAuthorization: authorization
+ )
+ }
+
+ fileprivate static func orderedCandidates(
+ for request: DoryVirtualMachineBackendPlanRequest
+ ) -> [(DoryVirtualizationBackendIdentity, DoryGraphicsAccelerationLevel)]? {
+ guard !request.acceptableGraphics.isEmpty,
+ Set(request.acceptableGraphics).count == request.acceptableGraphics.count else {
+ return nil
+ }
+ let defaults = DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends(
+ for: request.guest,
+ bootMedia: request.bootMedia.kind
+ )
+ let backends: [DoryVirtualizationBackendIdentity]
+ if let preferences = request.backendPreferences {
+ guard !preferences.isEmpty, Set(preferences).count == preferences.count else {
+ return nil
+ }
+ if request.backendPreferencePolicy == .required {
+ backends = preferences
+ } else {
+ var seen: Set = []
+ backends = (preferences + defaults).filter { seen.insert($0).inserted }
+ }
+ } else {
+ guard request.backendPreferencePolicy != .required else { return nil }
+ backends = defaults
+ }
+ return request.acceptableGraphics.flatMap { graphics in
+ backends.map { backend in (backend, graphics) }
+ }
+ }
+}
+
+public enum DoryResolvedMachinePlanConstructionError: Error, Sendable, Equatable {
+ case backendDescriptorMismatch
+ case capabilityDescriptorInvalid
+ case plannerResultInvalid
+ case fallbackDisallowed
+ case fallbackAuthorizationRequired
+ case fallbackAuthorizationInvalid
+}
+
+public enum DoryResolvedMachinePlanValidationCode: String, Codable, Sendable, Hashable {
+ case unsupportedSchemaVersion = "unsupported-schema-version"
+ case legacyPlanRequiresReplanning = "legacy-plan-requires-replanning"
+ case invalidMachineIdentifier = "invalid-machine-identifier"
+ case invalidRevision = "invalid-revision"
+ case invalidDefinitionDigest = "invalid-definition-digest"
+ case invalidBackendImplementation = "invalid-backend-implementation"
+ case invalidBackendRuntimeBuild = "invalid-backend-runtime-build"
+ case invalidVirtualHardwareABI = "invalid-virtual-hardware-abi"
+ case invalidResolverReference = "invalid-resolver-reference"
+ case invalidMediaBinding = "invalid-media-binding"
+ case invalidMediaEvidence = "invalid-media-evidence"
+ case unsupportedRuntimeCombination = "unsupported-runtime-combination"
+ case duplicateComponent = "duplicate-component"
+ case unorderedComponents = "unordered-components"
+ case invalidComponentEvidence = "invalid-component-evidence"
+ case missingGraphicsQualification = "missing-graphics-qualification"
+ case invalidGraphicsQualification = "invalid-graphics-qualification"
+ case missingRuntimeQualification = "missing-runtime-qualification"
+ case runtimeQualificationMismatch = "runtime-qualification-mismatch"
+ case invalidResourceAdmission = "invalid-resource-admission"
+ case invalidHostQualification = "invalid-host-qualification"
+ case unsupportedSupportTier = "unsupported-support-tier"
+ case missingExperimentalAuthorization = "missing-experimental-authorization"
+ case invalidExperimentalAuthorization = "invalid-experimental-authorization"
+ case missingSelectionEvidence = "missing-selection-evidence"
+ case invalidSelectionEvidence = "invalid-selection-evidence"
+ case fallbackDisallowed = "fallback-disallowed"
+ case missingFallbackAuthorization = "missing-fallback-authorization"
+ case invalidFallbackAuthorization = "invalid-fallback-authorization"
+}
+
+public struct DoryResolvedMachinePlanValidationIssue: Codable, Sendable, Equatable, Hashable {
+ public var code: DoryResolvedMachinePlanValidationCode
+ public var field: String
+
+ public init(code: DoryResolvedMachinePlanValidationCode, field: String) {
+ self.code = code
+ self.field = field
+ }
+}
+
+/// Durable output of capability resolution. Unlike desired state, every field is an exact launch
+/// decision or non-secret audit reference and is replaced whenever any bound evidence changes.
+public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
+ public static let oldestSupportedSchemaVersion: UInt16 = 1
+ public static let currentSchemaVersion: UInt16 = 2
+
+ public var schemaVersion: UInt16
+ public var sourceSchemaVersion: UInt16
+ public var migrationDisposition: DoryResolvedMachinePlanMigrationDisposition
+ public var machineID: String
+ public var definitionRevision: UInt64
+ public var definitionSHA256: String?
+ public var planRevision: UInt64
+ public var createdAtUnixMilliseconds: Int64
+ public var updatedAtUnixMilliseconds: Int64
+ public var guest: DoryGuestPlatform
+ public var backend: DoryVirtualizationBackendIdentity
+ public var backendImplementationIdentifier: String
+ public var backendRuntimeBuildIdentifier: String
+ public var virtualHardwareABIVersion: UInt16
+ public var bootMedia: DoryResolvedMachineBootMedia
+ public var components: [DoryResolvedBackendComponentEvidence]
+ public var devices: DoryVirtualMachineDeviceCapabilityRequest
+ public var graphics: DoryGraphicsAccelerationLevel
+ public var supportTier: DoryCapabilitySupportTier
+ public var selectionEvidence: DoryResolvedMachineBackendSelectionEvidence?
+ public var qualificationEvidence: DoryResolvedMachineQualificationEvidence
+ public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence?
+ public var hostQualification: DoryResolvedHostQualificationEvidence?
+ public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization?
+
+ public init(
+ machineID: String,
+ definitionRevision: UInt64,
+ definitionSHA256: String,
+ planRevision: UInt64,
+ createdAtUnixMilliseconds: Int64,
+ updatedAtUnixMilliseconds: Int64,
+ guest: DoryGuestPlatform,
+ backend: DoryVirtualizationBackendIdentity,
+ backendImplementationIdentifier: String,
+ backendRuntimeBuildIdentifier: String,
+ virtualHardwareABIVersion: UInt16,
+ bootMedia: DoryResolvedMachineBootMedia,
+ components: [DoryResolvedBackendComponentEvidence],
+ devices: DoryVirtualMachineDeviceCapabilityRequest,
+ graphics: DoryGraphicsAccelerationLevel,
+ supportTier: DoryCapabilitySupportTier,
+ selectionEvidence: DoryResolvedMachineBackendSelectionEvidence,
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence,
+ resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence,
+ hostQualification: DoryResolvedHostQualificationEvidence,
+ experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? = nil
+ ) {
+ schemaVersion = Self.currentSchemaVersion
+ sourceSchemaVersion = Self.currentSchemaVersion
+ migrationDisposition = .current
+ self.machineID = machineID
+ self.definitionRevision = definitionRevision
+ self.definitionSHA256 = definitionSHA256
+ self.planRevision = planRevision
+ self.createdAtUnixMilliseconds = createdAtUnixMilliseconds
+ self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds
+ self.guest = guest
+ self.backend = backend
+ self.backendImplementationIdentifier = backendImplementationIdentifier
+ self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.bootMedia = bootMedia
+ self.components = components
+ self.devices = devices
+ self.graphics = graphics
+ self.supportTier = supportTier
+ self.selectionEvidence = selectionEvidence
+ self.qualificationEvidence = qualificationEvidence
+ self.resourceAdmission = resourceAdmission
+ self.hostQualification = hostQualification
+ self.experimentalAuthorization = experimentalAuthorization
+ }
+
+ /// Builds the durable record from the planner-selected capability without allowing the
+ /// caller to substitute its backend, graphics, devices, ABI, or qualification references.
+ public init(
+ machineID: String,
+ definitionRevision: UInt64,
+ definitionSHA256: String,
+ planRevision: UInt64,
+ createdAtUnixMilliseconds: Int64,
+ updatedAtUnixMilliseconds: Int64,
+ backendDescriptor: MachineBackendDescriptor,
+ backendRuntimeBuildIdentifier: String,
+ resolverReference: DoryVMResolverReference?,
+ components: [DoryResolvedBackendComponentEvidence],
+ resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence,
+ hostQualification: DoryResolvedHostQualificationEvidence,
+ experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? = nil,
+ plannerRequest: DoryVirtualMachineBackendPlanRequest,
+ plannerResult: DoryVirtualMachineBackendPlanResult,
+ fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? = nil
+ ) throws {
+ guard let selectedCapability = plannerResult.selectedDescriptor else {
+ throw DoryResolvedMachinePlanConstructionError.plannerResultInvalid
+ }
+ guard backendDescriptor.identity == selectedCapability.request.backend else {
+ throw DoryResolvedMachinePlanConstructionError.backendDescriptorMismatch
+ }
+ guard selectedCapability.schemaVersion
+ == DoryVirtualMachineCapabilityDescriptor.currentSchemaVersion,
+ selectedCapability.evaluatorVersion
+ == DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ selectedCapability.availability.isUsable,
+ selectedCapability.availability.supportTier != .unsupported,
+ selectedCapability.resolvedDevices == selectedCapability.request.devices else {
+ throw DoryResolvedMachinePlanConstructionError.capabilityDescriptorInvalid
+ }
+ let selectionEvidence = try DoryResolvedMachineBackendSelectionEvidence.resolving(
+ request: plannerRequest,
+ result: plannerResult,
+ definitionRevision: definitionRevision,
+ fallbackAuthorization: fallbackAuthorization
+ )
+ self.init(
+ machineID: machineID,
+ definitionRevision: definitionRevision,
+ definitionSHA256: definitionSHA256,
+ planRevision: planRevision,
+ createdAtUnixMilliseconds: createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: updatedAtUnixMilliseconds,
+ guest: selectedCapability.request.guest,
+ backend: selectedCapability.request.backend,
+ backendImplementationIdentifier: backendDescriptor.implementationIdentifier,
+ backendRuntimeBuildIdentifier: backendRuntimeBuildIdentifier,
+ virtualHardwareABIVersion: selectedCapability.request.virtualHardwareABIVersion,
+ bootMedia: DoryResolvedMachineBootMedia(
+ resolverReference: resolverReference,
+ media: selectedCapability.request.bootMedia,
+ inspectionEvidence: selectedCapability.bootMediaInspectionEvidence,
+ mutableProvenanceEvidence: selectedCapability.mutableBootMediaProvenanceEvidence
+ ),
+ components: components,
+ devices: selectedCapability.request.devices,
+ graphics: selectedCapability.request.graphics,
+ supportTier: selectedCapability.availability.supportTier,
+ selectionEvidence: selectionEvidence,
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence(
+ graphics: selectedCapability.graphicsQualificationEvidence,
+ runtime: selectedCapability.runtimeQualificationEvidence
+ ),
+ resourceAdmission: resourceAdmission,
+ hostQualification: hostQualification,
+ experimentalAuthorization: experimentalAuthorization
+ )
+ guard validate().isEmpty else {
+ throw DoryResolvedMachinePlanConstructionError.capabilityDescriptorInvalid
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaVersion
+ case sourceSchemaVersion
+ case migrationDisposition
+ case machineID
+ case definitionRevision
+ case definitionSHA256
+ case planRevision
+ case createdAtUnixMilliseconds
+ case updatedAtUnixMilliseconds
+ case guest
+ case backend
+ case backendImplementationIdentifier
+ case backendRuntimeBuildIdentifier
+ case backendRuntimeBuildID
+ case virtualHardwareABIVersion
+ case bootMedia
+ case components
+ case componentDigests
+ case devices
+ case graphics
+ case supportTier
+ case selectionEvidence
+ case qualificationEvidence
+ case resourceAdmission
+ case hostQualification
+ case experimentalAuthorization
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let persistedSchema = try container.decode(UInt16.self, forKey: .schemaVersion)
+ switch persistedSchema {
+ case Self.oldestSupportedSchemaVersion:
+ schemaVersion = Self.currentSchemaVersion
+ sourceSchemaVersion = persistedSchema
+ migrationDisposition = .requiresReplanning
+ machineID = try container.decode(String.self, forKey: .machineID)
+ definitionRevision = try container.decode(UInt64.self, forKey: .definitionRevision)
+ definitionSHA256 = nil
+ planRevision = try container.decode(UInt64.self, forKey: .planRevision)
+ createdAtUnixMilliseconds = try container.decodeIfPresent(
+ Int64.self,
+ forKey: .createdAtUnixMilliseconds
+ ) ?? 0
+ updatedAtUnixMilliseconds = try container.decodeIfPresent(
+ Int64.self,
+ forKey: .updatedAtUnixMilliseconds
+ ) ?? createdAtUnixMilliseconds
+ guest = try container.decode(DoryGuestPlatform.self, forKey: .guest)
+ backend = try container.decode(DoryVirtualizationBackendIdentity.self, forKey: .backend)
+ backendImplementationIdentifier = "legacy-unresolved"
+ backendRuntimeBuildIdentifier = try container.decode(
+ String.self,
+ forKey: .backendRuntimeBuildID
+ )
+ virtualHardwareABIVersion = try container.decode(
+ UInt16.self,
+ forKey: .virtualHardwareABIVersion
+ )
+ bootMedia = DoryResolvedMachineBootMedia(
+ resolverReference: nil,
+ media: try container.decode(DoryBootMedia.self, forKey: .bootMedia)
+ )
+ let legacyDigests = try container.decode(
+ [String: String].self,
+ forKey: .componentDigests
+ )
+ components = legacyDigests.keys.sorted().map { identifier in
+ DoryResolvedBackendComponentEvidence(
+ componentIdentifier: identifier,
+ buildIdentifier: "legacy-unresolved",
+ artifactSHA256: legacyDigests[identifier] ?? ""
+ )
+ }
+ devices = try container.decodeIfPresent(
+ DoryVirtualMachineDeviceCapabilityRequest.self,
+ forKey: .devices
+ ) ?? .minimumBootable
+ graphics = try container.decode(DoryGraphicsAccelerationLevel.self, forKey: .graphics)
+ supportTier = .experimental
+ selectionEvidence = nil
+ qualificationEvidence = DoryResolvedMachineQualificationEvidence()
+ resourceAdmission = nil
+ hostQualification = nil
+ experimentalAuthorization = nil
+ case Self.currentSchemaVersion:
+ schemaVersion = persistedSchema
+ sourceSchemaVersion = try container.decodeIfPresent(
+ UInt16.self,
+ forKey: .sourceSchemaVersion
+ ) ?? persistedSchema
+ migrationDisposition = try container.decode(
+ DoryResolvedMachinePlanMigrationDisposition.self,
+ forKey: .migrationDisposition
+ )
+ machineID = try container.decode(String.self, forKey: .machineID)
+ definitionRevision = try container.decode(UInt64.self, forKey: .definitionRevision)
+ definitionSHA256 = try container.decodeIfPresent(String.self, forKey: .definitionSHA256)
+ planRevision = try container.decode(UInt64.self, forKey: .planRevision)
+ createdAtUnixMilliseconds = try container.decode(
+ Int64.self,
+ forKey: .createdAtUnixMilliseconds
+ )
+ updatedAtUnixMilliseconds = try container.decode(
+ Int64.self,
+ forKey: .updatedAtUnixMilliseconds
+ )
+ guest = try container.decode(DoryGuestPlatform.self, forKey: .guest)
+ backend = try container.decode(DoryVirtualizationBackendIdentity.self, forKey: .backend)
+ backendImplementationIdentifier = try container.decode(
+ String.self,
+ forKey: .backendImplementationIdentifier
+ )
+ backendRuntimeBuildIdentifier = try container.decode(
+ String.self,
+ forKey: .backendRuntimeBuildIdentifier
+ )
+ virtualHardwareABIVersion = try container.decode(
+ UInt16.self,
+ forKey: .virtualHardwareABIVersion
+ )
+ bootMedia = try container.decode(DoryResolvedMachineBootMedia.self, forKey: .bootMedia)
+ components = try container.decode(
+ [DoryResolvedBackendComponentEvidence].self,
+ forKey: .components
+ )
+ devices = try container.decode(
+ DoryVirtualMachineDeviceCapabilityRequest.self,
+ forKey: .devices
+ )
+ graphics = try container.decode(DoryGraphicsAccelerationLevel.self, forKey: .graphics)
+ supportTier = try container.decode(DoryCapabilitySupportTier.self, forKey: .supportTier)
+ selectionEvidence = try container.decodeIfPresent(
+ DoryResolvedMachineBackendSelectionEvidence.self,
+ forKey: .selectionEvidence
+ )
+ qualificationEvidence = try container.decode(
+ DoryResolvedMachineQualificationEvidence.self,
+ forKey: .qualificationEvidence
+ )
+ resourceAdmission = try container.decodeIfPresent(
+ DoryResolvedMachineResourceAdmissionEvidence.self,
+ forKey: .resourceAdmission
+ )
+ hostQualification = try container.decodeIfPresent(
+ DoryResolvedHostQualificationEvidence.self,
+ forKey: .hostQualification
+ )
+ experimentalAuthorization = try container.decodeIfPresent(
+ DoryResolvedExperimentalSupportAuthorization.self,
+ forKey: .experimentalAuthorization
+ )
+ default:
+ throw DecodingError.dataCorruptedError(
+ forKey: .schemaVersion,
+ in: container,
+ debugDescription: "Unsupported resolved machine plan schema \(persistedSchema)."
+ )
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(schemaVersion, forKey: .schemaVersion)
+ try container.encode(sourceSchemaVersion, forKey: .sourceSchemaVersion)
+ try container.encode(migrationDisposition, forKey: .migrationDisposition)
+ try container.encode(machineID, forKey: .machineID)
+ try container.encode(definitionRevision, forKey: .definitionRevision)
+ try container.encodeIfPresent(definitionSHA256, forKey: .definitionSHA256)
+ try container.encode(planRevision, forKey: .planRevision)
+ try container.encode(createdAtUnixMilliseconds, forKey: .createdAtUnixMilliseconds)
+ try container.encode(updatedAtUnixMilliseconds, forKey: .updatedAtUnixMilliseconds)
+ try container.encode(guest, forKey: .guest)
+ try container.encode(backend, forKey: .backend)
+ try container.encode(backendImplementationIdentifier, forKey: .backendImplementationIdentifier)
+ try container.encode(backendRuntimeBuildIdentifier, forKey: .backendRuntimeBuildIdentifier)
+ try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion)
+ try container.encode(bootMedia, forKey: .bootMedia)
+ try container.encode(components, forKey: .components)
+ try container.encode(devices, forKey: .devices)
+ try container.encode(graphics, forKey: .graphics)
+ try container.encode(supportTier, forKey: .supportTier)
+ try container.encodeIfPresent(selectionEvidence, forKey: .selectionEvidence)
+ try container.encode(qualificationEvidence, forKey: .qualificationEvidence)
+ try container.encodeIfPresent(resourceAdmission, forKey: .resourceAdmission)
+ try container.encodeIfPresent(hostQualification, forKey: .hostQualification)
+ try container.encodeIfPresent(experimentalAuthorization, forKey: .experimentalAuthorization)
+ }
+
+ public func validate() -> [DoryResolvedMachinePlanValidationIssue] {
+ var issues: [DoryResolvedMachinePlanValidationIssue] = []
+ func add(_ code: DoryResolvedMachinePlanValidationCode, _ field: String) {
+ issues.append(DoryResolvedMachinePlanValidationIssue(code: code, field: field))
+ }
+
+ if schemaVersion != Self.currentSchemaVersion {
+ add(.unsupportedSchemaVersion, "schemaVersion")
+ }
+ if migrationDisposition != .current || sourceSchemaVersion != Self.currentSchemaVersion {
+ add(.legacyPlanRequiresReplanning, "migrationDisposition")
+ }
+ if !Self.isSafeIdentifier(machineID) {
+ add(.invalidMachineIdentifier, "machineID")
+ }
+ if definitionRevision == 0 { add(.invalidRevision, "definitionRevision") }
+ if planRevision == 0 { add(.invalidRevision, "planRevision") }
+ if updatedAtUnixMilliseconds < createdAtUnixMilliseconds {
+ add(.invalidRevision, "updatedAtUnixMilliseconds")
+ }
+ if !(definitionSHA256.map(Self.isSHA256) ?? false) {
+ add(.invalidDefinitionDigest, "definitionSHA256")
+ }
+ if !Self.isSafeEvidenceIdentifier(backendImplementationIdentifier) {
+ add(.invalidBackendImplementation, "backendImplementationIdentifier")
+ }
+ if !Self.isSafeEvidenceIdentifier(backendRuntimeBuildIdentifier) {
+ add(.invalidBackendRuntimeBuild, "backendRuntimeBuildIdentifier")
+ }
+ if virtualHardwareABIVersion == 0 {
+ add(.invalidVirtualHardwareABI, "virtualHardwareABIVersion")
+ }
+
+ validateBootMedia(into: &issues)
+ validateComponents(into: &issues)
+ validateQualifications(into: &issues)
+ validateResourceAdmission(into: &issues)
+ validateHostQualification(into: &issues)
+ validateSupportAuthorization(into: &issues)
+ validateSelectionEvidence(into: &issues)
+ return issues
+ }
+
+ private func validateBootMedia(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ func add(_ code: DoryResolvedMachinePlanValidationCode, _ field: String) {
+ issues.append(DoryResolvedMachinePlanValidationIssue(code: code, field: field))
+ }
+ if let reference = bootMedia.resolverReference,
+ !Self.isSafeResolverReference(reference) {
+ add(.invalidResolverReference, "bootMedia.resolverReference")
+ }
+ let immutable = bootMedia.media.artifactSHA256
+ let mutable = bootMedia.media.mutableProvenance
+ if immutable.map(Self.isSHA256) == false || (immutable == nil) == (mutable == nil) {
+ add(.invalidMediaBinding, "bootMedia.media")
+ }
+ if bootMedia.media.kind == .virtualDisk {
+ if mutable == nil || immutable != nil {
+ add(.invalidMediaBinding, "bootMedia.media.mutableProvenance")
+ }
+ } else if immutable == nil || mutable != nil {
+ add(.invalidMediaBinding, "bootMedia.media.artifactSHA256")
+ }
+ let runtimeCombinationIsImplemented: Bool
+ switch (guest.family, backend) {
+ case (.linux, .doryHypervisor):
+ runtimeCombinationIsImplemented = bootMedia.media.kind == .installedLinuxBootBundle
+ case (.linux, .appleVirtualizationFramework),
+ (.linux, .qemuHypervisorFramework),
+ (.windows, .qemuHypervisorFramework):
+ runtimeCombinationIsImplemented = bootMedia.media.kind == .installerISO
+ || bootMedia.media.kind == .virtualDisk
+ case (.macOS, .appleVirtualizationFramework):
+ runtimeCombinationIsImplemented = bootMedia.media.kind == .macOSRestoreImage
+ || bootMedia.media.kind == .virtualDisk
+ default:
+ runtimeCombinationIsImplemented = false
+ }
+ if !runtimeCombinationIsImplemented {
+ add(.unsupportedRuntimeCombination, "bootMedia.media.kind")
+ }
+ if let inspection = bootMedia.inspectionEvidence {
+ guard Self.isSafeEvidenceIdentifier(inspection.inspectionIdentity),
+ Self.isSHA256(inspection.artifactSHA256),
+ Self.isSHA256(inspection.inspectionReportSHA256),
+ Self.isSafeEvidenceIdentifier(inspection.inspectorID),
+ inspection.inspectorVersion > 0,
+ inspection.artifactSHA256.lowercased() == immutable?.lowercased() else {
+ add(.invalidMediaEvidence, "bootMedia.inspectionEvidence")
+ return
+ }
+ } else if bootMedia.media.kind == .installerISO
+ || bootMedia.media.kind == .macOSRestoreImage {
+ add(.invalidMediaEvidence, "bootMedia.inspectionEvidence")
+ }
+ if let evidence = bootMedia.mutableProvenanceEvidence {
+ guard Self.isSafeEvidenceIdentifier(evidence.receiptIdentity),
+ Self.isSHA256(evidence.receiptSHA256),
+ Self.isSafeEvidenceIdentifier(evidence.resolverID),
+ evidence.resolverVersion > 0,
+ evidence.provenance == mutable else {
+ add(.invalidMediaEvidence, "bootMedia.mutableProvenanceEvidence")
+ return
+ }
+ } else if mutable != nil {
+ add(.invalidMediaEvidence, "bootMedia.mutableProvenanceEvidence")
+ }
+ }
+
+ private func validateComponents(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ var identifiers: Set = []
+ for (index, component) in components.enumerated() {
+ let field = "components[\(index)]"
+ if !identifiers.insert(component.componentIdentifier).inserted {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .duplicateComponent,
+ field: "\(field).componentIdentifier"
+ ))
+ }
+ if !Self.isSafeEvidenceIdentifier(component.componentIdentifier)
+ || !Self.isSafeEvidenceIdentifier(component.buildIdentifier)
+ || !Self.isSHA256(component.artifactSHA256) {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidComponentEvidence,
+ field: field
+ ))
+ }
+ }
+ if components.isEmpty {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidComponentEvidence,
+ field: "components"
+ ))
+ }
+ if components.map(\.componentIdentifier) != components.map(\.componentIdentifier).sorted() {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .unorderedComponents,
+ field: "components"
+ ))
+ }
+ }
+
+ private func validateQualifications(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ if backend == .doryHypervisor, graphics != .none {
+ guard let graphicsEvidence = qualificationEvidence.graphics else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .missingGraphicsQualification,
+ field: "qualificationEvidence.graphics"
+ ))
+ return validateRuntimeQualification(into: &issues)
+ }
+ if !Self.isValid(graphicsEvidence)
+ || graphicsEvidence.artifactSHA256.lowercased()
+ != bootMedia.media.artifactSHA256?.lowercased() {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidGraphicsQualification,
+ field: "qualificationEvidence.graphics"
+ ))
+ }
+ } else if let graphicsEvidence = qualificationEvidence.graphics,
+ !Self.isValid(graphicsEvidence) {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidGraphicsQualification,
+ field: "qualificationEvidence.graphics"
+ ))
+ }
+ validateRuntimeQualification(into: &issues)
+ }
+
+ private func validateRuntimeQualification(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ guard let runtime = qualificationEvidence.runtime else {
+ if supportTier == .supported {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .missingRuntimeQualification,
+ field: "qualificationEvidence.runtime"
+ ))
+ }
+ return
+ }
+ let mediaMatches = runtime.immutableArtifactSHA256?.lowercased()
+ == bootMedia.media.artifactSHA256?.lowercased()
+ && runtime.mutableProvenance == bootMedia.media.mutableProvenance
+ guard Self.isSafeEvidenceIdentifier(runtime.qualificationIdentity),
+ Self.isSHA256(runtime.qualificationReportSHA256),
+ Self.isSafeEvidenceIdentifier(runtime.signingKeyID),
+ runtime.qualificationFormatVersion > 0,
+ runtime.guest == guest,
+ runtime.bootMediaKind == bootMedia.media.kind,
+ mediaMatches,
+ runtime.backend == backend,
+ runtime.backendRuntimeBuildID == backendRuntimeBuildIdentifier,
+ runtime.virtualHardwareABIVersion == virtualHardwareABIVersion,
+ runtime.graphics == graphics,
+ runtime.devices == devices else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .runtimeQualificationMismatch,
+ field: "qualificationEvidence.runtime"
+ ))
+ return
+ }
+ }
+
+ private func validateResourceAdmission(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ guard let admission = resourceAdmission else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidResourceAdmission,
+ field: "resourceAdmission"
+ ))
+ return
+ }
+ let identifiersAreValid = Self.isSafeEvidenceIdentifier(admission.admissionIdentity)
+ && Self.isSHA256(admission.admissionReportSHA256)
+ && Self.isSafeEvidenceIdentifier(admission.assessorIdentifier)
+ && admission.assessorVersion > 0
+ let admittedValuesAreValid = admission.admittedVirtualCPUCount > 0
+ && admission.admittedMemoryBytes > 0
+ && admission.admittedStorageBytes > 0
+ let hostValuesAreValid = admission.hostLogicalCPUCount > 0
+ && admission.hostPhysicalMemoryBytes > 0
+ && admission.hostFreeStorageBytes > 0
+ let cpuFits = Self.sumFits(
+ within: admission.hostLogicalCPUCount,
+ admission.existingVirtualCPUCommitment,
+ admission.hostReservedLogicalCPUCount,
+ admission.admittedVirtualCPUCount
+ )
+ let memoryFits = Self.sumFits(
+ within: admission.hostPhysicalMemoryBytes,
+ admission.existingMemoryCommitmentBytes,
+ admission.hostReservedMemoryBytes,
+ admission.admittedMemoryBytes
+ )
+ let storageFits = Self.sumFits(
+ within: admission.hostFreeStorageBytes,
+ admission.existingStorageReservationBytes,
+ admission.hostReservedStorageBytes,
+ admission.admittedStorageBytes
+ )
+ if !identifiersAreValid || !admittedValuesAreValid || !hostValuesAreValid
+ || !cpuFits || !memoryFits || !storageFits {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidResourceAdmission,
+ field: "resourceAdmission"
+ ))
+ }
+ }
+
+ private func validateHostQualification(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ guard let host = hostQualification,
+ Self.isSafeEvidenceIdentifier(host.qualificationIdentity),
+ Self.isSHA256(host.qualificationReportSHA256),
+ Self.isSafeEvidenceIdentifier(host.hostHardwareModelIdentifier),
+ Self.isSafeEvidenceIdentifier(host.hostOperatingSystemBuild),
+ Self.isSafeEvidenceIdentifier(host.qualifierIdentifier),
+ host.qualifierVersion > 0,
+ host.backend == backend,
+ host.backendRuntimeBuildIdentifier == backendRuntimeBuildIdentifier,
+ host.virtualHardwareABIVersion == virtualHardwareABIVersion else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidHostQualification,
+ field: "hostQualification"
+ ))
+ return
+ }
+ }
+
+ private func validateSupportAuthorization(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ switch supportTier {
+ case .unsupported:
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .unsupportedSupportTier,
+ field: "supportTier"
+ ))
+ case .supported:
+ if experimentalAuthorization != nil {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidExperimentalAuthorization,
+ field: "experimentalAuthorization"
+ ))
+ }
+ case .experimental:
+ guard let authorization = experimentalAuthorization else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .missingExperimentalAuthorization,
+ field: "experimentalAuthorization"
+ ))
+ return
+ }
+ if !Self.isSafeEvidenceIdentifier(authorization.authorizationIdentity)
+ || authorization.definitionRevision != definitionRevision
+ || authorization.backend != backend
+ || authorization.authorizedAtUnixMilliseconds <= 0 {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidExperimentalAuthorization,
+ field: "experimentalAuthorization"
+ ))
+ }
+ }
+ }
+
+ private func validateSelectionEvidence(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ guard let selection = selectionEvidence else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .missingSelectionEvidence,
+ field: "selectionEvidence"
+ ))
+ return
+ }
+ let request = selection.plannerRequest
+ guard request.guest == guest,
+ request.bootMedia == bootMedia.media,
+ request.devices == devices,
+ request.virtualHardwareABIVersion == virtualHardwareABIVersion,
+ !request.acceptableGraphics.isEmpty,
+ Set(request.acceptableGraphics).count == request.acceptableGraphics.count else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidSelectionEvidence,
+ field: "selectionEvidence.plannerRequest"
+ ))
+ return
+ }
+
+ guard let orderedCandidates = DoryResolvedMachineBackendSelectionEvidence
+ .orderedCandidates(for: request) else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidSelectionEvidence,
+ field: "selectionEvidence.plannerRequest"
+ ))
+ return
+ }
+ let selectedIndex = Int(selection.selectedEvaluationIndex)
+ guard orderedCandidates.indices.contains(selectedIndex),
+ orderedCandidates[selectedIndex].0 == backend,
+ orderedCandidates[selectedIndex].1 == graphics,
+ selection.rejectedCandidates.count == selectedIndex else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidSelectionEvidence,
+ field: "selectionEvidence.selectedEvaluationIndex"
+ ))
+ return
+ }
+ for (index, rejected) in selection.rejectedCandidates.enumerated() {
+ let expected = orderedCandidates[index]
+ let rejectionIsValid = switch rejected.rejection {
+ case .unavailable:
+ !rejected.availability.isUsable
+ case .experimentalNotAuthorized:
+ rejected.availability.isUsable
+ && rejected.availability.supportTier == .experimental
+ && !request.allowsExperimentalBackends
+ }
+ if rejected.backend != expected.0
+ || rejected.graphics != expected.1
+ || !rejectionIsValid {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidSelectionEvidence,
+ field: "selectionEvidence.rejectedCandidates[\(index)]"
+ ))
+ }
+ }
+ if supportTier == .experimental, !request.allowsExperimentalBackends {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidSelectionEvidence,
+ field: "selectionEvidence.plannerRequest.allowsExperimentalBackends"
+ ))
+ }
+
+ switch selection.disposition {
+ case .primary:
+ if selectedIndex != 0 || !selection.rejectedCandidates.isEmpty
+ || selection.fallbackAuthorization != nil {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidSelectionEvidence,
+ field: "selectionEvidence.disposition"
+ ))
+ }
+ case .explicitAlternative:
+ if request.backendPreferencePolicy != .required
+ || selectedIndex == 0
+ || selection.fallbackAuthorization != nil {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidSelectionEvidence,
+ field: "selectionEvidence.disposition"
+ ))
+ }
+ case .approvedFallback:
+ if request.backendPreferencePolicy == .required {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .fallbackDisallowed,
+ field: "selectionEvidence.disposition"
+ ))
+ }
+ guard selectedIndex > 0 else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidSelectionEvidence,
+ field: "selectionEvidence.disposition"
+ ))
+ return
+ }
+ guard let authorization = selection.fallbackAuthorization else {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .missingFallbackAuthorization,
+ field: "selectionEvidence.fallbackAuthorization"
+ ))
+ return
+ }
+ let first = selection.rejectedCandidates[0]
+ if !Self.isSafeEvidenceIdentifier(authorization.authorizationIdentity)
+ || authorization.definitionRevision != definitionRevision
+ || authorization.fromBackend != first.backend
+ || authorization.fromGraphics != first.graphics
+ || authorization.toBackend != backend
+ || authorization.toGraphics != graphics
+ || authorization.authorizedAtUnixMilliseconds <= 0 {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidFallbackAuthorization,
+ field: "selectionEvidence.fallbackAuthorization"
+ ))
+ }
+ }
+ }
+
+ private static func isValid(_ evidence: DorySignedArtifactQualificationEvidence) -> Bool {
+ isSafeEvidenceIdentifier(evidence.manifestIdentity)
+ && isSHA256(evidence.artifactSHA256)
+ && isSHA256(evidence.manifestSHA256)
+ && isSafeEvidenceIdentifier(evidence.signingKeyID)
+ && evidence.manifestFormatVersion > 0
+ }
+
+ static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy { byte in
+ (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
+ }
+ }
+
+ static func isSafeIdentifier(_ value: String) -> Bool {
+ let bytes = Array(value.utf8)
+ guard (1...63).contains(bytes.count), isAlphaNumeric(bytes[0]) else { return false }
+ return bytes.dropFirst().allSatisfy {
+ isAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95
+ }
+ }
+
+ private static func isSafeResolverReference(_ value: DoryVMResolverReference) -> Bool {
+ isSafeEvidenceIdentifier(value.namespace) && isSafeEvidenceIdentifier(value.identifier)
+ }
+
+ private static func isSafeEvidenceIdentifier(_ value: String) -> Bool {
+ let bytes = Array(value.utf8)
+ guard (1...256).contains(bytes.count) else { return false }
+ return bytes.allSatisfy { byte in
+ isAlphaNumeric(byte) || byte == 45 || byte == 46 || byte == 47
+ || byte == 58 || byte == 64 || byte == 95
+ }
+ }
+
+ private static func isAlphaNumeric(_ byte: UInt8) -> Bool {
+ (byte >= 48 && byte <= 57)
+ || (byte >= 65 && byte <= 90)
+ || (byte >= 97 && byte <= 122)
+ }
+
+ private static func sumFits(within total: UInt64, _ values: UInt64...) -> Bool {
+ var remaining = total
+ for value in values {
+ guard value <= remaining else { return false }
+ remaining -= value
+ }
+ return true
+ }
+}
+
+/// Fresh daemon/resolver output supplied immediately before start. It deliberately duplicates the
+/// launch-critical plan fields so every mismatch can be named rather than hidden behind a Bool.
+public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable, Hashable {
+ public var guest: DoryGuestPlatform
+ public var backend: DoryVirtualizationBackendIdentity
+ public var backendImplementationIdentifier: String
+ public var backendRuntimeBuildIdentifier: String
+ public var virtualHardwareABIVersion: UInt16
+ public var bootMedia: DoryResolvedMachineBootMedia
+ public var components: [DoryResolvedBackendComponentEvidence]
+ public var devices: DoryVirtualMachineDeviceCapabilityRequest
+ public var graphics: DoryGraphicsAccelerationLevel
+ public var supportTier: DoryCapabilitySupportTier
+ public var selectionEvidence: DoryResolvedMachineBackendSelectionEvidence?
+ public var qualificationEvidence: DoryResolvedMachineQualificationEvidence
+ public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence?
+ public var hostQualification: DoryResolvedHostQualificationEvidence?
+ public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization?
+
+ public init(
+ guest: DoryGuestPlatform,
+ backend: DoryVirtualizationBackendIdentity,
+ backendImplementationIdentifier: String,
+ backendRuntimeBuildIdentifier: String,
+ virtualHardwareABIVersion: UInt16,
+ bootMedia: DoryResolvedMachineBootMedia,
+ components: [DoryResolvedBackendComponentEvidence],
+ devices: DoryVirtualMachineDeviceCapabilityRequest,
+ graphics: DoryGraphicsAccelerationLevel,
+ supportTier: DoryCapabilitySupportTier,
+ selectionEvidence: DoryResolvedMachineBackendSelectionEvidence?,
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence,
+ resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence?,
+ hostQualification: DoryResolvedHostQualificationEvidence?,
+ experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization?
+ ) {
+ self.guest = guest
+ self.backend = backend
+ self.backendImplementationIdentifier = backendImplementationIdentifier
+ self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.bootMedia = bootMedia
+ self.components = components
+ self.devices = devices
+ self.graphics = graphics
+ self.supportTier = supportTier
+ self.selectionEvidence = selectionEvidence
+ self.qualificationEvidence = qualificationEvidence
+ self.resourceAdmission = resourceAdmission
+ self.hostQualification = hostQualification
+ self.experimentalAuthorization = experimentalAuthorization
+ }
+
+ public init(plan: DoryResolvedMachinePlan) {
+ guest = plan.guest
+ backend = plan.backend
+ backendImplementationIdentifier = plan.backendImplementationIdentifier
+ backendRuntimeBuildIdentifier = plan.backendRuntimeBuildIdentifier
+ virtualHardwareABIVersion = plan.virtualHardwareABIVersion
+ bootMedia = plan.bootMedia
+ components = plan.components
+ devices = plan.devices
+ graphics = plan.graphics
+ supportTier = plan.supportTier
+ selectionEvidence = plan.selectionEvidence
+ qualificationEvidence = plan.qualificationEvidence
+ resourceAdmission = plan.resourceAdmission
+ hostQualification = plan.hostQualification
+ experimentalAuthorization = plan.experimentalAuthorization
+ }
+}
+
+public struct DoryResolvedMachinePlanStartRevalidationInput: Codable, Sendable, Equatable, Hashable {
+ public var machineID: String
+ public var expectedPlanRevision: UInt64
+ public var currentDefinitionRevision: UInt64
+ public var currentDefinitionSHA256: String
+ public var runtimeEvidence: DoryResolvedMachineRuntimeEvidence
+
+ public init(
+ machineID: String,
+ expectedPlanRevision: UInt64,
+ currentDefinitionRevision: UInt64,
+ currentDefinitionSHA256: String,
+ runtimeEvidence: DoryResolvedMachineRuntimeEvidence
+ ) {
+ self.machineID = machineID
+ self.expectedPlanRevision = expectedPlanRevision
+ self.currentDefinitionRevision = currentDefinitionRevision
+ self.currentDefinitionSHA256 = currentDefinitionSHA256
+ self.runtimeEvidence = runtimeEvidence
+ }
+}
+
+public enum DoryResolvedMachinePlanRevalidationCode: String, Codable, Sendable, Hashable {
+ case storedPlanInvalid = "stored-plan-invalid"
+ case machineIdentityMismatch = "machine-identity-mismatch"
+ case planRevisionMismatch = "plan-revision-mismatch"
+ case definitionRevisionMismatch = "definition-revision-mismatch"
+ case definitionDigestMismatch = "definition-digest-mismatch"
+ case guestMismatch = "guest-mismatch"
+ case backendMismatch = "backend-mismatch"
+ case backendImplementationMismatch = "backend-implementation-mismatch"
+ case backendRuntimeBuildMismatch = "backend-runtime-build-mismatch"
+ case virtualHardwareABIMismatch = "virtual-hardware-abi-mismatch"
+ case bootMediaEvidenceMismatch = "boot-media-evidence-mismatch"
+ case componentEvidenceMismatch = "component-evidence-mismatch"
+ case deviceContractMismatch = "device-contract-mismatch"
+ case graphicsMismatch = "graphics-mismatch"
+ case supportTierMismatch = "support-tier-mismatch"
+ case selectionEvidenceMismatch = "selection-evidence-mismatch"
+ case qualificationEvidenceMismatch = "qualification-evidence-mismatch"
+ case resourceAdmissionMismatch = "resource-admission-mismatch"
+ case hostQualificationMismatch = "host-qualification-mismatch"
+ case experimentalAuthorizationMismatch = "experimental-authorization-mismatch"
+}
+
+public struct DoryResolvedMachinePlanRevalidationIssue: Codable, Sendable, Equatable, Hashable {
+ public var code: DoryResolvedMachinePlanRevalidationCode
+ public var field: String
+
+ public init(code: DoryResolvedMachinePlanRevalidationCode, field: String) {
+ self.code = code
+ self.field = field
+ }
+}
+
+public enum DoryResolvedMachinePlanRevalidationState: String, Codable, Sendable, Hashable {
+ case valid
+ case rejected
+}
+
+public struct DoryResolvedMachinePlanRevalidationResult: Codable, Sendable, Equatable, Hashable {
+ public var state: DoryResolvedMachinePlanRevalidationState
+ public var issues: [DoryResolvedMachinePlanRevalidationIssue]
+
+ public var mayStart: Bool { state == .valid && issues.isEmpty }
+
+ public init(
+ state: DoryResolvedMachinePlanRevalidationState,
+ issues: [DoryResolvedMachinePlanRevalidationIssue]
+ ) {
+ self.state = state
+ self.issues = issues
+ }
+}
+
+public enum DoryResolvedMachinePlanStartValidator {
+ public static func revalidate(
+ _ plan: DoryResolvedMachinePlan,
+ against input: DoryResolvedMachinePlanStartRevalidationInput
+ ) -> DoryResolvedMachinePlanRevalidationResult {
+ var issues: [DoryResolvedMachinePlanRevalidationIssue] = []
+ func compare(
+ _ actual: T,
+ _ expected: T,
+ code: DoryResolvedMachinePlanRevalidationCode,
+ field: String
+ ) {
+ if actual != expected {
+ issues.append(DoryResolvedMachinePlanRevalidationIssue(code: code, field: field))
+ }
+ }
+
+ if !plan.validate().isEmpty {
+ issues.append(DoryResolvedMachinePlanRevalidationIssue(
+ code: .storedPlanInvalid,
+ field: "plan"
+ ))
+ }
+ compare(input.machineID, plan.machineID, code: .machineIdentityMismatch, field: "machineID")
+ compare(
+ input.expectedPlanRevision,
+ plan.planRevision,
+ code: .planRevisionMismatch,
+ field: "planRevision"
+ )
+ compare(
+ input.currentDefinitionRevision,
+ plan.definitionRevision,
+ code: .definitionRevisionMismatch,
+ field: "definitionRevision"
+ )
+ compare(
+ Optional(input.currentDefinitionSHA256.lowercased()),
+ plan.definitionSHA256?.lowercased(),
+ code: .definitionDigestMismatch,
+ field: "definitionSHA256"
+ )
+ let runtime = input.runtimeEvidence
+ compare(runtime.guest, plan.guest, code: .guestMismatch, field: "guest")
+ compare(runtime.backend, plan.backend, code: .backendMismatch, field: "backend")
+ compare(
+ runtime.backendImplementationIdentifier,
+ plan.backendImplementationIdentifier,
+ code: .backendImplementationMismatch,
+ field: "backendImplementationIdentifier"
+ )
+ compare(
+ runtime.backendRuntimeBuildIdentifier,
+ plan.backendRuntimeBuildIdentifier,
+ code: .backendRuntimeBuildMismatch,
+ field: "backendRuntimeBuildIdentifier"
+ )
+ compare(
+ runtime.virtualHardwareABIVersion,
+ plan.virtualHardwareABIVersion,
+ code: .virtualHardwareABIMismatch,
+ field: "virtualHardwareABIVersion"
+ )
+ compare(runtime.bootMedia, plan.bootMedia, code: .bootMediaEvidenceMismatch, field: "bootMedia")
+ compare(runtime.components, plan.components, code: .componentEvidenceMismatch, field: "components")
+ compare(runtime.devices, plan.devices, code: .deviceContractMismatch, field: "devices")
+ compare(runtime.graphics, plan.graphics, code: .graphicsMismatch, field: "graphics")
+ compare(
+ runtime.supportTier,
+ plan.supportTier,
+ code: .supportTierMismatch,
+ field: "supportTier"
+ )
+ compare(
+ runtime.selectionEvidence,
+ plan.selectionEvidence,
+ code: .selectionEvidenceMismatch,
+ field: "selectionEvidence"
+ )
+ compare(
+ runtime.qualificationEvidence,
+ plan.qualificationEvidence,
+ code: .qualificationEvidenceMismatch,
+ field: "qualificationEvidence"
+ )
+ compare(
+ runtime.resourceAdmission,
+ plan.resourceAdmission,
+ code: .resourceAdmissionMismatch,
+ field: "resourceAdmission"
+ )
+ compare(
+ runtime.hostQualification,
+ plan.hostQualification,
+ code: .hostQualificationMismatch,
+ field: "hostQualification"
+ )
+ compare(
+ runtime.experimentalAuthorization,
+ plan.experimentalAuthorization,
+ code: .experimentalAuthorizationMismatch,
+ field: "experimentalAuthorization"
+ )
+
+ return DoryResolvedMachinePlanRevalidationResult(
+ state: issues.isEmpty ? .valid : .rejected,
+ issues: issues
+ )
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlanRepository.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlanRepository.swift
new file mode 100644
index 00000000..d308c9f5
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlanRepository.swift
@@ -0,0 +1,423 @@
+import CryptoKit
+import Darwin
+import Foundation
+
+public enum DoryResolvedMachinePlanRepositoryError: Error, Sendable, Equatable, CustomStringConvertible {
+ case invalidMachineIdentifier(String)
+ case invalidPlan([DoryResolvedMachinePlanValidationIssue])
+ case planExists(String)
+ case planNotFound(String)
+ case stalePlanRevision(expected: UInt64, actual: UInt64)
+ case invalidPlanRevision(expected: UInt64, actual: UInt64)
+ case machineIdentityChanged(String)
+ case invalidRecord(String)
+ case filesystem(String)
+
+ public var description: String {
+ switch self {
+ case let .invalidMachineIdentifier(id):
+ "invalid resolved-plan machine identifier: \(id)"
+ case let .invalidPlan(issues):
+ "invalid resolved plan: "
+ + issues.map { "\($0.code.rawValue) at \($0.field)" }.joined(separator: ", ")
+ case let .planExists(id):
+ "resolved plan already exists: \(id)"
+ case let .planNotFound(id):
+ "resolved plan does not exist: \(id)"
+ case let .stalePlanRevision(expected, actual):
+ "stale resolved-plan revision: expected \(expected), found \(actual)"
+ case let .invalidPlanRevision(expected, actual):
+ "invalid replacement plan revision: expected \(expected), found \(actual)"
+ case let .machineIdentityChanged(id):
+ "resolved-plan machine identity cannot change: \(id)"
+ case let .invalidRecord(path):
+ "invalid resolved-plan repository record: \(path)"
+ case let .filesystem(message):
+ message
+ }
+ }
+}
+
+/// Integrity envelope for a resolved plan. Schema v1 records decode for migration but have no
+/// integrity digest and therefore always retain the plan's `requiresReplanning` disposition.
+public struct DoryResolvedMachinePlanRepositoryRecord: Codable, Sendable, Equatable {
+ public static let oldestSupportedSchemaVersion: UInt16 = 1
+ public static let currentSchemaVersion: UInt16 = 2
+
+ public var schemaVersion: UInt16
+ public var planSHA256: String?
+ public var plan: DoryResolvedMachinePlan
+
+ public init(planSHA256: String, plan: DoryResolvedMachinePlan) {
+ schemaVersion = Self.currentSchemaVersion
+ self.planSHA256 = planSHA256
+ self.plan = plan
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaVersion
+ case planSHA256
+ case plan
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ schemaVersion = try container.decode(UInt16.self, forKey: .schemaVersion)
+ guard schemaVersion == Self.oldestSupportedSchemaVersion
+ || schemaVersion == Self.currentSchemaVersion else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .schemaVersion,
+ in: container,
+ debugDescription: "Unsupported resolved-plan record schema \(schemaVersion)."
+ )
+ }
+ planSHA256 = try container.decodeIfPresent(String.self, forKey: .planSHA256)
+ plan = try container.decode(DoryResolvedMachinePlan.self, forKey: .plan)
+ }
+}
+
+/// Crash-safe, owner-only persistence for daemon-resolved runtime plans.
+public final class DoryResolvedMachinePlanRepository: @unchecked Sendable {
+ public static let recordFileName = "resolved-plan.json"
+
+ private static let temporaryPrefix = ".resolved-plan."
+ private static let maximumRecordBytes = 4 * 1_024 * 1_024
+
+ public let root: String
+ private let lock = NSLock()
+
+ public init(root: String) {
+ self.root = URL(fileURLWithPath: root).standardizedFileURL.path
+ }
+
+ public func create(_ plan: DoryResolvedMachinePlan) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try Self.validateCurrentPlan(plan)
+ guard plan.planRevision == 1 else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidPlanRevision(
+ expected: 1,
+ actual: plan.planRevision
+ )
+ }
+ let path = try prepareMachineDirectory(id: plan.machineID)
+ + "/\(Self.recordFileName)"
+ guard !Self.pathExists(path) else {
+ throw DoryResolvedMachinePlanRepositoryError.planExists(plan.machineID)
+ }
+ try publish(plan, path: path, replacing: false)
+ }
+
+ public func replace(
+ _ plan: DoryResolvedMachinePlan,
+ expectedPlanRevision: UInt64
+ ) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try Self.validateCurrentPlan(plan)
+ let current = try readRecordUnlocked(id: plan.machineID).plan
+ guard current.planRevision == expectedPlanRevision else {
+ throw DoryResolvedMachinePlanRepositoryError.stalePlanRevision(
+ expected: expectedPlanRevision,
+ actual: current.planRevision
+ )
+ }
+ guard expectedPlanRevision < UInt64.max,
+ plan.planRevision == expectedPlanRevision + 1 else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidPlanRevision(
+ expected: expectedPlanRevision == UInt64.max
+ ? UInt64.max
+ : expectedPlanRevision + 1,
+ actual: plan.planRevision
+ )
+ }
+ guard plan.machineID == current.machineID,
+ plan.createdAtUnixMilliseconds == current.createdAtUnixMilliseconds else {
+ throw DoryResolvedMachinePlanRepositoryError.machineIdentityChanged(plan.machineID)
+ }
+ try publish(plan, path: recordPath(id: plan.machineID), replacing: true)
+ }
+
+ public func read(id: String) throws -> DoryResolvedMachinePlan {
+ lock.lock()
+ defer { lock.unlock() }
+ return try readRecordUnlocked(id: id).plan
+ }
+
+ public func remove(id: String) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try Self.validateMachineIdentifier(id)
+ let path = recordPath(id: id)
+ guard Self.pathExists(path) else {
+ throw DoryResolvedMachinePlanRepositoryError.planNotFound(id)
+ }
+ _ = try Self.secureRead(path: path)
+ guard unlink(path) == 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "remove resolved plan at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ try Self.fsyncDirectory(machineDirectory(id: id))
+ }
+
+ private func readRecordUnlocked(id: String) throws -> DoryResolvedMachinePlanRepositoryRecord {
+ try Self.validateMachineIdentifier(id)
+ let path = recordPath(id: id)
+ guard Self.pathExists(path) else {
+ throw DoryResolvedMachinePlanRepositoryError.planNotFound(id)
+ }
+ let data = try Self.secureRead(path: path)
+ let record: DoryResolvedMachinePlanRepositoryRecord
+ do {
+ record = try JSONDecoder().decode(DoryResolvedMachinePlanRepositoryRecord.self, from: data)
+ } catch {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ guard record.plan.machineID == id else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ switch record.schemaVersion {
+ case DoryResolvedMachinePlanRepositoryRecord.oldestSupportedSchemaVersion:
+ guard record.plan.sourceSchemaVersion == DoryResolvedMachinePlan.oldestSupportedSchemaVersion,
+ record.plan.migrationDisposition == .requiresReplanning,
+ record.planSHA256 == nil else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ case DoryResolvedMachinePlanRepositoryRecord.currentSchemaVersion:
+ guard let expected = record.planSHA256,
+ Self.isSHA256(expected),
+ expected == Self.planSHA256(record.plan),
+ record.plan.validate().isEmpty else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ default:
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ return record
+ }
+
+ private func prepareMachineDirectory(id: String) throws -> String {
+ try Self.validateMachineIdentifier(id)
+ try Self.preparePrivateDirectory(root)
+ let directory = machineDirectory(id: id)
+ try Self.preparePrivateDirectory(directory)
+ return directory
+ }
+
+ private func machineDirectory(id: String) -> String { root + "/" + id }
+
+ private func recordPath(id: String) -> String {
+ machineDirectory(id: id) + "/" + Self.recordFileName
+ }
+
+ private func publish(
+ _ plan: DoryResolvedMachinePlan,
+ path: String,
+ replacing: Bool
+ ) throws {
+ let record = DoryResolvedMachinePlanRepositoryRecord(
+ planSHA256: Self.planSHA256(plan),
+ plan: plan
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data: Data
+ do {
+ data = try encoder.encode(record)
+ } catch {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "encode resolved plan for \(plan.machineID): \(error)"
+ )
+ }
+ guard !data.isEmpty, data.count <= Self.maximumRecordBytes else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+
+ let directory = URL(fileURLWithPath: path).deletingLastPathComponent().path
+ let temporaryPath = directory + "/\(Self.temporaryPrefix)\(UUID().uuidString.lowercased())"
+ let descriptor = open(
+ temporaryPath,
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
+ mode_t(0o600)
+ )
+ guard descriptor >= 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "create resolved-plan temporary record at \(temporaryPath): "
+ + String(cString: strerror(errno))
+ )
+ }
+ var shouldRemoveTemporary = true
+ defer {
+ _ = close(descriptor)
+ if shouldRemoveTemporary { _ = unlink(temporaryPath) }
+ }
+ do {
+ try data.withUnsafeBytes { bytes in
+ guard let base = bytes.baseAddress else { return }
+ var written = 0
+ while written < data.count {
+ let count = Darwin.write(
+ descriptor,
+ base.advanced(by: written),
+ data.count - written
+ )
+ if count < 0, errno == EINTR { continue }
+ guard count > 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "write resolved-plan temporary record at \(temporaryPath): "
+ + String(cString: strerror(errno))
+ )
+ }
+ written += count
+ }
+ }
+ guard fsync(descriptor) == 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "fsync resolved-plan temporary record at \(temporaryPath): "
+ + String(cString: strerror(errno))
+ )
+ }
+ if replacing {
+ guard rename(temporaryPath, path) == 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "publish resolved plan at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ } else {
+ guard link(temporaryPath, path) == 0 else {
+ if errno == EEXIST {
+ throw DoryResolvedMachinePlanRepositoryError.planExists(plan.machineID)
+ }
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "publish resolved plan at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ guard unlink(temporaryPath) == 0 else {
+ _ = unlink(path)
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "remove resolved-plan temporary record at \(temporaryPath): "
+ + String(cString: strerror(errno))
+ )
+ }
+ }
+ shouldRemoveTemporary = false
+ try Self.fsyncDirectory(directory)
+ } catch let error as DoryResolvedMachinePlanRepositoryError {
+ throw error
+ } catch {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "publish resolved plan at \(path): \(error)"
+ )
+ }
+ }
+
+ private static func validateCurrentPlan(_ plan: DoryResolvedMachinePlan) throws {
+ let issues = plan.validate()
+ guard issues.isEmpty else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidPlan(issues)
+ }
+ }
+
+ private static func validateMachineIdentifier(_ id: String) throws {
+ guard DoryResolvedMachinePlan.isSafeIdentifier(id) else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidMachineIdentifier(id)
+ }
+ }
+
+ private static func preparePrivateDirectory(_ path: String) throws {
+ if !pathExists(path) {
+ do {
+ try FileManager.default.createDirectory(
+ atPath: path,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ } catch {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "create resolved-plan directory at \(path): \(error)"
+ )
+ }
+ _ = chmod(path, mode_t(0o700))
+ }
+ var info = stat()
+ guard lstat(path, &info) == 0,
+ (info.st_mode & S_IFMT) == S_IFDIR,
+ info.st_uid == getuid(),
+ (info.st_mode & 0o077) == 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ }
+
+ private static func secureRead(path: String) throws -> Data {
+ var before = stat()
+ guard lstat(path, &before) == 0,
+ (before.st_mode & S_IFMT) == S_IFREG,
+ before.st_uid == getuid(),
+ before.st_nlink == 1,
+ (before.st_mode & 0o077) == 0,
+ before.st_size > 0,
+ before.st_size <= maximumRecordBytes else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ defer { _ = close(descriptor) }
+ var after = stat()
+ guard fstat(descriptor, &after) == 0,
+ before.st_dev == after.st_dev,
+ before.st_ino == after.st_ino,
+ before.st_size == after.st_size else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ do {
+ let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false)
+ let data = try handle.read(upToCount: maximumRecordBytes + 1) ?? Data()
+ guard !data.isEmpty,
+ data.count <= maximumRecordBytes,
+ data.count == Int(after.st_size) else {
+ throw DoryResolvedMachinePlanRepositoryError.invalidRecord(path)
+ }
+ return data
+ } catch let error as DoryResolvedMachinePlanRepositoryError {
+ throw error
+ } catch {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "read resolved plan at \(path): \(error)"
+ )
+ }
+ }
+
+ private static func fsyncDirectory(_ path: String) throws {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC)
+ guard descriptor >= 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "open resolved-plan directory at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ defer { _ = close(descriptor) }
+ guard fsync(descriptor) == 0 else {
+ throw DoryResolvedMachinePlanRepositoryError.filesystem(
+ "fsync resolved-plan directory at \(path): \(String(cString: strerror(errno)))"
+ )
+ }
+ }
+
+ private static func planSHA256(_ plan: DoryResolvedMachinePlan) -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ guard let data = try? encoder.encode(plan) else { return "" }
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ DoryResolvedMachinePlan.isSHA256(value)
+ }
+
+ private static func pathExists(_ path: String) -> Bool {
+ var info = stat()
+ return lstat(path, &info) == 0
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift
new file mode 100644
index 00000000..be75d5cc
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift
@@ -0,0 +1,927 @@
+import Darwin
+import DoryOperations
+@testable import DorydKit
+import Foundation
+import Testing
+
+@Suite("Resolved machine plan")
+struct DoryResolvedMachinePlanTests {
+ @Test("current exact plan validates and round trips")
+ func roundTrip() throws {
+ let plan = supportedPlan()
+ #expect(plan.validate().isEmpty)
+ let encoded = try JSONEncoder().encode(plan)
+ #expect(try JSONDecoder().decode(DoryResolvedMachinePlan.self, from: encoded) == plan)
+ }
+
+ @Test("golden schema v1 decodes only as a non-runnable migration")
+ func goldenV1Migration() throws {
+ let migrated = try JSONDecoder().decode(
+ DoryResolvedMachinePlan.self,
+ from: Data(Self.goldenV1Plan.utf8)
+ )
+ #expect(migrated.schemaVersion == DoryResolvedMachinePlan.currentSchemaVersion)
+ #expect(migrated.sourceSchemaVersion == 1)
+ #expect(migrated.migrationDisposition == .requiresReplanning)
+ #expect(migrated.backendRuntimeBuildIdentifier == "raw-runtime-1")
+ #expect(migrated.components.map(\.componentIdentifier) == ["dory-hv", "renderer"])
+ #expect(migrated.validate().contains { $0.code == .legacyPlanRequiresReplanning })
+
+ let input = DoryResolvedMachinePlanStartRevalidationInput(
+ machineID: migrated.machineID,
+ expectedPlanRevision: migrated.planRevision,
+ currentDefinitionRevision: migrated.definitionRevision,
+ currentDefinitionSHA256: digest("1"),
+ runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: migrated)
+ )
+ let result = DoryResolvedMachinePlanStartValidator.revalidate(migrated, against: input)
+ #expect(!result.mayStart)
+ #expect(result.issues.contains { $0.code == .storedPlanInvalid })
+ }
+
+ @Test("exact fresh evidence authorizes start")
+ func exactStartEvidence() {
+ let plan = supportedPlan()
+ let result = DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: exactInput(for: plan)
+ )
+ #expect(result.mayStart)
+ #expect(result.issues.isEmpty)
+ }
+
+ @Test("definition and plan revisions are exact start gates")
+ func revisionMismatch() {
+ let plan = supportedPlan()
+ var input = exactInput(for: plan)
+ input.expectedPlanRevision += 1
+ input.currentDefinitionRevision += 1
+ input.currentDefinitionSHA256 = digest("9")
+
+ let codes = Set(DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: input
+ ).issues.map(\.code))
+ #expect(codes.contains(.planRevisionMismatch))
+ #expect(codes.contains(.definitionRevisionMismatch))
+ #expect(codes.contains(.definitionDigestMismatch))
+ }
+
+ @Test("backend runtime component host and admission evidence mismatches are explicit")
+ func runtimeEvidenceMismatch() {
+ let plan = supportedPlan()
+ var input = exactInput(for: plan)
+ input.runtimeEvidence.backendRuntimeBuildIdentifier = "raw-runtime-2"
+ input.runtimeEvidence.components[0].artifactSHA256 = digest("9")
+ input.runtimeEvidence.hostQualification?.hostOperatingSystemBuild = "26B999"
+ input.runtimeEvidence.resourceAdmission?.existingMemoryCommitmentBytes += 1
+
+ let codes = Set(DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: input
+ ).issues.map(\.code))
+ #expect(codes.contains(.backendRuntimeBuildMismatch))
+ #expect(codes.contains(.componentEvidenceMismatch))
+ #expect(codes.contains(.hostQualificationMismatch))
+ #expect(codes.contains(.resourceAdmissionMismatch))
+ }
+
+ @Test("immutable media and qualification evidence mismatches are exact")
+ func immutableEvidenceMismatch() {
+ let plan = supportedPlan()
+ var input = exactInput(for: plan)
+ input.runtimeEvidence.bootMedia.media.artifactSHA256 = digest("9")
+ input.runtimeEvidence.qualificationEvidence.runtime?.qualificationReportSHA256 = digest("8")
+
+ let codes = Set(DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: input
+ ).issues.map(\.code))
+ #expect(codes.contains(.bootMediaEvidenceMismatch))
+ #expect(codes.contains(.qualificationEvidenceMismatch))
+ }
+
+ @Test("mutable disk provenance revision mismatch rejects start")
+ func mutableProvenanceMismatch() {
+ let plan = mutableVZPlan()
+ #expect(plan.validate().isEmpty)
+ var input = exactInput(for: plan)
+ input.runtimeEvidence.bootMedia.media.mutableProvenance?.revision += 1
+
+ let result = DoryResolvedMachinePlanStartValidator.revalidate(plan, against: input)
+ #expect(!result.mayStart)
+ #expect(result.issues.contains { $0.code == .bootMediaEvidenceMismatch })
+ }
+
+ @Test("support tier is revalidated and unsupported plans never validate")
+ func supportTierSafety() {
+ let plan = supportedPlan()
+ var input = exactInput(for: plan)
+ input.runtimeEvidence.supportTier = .experimental
+ #expect(DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: input
+ ).issues.contains { $0.code == .supportTierMismatch })
+
+ var unsupported = plan
+ unsupported.supportTier = .unsupported
+ #expect(unsupported.validate().contains { $0.code == .unsupportedSupportTier })
+ #expect(!DoryResolvedMachinePlanStartValidator.revalidate(
+ unsupported,
+ against: exactInput(for: unsupported)
+ ).mayStart)
+ }
+
+ @Test("experimental plan requires exact persisted authorization")
+ func experimentalAuthorization() {
+ var plan = supportedPlan()
+ plan.supportTier = .experimental
+ plan.qualificationEvidence.runtime = nil
+ plan.selectionEvidence?.plannerRequest.allowsExperimentalBackends = true
+ #expect(plan.validate().contains { $0.code == .missingExperimentalAuthorization })
+
+ plan.experimentalAuthorization = DoryResolvedExperimentalSupportAuthorization(
+ authorizationIdentity: "experimental-consent-1",
+ definitionRevision: plan.definitionRevision,
+ backend: plan.backend,
+ authorizedAtUnixMilliseconds: plan.createdAtUnixMilliseconds
+ )
+ #expect(plan.validate().isEmpty)
+ var input = exactInput(for: plan)
+ input.runtimeEvidence.experimentalAuthorization?.authorizationIdentity = "different-consent"
+ #expect(DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: input
+ ).issues.contains { $0.code == .experimentalAuthorizationMismatch })
+ }
+
+ @Test("selected capability construction rejects descriptor and availability substitution")
+ func selectedCapabilityConstruction() {
+ let plan = supportedPlan()
+ let capability = capabilityDescriptor(from: plan, availability: DoryCapabilityAvailability(
+ supportTier: .supported,
+ state: .available
+ ))
+ let plannerRequest = backendPlannerRequest(from: plan)
+ let plannerResult = DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: capability,
+ evaluatedDescriptors: [capability],
+ failure: nil
+ )
+
+ #expect(throws: DoryResolvedMachinePlanConstructionError.backendDescriptorMismatch) {
+ _ = try DoryResolvedMachinePlan(
+ machineID: plan.machineID,
+ definitionRevision: plan.definitionRevision,
+ definitionSHA256: plan.definitionSHA256!,
+ planRevision: 1,
+ createdAtUnixMilliseconds: plan.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: plan.updatedAtUnixMilliseconds,
+ backendDescriptor: VirtualizationFrameworkLinuxMachineBackend.backendDescriptor,
+ backendRuntimeBuildIdentifier: plan.backendRuntimeBuildIdentifier,
+ resolverReference: plan.bootMedia.resolverReference,
+ components: plan.components,
+ resourceAdmission: plan.resourceAdmission!,
+ hostQualification: plan.hostQualification!,
+ plannerRequest: plannerRequest,
+ plannerResult: plannerResult
+ )
+ }
+
+ let unavailable = capabilityDescriptor(from: plan, availability: DoryCapabilityAvailability(
+ supportTier: .unsupported,
+ state: .unavailable
+ ))
+ let unavailableResult = DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: unavailable,
+ evaluatedDescriptors: [unavailable],
+ failure: nil
+ )
+ #expect(throws: DoryResolvedMachinePlanConstructionError.capabilityDescriptorInvalid) {
+ _ = try DoryResolvedMachinePlan(
+ machineID: plan.machineID,
+ definitionRevision: plan.definitionRevision,
+ definitionSHA256: plan.definitionSHA256!,
+ planRevision: 1,
+ createdAtUnixMilliseconds: plan.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: plan.updatedAtUnixMilliseconds,
+ backendDescriptor: RawHVLinuxMachineBackend.backendDescriptor,
+ backendRuntimeBuildIdentifier: plan.backendRuntimeBuildIdentifier,
+ resolverReference: plan.bootMedia.resolverReference,
+ components: plan.components,
+ resourceAdmission: plan.resourceAdmission!,
+ hostQualification: plan.hostQualification!,
+ plannerRequest: plannerRequest,
+ plannerResult: unavailableResult
+ )
+ }
+ }
+
+ @Test("internal evidence bindings reject mismatched host runtime and resource overcommit")
+ func structuralEvidenceValidation() {
+ var hostMismatch = supportedPlan()
+ hostMismatch.hostQualification?.backendRuntimeBuildIdentifier = "other-runtime"
+ #expect(hostMismatch.validate().contains { $0.code == .invalidHostQualification })
+
+ var overcommitted = supportedPlan()
+ overcommitted.resourceAdmission?.existingMemoryCommitmentBytes = UInt64.max
+ #expect(overcommitted.validate().contains { $0.code == .invalidResourceAdmission })
+
+ var qualificationMismatch = supportedPlan()
+ qualificationMismatch.qualificationEvidence.runtime?.backendRuntimeBuildID = "other-runtime"
+ #expect(qualificationMismatch.validate().contains { $0.code == .runtimeQualificationMismatch })
+ }
+
+ @Test("preferred unavailable candidate persists an approved named fallback")
+ func approvedFallback() throws {
+ let plan = try fallbackVZPlan(policy: .preferred, includeAuthorization: true)
+ let selection = try #require(plan.selectionEvidence)
+ #expect(selection.disposition == .approvedFallback)
+ #expect(selection.selectedEvaluationIndex == 1)
+ #expect(selection.rejectedCandidates.count == 1)
+ #expect(selection.rejectedCandidates[0].backend == .doryHypervisor)
+ #expect(selection.rejectedCandidates[0].availability.reason?.code
+ == .bootMediaDoesNotSupportBackend)
+ #expect(selection.fallbackAuthorization?.fromBackend == .doryHypervisor)
+ #expect(selection.fallbackAuthorization?.toBackend == .appleVirtualizationFramework)
+ #expect(plan.validate().isEmpty)
+ #expect(DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: exactInput(for: plan)
+ ).mayStart)
+
+ let roundTrip = try JSONDecoder().decode(
+ DoryResolvedMachinePlan.self,
+ from: JSONEncoder().encode(plan)
+ )
+ #expect(roundTrip.selectionEvidence == selection)
+ }
+
+ @Test("preferred fallback requires approval and required alternatives do not")
+ func fallbackPolicySafety() throws {
+ #expect(throws: DoryResolvedMachinePlanConstructionError.fallbackAuthorizationRequired) {
+ _ = try fallbackVZPlan(policy: .preferred, includeAuthorization: false)
+ }
+
+ let requiredAlternative = try fallbackVZPlan(
+ policy: .required,
+ includeAuthorization: false
+ )
+ #expect(requiredAlternative.selectionEvidence?.disposition == .explicitAlternative)
+ #expect(requiredAlternative.selectionEvidence?.fallbackAuthorization == nil)
+ #expect(requiredAlternative.validate().isEmpty)
+
+ #expect(throws: DoryResolvedMachinePlanConstructionError.fallbackAuthorizationInvalid) {
+ _ = try fallbackVZPlan(policy: .required, includeAuthorization: true)
+ }
+ }
+
+ @Test("required policy permits an explicitly listed lower graphics contract")
+ func requiredGraphicsAlternative() throws {
+ var plan = supportedPlan()
+ let request = DoryVirtualMachineBackendPlanRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ acceptableGraphics: [.hardwareAccelerated3D, .hostAcceleratedDisplay],
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion,
+ backendPreferences: [.doryHypervisor],
+ backendPreferencePolicy: .required
+ )
+ let rejected = DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: DoryVirtualMachineCapabilityRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ backend: .doryHypervisor,
+ graphics: .hardwareAccelerated3D,
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ ),
+ availability: DoryCapabilityAvailability(
+ supportTier: .unsupported,
+ state: .unavailable,
+ reason: DoryCapabilityReason(
+ code: .acceleratedRendererUnavailable,
+ message: "The requested renderer is unavailable."
+ )
+ )
+ )
+ let selected = capabilityDescriptor(
+ from: plan,
+ availability: DoryCapabilityAvailability(
+ supportTier: .supported,
+ state: .available
+ )
+ )
+ plan.selectionEvidence = try DoryResolvedMachineBackendSelectionEvidence.resolving(
+ request: request,
+ result: DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: selected,
+ evaluatedDescriptors: [rejected, selected],
+ failure: nil
+ ),
+ definitionRevision: plan.definitionRevision
+ )
+
+ #expect(plan.selectionEvidence?.disposition == .explicitAlternative)
+ #expect(plan.selectionEvidence?.selectedEvaluationIndex == 1)
+ #expect(plan.validate().isEmpty)
+ }
+
+ @Test("required policy rejects a backend the request did not list")
+ func requiredImplicitBackendRejection() {
+ let plan = mutableVZPlan()
+ let request = DoryVirtualMachineBackendPlanRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ acceptableGraphics: [plan.graphics],
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion,
+ backendPreferences: [.doryHypervisor],
+ backendPreferencePolicy: .required
+ )
+ let selected = capabilityDescriptor(
+ from: plan,
+ availability: DoryCapabilityAvailability(
+ supportTier: .supported,
+ state: .available
+ )
+ )
+ let result = DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: selected,
+ evaluatedDescriptors: [selected],
+ failure: nil
+ )
+
+ #expect(throws: DoryResolvedMachinePlanConstructionError.plannerResultInvalid) {
+ _ = try DoryResolvedMachineBackendSelectionEvidence.resolving(
+ request: request,
+ result: result,
+ definitionRevision: plan.definitionRevision
+ )
+ }
+ }
+
+ @Test("missing or changed fallback evidence rejects start")
+ func fallbackTamperSafety() throws {
+ let plan = try fallbackVZPlan(policy: .preferred, includeAuthorization: true)
+ var missing = plan
+ missing.selectionEvidence?.fallbackAuthorization = nil
+ #expect(missing.validate().contains { $0.code == .missingFallbackAuthorization })
+ #expect(!DoryResolvedMachinePlanStartValidator.revalidate(
+ missing,
+ against: exactInput(for: missing)
+ ).mayStart)
+
+ var input = exactInput(for: plan)
+ input.runtimeEvidence.selectionEvidence?.fallbackAuthorization?.authorizationIdentity
+ = "different-fallback-consent"
+ #expect(DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: input
+ ).issues.contains { $0.code == .selectionEvidenceMismatch })
+ }
+
+ private func exactInput(
+ for plan: DoryResolvedMachinePlan
+ ) -> DoryResolvedMachinePlanStartRevalidationInput {
+ DoryResolvedMachinePlanStartRevalidationInput(
+ machineID: plan.machineID,
+ expectedPlanRevision: plan.planRevision,
+ currentDefinitionRevision: plan.definitionRevision,
+ currentDefinitionSHA256: plan.definitionSHA256 ?? "",
+ runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: plan)
+ )
+ }
+
+ private func capabilityDescriptor(
+ from plan: DoryResolvedMachinePlan,
+ availability: DoryCapabilityAvailability
+ ) -> DoryVirtualMachineCapabilityDescriptor {
+ DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: DoryVirtualMachineCapabilityRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ backend: plan.backend,
+ graphics: plan.graphics,
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ ),
+ availability: availability,
+ resolvedDevices: availability.isUsable ? plan.devices : nil,
+ graphicsQualificationEvidence: plan.qualificationEvidence.graphics,
+ bootMediaInspectionEvidence: plan.bootMedia.inspectionEvidence,
+ mutableBootMediaProvenanceEvidence: plan.bootMedia.mutableProvenanceEvidence,
+ runtimeQualificationEvidence: plan.qualificationEvidence.runtime
+ )
+ }
+
+ private func fallbackVZPlan(
+ policy: DoryVirtualMachineBackendPreferencePolicy,
+ includeAuthorization: Bool
+ ) throws -> DoryResolvedMachinePlan {
+ var plan = mutableVZPlan()
+ let request = DoryVirtualMachineBackendPlanRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ acceptableGraphics: [plan.graphics],
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion,
+ backendPreferences: [.doryHypervisor, .appleVirtualizationFramework],
+ backendPreferencePolicy: policy
+ )
+ let rejectedRequest = DoryVirtualMachineCapabilityRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ backend: .doryHypervisor,
+ graphics: plan.graphics,
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ )
+ let rejected = DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: rejectedRequest,
+ availability: DoryCapabilityAvailability(
+ supportTier: .unsupported,
+ state: .unavailable,
+ reason: DoryCapabilityReason(
+ code: .bootMediaDoesNotSupportBackend,
+ message: "The preferred backend cannot boot this media."
+ )
+ )
+ )
+ let selected = capabilityDescriptor(
+ from: plan,
+ availability: DoryCapabilityAvailability(
+ supportTier: .supported,
+ state: .available
+ )
+ )
+ let result = DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: selected,
+ evaluatedDescriptors: [rejected, selected],
+ failure: nil
+ )
+ let authorization = includeAuthorization
+ ? DoryResolvedMachineFallbackAuthorization(
+ authorizationIdentity: "fallback-consent-1",
+ definitionRevision: plan.definitionRevision,
+ fromBackend: .doryHypervisor,
+ fromGraphics: plan.graphics,
+ toBackend: .appleVirtualizationFramework,
+ toGraphics: plan.graphics,
+ authorizedAtUnixMilliseconds: plan.createdAtUnixMilliseconds
+ )
+ : nil
+ plan.selectionEvidence = try DoryResolvedMachineBackendSelectionEvidence.resolving(
+ request: request,
+ result: result,
+ definitionRevision: plan.definitionRevision,
+ fallbackAuthorization: authorization
+ )
+ return plan
+ }
+
+ fileprivate static let goldenV1Plan = """
+ {
+ "schemaVersion": 1,
+ "machineID": "workspace-one",
+ "definitionRevision": 3,
+ "planRevision": 4,
+ "createdAtUnixMilliseconds": 1700000000000,
+ "updatedAtUnixMilliseconds": 1700000000100,
+ "guest": {"family": "linux", "architecture": "arm64"},
+ "backend": "dory-hypervisor",
+ "backendRuntimeBuildID": "raw-runtime-1",
+ "virtualHardwareABIVersion": 1,
+ "bootMedia": {
+ "kind": "installed-linux-boot-bundle",
+ "source": "dory-bundled",
+ "artifactSHA256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ "componentDigests": {
+ "renderer": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ "dory-hv": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ },
+ "devices": {
+ "networkAttachment": "shared-nat",
+ "audioInput": false,
+ "audioOutput": false,
+ "keyboard": false,
+ "pointer": false,
+ "directorySharing": false,
+ "clipboard": false,
+ "clockSynchronization": false,
+ "dynamicDisplay": false,
+ "gracefulShutdown": false
+ },
+ "graphics": "host-accelerated-display"
+ }
+ """
+}
+
+@Suite("Resolved machine plan repository")
+struct DoryResolvedMachinePlanRepositoryTests {
+ @Test("create read replace uses owner-only crash-safe optimistic revisions")
+ func optimisticLifecycle() throws {
+ try withRepository { repository, root in
+ let initial = supportedPlan()
+ try repository.create(initial)
+ #expect(try repository.read(id: initial.machineID) == initial)
+
+ let recordPath = root + "/" + initial.machineID + "/"
+ + DoryResolvedMachinePlanRepository.recordFileName
+ var info = stat()
+ #expect(lstat(recordPath, &info) == 0)
+ #expect((info.st_mode & 0o777) == 0o600)
+ #expect(info.st_nlink == 1)
+
+ var replacement = initial
+ replacement.planRevision = 2
+ replacement.updatedAtUnixMilliseconds += 1
+ try repository.replace(replacement, expectedPlanRevision: 1)
+ #expect(try repository.read(id: initial.machineID) == replacement)
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.stalePlanRevision(
+ expected: 1,
+ actual: 2
+ )) {
+ try repository.replace(replacement, expectedPlanRevision: 1)
+ }
+
+ var skipped = replacement
+ skipped.planRevision = 4
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.invalidPlanRevision(
+ expected: 3,
+ actual: 4
+ )) {
+ try repository.replace(skipped, expectedPlanRevision: 2)
+ }
+ }
+ }
+
+ @Test("record integrity digest rejects edited plan bytes")
+ func tamperedRecord() throws {
+ try withRepository { repository, root in
+ let plan = supportedPlan()
+ try repository.create(plan)
+ let path = root + "/" + plan.machineID + "/"
+ + DoryResolvedMachinePlanRepository.recordFileName
+ var text = try String(contentsOfFile: path, encoding: .utf8)
+ text = text.replacingOccurrences(of: "raw-runtime-1", with: "raw-runtime-2")
+ try Data(text.utf8).write(to: URL(fileURLWithPath: path))
+ _ = chmod(path, mode_t(0o600))
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.invalidRecord(path)) {
+ _ = try repository.read(id: plan.machineID)
+ }
+ }
+ }
+
+ @Test("schema v1 repository record remains readable but cannot authorize launch")
+ func repositoryV1Migration() throws {
+ try withRepository { repository, root in
+ let machineID = "workspace-one"
+ let directory = root + "/" + machineID
+ try FileManager.default.createDirectory(
+ atPath: directory,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let record = "{\"schemaVersion\":1,\"plan\":\(DoryResolvedMachinePlanTests.goldenV1Plan)}"
+ let path = directory + "/" + DoryResolvedMachinePlanRepository.recordFileName
+ #expect(FileManager.default.createFile(
+ atPath: path,
+ contents: Data(record.utf8),
+ attributes: [.posixPermissions: 0o600]
+ ))
+
+ let migrated = try repository.read(id: machineID)
+ #expect(migrated.migrationDisposition == .requiresReplanning)
+ #expect(!DoryResolvedMachinePlanStartValidator.revalidate(
+ migrated,
+ against: DoryResolvedMachinePlanStartRevalidationInput(
+ machineID: machineID,
+ expectedPlanRevision: migrated.planRevision,
+ currentDefinitionRevision: migrated.definitionRevision,
+ currentDefinitionSHA256: digest("1"),
+ runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: migrated)
+ )
+ ).mayStart)
+ }
+ }
+
+ @Test("repository rejects symlink hard-link public and oversized records")
+ func hostileRecords() throws {
+ try withRepository { repository, root in
+ let plan = supportedPlan()
+ try repository.create(plan)
+ let directory = root + "/" + plan.machineID
+ let record = directory + "/" + DoryResolvedMachinePlanRepository.recordFileName
+ let saved = directory + "/saved"
+ try FileManager.default.moveItem(atPath: record, toPath: saved)
+
+ #expect(symlink(saved, record) == 0)
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ _ = try repository.read(id: plan.machineID)
+ }
+ #expect(unlink(record) == 0)
+
+ #expect(link(saved, record) == 0)
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ _ = try repository.read(id: plan.machineID)
+ }
+ #expect(unlink(record) == 0)
+
+ try FileManager.default.copyItem(atPath: saved, toPath: record)
+ _ = chmod(record, mode_t(0o644))
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ _ = try repository.read(id: plan.machineID)
+ }
+ #expect(unlink(record) == 0)
+
+ #expect(FileManager.default.createFile(atPath: record, contents: Data([0])))
+ let descriptor = open(record, O_WRONLY | O_CLOEXEC)
+ #expect(descriptor >= 0)
+ #expect(ftruncate(descriptor, off_t(5 * 1_024 * 1_024)) == 0)
+ _ = close(descriptor)
+ _ = chmod(record, mode_t(0o600))
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ _ = try repository.read(id: plan.machineID)
+ }
+ }
+ }
+
+ @Test("invalid and experimental-without-authorization plans are never published")
+ func publicationValidation() throws {
+ try withRepository { repository, _ in
+ var unsupported = supportedPlan()
+ unsupported.supportTier = .unsupported
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ try repository.create(unsupported)
+ }
+
+ var experimental = supportedPlan()
+ experimental.supportTier = .experimental
+ experimental.qualificationEvidence.runtime = nil
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ try repository.create(experimental)
+ }
+ }
+ }
+
+ private func withRepository(
+ _ body: (DoryResolvedMachinePlanRepository, String) throws -> Void
+ ) throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-resolved-plan-\(UUID().uuidString)")
+ .path
+ defer { try? FileManager.default.removeItem(atPath: root) }
+ try body(DoryResolvedMachinePlanRepository(root: root), root)
+ }
+}
+
+private func supportedPlan() -> DoryResolvedMachinePlan {
+ let artifact = digest("a")
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let media = DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifactSHA256: artifact
+ )
+ return DoryResolvedMachinePlan(
+ machineID: "workspace-one",
+ definitionRevision: 3,
+ definitionSHA256: digest("1"),
+ planRevision: 1,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000,
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ backend: .doryHypervisor,
+ backendImplementationIdentifier: "dory.raw-hv-linux.compatibility.v1",
+ backendRuntimeBuildIdentifier: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ bootMedia: DoryResolvedMachineBootMedia(
+ resolverReference: DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "ubuntu-desktop-1"
+ ),
+ media: media
+ ),
+ components: [
+ DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: "raw-runtime-1",
+ artifactSHA256: digest("d")
+ ),
+ DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "renderer",
+ buildIdentifier: "renderer-1",
+ artifactSHA256: digest("e")
+ ),
+ ],
+ devices: devices,
+ graphics: .hostAcceleratedDisplay,
+ supportTier: .supported,
+ selectionEvidence: primarySelectionEvidence(
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ media: media,
+ backend: .doryHypervisor,
+ graphics: .hostAcceleratedDisplay,
+ devices: devices
+ ),
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence(
+ graphics: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: "ubuntu-graphics-1",
+ artifactSHA256: artifact,
+ manifestSHA256: digest("b"),
+ signingKeyID: "dory-release-1",
+ manifestFormatVersion: 1
+ ),
+ runtime: runtimeQualification(
+ media: media,
+ backend: .doryHypervisor,
+ runtimeBuild: "raw-runtime-1",
+ graphics: .hostAcceleratedDisplay,
+ devices: devices
+ )
+ ),
+ resourceAdmission: resourceAdmission(),
+ hostQualification: hostQualification(
+ backend: .doryHypervisor,
+ runtimeBuild: "raw-runtime-1"
+ )
+ )
+}
+
+private func mutableVZPlan() -> DoryResolvedMachinePlan {
+ let provenance = DoryMutableBootMediaProvenanceReference(
+ repositoryIdentity: "machine-store",
+ mediaIdentity: "workspace-one-disk",
+ revision: 7
+ )
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let media = DoryBootMedia(
+ kind: .virtualDisk,
+ source: .userProvided,
+ mutableProvenance: provenance
+ )
+ return DoryResolvedMachinePlan(
+ machineID: "workspace-one",
+ definitionRevision: 3,
+ definitionSHA256: digest("1"),
+ planRevision: 1,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000,
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ backend: .appleVirtualizationFramework,
+ backendImplementationIdentifier: "dory.vz-linux.compatibility.v1",
+ backendRuntimeBuildIdentifier: "vz-runtime-1",
+ virtualHardwareABIVersion: 1,
+ bootMedia: DoryResolvedMachineBootMedia(
+ resolverReference: DoryVMResolverReference(
+ namespace: "machine",
+ identifier: "workspace-one-disk"
+ ),
+ media: media,
+ mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence(
+ receiptIdentity: "disk-receipt-7",
+ provenance: provenance,
+ receiptSHA256: digest("7"),
+ resolverID: "machine-store",
+ resolverVersion: 1
+ )
+ ),
+ components: [DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-vmm",
+ buildIdentifier: "vz-runtime-1",
+ artifactSHA256: digest("d")
+ )],
+ devices: devices,
+ graphics: .software,
+ supportTier: .supported,
+ selectionEvidence: primarySelectionEvidence(
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ media: media,
+ backend: .appleVirtualizationFramework,
+ graphics: .software,
+ devices: devices
+ ),
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence(
+ runtime: runtimeQualification(
+ media: media,
+ backend: .appleVirtualizationFramework,
+ runtimeBuild: "vz-runtime-1",
+ graphics: .software,
+ devices: devices
+ )
+ ),
+ resourceAdmission: resourceAdmission(),
+ hostQualification: hostQualification(
+ backend: .appleVirtualizationFramework,
+ runtimeBuild: "vz-runtime-1"
+ )
+ )
+}
+
+private func runtimeQualification(
+ media: DoryBootMedia,
+ backend: DoryVirtualizationBackendIdentity,
+ runtimeBuild: String,
+ graphics: DoryGraphicsAccelerationLevel,
+ devices: DoryVirtualMachineDeviceCapabilityRequest
+) -> DoryVirtualMachineRuntimeQualificationEvidence {
+ DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "runtime-qualification-1",
+ qualificationReportSHA256: digest("c"),
+ signingKeyID: "dory-runtime-1",
+ qualificationFormatVersion: 1,
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ bootMediaKind: media.kind,
+ immutableArtifactSHA256: media.artifactSHA256,
+ mutableProvenance: media.mutableProvenance,
+ backend: backend,
+ backendRuntimeBuildID: runtimeBuild,
+ virtualHardwareABIVersion: 1,
+ graphics: graphics,
+ devices: devices
+ )
+}
+
+private func primarySelectionEvidence(
+ guest: DoryGuestPlatform,
+ media: DoryBootMedia,
+ backend: DoryVirtualizationBackendIdentity,
+ graphics: DoryGraphicsAccelerationLevel,
+ devices: DoryVirtualMachineDeviceCapabilityRequest
+) -> DoryResolvedMachineBackendSelectionEvidence {
+ DoryResolvedMachineBackendSelectionEvidence(
+ disposition: .primary,
+ plannerRequest: DoryVirtualMachineBackendPlanRequest(
+ guest: guest,
+ bootMedia: media,
+ acceptableGraphics: [graphics],
+ devices: devices,
+ backendPreferences: [backend],
+ backendPreferencePolicy: .required
+ ),
+ selectedEvaluationIndex: 0,
+ rejectedCandidates: []
+ )
+}
+
+private func backendPlannerRequest(
+ from plan: DoryResolvedMachinePlan
+) -> DoryVirtualMachineBackendPlanRequest {
+ plan.selectionEvidence?.plannerRequest ?? DoryVirtualMachineBackendPlanRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ acceptableGraphics: [plan.graphics],
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion,
+ backendPreferences: [plan.backend],
+ backendPreferencePolicy: .required
+ )
+}
+
+private func resourceAdmission() -> DoryResolvedMachineResourceAdmissionEvidence {
+ DoryResolvedMachineResourceAdmissionEvidence(
+ admittedVirtualCPUCount: 4,
+ admittedMemoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ admittedStorageBytes: 64 * 1_024 * 1_024 * 1_024,
+ hostLogicalCPUCount: 12,
+ hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024,
+ hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024,
+ existingVirtualCPUCommitment: 2,
+ existingMemoryCommitmentBytes: 4 * 1_024 * 1_024 * 1_024,
+ existingStorageReservationBytes: 32 * 1_024 * 1_024 * 1_024,
+ hostReservedLogicalCPUCount: 2,
+ hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024,
+ admissionIdentity: "resource-admission-1",
+ admissionReportSHA256: digest("f"),
+ assessorIdentifier: "dory-resource-policy",
+ assessorVersion: 1
+ )
+}
+
+private func hostQualification(
+ backend: DoryVirtualizationBackendIdentity,
+ runtimeBuild: String
+) -> DoryResolvedHostQualificationEvidence {
+ DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: "host-qualification-1",
+ qualificationReportSHA256: digest("6"),
+ hostHardwareModelIdentifier: "Mac16.1",
+ hostOperatingSystemBuild: "26A5406c",
+ backend: backend,
+ backendRuntimeBuildIdentifier: runtimeBuild,
+ virtualHardwareABIVersion: 1,
+ qualifierIdentifier: "dory-host-qualifier",
+ qualifierVersion: 1
+ )
+}
+
+private func digest(_ character: Character) -> String {
+ String(repeating: String(character), count: 64)
+}
From e80d40a124e972ec8945173bb9a4ed98059ec0ac Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 12:38:06 +0000
Subject: [PATCH 016/338] feat(vm): add daemon runtime planning and
revalidation
---
...emonVirtualMachineLaunchPlanResolver.swift | 371 ++++++++++++
...yDaemonVirtualMachineRuntimePlanning.swift | 499 ++++++++++++++++
...onVirtualMachineRuntimePlanningTests.swift | 531 ++++++++++++++++++
3 files changed, 1401 insertions(+)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
new file mode 100644
index 00000000..e24ba4f2
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
@@ -0,0 +1,371 @@
+import DoryOperations
+import Foundation
+
+public protocol DoryDaemonVirtualMachineExactCapabilityEvaluating: Sendable {
+ func evaluate(
+ _ request: DoryVirtualMachineCapabilityRequest,
+ inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ ) -> DoryVirtualMachineCapabilityDescriptor
+}
+
+/// Re-evaluates one exact persisted choice. It does not invoke backend selection and cannot
+/// downgrade graphics, devices, or backend identity during start.
+public struct DoryAppleSiliconDaemonVirtualMachineExactCapabilityEvaluator:
+ DoryDaemonVirtualMachineExactCapabilityEvaluating
+{
+ public init() {}
+
+ public func evaluate(
+ _ request: DoryVirtualMachineCapabilityRequest,
+ inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ ) -> DoryVirtualMachineCapabilityDescriptor {
+ DoryAppleSiliconCapabilityEvaluator.evaluate(
+ request,
+ host: inventory.hostFacts,
+ trustedGuestImageGraphicsQualification: inventory.media.guestGraphicsQualification,
+ trustedBootMediaInspection: inventory.media.bootInspection,
+ trustedMutableBootMediaProvenance: inventory.media.mutableProvenance,
+ trustedRuntimeQualification: inventory.exactStartRuntimeQualification
+ )
+ }
+}
+
+public struct DoryDaemonVirtualMachineStartEvidenceCollection: Sendable, Equatable {
+ public var capability: DoryVirtualMachineCapabilityDescriptor
+ public var runtimeEvidence: DoryResolvedMachineRuntimeEvidence
+
+ public init(
+ capability: DoryVirtualMachineCapabilityDescriptor,
+ runtimeEvidence: DoryResolvedMachineRuntimeEvidence
+ ) {
+ self.capability = capability
+ self.runtimeEvidence = runtimeEvidence
+ }
+}
+
+public protocol DoryDaemonVirtualMachineStartEvidenceCollecting: Sendable {
+ func collectFreshEvidence(
+ for plan: DoryResolvedMachinePlan
+ ) throws -> DoryDaemonVirtualMachineStartEvidenceCollection
+}
+
+public enum DoryDaemonVirtualMachineStartEvidenceFailureCode: String, Sendable, Equatable {
+ case mediaReferenceUnavailable = "media-reference-unavailable"
+ case inventoryUnavailable = "inventory-unavailable"
+ case mediaInventoryMismatch = "media-inventory-mismatch"
+ case backendUnavailable = "backend-unavailable"
+ case backendInventoryUnavailable = "backend-inventory-unavailable"
+ case exactCapabilityUnavailable = "exact-capability-unavailable"
+}
+
+public struct DoryDaemonVirtualMachineStartEvidenceFailure: Error, Sendable, Equatable {
+ public var code: DoryDaemonVirtualMachineStartEvidenceFailureCode
+ public var message: String
+
+ public init(code: DoryDaemonVirtualMachineStartEvidenceFailureCode, message: String) {
+ self.code = code
+ self.message = message
+ }
+}
+
+/// Production evidence collector. Every volatile field is rebuilt from a fresh daemon inventory;
+/// persisted audit references are never promoted back into trusted decisions.
+public final class DoryDaemonVirtualMachineStartEvidenceCollector:
+ DoryDaemonVirtualMachineStartEvidenceCollecting,
+ @unchecked Sendable
+{
+ private let registry: BackendRegistry
+ private let inventory: any DoryDaemonVirtualMachineTrustInventory
+ private let evaluator: any DoryDaemonVirtualMachineExactCapabilityEvaluating
+
+ public init(
+ registry: BackendRegistry,
+ inventory: any DoryDaemonVirtualMachineTrustInventory,
+ evaluator: any DoryDaemonVirtualMachineExactCapabilityEvaluating =
+ DoryAppleSiliconDaemonVirtualMachineExactCapabilityEvaluator()
+ ) {
+ self.registry = registry
+ self.inventory = inventory
+ self.evaluator = evaluator
+ }
+
+ public func collectFreshEvidence(
+ for plan: DoryResolvedMachinePlan
+ ) throws -> DoryDaemonVirtualMachineStartEvidenceCollection {
+ guard let reference = plan.bootMedia.resolverReference else {
+ throw failure(.mediaReferenceUnavailable, "The persisted plan has no media resolver reference.")
+ }
+ let persistedRequest = DoryVirtualMachineCapabilityRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ backend: plan.backend,
+ graphics: plan.graphics,
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ )
+ let snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ do {
+ snapshot = try inventory.startInventory(for: DoryDaemonVirtualMachineStartInventoryRequest(
+ machineID: plan.machineID,
+ definitionRevision: plan.definitionRevision,
+ planRevision: plan.planRevision,
+ bootMediaReference: reference,
+ exactCapabilityRequest: persistedRequest
+ ))
+ } catch {
+ throw failure(.inventoryUnavailable, "Fresh trusted start inventory could not be resolved.")
+ }
+ guard snapshot.media.reference == reference else {
+ throw failure(.mediaInventoryMismatch, "Fresh media resolved under a different identity.")
+ }
+ guard let backend = registry.backend(for: plan.backend),
+ backend.descriptor.identity == plan.backend else {
+ throw failure(.backendUnavailable, "The persisted backend is not registered.")
+ }
+ guard let runtime = snapshot.backendRuntime(for: plan.backend) else {
+ throw failure(.backendInventoryUnavailable, "Exact backend runtime evidence is unavailable.")
+ }
+ let exactRequest = DoryVirtualMachineCapabilityRequest(
+ guest: plan.guest,
+ bootMedia: snapshot.media.media,
+ backend: plan.backend,
+ graphics: plan.graphics,
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ )
+ let capability = evaluator.evaluate(exactRequest, inventory: snapshot)
+ guard capability.schemaVersion
+ == DoryVirtualMachineCapabilityDescriptor.currentSchemaVersion,
+ capability.evaluatorVersion
+ == DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ capability.request == exactRequest,
+ capability.availability.isUsable,
+ capability.resolvedDevices == exactRequest.devices else {
+ throw failure(.exactCapabilityUnavailable, "Fresh evidence does not authorize the exact capability.")
+ }
+ let runtimeEvidence = DoryResolvedMachineRuntimeEvidence(
+ guest: exactRequest.guest,
+ backend: exactRequest.backend,
+ backendImplementationIdentifier: backend.descriptor.implementationIdentifier,
+ backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier,
+ virtualHardwareABIVersion: exactRequest.virtualHardwareABIVersion,
+ bootMedia: DoryResolvedMachineBootMedia(
+ resolverReference: snapshot.media.reference,
+ media: exactRequest.bootMedia,
+ inspectionEvidence: capability.bootMediaInspectionEvidence,
+ mutableProvenanceEvidence: capability.mutableBootMediaProvenanceEvidence
+ ),
+ components: runtime.components.sorted {
+ $0.componentIdentifier < $1.componentIdentifier
+ },
+ devices: exactRequest.devices,
+ graphics: exactRequest.graphics,
+ supportTier: capability.availability.supportTier,
+ selectionEvidence: plan.selectionEvidence,
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence(
+ graphics: capability.graphicsQualificationEvidence,
+ runtime: capability.runtimeQualificationEvidence
+ ),
+ resourceAdmission: snapshot.resourceAdmission,
+ hostQualification: runtime.hostQualification,
+ experimentalAuthorization: plan.experimentalAuthorization
+ )
+ return DoryDaemonVirtualMachineStartEvidenceCollection(
+ capability: capability,
+ runtimeEvidence: runtimeEvidence
+ )
+ }
+
+ private func failure(
+ _ code: DoryDaemonVirtualMachineStartEvidenceFailureCode,
+ _ message: String
+ ) -> DoryDaemonVirtualMachineStartEvidenceFailure {
+ DoryDaemonVirtualMachineStartEvidenceFailure(code: code, message: message)
+ }
+}
+
+public struct DoryDaemonVirtualMachineLaunchPlanRequest: Sendable {
+ public var definition: DoryVirtualMachineDefinition
+ /// Fresh canonical sorted-key encoding of `definition`.
+ public var canonicalDefinitionData: Data
+ public var machine: DoryMachineConfiguration
+ public var expectedPlanRevision: UInt64
+
+ public init(
+ definition: DoryVirtualMachineDefinition,
+ canonicalDefinitionData: Data,
+ machine: DoryMachineConfiguration,
+ expectedPlanRevision: UInt64
+ ) {
+ self.definition = definition
+ self.canonicalDefinitionData = canonicalDefinitionData
+ self.machine = machine
+ self.expectedPlanRevision = expectedPlanRevision
+ }
+}
+
+/// Non-Codable by design: compatibility configuration may contain paths or environment secrets.
+public struct DoryDaemonVirtualMachineLaunchPlanResolution: Sendable {
+ public var resolvedPlan: DoryResolvedMachinePlan
+ public var resolvedPlanSHA256: String
+ public var revalidation: DoryResolvedMachinePlanRevalidationResult
+ public var backendPlan: MachineBackendPlan
+
+ public init(
+ resolvedPlan: DoryResolvedMachinePlan,
+ resolvedPlanSHA256: String,
+ revalidation: DoryResolvedMachinePlanRevalidationResult,
+ backendPlan: MachineBackendPlan
+ ) {
+ self.resolvedPlan = resolvedPlan
+ self.resolvedPlanSHA256 = resolvedPlanSHA256
+ self.revalidation = revalidation
+ self.backendPlan = backendPlan
+ }
+}
+
+public enum DoryDaemonVirtualMachineLaunchPlanFailureCode: String, Sendable, Equatable {
+ case invalidDefinition = "invalid-definition"
+ case emptyDefinitionAuthority = "empty-definition-authority"
+ case definitionAuthorityMismatch = "definition-authority-mismatch"
+ case machineIdentityMismatch = "machine-identity-mismatch"
+ case planNotFound = "plan-not-found"
+ case planRepositoryRejected = "plan-repository-rejected"
+ case freshEvidenceUnavailable = "fresh-evidence-unavailable"
+ case staleOrMismatchedPlan = "stale-or-mismatched-plan"
+ case backendPlanRejected = "backend-plan-rejected"
+ case backendPlanIdentityMismatch = "backend-plan-identity-mismatch"
+}
+
+public struct DoryDaemonVirtualMachineLaunchPlanFailure: Error, Sendable, Equatable {
+ public var code: DoryDaemonVirtualMachineLaunchPlanFailureCode
+ public var message: String
+ public var revalidationIssues: [DoryResolvedMachinePlanRevalidationIssue]
+
+ public init(
+ code: DoryDaemonVirtualMachineLaunchPlanFailureCode,
+ message: String,
+ revalidationIssues: [DoryResolvedMachinePlanRevalidationIssue] = []
+ ) {
+ self.code = code
+ self.message = message
+ self.revalidationIssues = revalidationIssues
+ }
+}
+
+public protocol DoryDaemonVirtualMachineLaunchPlanResolving: Sendable {
+ func resolve(
+ _ request: DoryDaemonVirtualMachineLaunchPlanRequest
+ ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution
+}
+
+/// Reads and revalidates a previously selected plan. It deliberately has no capability planner,
+/// fallback authorization, or plan-writing dependency, so start cannot silently re-plan.
+public final class DoryDaemonVirtualMachineLaunchPlanResolver:
+ DoryDaemonVirtualMachineLaunchPlanResolving,
+ @unchecked Sendable
+{
+ private let registry: BackendRegistry
+ private let plans: any DoryResolvedMachinePlanStoring
+ private let evidenceCollector: any DoryDaemonVirtualMachineStartEvidenceCollecting
+
+ public init(
+ registry: BackendRegistry,
+ plans: any DoryResolvedMachinePlanStoring,
+ evidenceCollector: any DoryDaemonVirtualMachineStartEvidenceCollecting
+ ) {
+ self.registry = registry
+ self.plans = plans
+ self.evidenceCollector = evidenceCollector
+ }
+
+ public func resolve(
+ _ request: DoryDaemonVirtualMachineLaunchPlanRequest
+ ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution {
+ guard request.definition.validate().isEmpty else {
+ throw failure(.invalidDefinition, "Workspace definition validation failed.")
+ }
+ guard !request.canonicalDefinitionData.isEmpty else {
+ throw failure(.emptyDefinitionAuthority, "Fresh definition authority bytes are required.")
+ }
+ guard request.canonicalDefinitionData
+ == DoryDaemonVirtualMachinePlanningCoordinator.canonicalDefinitionData(
+ request.definition
+ ) else {
+ throw failure(
+ .definitionAuthorityMismatch,
+ "Definition bytes are not the canonical encoding of the supplied workspace."
+ )
+ }
+ guard request.machine.id == request.definition.identity.id else {
+ throw failure(.machineIdentityMismatch, "Legacy machine and workspace identities differ.")
+ }
+ let plan: DoryResolvedMachinePlan
+ do {
+ plan = try plans.read(id: request.definition.identity.id)
+ } catch let error as DoryResolvedMachinePlanRepositoryError {
+ if case .planNotFound = error {
+ throw failure(.planNotFound, "No durable resolved plan exists for this machine.")
+ }
+ throw failure(.planRepositoryRejected, "The durable plan record failed repository validation.")
+ } catch {
+ throw failure(.planRepositoryRejected, "The durable plan record could not be read.")
+ }
+ let fresh: DoryDaemonVirtualMachineStartEvidenceCollection
+ do { fresh = try evidenceCollector.collectFreshEvidence(for: plan) }
+ catch {
+ throw failure(.freshEvidenceUnavailable, "Fresh trusted runtime evidence is unavailable.")
+ }
+ let revalidation = DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: DoryResolvedMachinePlanStartRevalidationInput(
+ machineID: request.definition.identity.id,
+ expectedPlanRevision: request.expectedPlanRevision,
+ currentDefinitionRevision: request.definition.lifecycle.revision,
+ currentDefinitionSHA256: DoryDaemonVirtualMachinePlanningCoordinator.sha256(
+ request.canonicalDefinitionData
+ ),
+ runtimeEvidence: fresh.runtimeEvidence
+ )
+ )
+ guard revalidation.mayStart else {
+ throw DoryDaemonVirtualMachineLaunchPlanFailure(
+ code: .staleOrMismatchedPlan,
+ message: "The persisted plan no longer matches current daemon evidence.",
+ revalidationIssues: revalidation.issues
+ )
+ }
+ let capabilityPlan = DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: fresh.capability,
+ evaluatedDescriptors: [fresh.capability],
+ failure: nil
+ )
+ let mapped = registry.plan(MachineBackendPlanRequest(
+ machine: request.machine,
+ capabilityPlan: capabilityPlan
+ ))
+ guard let backendPlan = mapped.plan, mapped.failure == nil else {
+ throw failure(.backendPlanRejected, "The exact backend adapter rejected the launch plan.")
+ }
+ guard backendPlan.backend.identity == plan.backend,
+ backendPlan.backend.implementationIdentifier
+ == plan.backendImplementationIdentifier,
+ backendPlan.capability == fresh.capability,
+ backendPlan.machine.id == plan.machineID else {
+ throw failure(.backendPlanIdentityMismatch, "The adapter plan differs from the durable selection.")
+ }
+ return DoryDaemonVirtualMachineLaunchPlanResolution(
+ resolvedPlan: plan,
+ resolvedPlanSHA256: DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(plan),
+ revalidation: revalidation,
+ backendPlan: backendPlan
+ )
+ }
+
+ private func failure(
+ _ code: DoryDaemonVirtualMachineLaunchPlanFailureCode,
+ _ message: String
+ ) -> DoryDaemonVirtualMachineLaunchPlanFailure {
+ DoryDaemonVirtualMachineLaunchPlanFailure(code: code, message: message)
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
new file mode 100644
index 00000000..5791f382
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
@@ -0,0 +1,499 @@
+import CryptoKit
+import DoryOperations
+import Foundation
+
+/// Inventory output stays daemon-local and non-Codable. The opaque trusted records can only be
+/// created by DoryOperations resolvers, so API/UI intent cannot manufacture qualification facts.
+public struct DoryDaemonVirtualMachineResolvedMedia: Sendable {
+ public var reference: DoryVMResolverReference
+ public var media: DoryBootMedia
+ public var guestGraphicsQualification: DoryTrustedGuestImageGraphicsQualification?
+ public var bootInspection: DoryTrustedBootMediaInspection?
+ public var mutableProvenance: DoryTrustedMutableBootMediaProvenance?
+
+ public init(
+ reference: DoryVMResolverReference,
+ media: DoryBootMedia,
+ guestGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil,
+ bootInspection: DoryTrustedBootMediaInspection? = nil,
+ mutableProvenance: DoryTrustedMutableBootMediaProvenance? = nil
+ ) {
+ self.reference = reference
+ self.media = media
+ self.guestGraphicsQualification = guestGraphicsQualification
+ self.bootInspection = bootInspection
+ self.mutableProvenance = mutableProvenance
+ }
+}
+
+public struct DoryDaemonVirtualMachineBackendRuntimeInventory: Sendable, Equatable {
+ public var backend: DoryVirtualizationBackendIdentity
+ public var runtimeBuildIdentifier: String
+ public var components: [DoryResolvedBackendComponentEvidence]
+ public var hostQualification: DoryResolvedHostQualificationEvidence
+
+ public init(
+ backend: DoryVirtualizationBackendIdentity,
+ runtimeBuildIdentifier: String,
+ components: [DoryResolvedBackendComponentEvidence],
+ hostQualification: DoryResolvedHostQualificationEvidence
+ ) {
+ self.backend = backend
+ self.runtimeBuildIdentifier = runtimeBuildIdentifier
+ self.components = components
+ self.hostQualification = hostQualification
+ }
+}
+
+public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable {
+ public var hostFacts: DoryAppleSiliconHostFacts
+ public var media: DoryDaemonVirtualMachineResolvedMedia
+ public var backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory]
+ public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence
+ public var runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification]
+ /// Resolver-selected record for one exact start request. The evaluator still verifies every
+ /// media, backend-build, ABI, graphics, and device binding before treating it as authority.
+ public var exactStartRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification?
+
+ public init(
+ hostFacts: DoryAppleSiliconHostFacts,
+ media: DoryDaemonVirtualMachineResolvedMedia,
+ backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory],
+ resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence,
+ runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [],
+ exactStartRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? = nil
+ ) {
+ self.hostFacts = hostFacts
+ self.media = media
+ self.backendRuntimes = backendRuntimes
+ self.resourceAdmission = resourceAdmission
+ self.runtimeQualifications = runtimeQualifications
+ self.exactStartRuntimeQualification = exactStartRuntimeQualification
+ }
+
+ public func backendRuntime(
+ for backend: DoryVirtualizationBackendIdentity
+ ) -> DoryDaemonVirtualMachineBackendRuntimeInventory? {
+ let matches = backendRuntimes.filter { $0.backend == backend }
+ return matches.count == 1 ? matches[0] : nil
+ }
+}
+
+public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable {
+ public var machineID: String
+ public var definitionRevision: UInt64
+ public var guest: DoryGuestPlatform
+ public var bootMedia: DoryVMBootMediaReference
+ public var resources: DoryVMResourceRequest
+ public var devices: DoryVirtualMachineDeviceCapabilityRequest
+ public var acceptableGraphics: [DoryGraphicsAccelerationLevel]
+ public var virtualHardwareABIVersion: UInt16
+
+ public init(
+ machineID: String,
+ definitionRevision: UInt64,
+ guest: DoryGuestPlatform,
+ bootMedia: DoryVMBootMediaReference,
+ resources: DoryVMResourceRequest,
+ devices: DoryVirtualMachineDeviceCapabilityRequest,
+ acceptableGraphics: [DoryGraphicsAccelerationLevel],
+ virtualHardwareABIVersion: UInt16
+ ) {
+ self.machineID = machineID
+ self.definitionRevision = definitionRevision
+ self.guest = guest
+ self.bootMedia = bootMedia
+ self.resources = resources
+ self.devices = devices
+ self.acceptableGraphics = acceptableGraphics
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ }
+}
+
+public struct DoryDaemonVirtualMachineStartInventoryRequest: Sendable, Equatable {
+ public var machineID: String
+ public var definitionRevision: UInt64
+ public var planRevision: UInt64
+ public var bootMediaReference: DoryVMResolverReference
+ public var exactCapabilityRequest: DoryVirtualMachineCapabilityRequest
+
+ public init(
+ machineID: String,
+ definitionRevision: UInt64,
+ planRevision: UInt64,
+ bootMediaReference: DoryVMResolverReference,
+ exactCapabilityRequest: DoryVirtualMachineCapabilityRequest
+ ) {
+ self.machineID = machineID
+ self.definitionRevision = definitionRevision
+ self.planRevision = planRevision
+ self.bootMediaReference = bootMediaReference
+ self.exactCapabilityRequest = exactCapabilityRequest
+ }
+}
+
+/// Implemented only by daemon-owned resolvers. Callers provide intent; they never provide trusted
+/// booleans or signed-evidence decisions.
+public protocol DoryDaemonVirtualMachineTrustInventory: Sendable {
+ func planningInventory(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot
+
+ func startInventory(
+ for request: DoryDaemonVirtualMachineStartInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot
+}
+
+public protocol DoryDaemonVirtualMachineCapabilityPlanning: Sendable {
+ func plan(
+ _ request: DoryVirtualMachineBackendPlanRequest,
+ inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ ) -> DoryVirtualMachineBackendPlanResult
+}
+
+public struct DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner:
+ DoryDaemonVirtualMachineCapabilityPlanning
+{
+ public init() {}
+
+ public func plan(
+ _ request: DoryVirtualMachineBackendPlanRequest,
+ inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ ) -> DoryVirtualMachineBackendPlanResult {
+ DoryAppleSiliconVirtualMachineBackendPlanner.plan(
+ request,
+ host: inventory.hostFacts,
+ trustedGuestImageGraphicsQualification: inventory.media.guestGraphicsQualification,
+ trustedBootMediaInspection: inventory.media.bootInspection,
+ trustedMutableBootMediaProvenance: inventory.media.mutableProvenance,
+ trustedRuntimeQualifications: inventory.runtimeQualifications
+ )
+ }
+}
+
+public protocol DoryResolvedMachinePlanStoring: Sendable {
+ func create(_ plan: DoryResolvedMachinePlan) throws
+ func replace(_ plan: DoryResolvedMachinePlan, expectedPlanRevision: UInt64) throws
+ func read(id: String) throws -> DoryResolvedMachinePlan
+}
+
+extension DoryResolvedMachinePlanRepository: DoryResolvedMachinePlanStoring {}
+
+public enum DoryDaemonVirtualMachinePlanPublication: Sendable, Equatable {
+ case create
+ case replace(expectedPlanRevision: UInt64)
+}
+
+public struct DoryDaemonVirtualMachinePlanningRequest: Sendable {
+ public var definition: DoryVirtualMachineDefinition
+ /// Canonical sorted-key encoding of `definition`. Only its digest is persisted.
+ public var canonicalDefinitionData: Data
+ public var machine: DoryMachineConfiguration
+ public var publication: DoryDaemonVirtualMachinePlanPublication
+ public var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization?
+ public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization?
+
+ public init(
+ definition: DoryVirtualMachineDefinition,
+ canonicalDefinitionData: Data,
+ machine: DoryMachineConfiguration,
+ publication: DoryDaemonVirtualMachinePlanPublication,
+ fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? = nil,
+ experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? = nil
+ ) {
+ self.definition = definition
+ self.canonicalDefinitionData = canonicalDefinitionData
+ self.machine = machine
+ self.publication = publication
+ self.fallbackAuthorization = fallbackAuthorization
+ self.experimentalAuthorization = experimentalAuthorization
+ }
+}
+
+public struct DoryDaemonVirtualMachinePlanningResult: Sendable {
+ public var plannerRequest: DoryVirtualMachineBackendPlanRequest
+ public var plannerResult: DoryVirtualMachineBackendPlanResult
+ public var resolvedPlan: DoryResolvedMachinePlan
+ public var resolvedPlanSHA256: String
+ public var backendPlan: MachineBackendPlan
+
+ public init(
+ plannerRequest: DoryVirtualMachineBackendPlanRequest,
+ plannerResult: DoryVirtualMachineBackendPlanResult,
+ resolvedPlan: DoryResolvedMachinePlan,
+ resolvedPlanSHA256: String,
+ backendPlan: MachineBackendPlan
+ ) {
+ self.plannerRequest = plannerRequest
+ self.plannerResult = plannerResult
+ self.resolvedPlan = resolvedPlan
+ self.resolvedPlanSHA256 = resolvedPlanSHA256
+ self.backendPlan = backendPlan
+ }
+}
+
+public enum DoryDaemonVirtualMachinePlanningFailureCode: String, Sendable, Equatable {
+ case invalidDefinition = "invalid-definition"
+ case emptyDefinitionAuthority = "empty-definition-authority"
+ case definitionAuthorityMismatch = "definition-authority-mismatch"
+ case machineIdentityMismatch = "machine-identity-mismatch"
+ case bootMediaUnavailable = "boot-media-unavailable"
+ case mediaInventoryMismatch = "media-inventory-mismatch"
+ case capabilityUnavailable = "capability-unavailable"
+ case backendUnavailable = "backend-unavailable"
+ case backendInventoryUnavailable = "backend-inventory-unavailable"
+ case resourceAdmissionMismatch = "resource-admission-mismatch"
+ case planConstructionRejected = "plan-construction-rejected"
+ case backendPlanRejected = "backend-plan-rejected"
+ case persistenceRejected = "persistence-rejected"
+}
+
+public struct DoryDaemonVirtualMachinePlanningFailure: Error, Sendable, Equatable {
+ public var code: DoryDaemonVirtualMachinePlanningFailureCode
+ public var message: String
+
+ public init(code: DoryDaemonVirtualMachinePlanningFailureCode, message: String) {
+ self.code = code
+ self.message = message
+ }
+}
+
+public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Sendable {
+ private let registry: BackendRegistry
+ private let inventory: any DoryDaemonVirtualMachineTrustInventory
+ private let capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning
+ private let plans: any DoryResolvedMachinePlanStoring
+ private let now: @Sendable () -> Int64
+
+ public init(
+ registry: BackendRegistry,
+ inventory: any DoryDaemonVirtualMachineTrustInventory,
+ plans: any DoryResolvedMachinePlanStoring,
+ capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning =
+ DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner(),
+ now: @escaping @Sendable () -> Int64 = {
+ Int64((Date().timeIntervalSince1970 * 1_000).rounded(.towardZero))
+ }
+ ) {
+ self.registry = registry
+ self.inventory = inventory
+ self.plans = plans
+ self.capabilityPlanner = capabilityPlanner
+ self.now = now
+ }
+
+ public func resolveAndPersist(
+ _ input: DoryDaemonVirtualMachinePlanningRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningResult {
+ let definition = input.definition
+ let definitionIssues = definition.validate()
+ guard definitionIssues.isEmpty else {
+ throw failure(.invalidDefinition, "Workspace definition validation failed.")
+ }
+ guard !input.canonicalDefinitionData.isEmpty else {
+ throw failure(.emptyDefinitionAuthority, "Definition authority bytes are required.")
+ }
+ guard input.canonicalDefinitionData == Self.canonicalDefinitionData(definition) else {
+ throw failure(
+ .definitionAuthorityMismatch,
+ "Definition bytes are not the canonical encoding of the supplied workspace."
+ )
+ }
+ guard input.machine.id == definition.identity.id else {
+ throw failure(.machineIdentityMismatch, "Legacy machine and workspace identities differ.")
+ }
+ guard let bootReference = Self.primaryBootMedia(in: definition) else {
+ throw failure(.bootMediaUnavailable, "The workspace has no primary boot device.")
+ }
+ let devices = Self.devices(for: definition)
+ let inventoryRequest = DoryDaemonVirtualMachineInventoryRequest(
+ machineID: definition.identity.id,
+ definitionRevision: definition.lifecycle.revision,
+ guest: definition.guest,
+ bootMedia: bootReference,
+ resources: definition.resources,
+ devices: devices,
+ acceptableGraphics: definition.graphics.acceptableLevels,
+ virtualHardwareABIVersion: definition.virtualHardwareABIVersion
+ )
+ let snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ do {
+ snapshot = try inventory.planningInventory(for: inventoryRequest)
+ } catch {
+ throw failure(.capabilityUnavailable, "Trusted planning inventory could not be resolved.")
+ }
+ guard snapshot.media.reference == bootReference.artifact,
+ snapshot.media.media.kind == bootReference.kind,
+ snapshot.media.media.source == bootReference.source else {
+ throw failure(.mediaInventoryMismatch, "Resolved media does not match the boot reference.")
+ }
+ guard snapshot.resourceAdmission.admittedVirtualCPUCount
+ == definition.resources.virtualCPUCount,
+ snapshot.resourceAdmission.admittedMemoryBytes == definition.resources.memoryBytes,
+ snapshot.resourceAdmission.admittedStorageBytes == definition.resources.diskBytes else {
+ throw failure(.resourceAdmissionMismatch, "Resource admission does not match requested resources.")
+ }
+
+ let plannerRequest = Self.plannerRequest(
+ definition: definition,
+ media: snapshot.media.media,
+ devices: devices,
+ allowsExperimental: input.experimentalAuthorization != nil
+ )
+ let plannerResult = capabilityPlanner.plan(plannerRequest, inventory: snapshot)
+ guard plannerResult.failure == nil, let selected = plannerResult.selectedDescriptor else {
+ throw failure(.capabilityUnavailable, "No trusted runnable capability was selected.")
+ }
+ guard let backend = registry.backend(for: selected.request.backend) else {
+ throw failure(.backendUnavailable, "The selected backend is not registered.")
+ }
+ guard let runtime = snapshot.backendRuntime(for: selected.request.backend) else {
+ throw failure(.backendInventoryUnavailable, "Exact backend runtime evidence is unavailable.")
+ }
+ let backendResult = registry.plan(MachineBackendPlanRequest(
+ machine: input.machine,
+ capabilityPlan: plannerResult
+ ))
+ guard let backendPlan = backendResult.plan, backendResult.failure == nil,
+ backendPlan.backend == backend.descriptor,
+ backendPlan.machine == input.machine,
+ backendPlan.capability == selected else {
+ throw failure(.backendPlanRejected, "The selected adapter rejected the machine plan.")
+ }
+
+ let timing: (revision: UInt64, created: Int64, updated: Int64)
+ switch input.publication {
+ case .create:
+ let timestamp = now()
+ timing = (1, timestamp, timestamp)
+ case let .replace(expected):
+ let current: DoryResolvedMachinePlan
+ do { current = try plans.read(id: definition.identity.id) }
+ catch { throw failure(.persistenceRejected, "The current resolved plan could not be read.") }
+ guard expected < UInt64.max else {
+ throw failure(.persistenceRejected, "The resolved plan revision is exhausted.")
+ }
+ timing = (expected + 1, current.createdAtUnixMilliseconds,
+ max(now(), current.createdAtUnixMilliseconds))
+ }
+
+ let plan: DoryResolvedMachinePlan
+ do {
+ plan = try DoryResolvedMachinePlan(
+ machineID: definition.identity.id,
+ definitionRevision: definition.lifecycle.revision,
+ definitionSHA256: Self.sha256(input.canonicalDefinitionData),
+ planRevision: timing.revision,
+ createdAtUnixMilliseconds: timing.created,
+ updatedAtUnixMilliseconds: timing.updated,
+ backendDescriptor: backend.descriptor,
+ backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier,
+ resolverReference: snapshot.media.reference,
+ components: runtime.components.sorted {
+ $0.componentIdentifier < $1.componentIdentifier
+ },
+ resourceAdmission: snapshot.resourceAdmission,
+ hostQualification: runtime.hostQualification,
+ experimentalAuthorization: input.experimentalAuthorization,
+ plannerRequest: plannerRequest,
+ plannerResult: plannerResult,
+ fallbackAuthorization: input.fallbackAuthorization
+ )
+ } catch {
+ throw failure(.planConstructionRejected, "Trusted evidence did not form a valid durable plan.")
+ }
+ do {
+ switch input.publication {
+ case .create: try plans.create(plan)
+ case let .replace(expected):
+ try plans.replace(plan, expectedPlanRevision: expected)
+ }
+ } catch {
+ throw failure(.persistenceRejected, "The durable plan could not be published.")
+ }
+ return DoryDaemonVirtualMachinePlanningResult(
+ plannerRequest: plannerRequest,
+ plannerResult: plannerResult,
+ resolvedPlan: plan,
+ resolvedPlanSHA256: Self.planSHA256(plan),
+ backendPlan: backendPlan
+ )
+ }
+
+ static func primaryBootMedia(
+ in definition: DoryVirtualMachineDefinition
+ ) -> DoryVMBootMediaReference? {
+ guard let id = definition.boot.order.first else { return nil }
+ return definition.boot.devices.first { $0.id == id }
+ }
+
+ static func devices(
+ for definition: DoryVirtualMachineDefinition
+ ) -> DoryVirtualMachineDeviceCapabilityRequest {
+ let networkAttachment: DoryVirtualMachineNetworkAttachmentMode
+ switch definition.networkMode {
+ case .sharedNAT: networkAttachment = .sharedNAT
+ case .bridged: networkAttachment = .bridged
+ case .isolated: networkAttachment = .isolated
+ }
+ return DoryVirtualMachineDeviceCapabilityRequest(
+ networkAttachment: networkAttachment,
+ audioInput: definition.audio.inputEnabled,
+ audioOutput: definition.audio.outputEnabled,
+ keyboard: definition.input.keyboardEnabled,
+ pointer: definition.input.pointerEnabled,
+ directorySharing: !definition.shares.isEmpty,
+ clipboard: definition.integrations.contains(.clipboard),
+ clockSynchronization: definition.integrations.contains(.clockSynchronization),
+ dynamicDisplay: definition.integrations.contains(.dynamicDisplay),
+ gracefulShutdown: definition.integrations.contains(.gracefulShutdown)
+ )
+ }
+
+ private static func plannerRequest(
+ definition: DoryVirtualMachineDefinition,
+ media: DoryBootMedia,
+ devices: DoryVirtualMachineDeviceCapabilityRequest,
+ allowsExperimental: Bool
+ ) -> DoryVirtualMachineBackendPlanRequest {
+ let preferences = definition.backendPreference.backend.map { [$0] }
+ let policy: DoryVirtualMachineBackendPreferencePolicy =
+ definition.backendPreference.mode == .required ? .required : .preferred
+ return DoryVirtualMachineBackendPlanRequest(
+ guest: definition.guest,
+ bootMedia: media,
+ acceptableGraphics: definition.graphics.acceptableLevels,
+ devices: devices,
+ virtualHardwareABIVersion: definition.virtualHardwareABIVersion,
+ backendPreferences: preferences,
+ backendPreferencePolicy: policy,
+ allowsExperimentalBackends: allowsExperimental
+ )
+ }
+
+ static func planSHA256(_ plan: DoryResolvedMachinePlan) -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ guard let bytes = try? encoder.encode(plan) else { return "" }
+ return sha256(bytes)
+ }
+
+ public static func canonicalDefinitionData(
+ _ definition: DoryVirtualMachineDefinition
+ ) -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ return (try? encoder.encode(definition)) ?? Data()
+ }
+
+ static func sha256(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private func failure(
+ _ code: DoryDaemonVirtualMachinePlanningFailureCode,
+ _ message: String
+ ) -> DoryDaemonVirtualMachinePlanningFailure {
+ DoryDaemonVirtualMachinePlanningFailure(code: code, message: message)
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
new file mode 100644
index 00000000..08014aa7
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
@@ -0,0 +1,531 @@
+import DoryOperations
+@testable import DorydKit
+import Foundation
+import Testing
+
+@Suite("Daemon virtual-machine runtime planning")
+struct DoryDaemonVirtualMachineRuntimePlanningTests {
+ @Test("planning constructs persists and exposes an exact plan digest")
+ func planningSuccess() throws {
+ let fixture = try Fixture()
+ let result = try fixture.coordinator.resolveAndPersist(fixture.planningRequest())
+
+ #expect(result.resolvedPlan.validate().isEmpty)
+ #expect(result.resolvedPlan.definitionSHA256
+ == DoryDaemonVirtualMachinePlanningCoordinator.sha256(fixture.canonicalDefinition))
+ #expect(result.resolvedPlanSHA256.count == 64)
+ #expect(result.backendPlan.backend.identity == .doryHypervisor)
+ #expect(try fixture.store.read(id: fixture.definition.identity.id) == result.resolvedPlan)
+ }
+
+ @Test("canonical definition bytes cannot be substituted")
+ func canonicalDefinitionBinding() throws {
+ let fixture = try Fixture()
+ var request = fixture.planningRequest()
+ request.canonicalDefinitionData = Data("not-the-definition".utf8)
+ do {
+ _ = try fixture.coordinator.resolveAndPersist(request)
+ Issue.record("Expected canonical definition rejection")
+ } catch let failure as DoryDaemonVirtualMachinePlanningFailure {
+ #expect(failure.code == .definitionAuthorityMismatch)
+ }
+ }
+
+ @Test("planning rejects an adapter that substitutes machine or capability")
+ func substitutedBackendPlan() throws {
+ let fixture = try Fixture()
+ for substitution in [BackendPlanSubstitution.machine, .capability] {
+ let registry = try BackendRegistry(backends: [SubstitutingBackend(substitution)])
+ let coordinator = DoryDaemonVirtualMachinePlanningCoordinator(
+ registry: registry,
+ inventory: fixture.inventory,
+ plans: FixturePlanStore(),
+ capabilityPlanner: FixturePlanner(capability: fixture.capability),
+ now: { 1_700_000_000_000 }
+ )
+ do {
+ _ = try coordinator.resolveAndPersist(fixture.planningRequest())
+ Issue.record("Expected substituted backend plan rejection")
+ } catch let failure as DoryDaemonVirtualMachinePlanningFailure {
+ #expect(failure.code == .backendPlanRejected)
+ }
+ }
+ }
+
+ @Test("default planner does not promote missing runtime qualification")
+ func defaultPlannerFailsClosed() throws {
+ let fixture = try Fixture()
+ let request = fixture.plannerRequest
+ let result = DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner().plan(
+ request,
+ inventory: fixture.inventory.snapshot
+ )
+ #expect(result.selectedDescriptor == nil)
+ #expect(result.failure?.code == .noCandidate)
+ #expect(result.evaluatedDescriptors.first?.availability.reason?.code
+ == .runtimeQualificationUnavailable)
+ }
+
+ @Test("create to start maps only the persisted backend without replanning")
+ func createToStart() throws {
+ let fixture = try Fixture()
+ let planned = try fixture.coordinator.resolveAndPersist(fixture.planningRequest())
+ let collector = FixtureEvidenceCollector(collection: DoryDaemonVirtualMachineStartEvidenceCollection(
+ capability: planned.plannerResult.selectedDescriptor!,
+ runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: planned.resolvedPlan)
+ ))
+ let resolver = DoryDaemonVirtualMachineLaunchPlanResolver(
+ registry: fixture.registry,
+ plans: fixture.store,
+ evidenceCollector: collector
+ )
+ let resolved = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest(
+ definition: fixture.definition,
+ canonicalDefinitionData: fixture.canonicalDefinition,
+ machine: fixture.machine,
+ expectedPlanRevision: planned.resolvedPlan.planRevision
+ ))
+
+ #expect(resolved.revalidation.mayStart)
+ #expect(resolved.resolvedPlan == planned.resolvedPlan)
+ #expect(resolved.resolvedPlanSHA256 == planned.resolvedPlanSHA256)
+ #expect(resolved.backendPlan.backend.identity == planned.resolvedPlan.backend)
+ #expect(collector.collectionCount == 1)
+ }
+
+ @Test("fresh evidence mismatch rejects before backend plan mapping")
+ func startEvidenceMismatch() throws {
+ let fixture = try Fixture()
+ let planned = try fixture.coordinator.resolveAndPersist(fixture.planningRequest())
+ var evidence = DoryResolvedMachineRuntimeEvidence(plan: planned.resolvedPlan)
+ evidence.backendRuntimeBuildIdentifier = "different-runtime"
+ let collector = FixtureEvidenceCollector(collection: DoryDaemonVirtualMachineStartEvidenceCollection(
+ capability: planned.plannerResult.selectedDescriptor!,
+ runtimeEvidence: evidence
+ ))
+ let resolver = DoryDaemonVirtualMachineLaunchPlanResolver(
+ registry: fixture.registry,
+ plans: fixture.store,
+ evidenceCollector: collector
+ )
+ do {
+ _ = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest(
+ definition: fixture.definition,
+ canonicalDefinitionData: fixture.canonicalDefinition,
+ machine: fixture.machine,
+ expectedPlanRevision: planned.resolvedPlan.planRevision
+ ))
+ Issue.record("Expected fresh evidence rejection")
+ } catch let failure as DoryDaemonVirtualMachineLaunchPlanFailure {
+ #expect(failure.code == .staleOrMismatchedPlan)
+ #expect(failure.revalidationIssues.contains {
+ $0.code == .backendRuntimeBuildMismatch
+ })
+ }
+ }
+
+ @Test("missing durable plan has a typed launch failure")
+ func missingPlan() throws {
+ let fixture = try Fixture()
+ let resolver = DoryDaemonVirtualMachineLaunchPlanResolver(
+ registry: fixture.registry,
+ plans: fixture.store,
+ evidenceCollector: FixtureEvidenceCollector(error: FixtureError.unavailable)
+ )
+ do {
+ _ = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest(
+ definition: fixture.definition,
+ canonicalDefinitionData: fixture.canonicalDefinition,
+ machine: fixture.machine,
+ expectedPlanRevision: 1
+ ))
+ Issue.record("Expected missing plan rejection")
+ } catch let failure as DoryDaemonVirtualMachineLaunchPlanFailure {
+ #expect(failure.code == .planNotFound)
+ }
+ }
+}
+
+private struct Fixture {
+ let definition: DoryVirtualMachineDefinition
+ let canonicalDefinition: Data
+ let machine: DoryMachineConfiguration
+ let media: DoryBootMedia
+ let plannerRequest: DoryVirtualMachineBackendPlanRequest
+ let capability: DoryVirtualMachineCapabilityDescriptor
+ let store = FixturePlanStore()
+ let inventory: FixtureInventory
+ let registry: BackendRegistry
+ let coordinator: DoryDaemonVirtualMachinePlanningCoordinator
+
+ init() throws {
+ let bootArtifact = DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "ubuntu-runtime"
+ )
+ let diskArtifact = DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "ubuntu-disk"
+ )
+ let resources = DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 2 * 1_024 * 1_024 * 1_024,
+ diskBytes: 32 * 1_024 * 1_024 * 1_024
+ )
+ definition = DoryVirtualMachineDefinition(
+ identity: DoryVirtualMachineIdentity(id: "runtime-linux", name: "Runtime Linux"),
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ workload: .desktop,
+ boot: DoryVMBootConfiguration(
+ phase: .normal,
+ devices: [DoryVMBootMediaReference(
+ id: "system",
+ role: .system,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifact: bootArtifact,
+ removable: false
+ )],
+ order: ["system"]
+ ),
+ backendPreference: DoryVMBackendPreference(
+ mode: .required,
+ backend: .doryHypervisor
+ ),
+ graphics: DoryVMGraphicsPolicy(acceptableLevels: [.none]),
+ resources: resources,
+ storage: [DoryVMStorageAttachment(
+ id: "system-disk",
+ role: .system,
+ artifact: diskArtifact,
+ capacityBytes: resources.diskBytes
+ )],
+ audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false),
+ input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false),
+ lifecycle: DoryVMLifecycleMetadata(
+ revision: 1,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000
+ )
+ )
+ canonicalDefinition = DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(definition)
+ machine = DoryMachineConfiguration(
+ id: definition.identity.id,
+ kernelPath: "/fixture/direct-kernel",
+ rootfsPath: "/fixture/linux.raw",
+ bootMode: .linuxKernel,
+ displayMode: .desktop
+ )
+ media = DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifactSHA256: digest("a")
+ )
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ plannerRequest = DoryVirtualMachineBackendPlanRequest(
+ guest: definition.guest,
+ bootMedia: media,
+ acceptableGraphics: [.none],
+ devices: devices,
+ backendPreferences: [.doryHypervisor],
+ backendPreferencePolicy: .required
+ )
+ let runtimeQualification = DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "runtime-qualification-1",
+ qualificationReportSHA256: digest("b"),
+ signingKeyID: "dory-runtime-1",
+ qualificationFormatVersion: 1,
+ guest: definition.guest,
+ bootMediaKind: media.kind,
+ immutableArtifactSHA256: media.artifactSHA256,
+ backend: .doryHypervisor,
+ backendRuntimeBuildID: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ graphics: .none,
+ devices: devices
+ )
+ capability = DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: DoryVirtualMachineCapabilityRequest(
+ guest: definition.guest,
+ bootMedia: media,
+ backend: .doryHypervisor,
+ graphics: .none,
+ devices: devices
+ ),
+ availability: DoryCapabilityAvailability(supportTier: .supported, state: .available),
+ resolvedDevices: devices,
+ runtimeQualificationEvidence: runtimeQualification
+ )
+ let admission = resourceAdmission(resources)
+ let snapshot = DoryDaemonVirtualMachineTrustedInventorySnapshot(
+ hostFacts: hostFacts(),
+ media: DoryDaemonVirtualMachineResolvedMedia(reference: bootArtifact, media: media),
+ backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory(
+ backend: .doryHypervisor,
+ runtimeBuildIdentifier: "raw-runtime-1",
+ components: [DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: "raw-runtime-1",
+ artifactSHA256: digest("c")
+ )],
+ hostQualification: hostQualification()
+ )],
+ resourceAdmission: admission
+ )
+ inventory = FixtureInventory(snapshot: snapshot)
+ registry = try BackendRegistry(backends: [RawHVLinuxMachineBackend(
+ executablePath: "/fixture/dory-hv",
+ operations: MachineBackendCompatibilityOperations(
+ start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) },
+ stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }
+ ),
+ executableIsAvailable: { _ in true }
+ )])
+ coordinator = DoryDaemonVirtualMachinePlanningCoordinator(
+ registry: registry,
+ inventory: inventory,
+ plans: store,
+ capabilityPlanner: FixturePlanner(capability: capability),
+ now: { 1_700_000_000_000 }
+ )
+ }
+
+ func planningRequest() -> DoryDaemonVirtualMachinePlanningRequest {
+ DoryDaemonVirtualMachinePlanningRequest(
+ definition: definition,
+ canonicalDefinitionData: canonicalDefinition,
+ machine: machine,
+ publication: .create
+ )
+ }
+}
+
+private struct FixturePlanner: DoryDaemonVirtualMachineCapabilityPlanning {
+ let capability: DoryVirtualMachineCapabilityDescriptor
+
+ func plan(
+ _ request: DoryVirtualMachineBackendPlanRequest,
+ inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ ) -> DoryVirtualMachineBackendPlanResult {
+ DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: capability,
+ evaluatedDescriptors: [capability],
+ failure: nil
+ )
+ }
+}
+
+private enum BackendPlanSubstitution { case machine, capability }
+
+private struct SubstitutingBackend: MachineBackend {
+ let substitution: BackendPlanSubstitution
+ let descriptor = RawHVLinuxMachineBackend.backendDescriptor
+
+ init(_ substitution: BackendPlanSubstitution) {
+ self.substitution = substitution
+ }
+
+ func probe() -> MachineBackendProbeResult {
+ MachineBackendProbeResult(descriptor: descriptor, state: .available)
+ }
+
+ func plan(_ request: MachineBackendPlanRequest) -> MachineBackendPlanResult {
+ guard var capability = request.capabilityPlan.selectedDescriptor else {
+ return MachineBackendPlanResult(
+ plan: nil,
+ probe: probe(),
+ failure: MachineBackendFailure(
+ code: .capabilityPlanRejected,
+ backend: descriptor.identity,
+ message: "Fixture planner did not select a capability."
+ )
+ )
+ }
+ var machine = request.machine
+ switch substitution {
+ case .machine:
+ machine.id = "substituted-machine"
+ case .capability:
+ capability.request.bootMedia.artifactSHA256 = digest("9")
+ }
+ return MachineBackendPlanResult(
+ plan: MachineBackendPlan(
+ backend: descriptor,
+ machine: machine,
+ capability: capability
+ ),
+ probe: probe(),
+ failure: nil
+ )
+ }
+
+ func start(_ plan: MachineBackendPlan) -> MachineBackendOperationResult {
+ unsupported(.start)
+ }
+
+ func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ unsupported(.stop)
+ }
+
+ func pause(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ unsupported(.pause)
+ }
+
+ func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
+ unsupported(.resume)
+ }
+
+ private func unsupported(
+ _ operation: MachineBackendLifecycleOperation
+ ) -> MachineBackendOperationResult {
+ MachineBackendOperationResult(
+ operation: operation,
+ backend: descriptor.identity,
+ observation: nil,
+ failure: MachineBackendFailure(
+ code: .lifecycleOperationUnsupported,
+ backend: descriptor.identity,
+ message: "Fixture backend does not launch machines."
+ )
+ )
+ }
+}
+
+private struct FixtureInventory: DoryDaemonVirtualMachineTrustInventory {
+ let snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot
+
+ func planningInventory(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { snapshot }
+
+ func startInventory(
+ for request: DoryDaemonVirtualMachineStartInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot { snapshot }
+}
+
+private final class FixturePlanStore: DoryResolvedMachinePlanStoring, @unchecked Sendable {
+ private let lock = NSLock()
+ private var plan: DoryResolvedMachinePlan?
+
+ func create(_ plan: DoryResolvedMachinePlan) throws {
+ lock.withLock { self.plan = plan }
+ }
+
+ func replace(_ plan: DoryResolvedMachinePlan, expectedPlanRevision: UInt64) throws {
+ lock.withLock { self.plan = plan }
+ }
+
+ func read(id: String) throws -> DoryResolvedMachinePlan {
+ try lock.withLock {
+ guard let plan, plan.machineID == id else {
+ throw DoryResolvedMachinePlanRepositoryError.planNotFound(id)
+ }
+ return plan
+ }
+ }
+}
+
+private final class FixtureEvidenceCollector:
+ DoryDaemonVirtualMachineStartEvidenceCollecting,
+ @unchecked Sendable
+{
+ private let lock = NSLock()
+ private let result: Result
+ private var count = 0
+
+ init(collection: DoryDaemonVirtualMachineStartEvidenceCollection) {
+ result = .success(collection)
+ }
+
+ init(error: FixtureError) {
+ result = .failure(error)
+ }
+
+ var collectionCount: Int { lock.withLock { count } }
+
+ func collectFreshEvidence(
+ for plan: DoryResolvedMachinePlan
+ ) throws -> DoryDaemonVirtualMachineStartEvidenceCollection {
+ try lock.withLock {
+ count += 1
+ return try result.get()
+ }
+ }
+}
+
+private enum FixtureError: Error { case unavailable }
+
+private func hostFacts() -> DoryAppleSiliconHostFacts {
+ DoryAppleSiliconHostFacts(
+ macOSMajorVersion: 26,
+ virtualizationFrameworkAvailable: true,
+ hypervisorFrameworkAvailable: true,
+ doryHypervisorAvailable: true,
+ qemuHypervisorFrameworkAvailable: false,
+ windowsUEFIFirmwareAvailable: false,
+ windowsSecureBootAvailable: false,
+ windowsSBSADeviceModelAvailable: false,
+ virtualTPM20Available: false,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: false,
+ networkAvailable: false,
+ displayAvailable: false,
+ inputAvailable: false
+ ),
+ macOSGuestVirtualizationSupported: false,
+ macOSRestoreImageInstallationSupported: false,
+ doryMacOSBackendAvailable: false,
+ doryMacOSBackendQualified: false,
+ metalAvailable: true,
+ doryAcceleratedRendererAvailable: true,
+ runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: "raw-runtime-1",
+ virtualizationFrameworkAdapterBuildID: "vz-runtime-1",
+ qemuRuntimeBuildID: "qemu-runtime-1"
+ )
+ )
+}
+
+private func resourceAdmission(
+ _ resources: DoryVMResourceRequest
+) -> DoryResolvedMachineResourceAdmissionEvidence {
+ DoryResolvedMachineResourceAdmissionEvidence(
+ admittedVirtualCPUCount: resources.virtualCPUCount,
+ admittedMemoryBytes: resources.memoryBytes,
+ admittedStorageBytes: resources.diskBytes,
+ hostLogicalCPUCount: 12,
+ hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024,
+ hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024,
+ existingVirtualCPUCommitment: 2,
+ existingMemoryCommitmentBytes: 2 * 1_024 * 1_024 * 1_024,
+ existingStorageReservationBytes: 16 * 1_024 * 1_024 * 1_024,
+ hostReservedLogicalCPUCount: 2,
+ hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024,
+ admissionIdentity: "resource-admission-1",
+ admissionReportSHA256: digest("d"),
+ assessorIdentifier: "dory-resource-policy",
+ assessorVersion: 1
+ )
+}
+
+private func hostQualification() -> DoryResolvedHostQualificationEvidence {
+ DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: "host-qualification-1",
+ qualificationReportSHA256: digest("e"),
+ hostHardwareModelIdentifier: "Mac16.1",
+ hostOperatingSystemBuild: "26A5406c",
+ backend: .doryHypervisor,
+ backendRuntimeBuildIdentifier: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ qualifierIdentifier: "dory-host-qualifier",
+ qualifierVersion: 1
+ )
+}
+
+private func digest(_ value: Character) -> String {
+ String(repeating: String(value), count: 64)
+}
From d4b45c0dcb71c7978639e3cb36c8c5da543038df Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 12:46:45 +0000
Subject: [PATCH 017/338] feat(vm): gate launches on resolved runtime evidence
---
.../LinuxMachineBackendAdapters.swift | 67 +-
.../Sources/DorydKit/MachineBackend.swift | 22 +
.../Sources/DorydKit/MachineManager.swift | 513 ++++++++++++++-
dory-core-swift/Sources/doryd/main.swift | 13 +-
...eManagerResolvedPlanIntegrationTests.swift | 607 ++++++++++++++++++
5 files changed, 1180 insertions(+), 42 deletions(-)
create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
index c01f4890..f7876170 100644
--- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
+++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
@@ -2,11 +2,15 @@ import DoryOperations
import Foundation
public typealias MachineBackendLifecycleHandler = @Sendable (String) throws -> MachineBackendRuntimeObservation
+public typealias MachineBackendAuthorizedStartHandler = @Sendable (
+ MachineBackendLaunchBinding
+) throws -> MachineBackendRuntimeObservation
/// Hooks implemented by the existing machine launcher. Keeping them injectable lets the seam be
/// qualified before MachineManager starts consuming BackendRegistry.
public struct MachineBackendCompatibilityOperations: Sendable {
public var start: MachineBackendLifecycleHandler
+ public var authorizedStart: MachineBackendAuthorizedStartHandler
public var stop: MachineBackendLifecycleHandler
public init(
@@ -14,21 +18,24 @@ public struct MachineBackendCompatibilityOperations: Sendable {
stop: @escaping MachineBackendLifecycleHandler
) {
self.start = start
+ authorizedStart = { binding in try start(binding.machineID) }
self.stop = stop
}
- /// Compatibility bridge to today's launch path. Both Linux adapters may share this bridge;
- /// their validated backend plans determine which adapter is allowed to invoke it.
- public init(machineManager: MachineManager) {
- self.init(
- start: { id in
- MachineBackendRuntimeObservation(try machineManager.start(id: id))
- },
- stop: { id in
- MachineBackendRuntimeObservation(try machineManager.stop(id: id))
- }
- )
+ public init(
+ authorizedStart: @escaping MachineBackendAuthorizedStartHandler,
+ stop: @escaping MachineBackendLifecycleHandler
+ ) {
+ start = { _ in
+ throw MachineBackendFailure(
+ code: .lifecycleOperationFailed,
+ message: "An adapter-issued launch binding is required."
+ )
+ }
+ self.authorizedStart = authorizedStart
+ self.stop = stop
}
+
}
public extension MachineBackendRuntimeObservation {
@@ -61,6 +68,7 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable {
) -> String?
let descriptor: MachineBackendDescriptor
+ private let componentIdentifier: String
private let executablePath: String?
private let executableIsAvailable: @Sendable (String) -> Bool
private let operations: MachineBackendCompatibilityOperations
@@ -68,12 +76,14 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable {
init(
descriptor: MachineBackendDescriptor,
+ componentIdentifier: String,
executablePath: String?,
executableIsAvailable: @escaping @Sendable (String) -> Bool,
operations: MachineBackendCompatibilityOperations,
validateMachine: @escaping MachineValidator
) {
self.descriptor = descriptor
+ self.componentIdentifier = componentIdentifier
self.executablePath = executablePath
self.executableIsAvailable = executableIsAvailable
self.operations = operations
@@ -192,7 +202,19 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable {
guard descriptor.lifecycle.start else {
return unsupportedOperation(.start)
}
- return perform(.start, machineID: plan.machine.id, operation: operations.start)
+ guard let executablePath, !executablePath.isEmpty else {
+ return failedOperation(
+ .start,
+ code: .componentNotConfigured,
+ message: "The backend helper path is not configured."
+ )
+ }
+ return performAuthorizedStart(MachineBackendLaunchBinding(
+ machineID: plan.machine.id,
+ backend: descriptor,
+ componentIdentifier: componentIdentifier,
+ executablePath: executablePath
+ ))
}
func stop(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
@@ -283,6 +305,25 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable {
}
}
+ private func performAuthorizedStart(
+ _ binding: MachineBackendLaunchBinding
+ ) -> MachineBackendOperationResult {
+ do {
+ return MachineBackendOperationResult(
+ operation: .start,
+ backend: descriptor.identity,
+ observation: try operations.authorizedStart(binding),
+ failure: nil
+ )
+ } catch {
+ return failedOperation(
+ .start,
+ code: .lifecycleOperationFailed,
+ message: String(describing: error)
+ )
+ }
+ }
+
private func unsupportedOperation(
_ operation: MachineBackendLifecycleOperation
) -> MachineBackendOperationResult {
@@ -335,6 +376,7 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable
) {
core = LinuxMachineBackendAdapterCore(
descriptor: Self.backendDescriptor,
+ componentIdentifier: "dory-hv",
executablePath: executablePath,
executableIsAvailable: executableIsAvailable,
operations: operations,
@@ -396,6 +438,7 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @
) {
core = LinuxMachineBackendAdapterCore(
descriptor: Self.backendDescriptor,
+ componentIdentifier: "dory-vmm",
executablePath: executablePath,
executableIsAvailable: executableIsAvailable,
operations: operations,
diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift
index 44c79856..0d2ff36e 100644
--- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift
@@ -202,6 +202,28 @@ public struct MachineBackendRuntimeRequest: Codable, Sendable, Equatable, Hashab
}
}
+/// Daemon-local launch authority issued by the exact adapter instance that revalidated a plan.
+/// It is deliberately not Codable: host executable paths are runtime wiring, never persisted VM
+/// desired state or portable plan evidence.
+public struct MachineBackendLaunchBinding: Sendable, Equatable {
+ public var machineID: String
+ public var backend: MachineBackendDescriptor
+ public var componentIdentifier: String
+ public var executablePath: String
+
+ public init(
+ machineID: String,
+ backend: MachineBackendDescriptor,
+ componentIdentifier: String,
+ executablePath: String
+ ) {
+ self.machineID = machineID
+ self.backend = backend
+ self.componentIdentifier = componentIdentifier
+ self.executablePath = executablePath
+ }
+}
+
public enum MachineBackendLifecycleOperation: String, Codable, Sendable, Equatable, Hashable {
case start
case stop
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index d53c847f..d90f4d17 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -590,9 +590,35 @@ public struct DoryWorkspaceProjectionDiagnostic: Sendable, Equatable {
}
}
+public struct DoryMachineResolvedLaunchIdentity: Sendable, Equatable {
+ public var planRevision: UInt64
+ public var planSHA256: String
+ public var definitionRevision: UInt64
+ public var backend: DoryVirtualizationBackendIdentity
+ public var backendRuntimeBuildIdentifier: String
+ public var virtualHardwareABIVersion: UInt16
+
+ public init(plan: DoryResolvedMachinePlan, planSHA256: String) {
+ planRevision = plan.planRevision
+ self.planSHA256 = planSHA256
+ definitionRevision = plan.definitionRevision
+ backend = plan.backend
+ backendRuntimeBuildIdentifier = plan.backendRuntimeBuildIdentifier
+ virtualHardwareABIVersion = plan.virtualHardwareABIVersion
+ }
+}
+
+public enum DoryMachineLaunchPolicy: String, Sendable, Equatable {
+ /// Explicit transition mode for machines that predate durable workspace plans.
+ case legacyCompatibility
+ /// A start is rejected unless the complete persisted-plan trust path is installed.
+ case requireResolvedPlan
+}
+
public final class MachineManager: @unchecked Sendable {
public typealias AgentConnector = @Sendable (String) throws -> any AgentControlClient
public typealias ProcessStarter = @Sendable (HvProcess) throws -> Void
+ public typealias ResolvedPlanRevisionProvider = @Sendable (_ machineID: String) -> UInt64?
private static let deletionQuarantinePrefix = ".dory-machine-delete-"
private static let machineDiskTemporaryPrefix = ".rootfs.ext4.tmp-"
@@ -624,14 +650,22 @@ public final class MachineManager: @unchecked Sendable {
private let balloonController: any MachineBalloonControlling
private let processStarter: ProcessStarter
private let workspaceRepository: DoryWorkspaceRepository
+ private let launchPolicy: DoryMachineLaunchPolicy
private let operationLock = NSRecursiveLock()
private let lock = NSLock()
private var machines: [String: MachineEntry] = [:]
private var deletingMachineIDs: Set = []
private var workspaceProjectionDiagnostics: [String: DoryWorkspaceProjectionDiagnostic] = [:]
+ private var resolvedLaunchRegistry: BackendRegistry?
+ private var resolvedLaunchPlanResolver: (any DoryDaemonVirtualMachineLaunchPlanResolving)?
+ private var resolvedLaunchPlanStore: (any DoryResolvedMachinePlanStoring)?
+ private var resolvedPlanRevisionProvider: ResolvedPlanRevisionProvider?
+ private var pendingResolvedStart: PendingResolvedMachineStart?
+ private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:]
public init(
configuration: MachineManagerConfiguration,
+ launchPolicy: DoryMachineLaunchPolicy = .legacyCompatibility,
balloonController: any MachineBalloonControlling = UnixMachineBalloonController(),
agentConnector: @escaping AgentConnector = { socketPath in
try LocalAgentControl.connect(socketPath: socketPath)
@@ -639,6 +673,7 @@ public final class MachineManager: @unchecked Sendable {
processStarter: @escaping ProcessStarter = { process in try process.start() }
) {
self.configuration = configuration
+ self.launchPolicy = launchPolicy
self.balloonController = balloonController
self.agentConnector = agentConnector
self.processStarter = processStarter
@@ -665,6 +700,61 @@ public final class MachineManager: @unchecked Sendable {
recoverInterruptedDesktopUpdates()
}
+ /// Enables the explicit plan-driven launch path exactly once. Machines keep using the
+ /// labeled legacy compatibility path until this production trust infrastructure is injected;
+ /// once installed, a start never falls back when plan validation or adapter dispatch fails.
+ public func installResolvedLaunchInfrastructure(
+ registry: BackendRegistry,
+ resolver: any DoryDaemonVirtualMachineLaunchPlanResolving,
+ plans: any DoryResolvedMachinePlanStoring,
+ expectedPlanRevision: @escaping ResolvedPlanRevisionProvider
+ ) throws {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ guard launchPolicy == .requireResolvedPlan else {
+ throw MachineManagerError.persistence(
+ "resolved launch infrastructure requires the requireResolvedPlan policy"
+ )
+ }
+ guard resolvedLaunchRegistry == nil, resolvedLaunchPlanResolver == nil,
+ resolvedLaunchPlanStore == nil,
+ resolvedPlanRevisionProvider == nil,
+ pendingResolvedStart == nil else {
+ throw MachineManagerError.persistence(
+ "resolved launch infrastructure is already installed"
+ )
+ }
+ resolvedLaunchRegistry = registry
+ resolvedLaunchPlanResolver = resolver
+ resolvedLaunchPlanStore = plans
+ resolvedPlanRevisionProvider = expectedPlanRevision
+ }
+
+ /// Operations for the current Linux compatibility adapters. Start is protected by a
+ /// single-use authorization installed only after persisted-plan revalidation; calling the
+ /// returned operation directly cannot bypass the plan-driven public start path.
+ public func resolvedLaunchCompatibilityOperations(
+ for backend: DoryVirtualizationBackendIdentity
+ ) -> MachineBackendCompatibilityOperations {
+ MachineBackendCompatibilityOperations(
+ authorizedStart: { [weak self] binding in
+ guard let self else {
+ throw MachineManagerError.persistence("machine manager is unavailable")
+ }
+ return try self.performAuthorizedResolvedBackendStart(
+ binding: binding,
+ expectedBackend: backend
+ )
+ },
+ stop: { [weak self] id in
+ guard let self else {
+ throw MachineManagerError.persistence("machine manager is unavailable")
+ }
+ return MachineBackendRuntimeObservation(try self.stop(id: id))
+ }
+ )
+ }
+
@discardableResult
public func create(_ machine: DoryMachineConfiguration) throws -> DoryMachineStatus {
operationLock.lock()
@@ -766,8 +856,270 @@ public final class MachineManager: @unchecked Sendable {
public func start(id: String) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
+ switch launchPolicy {
+ case .legacyCompatibility:
+ let prepared = try prepareMachineStart(
+ id: id,
+ requiresAuthoritativeDefinition: false
+ )
+ return try spawnPreparedMachine(prepared.machine, launchBinding: nil)
+ case .requireResolvedPlan:
+ guard let registry = resolvedLaunchRegistry,
+ let resolver = resolvedLaunchPlanResolver,
+ let planStore = resolvedLaunchPlanStore,
+ let revisionProvider = resolvedPlanRevisionProvider else {
+ throw MachineManagerError.persistence(
+ "resolved launch infrastructure is not installed"
+ )
+ }
+ return try startResolvedMachine(
+ id: id,
+ registry: registry,
+ resolver: resolver,
+ planStore: planStore,
+ revisionProvider: revisionProvider
+ )
+ }
+ }
+
+ private func startResolvedMachine(
+ id: String,
+ registry: BackendRegistry,
+ resolver: any DoryDaemonVirtualMachineLaunchPlanResolving,
+ planStore: any DoryResolvedMachinePlanStoring,
+ revisionProvider: ResolvedPlanRevisionProvider
+ ) throws -> DoryMachineStatus {
+ let prepared = try prepareMachineStart(id: id, requiresAuthoritativeDefinition: true)
+ guard let definition = prepared.definition,
+ let definitionData = prepared.canonicalDefinitionData else {
+ throw MachineManagerError.persistence(
+ "authoritative workspace definition is unavailable for resolved launch"
+ )
+ }
+ guard let expectedPlanRevision = revisionProvider(id), expectedPlanRevision > 0 else {
+ throw MachineManagerError.persistence(
+ "no resolved-plan revision is pinned for machine \(id)"
+ )
+ }
+ let resolved: DoryDaemonVirtualMachineLaunchPlanResolution
+ do {
+ resolved = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest(
+ definition: definition,
+ canonicalDefinitionData: definitionData,
+ machine: prepared.machine,
+ expectedPlanRevision: expectedPlanRevision
+ ))
+ } catch {
+ throw MachineManagerError.persistence("resolved launch rejected: \(error)")
+ }
+ try validateResolvedLaunch(
+ resolved,
+ machine: prepared.machine,
+ definition: definition,
+ canonicalDefinitionData: definitionData
+ )
+ try revalidatePreparedAuthorityImmediatelyBeforeSpawn(
+ prepared,
+ expectedDefinition: definition,
+ expectedCanonicalDefinitionData: definitionData
+ )
+ try revalidateResolvedPlanAuthorityImmediatelyBeforeSpawn(
+ resolved,
+ planStore: planStore
+ )
+
+ pendingResolvedStart = PendingResolvedMachineStart(
+ machine: prepared.machine,
+ backend: resolved.backendPlan.backend,
+ runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier,
+ runtimeComponents: resolved.resolvedPlan.components,
+ planRevision: resolved.resolvedPlan.planRevision,
+ planSHA256: resolved.resolvedPlanSHA256
+ )
+ defer { pendingResolvedStart = nil }
+ let operation = registry.start(resolved.backendPlan)
+ guard operation.isSuccess,
+ operation.backend == resolved.resolvedPlan.backend,
+ operation.observation?.machineID == id else {
+ let failure = operation.failure?.message ?? "backend adapter returned no observation"
+ throw MachineManagerError.persistence("resolved backend start failed: \(failure)")
+ }
+ guard let status = status(id: id), [.starting, .running].contains(status.state) else {
+ throw MachineManagerError.persistence(
+ "resolved backend did not enter MachineManager's prepared spawn path"
+ )
+ }
+ resolvedLaunchIdentities[id] = DoryMachineResolvedLaunchIdentity(
+ plan: resolved.resolvedPlan,
+ planSHA256: resolved.resolvedPlanSHA256
+ )
+ return status
+ }
+
+ private func revalidateResolvedPlanAuthorityImmediatelyBeforeSpawn(
+ _ resolved: DoryDaemonVirtualMachineLaunchPlanResolution,
+ planStore: any DoryResolvedMachinePlanStoring
+ ) throws {
+ let current: DoryResolvedMachinePlan
+ do {
+ current = try planStore.read(id: resolved.resolvedPlan.machineID)
+ } catch {
+ throw MachineManagerError.persistence(
+ "resolved-plan authority changed during launch validation: \(error)"
+ )
+ }
+ guard current.planRevision == resolved.resolvedPlan.planRevision,
+ current == resolved.resolvedPlan,
+ try Self.canonicalResolvedPlanSHA256(current)
+ == resolved.resolvedPlanSHA256.lowercased() else {
+ throw MachineManagerError.persistence(
+ "resolved-plan authority changed during launch validation"
+ )
+ }
+ }
+
+ private func revalidatePreparedAuthorityImmediatelyBeforeSpawn(
+ _ prepared: PreparedMachineStart,
+ expectedDefinition: DoryVirtualMachineDefinition,
+ expectedCanonicalDefinitionData: Data
+ ) throws {
+ guard let preparedLegacyData = prepared.authoritativeLegacyData,
+ let currentLegacyData = Self.readPrivateMetadata(
+ path: machineConfigPath(id: prepared.machine.id)
+ ),
+ currentLegacyData == preparedLegacyData,
+ let decoded = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: currentLegacyData
+ ),
+ decoded == prepared.machine,
+ let currentDefinition = reconcileWorkspaceProjection(
+ machine: prepared.machine,
+ authoritativeLegacyData: currentLegacyData
+ ),
+ currentDefinition == expectedDefinition,
+ try Self.canonicalDefinitionData(currentDefinition)
+ == expectedCanonicalDefinitionData else {
+ throw MachineManagerError.persistence(
+ "machine authority changed during resolved launch validation"
+ )
+ }
+ }
+
+ private func performAuthorizedResolvedBackendStart(
+ binding: MachineBackendLaunchBinding,
+ expectedBackend: DoryVirtualizationBackendIdentity
+ ) throws -> MachineBackendRuntimeObservation {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ guard let authorization = pendingResolvedStart,
+ authorization.machine.id == binding.machineID,
+ authorization.backend == binding.backend,
+ binding.backend.identity == expectedBackend,
+ !binding.executablePath.isEmpty else {
+ throw MachineManagerError.persistence(
+ "backend start lacks a validated resolved-plan authorization"
+ )
+ }
+ let executableSHA256: String
+ do {
+ executableSHA256 = try Self.fileSHA256(path: binding.executablePath)
+ } catch {
+ pendingResolvedStart = nil
+ throw MachineManagerError.persistence(
+ "resolved backend executable could not be verified: \(error)"
+ )
+ }
+ let matchingComponents = authorization.runtimeComponents.filter {
+ $0.componentIdentifier == binding.componentIdentifier
+ && $0.buildIdentifier == authorization.runtimeBuildIdentifier
+ && $0.artifactSHA256.lowercased() == executableSHA256
+ }
+ guard matchingComponents.count == 1 else {
+ pendingResolvedStart = nil
+ throw MachineManagerError.persistence(
+ "resolved backend executable does not match qualified runtime evidence"
+ )
+ }
+ // Consume before process construction so a second or delayed adapter callback cannot
+ // reuse the validated context, including after a spawn failure.
+ pendingResolvedStart = nil
+ return MachineBackendRuntimeObservation(try spawnPreparedMachine(
+ authorization.machine,
+ launchBinding: binding
+ ))
+ }
+
+ private func validateResolvedLaunch(
+ _ resolved: DoryDaemonVirtualMachineLaunchPlanResolution,
+ machine: DoryMachineConfiguration,
+ definition: DoryVirtualMachineDefinition,
+ canonicalDefinitionData: Data
+ ) throws {
+ let plan = resolved.resolvedPlan
+ let capability = resolved.backendPlan.capability
+ let definitionDigest = SHA256.hash(data: canonicalDefinitionData)
+ .map { String(format: "%02x", $0) }.joined()
+ let canonicalPlanDigest = try Self.canonicalResolvedPlanSHA256(plan)
+ let expectedMemoryBytes = machine.memoryMB.multipliedReportingOverflow(by: 1_048_576)
+ guard !expectedMemoryBytes.overflow,
+ resolved.revalidation.mayStart,
+ plan.validate().isEmpty,
+ plan.machineID == machine.id,
+ plan.definitionRevision == definition.lifecycle.revision,
+ plan.definitionSHA256?.lowercased() == definitionDigest,
+ resolved.resolvedPlanSHA256.lowercased() == canonicalPlanDigest,
+ plan.resourceAdmission?.admittedVirtualCPUCount == UInt64(machine.cpuCount),
+ plan.resourceAdmission?.admittedMemoryBytes == expectedMemoryBytes.partialValue,
+ plan.resourceAdmission?.admittedStorageBytes == definition.resources.diskBytes,
+ resolved.backendPlan.machine == machine,
+ resolved.backendPlan.backend.identity == plan.backend,
+ resolved.backendPlan.backend.implementationIdentifier
+ == plan.backendImplementationIdentifier,
+ capability.request.guest == plan.guest,
+ capability.request.backend == plan.backend,
+ capability.request.bootMedia == plan.bootMedia.media,
+ capability.request.devices == plan.devices,
+ capability.request.graphics == plan.graphics,
+ capability.request.virtualHardwareABIVersion == plan.virtualHardwareABIVersion,
+ capability.availability.supportTier == plan.supportTier,
+ capability.availability.isUsable,
+ capability.resolvedDevices == plan.devices else {
+ throw MachineManagerError.persistence(
+ "resolved plan does not exactly match current definition and adapter evidence"
+ )
+ }
+ }
+
+ private static func canonicalResolvedPlanSHA256(
+ _ plan: DoryResolvedMachinePlan
+ ) throws -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = try encoder.encode(plan)
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func fileSHA256(path: String) throws -> String {
+ guard let handle = FileHandle(forReadingAtPath: path) else {
+ throw MachineManagerError.persistence("cannot open runtime executable at \(path)")
+ }
+ defer { _ = try? handle.close() }
+ var hash = SHA256()
+ while true {
+ let data = try handle.read(upToCount: 1_048_576) ?? Data()
+ if data.isEmpty { break }
+ hash.update(data: data)
+ }
+ return hash.finalize().map { String(format: "%02x", $0) }.joined()
+ }
+
+ private func prepareMachineStart(
+ id: String,
+ requiresAuthoritativeDefinition: Bool
+ ) throws -> PreparedMachineStart {
lock.lock()
- guard var entry = machines[id] else {
+ guard let entry = machines[id] else {
lock.unlock()
throw MachineManagerError.unknownMachine(id)
}
@@ -776,35 +1128,75 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.alreadyRunning(id)
}
lock.unlock()
- do {
- try ensureInstalledLinuxBootBundleIfNeeded(entry.configuration)
- try materializeInstalledLinuxBootRuntimeIfNeeded(entry.configuration)
- if let authoritativeLegacyData = Self.readPrivateMetadata(
- path: machineConfigPath(id: entry.configuration.id)
- ) {
- // Boot-bundle materialization changes authoritative inspection facts without
- // changing machine.json. Bind that fact change and advance the projection here.
- reconcileWorkspaceProjection(
- machine: entry.configuration,
- authoritativeLegacyData: authoritativeLegacyData
- )
+
+ let machine = entry.configuration
+ try ensureInstalledLinuxBootBundleIfNeeded(machine)
+ try materializeInstalledLinuxBootRuntimeIfNeeded(machine)
+ let authoritativeLegacyData = Self.readPrivateMetadata(
+ path: machineConfigPath(id: machine.id)
+ )
+ var authoritativeDefinition: DoryVirtualMachineDefinition?
+ if let authoritativeLegacyData {
+ if requiresAuthoritativeDefinition {
+ guard let decoded = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: authoritativeLegacyData
+ ), decoded == machine else {
+ throw MachineManagerError.persistence(
+ "authoritative machine metadata changed before resolved launch"
+ )
+ }
}
- try Self.validateLaunchConfiguration(entry.configuration)
- try validateManagedMachineArtifacts(entry.configuration)
- try validateRuntimeAvailability(entry.configuration)
- } catch {
- throw error
+ // Boot-bundle materialization can change authoritative inspection facts without
+ // changing machine.json. Reconcile those facts immediately before launch.
+ authoritativeDefinition = reconcileWorkspaceProjection(
+ machine: machine,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ } else if requiresAuthoritativeDefinition {
+ throw MachineManagerError.persistence(
+ "authoritative machine metadata is unavailable for resolved launch"
+ )
+ }
+ try Self.validateLaunchConfiguration(machine)
+ try validateManagedMachineArtifacts(machine)
+ if !requiresAuthoritativeDefinition {
+ try validateRuntimeAvailability(machine)
}
+ if requiresAuthoritativeDefinition, authoritativeDefinition == nil {
+ throw MachineManagerError.persistence(
+ "authoritative workspace projection is unavailable for resolved launch"
+ )
+ }
+ let definitionData = try authoritativeDefinition.map(Self.canonicalDefinitionData)
+ return PreparedMachineStart(
+ machine: machine,
+ definition: authoritativeDefinition,
+ canonicalDefinitionData: definitionData,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ }
+
+ private func spawnPreparedMachine(
+ _ preparedMachine: DoryMachineConfiguration,
+ launchBinding: MachineBackendLaunchBinding?
+ ) throws -> DoryMachineStatus {
lock.lock()
- guard let currentEntry = machines[id] else {
+ guard var entry = machines[preparedMachine.id] else {
lock.unlock()
- throw MachineManagerError.unknownMachine(id)
+ throw MachineManagerError.unknownMachine(preparedMachine.id)
+ }
+ guard entry.configuration == preparedMachine else {
+ lock.unlock()
+ throw MachineManagerError.persistence(
+ "machine configuration changed after launch validation"
+ )
}
- entry = currentEntry
if entry.process?.isRunning == true {
lock.unlock()
- throw MachineManagerError.alreadyRunning(id)
+ throw MachineManagerError.alreadyRunning(preparedMachine.id)
}
+ let id = preparedMachine.id
let handoffPath = configuration.requiresReadyHandoff ? handoffSocketPath(id: id) : nil
let launchID = UUID()
let handoffServer: VmmHandoffServer?
@@ -824,7 +1216,8 @@ public final class MachineManager: @unchecked Sendable {
do {
processConfiguration = try self.processConfiguration(
for: entry.configuration,
- handoffPath: handoffPath
+ handoffPath: handoffPath,
+ resolvedLaunchBinding: launchBinding
)
} catch {
handoffServer?.stop()
@@ -1033,6 +1426,7 @@ public final class MachineManager: @unchecked Sendable {
deletingMachineIDs.remove(id)
workspaceProjectionDiagnostics.removeValue(forKey: id)
lock.unlock()
+ resolvedLaunchIdentities.removeValue(forKey: id)
try? FileManager.default.removeItem(atPath: machineRuntimeDirectory(id: id))
if let quarantinePath {
@@ -1773,6 +2167,15 @@ public final class MachineManager: @unchecked Sendable {
return workspaceProjectionDiagnostics[id]
}
+ /// Immutable identity of the last plan-driven launch accepted for this machine. Runtime
+ /// status/snapshot/export can consume this hook without copying the full trust evidence yet.
+ public func resolvedLaunchIdentity(id: String) -> DoryMachineResolvedLaunchIdentity? {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ guard machines[id] != nil else { return nil }
+ return resolvedLaunchIdentities[id]
+ }
+
private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus {
if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true {
return DoryMachineStatus(
@@ -1818,9 +2221,13 @@ public final class MachineManager: @unchecked Sendable {
private func processConfiguration(
for machine: DoryMachineConfiguration,
- handoffPath: String?
+ handoffPath: String?,
+ resolvedLaunchBinding: MachineBackendLaunchBinding?
) throws -> HvProcessConfiguration {
- let target = processTarget(for: machine)
+ let target = try processTarget(
+ for: machine,
+ resolvedLaunchBinding: resolvedLaunchBinding
+ )
return HvProcessConfiguration(
executablePath: target.executablePath,
arguments: try processArguments(
@@ -1836,11 +2243,30 @@ public final class MachineManager: @unchecked Sendable {
)
}
- private func processTarget(for machine: DoryMachineConfiguration) -> (
+ private func processTarget(
+ for machine: DoryMachineConfiguration,
+ resolvedLaunchBinding: MachineBackendLaunchBinding?
+ ) throws -> (
executablePath: String,
baseArguments: [String],
acceleratedDesktop: Bool
) {
+ if let binding = resolvedLaunchBinding {
+ switch binding.backend.identity {
+ case .doryHypervisor:
+ return (
+ binding.executablePath,
+ configuration.acceleratedDesktopBaseArguments,
+ true
+ )
+ case .appleVirtualizationFramework:
+ return (binding.executablePath, configuration.baseArguments, false)
+ default:
+ throw MachineManagerError.persistence(
+ "resolved backend \(binding.backend.identity.rawValue) has no MachineManager launcher"
+ )
+ }
+ }
let desktopPreference = try? DoryDesktopVMMPreference(environment: machine.environment)
let supportsAcceleratedBoot = machine.bootMode == .linuxKernel
|| (machine.bootMode == .efi
@@ -2709,8 +3135,9 @@ public final class MachineManager: @unchecked Sendable {
}
// The rename above is the commit point. Projection is intentionally best-effort and
// ordered afterwards: a v2 failure must never roll back or hide working legacy metadata.
+ resolvedLaunchIdentities.removeValue(forKey: machine.id)
if let authoritativeLegacyData {
- reconcileWorkspaceProjection(
+ _ = reconcileWorkspaceProjection(
machine: machine,
authoritativeLegacyData: authoritativeLegacyData
)
@@ -2732,14 +3159,14 @@ public final class MachineManager: @unchecked Sendable {
)
continue
}
- reconcileWorkspaceProjection(machine: machine, authoritativeLegacyData: data)
+ _ = reconcileWorkspaceProjection(machine: machine, authoritativeLegacyData: data)
}
}
private func reconcileWorkspaceProjection(
machine: DoryMachineConfiguration,
authoritativeLegacyData: Data
- ) {
+ ) -> DoryVirtualMachineDefinition? {
do {
let facts = try workspaceMigrationFacts(for: machine)
let migration = try DoryMachineConfigurationMigrationBridge.migrate(
@@ -2757,6 +3184,7 @@ public final class MachineManager: @unchecked Sendable {
),
id: machine.id
)
+ return result.definition
} catch let error as DoryMachineConfigurationMigrationError {
setWorkspaceProjectionDiagnostic(
DoryWorkspaceProjectionDiagnostic(
@@ -2766,6 +3194,7 @@ public final class MachineManager: @unchecked Sendable {
),
id: machine.id
)
+ return nil
} catch let error as DoryWorkspaceRepositoryError {
setWorkspaceProjectionDiagnostic(
DoryWorkspaceProjectionDiagnostic(
@@ -2775,6 +3204,7 @@ public final class MachineManager: @unchecked Sendable {
),
id: machine.id
)
+ return nil
} catch {
setWorkspaceProjectionDiagnostic(
DoryWorkspaceProjectionDiagnostic(
@@ -2784,9 +3214,18 @@ public final class MachineManager: @unchecked Sendable {
),
id: machine.id
)
+ return nil
}
}
+ private static func canonicalDefinitionData(
+ _ definition: DoryVirtualMachineDefinition
+ ) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ return try encoder.encode(definition)
+ }
+
private func workspaceMigrationFacts(
for machine: DoryMachineConfiguration
) throws -> DoryMachineConfigurationMigrationFacts {
@@ -4070,6 +4509,22 @@ private struct WorkspaceMigrationAuthorityFacts: Codable {
var lifecycle: DoryVMLifecycleMetadata
}
+private struct PreparedMachineStart {
+ var machine: DoryMachineConfiguration
+ var definition: DoryVirtualMachineDefinition?
+ var canonicalDefinitionData: Data?
+ var authoritativeLegacyData: Data?
+}
+
+private struct PendingResolvedMachineStart {
+ var machine: DoryMachineConfiguration
+ var backend: MachineBackendDescriptor
+ var runtimeBuildIdentifier: String
+ var runtimeComponents: [DoryResolvedBackendComponentEvidence]
+ var planRevision: UInt64
+ var planSHA256: String
+}
+
private struct MachineEntry {
var configuration: DoryMachineConfiguration
var state: DoryMachineState
diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift
index 0345655d..cb9ad3dc 100644
--- a/dory-core-swift/Sources/doryd/main.swift
+++ b/dory-core-swift/Sources/doryd/main.swift
@@ -63,7 +63,18 @@ let idleController = IdleController()
let dockerTier = dorydEnvironment.dockerTierConfiguration().map {
DockerTier(configuration: $0, idleController: idleController)
}
-let machineManager = dorydEnvironment.machineManagerConfiguration().map { MachineManager(configuration: $0) }
+let machineManager = dorydEnvironment.machineManagerConfiguration().map { configuration in
+ // Transitional and explicit: existing machines remain launchable until the production trusted
+ // inventory collector is installed. MachineManager's resolved-plan policy fails closed and is
+ // not enabled here without that evidence source.
+ FileHandle.standardError.write(Data(
+ "doryd: VM launch policy legacyCompatibility (trusted resolved-plan inventory unavailable)\n".utf8
+ ))
+ return MachineManager(
+ configuration: configuration,
+ launchPolicy: .legacyCompatibility
+ )
+}
let sandboxTTLReconciler = machineManager.map { manager in
SandboxTTLReconciler(
machines: manager,
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
new file mode 100644
index 00000000..bd52bb01
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -0,0 +1,607 @@
+import CryptoKit
+import DoryOperations
+import Foundation
+import Testing
+@testable import DorydKit
+
+@Suite("MachineManager resolved-plan launch integration", .serialized)
+struct MachineManagerResolvedPlanIntegrationTests {
+ @Test("validated persisted plan dispatches once without public-start recursion")
+ func exactPlanDispatchesThroughRegistry() throws {
+ try withHarness("success") { manager, starter, state in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ #expect(throws: (any Error).self) {
+ _ = try operations.start("dev")
+ }
+ #expect(starter.count == 0)
+
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(request: request)
+ plans.set(resolution.resolvedPlan)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ let status = try manager.start(id: "dev")
+ #expect(status.state == .running)
+ #expect(starter.count == 1)
+ #expect(resolver.callCount == 1)
+ let identity = try #require(manager.resolvedLaunchIdentity(id: "dev"))
+ #expect(identity.planRevision == 1)
+ #expect(identity.backend == .doryHypervisor)
+ #expect(identity.planSHA256.count == 64)
+ #expect(FileManager.default.fileExists(atPath: state + "/dev/machine.json"))
+ _ = try manager.stop(id: "dev")
+ }
+ }
+
+ @Test("missing stale tampered and rejected plans never reach process starter")
+ func rejectedPlansFailBeforeSpawn() throws {
+ enum Scenario: CaseIterable {
+ case missing
+ case staleDefinition
+ case tamperedDigest
+ case rejectedEvidence
+ case adapterPlanMismatch
+ }
+
+ for scenario in Scenario.allCases {
+ try withHarness("reject-\(scenario)") { manager, starter, _ in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ switch scenario {
+ case .missing:
+ throw DoryDaemonVirtualMachineLaunchPlanFailure(
+ code: .planNotFound,
+ message: "fixture missing plan"
+ )
+ case .staleDefinition:
+ var resolution = try exactResolution(request: request)
+ resolution.resolvedPlan.definitionRevision += 1
+ resolution.resolvedPlanSHA256 = try planSHA256(
+ resolution.resolvedPlan
+ )
+ return resolution
+ case .tamperedDigest:
+ var resolution = try exactResolution(request: request)
+ resolution.resolvedPlanSHA256 = digest("9")
+ return resolution
+ case .rejectedEvidence:
+ var resolution = try exactResolution(request: request)
+ resolution.revalidation = DoryResolvedMachinePlanRevalidationResult(
+ state: .rejected,
+ issues: [DoryResolvedMachinePlanRevalidationIssue(
+ code: .componentEvidenceMismatch,
+ field: "components"
+ )]
+ )
+ return resolution
+ case .adapterPlanMismatch:
+ var resolution = try exactResolution(request: request)
+ resolution.backendPlan.machine.memoryMB += 1_024
+ return resolution
+ }
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ #expect(manager.status(id: "dev")?.state == .created)
+ #expect(manager.resolvedLaunchIdentity(id: "dev") == nil)
+ }
+ }
+ }
+
+ @Test("machine metadata mutation during evidence collection is rejected before spawn")
+ func authorityTOCTOUIsRejected() throws {
+ try withHarness("authority-toctou") { manager, starter, state in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ var changed = request.machine
+ changed.memoryMB += 1_024
+ let path = state + "/dev/machine.json"
+ try DoryMachineConfigurationMigrationBridge.encodeLegacy(changed).write(
+ to: URL(fileURLWithPath: path),
+ options: .atomic
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ return try exactResolution(request: request)
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ #expect(manager.status(id: "dev")?.state == .created)
+ }
+ }
+
+ @Test("missing pinned plan revision fails without invoking resolver or process")
+ func missingPinnedRevisionFailsClosed() throws {
+ try withHarness("missing-revision") { manager, starter, _ in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ try exactResolution(request: request)
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in nil }
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(resolver.callCount == 0)
+ #expect(starter.count == 0)
+ }
+ }
+
+ @Test("resolved plan replacement during evidence collection is rejected before spawn")
+ func planAuthorityTOCTOUIsRejected() throws {
+ try withHarness("plan-toctou") { manager, starter, _ in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(request: request)
+ var replacement = resolution.resolvedPlan
+ replacement.planRevision += 1
+ replacement.updatedAtUnixMilliseconds += 1
+ plans.set(replacement)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ #expect(manager.status(id: "dev")?.state == .created)
+ }
+ }
+
+ @Test("same-path runtime replacement is rejected before process starter")
+ func swappedRuntimeArtifactIsRejected() throws {
+ try withHarness("runtime-swap") { manager, starter, state in
+ let runtimePath = state + "/adapter-runtime"
+ try writeExecutable("#!/bin/sh\nexec /bin/sleep \"$@\"\n", path: runtimePath)
+ let qualifiedSHA256 = try fileSHA256(path: runtimePath)
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(
+ operations: operations,
+ executablePath: runtimePath
+ )
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(
+ request: request,
+ componentSHA256: qualifiedSHA256
+ )
+ plans.set(resolution.resolvedPlan)
+ try writeExecutable("#!/bin/sh\nexit 0\n", path: runtimePath)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ }
+ }
+
+ @Test("adapter-issued executable is launched instead of manager backend lookup")
+ func adapterExecutableBindingIsUsed() throws {
+ try withHarness(
+ "adapter-binding",
+ acceleratedExecutablePath: nil
+ ) { manager, starter, state in
+ let runtimePath = state + "/adapter-runtime"
+ try writeExecutable("#!/bin/sh\nexec /bin/sleep \"$@\"\n", path: runtimePath)
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(
+ operations: operations,
+ executablePath: runtimePath
+ )
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(
+ request: request,
+ componentSHA256: try fileSHA256(path: runtimePath)
+ )
+ plans.set(resolution.resolvedPlan)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ let status = try manager.start(id: "dev")
+ #expect(status.state == .running)
+ #expect(starter.count == 1)
+ }
+ }
+
+ @Test("launch policy explicitly gates compatibility and resolved starts")
+ func launchPolicyIsExplicit() throws {
+ try withHarness("required-no-infra") { manager, starter, _ in
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ }
+ try withHarness("legacy", launchPolicy: .legacyCompatibility) {
+ manager, starter, _ in
+ let status = try manager.start(id: "dev")
+ #expect(status.state == .running)
+ #expect(starter.count == 1)
+ }
+ }
+
+ private func withHarness(
+ _ label: String,
+ launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan,
+ acceleratedExecutablePath: String? = "/bin/sleep",
+ _ body: (MachineManager, CountingProcessStarter, String) throws -> Void
+ ) throws {
+ let state = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-resolved-start-\(label)-\(UUID().uuidString)")
+ .path
+ let starter = CountingProcessStarter()
+ let manager = MachineManager(
+ configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ acceleratedDesktopExecutablePath: acceleratedExecutablePath,
+ stateDirectory: state,
+ baseArguments: ["30"],
+ acceleratedDesktopBaseArguments: ["30"],
+ passMachineArguments: false,
+ requiresReadyHandoff: false
+ ),
+ launchPolicy: launchPolicy,
+ processStarter: { process in try starter.start(process) }
+ )
+ defer {
+ _ = try? manager.stop(id: "dev")
+ _ = try? manager.delete(id: "dev")
+ _ = try? FileManager.default.removeItem(atPath: state)
+ }
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ memoryMB: 2_048,
+ cpuCount: 2,
+ displayMode: .desktop
+ ))
+ try body(manager, starter, state)
+ }
+
+ private func rawRegistry(
+ operations: MachineBackendCompatibilityOperations,
+ executablePath: String = "/bin/sleep"
+ ) throws -> BackendRegistry {
+ try BackendRegistry(backends: [RawHVLinuxMachineBackend(
+ executablePath: executablePath,
+ operations: operations
+ )])
+ }
+
+ private func exactResolution(
+ request: DoryDaemonVirtualMachineLaunchPlanRequest,
+ componentSHA256: String? = nil
+ ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution {
+ let definitionDigest = SHA256.hash(data: request.canonicalDefinitionData)
+ .map { String(format: "%02x", $0) }.joined()
+ let artifact = digest("a")
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let media = DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .userProvided,
+ artifactSHA256: artifact
+ )
+ let runtime = "raw-runtime-1"
+ let launcherSHA256: String
+ if let componentSHA256 {
+ launcherSHA256 = componentSHA256
+ } else {
+ launcherSHA256 = try fileSHA256(path: "/bin/sleep")
+ }
+ let plan = DoryResolvedMachinePlan(
+ machineID: request.machine.id,
+ definitionRevision: request.definition.lifecycle.revision,
+ definitionSHA256: definitionDigest,
+ planRevision: request.expectedPlanRevision,
+ createdAtUnixMilliseconds: request.definition.lifecycle.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: request.definition.lifecycle.updatedAtUnixMilliseconds,
+ guest: request.definition.guest,
+ backend: .doryHypervisor,
+ backendImplementationIdentifier:
+ RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier,
+ backendRuntimeBuildIdentifier: runtime,
+ virtualHardwareABIVersion: request.definition.virtualHardwareABIVersion,
+ bootMedia: DoryResolvedMachineBootMedia(
+ resolverReference: DoryVMResolverReference(
+ namespace: "machine",
+ identifier: "dev-kernel"
+ ),
+ media: media
+ ),
+ components: [DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: runtime,
+ artifactSHA256: launcherSHA256
+ )],
+ devices: devices,
+ graphics: .hostAcceleratedDisplay,
+ supportTier: .supported,
+ selectionEvidence: DoryResolvedMachineBackendSelectionEvidence(
+ disposition: .primary,
+ plannerRequest: DoryVirtualMachineBackendPlanRequest(
+ guest: request.definition.guest,
+ bootMedia: media,
+ acceptableGraphics: [.hostAcceleratedDisplay],
+ devices: devices,
+ virtualHardwareABIVersion:
+ request.definition.virtualHardwareABIVersion,
+ backendPreferences: [.doryHypervisor],
+ backendPreferencePolicy: .required
+ ),
+ selectedEvaluationIndex: 0,
+ rejectedCandidates: []
+ ),
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence(
+ graphics: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: "graphics-qualification-1",
+ artifactSHA256: artifact,
+ manifestSHA256: digest("b"),
+ signingKeyID: "dory-release-1",
+ manifestFormatVersion: 1
+ ),
+ runtime: runtimeQualification(
+ guest: request.definition.guest,
+ media: media,
+ runtimeBuild: runtime,
+ devices: devices,
+ virtualHardwareABIVersion:
+ request.definition.virtualHardwareABIVersion
+ )
+ ),
+ resourceAdmission: resourceAdmission(
+ machine: request.machine,
+ diskBytes: request.definition.resources.diskBytes
+ ),
+ hostQualification: DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: "host-qualification-1",
+ qualificationReportSHA256: digest("6"),
+ hostHardwareModelIdentifier: "Mac16.1",
+ hostOperatingSystemBuild: "26A5406c",
+ backend: .doryHypervisor,
+ backendRuntimeBuildIdentifier: runtime,
+ virtualHardwareABIVersion: request.definition.virtualHardwareABIVersion,
+ qualifierIdentifier: "dory-host-qualifier",
+ qualifierVersion: 1
+ )
+ )
+ guard plan.validate().isEmpty else {
+ throw MachineManagerError.persistence("fixture plan is invalid")
+ }
+ let capability = DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: DoryVirtualMachineCapabilityRequest(
+ guest: plan.guest,
+ bootMedia: media,
+ backend: .doryHypervisor,
+ graphics: plan.graphics,
+ devices: devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ ),
+ availability: DoryCapabilityAvailability(
+ supportTier: .supported,
+ state: .available
+ ),
+ resolvedDevices: devices,
+ graphicsQualificationEvidence: plan.qualificationEvidence.graphics,
+ runtimeQualificationEvidence: plan.qualificationEvidence.runtime
+ )
+ return DoryDaemonVirtualMachineLaunchPlanResolution(
+ resolvedPlan: plan,
+ resolvedPlanSHA256: try planSHA256(plan),
+ revalidation: DoryResolvedMachinePlanStartValidator.revalidate(
+ plan,
+ against: DoryResolvedMachinePlanStartRevalidationInput(
+ machineID: plan.machineID,
+ expectedPlanRevision: plan.planRevision,
+ currentDefinitionRevision: plan.definitionRevision,
+ currentDefinitionSHA256: definitionDigest,
+ runtimeEvidence: DoryResolvedMachineRuntimeEvidence(plan: plan)
+ )
+ ),
+ backendPlan: MachineBackendPlan(
+ backend: RawHVLinuxMachineBackend.backendDescriptor,
+ machine: request.machine,
+ capability: capability
+ )
+ )
+ }
+
+ private func runtimeQualification(
+ guest: DoryGuestPlatform,
+ media: DoryBootMedia,
+ runtimeBuild: String,
+ devices: DoryVirtualMachineDeviceCapabilityRequest,
+ virtualHardwareABIVersion: UInt16
+ ) -> DoryVirtualMachineRuntimeQualificationEvidence {
+ DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "runtime-qualification-1",
+ qualificationReportSHA256: digest("c"),
+ signingKeyID: "dory-runtime-1",
+ qualificationFormatVersion: 1,
+ guest: guest,
+ bootMediaKind: media.kind,
+ immutableArtifactSHA256: media.artifactSHA256,
+ backend: .doryHypervisor,
+ backendRuntimeBuildID: runtimeBuild,
+ virtualHardwareABIVersion: virtualHardwareABIVersion,
+ graphics: .hostAcceleratedDisplay,
+ devices: devices
+ )
+ }
+
+ private func resourceAdmission(
+ machine: DoryMachineConfiguration,
+ diskBytes: UInt64
+ ) -> DoryResolvedMachineResourceAdmissionEvidence {
+ DoryResolvedMachineResourceAdmissionEvidence(
+ admittedVirtualCPUCount: UInt64(machine.cpuCount),
+ admittedMemoryBytes: machine.memoryMB * 1_048_576,
+ admittedStorageBytes: diskBytes,
+ hostLogicalCPUCount: 12,
+ hostPhysicalMemoryBytes: 32 * 1_073_741_824,
+ hostFreeStorageBytes: 512 * 1_073_741_824,
+ existingVirtualCPUCommitment: 0,
+ existingMemoryCommitmentBytes: 0,
+ existingStorageReservationBytes: 0,
+ hostReservedLogicalCPUCount: 2,
+ hostReservedMemoryBytes: 8 * 1_073_741_824,
+ hostReservedStorageBytes: 32 * 1_073_741_824,
+ admissionIdentity: "resource-admission-1",
+ admissionReportSHA256: digest("f"),
+ assessorIdentifier: "dory-resource-policy",
+ assessorVersion: 1
+ )
+ }
+
+ private func planSHA256(_ plan: DoryResolvedMachinePlan) throws -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ return SHA256.hash(data: try encoder.encode(plan))
+ .map { String(format: "%02x", $0) }.joined()
+ }
+
+ private func fileSHA256(path: String) throws -> String {
+ SHA256.hash(data: try Data(contentsOf: URL(fileURLWithPath: path)))
+ .map { String(format: "%02x", $0) }.joined()
+ }
+
+ private func writeExecutable(_ contents: String, path: String) throws {
+ try Data(contents.utf8).write(to: URL(fileURLWithPath: path), options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o700],
+ ofItemAtPath: path
+ )
+ }
+
+ private func digest(_ character: Character) -> String {
+ String(repeating: String(character), count: 64)
+ }
+}
+
+private final class ClosureLaunchResolver:
+ DoryDaemonVirtualMachineLaunchPlanResolving,
+ @unchecked Sendable
+{
+ typealias Handler = @Sendable (
+ DoryDaemonVirtualMachineLaunchPlanRequest
+ ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution
+
+ private let lock = NSLock()
+ private var calls = 0
+ private let handler: Handler
+
+ init(handler: @escaping Handler) {
+ self.handler = handler
+ }
+
+ var callCount: Int { lock.withLock { calls } }
+
+ func resolve(
+ _ request: DoryDaemonVirtualMachineLaunchPlanRequest
+ ) throws -> DoryDaemonVirtualMachineLaunchPlanResolution {
+ lock.withLock { calls += 1 }
+ return try handler(request)
+ }
+}
+
+private final class CountingProcessStarter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var starts = 0
+
+ var count: Int { lock.withLock { starts } }
+
+ func start(_ process: HvProcess) throws {
+ lock.withLock { starts += 1 }
+ try process.start()
+ }
+}
+
+private final class MutablePlanStore: DoryResolvedMachinePlanStoring, @unchecked Sendable {
+ private let lock = NSLock()
+ private var plan: DoryResolvedMachinePlan?
+
+ func set(_ plan: DoryResolvedMachinePlan?) {
+ lock.withLock { self.plan = plan }
+ }
+
+ func create(_ plan: DoryResolvedMachinePlan) throws { set(plan) }
+
+ func replace(
+ _ plan: DoryResolvedMachinePlan,
+ expectedPlanRevision: UInt64
+ ) throws {
+ lock.withLock { self.plan = plan }
+ }
+
+ func read(id: String) throws -> DoryResolvedMachinePlan {
+ guard let plan = lock.withLock({ self.plan }), plan.machineID == id else {
+ throw DoryResolvedMachinePlanRepositoryError.planNotFound(id)
+ }
+ return plan
+ }
+}
From 96740b3c3240145ea757c16add2ef280b9d13833 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 13:02:49 +0000
Subject: [PATCH 018/338] feat(vm): verify signed runtime qualifications
---
.../DoryOperations/DoryComponents.swift | 91 +++-
...yVirtualMachineQualificationManifest.swift | 506 ++++++++++++++++++
...ualMachineQualificationManifestTests.swift | 338 ++++++++++++
3 files changed, 931 insertions(+), 4 deletions(-)
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift
diff --git a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift
index bbcf9233..a1769191 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift
@@ -150,9 +150,35 @@ public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable {
}
}
+/// Catalog-declared location of semantic VM qualification data. The manifest bytes are an
+/// ordinary component asset, so their installed digest is covered by the signed catalog and the
+/// component store's immutable-installation checks. Schema-v1 catalogs cannot declare this root.
+public struct DoryComponentVirtualMachineQualificationAsset: Codable, Sendable, Equatable {
+ public let component: DoryComponentID
+ public let path: String
+ public let manifestIdentity: String
+ public let manifestFormatVersion: UInt16
+ public let signingKeyID: String
+
+ public init(
+ component: DoryComponentID,
+ path: String,
+ manifestIdentity: String,
+ manifestFormatVersion: UInt16,
+ signingKeyID: String
+ ) {
+ self.component = component
+ self.path = path
+ self.manifestIdentity = manifestIdentity
+ self.manifestFormatVersion = manifestFormatVersion
+ self.signingKeyID = signingKeyID
+ }
+}
+
public struct DoryComponentCatalog: Codable, Sendable, Equatable {
public static let kind = "dev.dory.component-catalog"
- public static let schemaVersion = 1
+ public static let oldestSupportedSchemaVersion = 1
+ public static let schemaVersion = 2
public let kind: String
public let schemaVersion: Int
@@ -161,21 +187,25 @@ public struct DoryComponentCatalog: Codable, Sendable, Equatable {
public let minimumAppVersion: String
public let architecture: String
public let components: [DoryComponentRelease]
+ public let virtualMachineQualification: DoryComponentVirtualMachineQualificationAsset?
public init(
+ schemaVersion: Int = Self.schemaVersion,
releaseVersion: String,
generatedAt: String,
minimumAppVersion: String,
architecture: String,
- components: [DoryComponentRelease]
+ components: [DoryComponentRelease],
+ virtualMachineQualification: DoryComponentVirtualMachineQualificationAsset? = nil
) {
kind = Self.kind
- schemaVersion = Self.schemaVersion
+ self.schemaVersion = schemaVersion
self.releaseVersion = releaseVersion
self.generatedAt = generatedAt
self.minimumAppVersion = minimumAppVersion
self.architecture = architecture
self.components = components
+ self.virtualMachineQualification = virtualMachineQualification
}
public func component(_ id: DoryComponentID) -> DoryComponentRelease? {
@@ -256,7 +286,8 @@ public enum DoryComponentCatalogVerifier {
appVersion: String
) throws {
guard catalog.kind == DoryComponentCatalog.kind,
- catalog.schemaVersion == DoryComponentCatalog.schemaVersion,
+ catalog.schemaVersion >= DoryComponentCatalog.oldestSupportedSchemaVersion,
+ catalog.schemaVersion <= DoryComponentCatalog.schemaVersion,
validVersion(catalog.releaseVersion),
validVersion(catalog.minimumAppVersion),
validTimestamp(catalog.generatedAt),
@@ -284,6 +315,27 @@ public enum DoryComponentCatalogVerifier {
for component in catalog.components {
try validate(component, available: Set(ids))
}
+ if catalog.schemaVersion == DoryComponentCatalog.oldestSupportedSchemaVersion,
+ catalog.virtualMachineQualification != nil {
+ throw DoryComponentError.invalidCatalog(
+ "schema-v1 catalogs cannot declare VM qualification authority"
+ )
+ }
+ if let qualification = catalog.virtualMachineQualification {
+ guard catalog.schemaVersion == DoryComponentCatalog.schemaVersion,
+ qualification.component.isRemovable,
+ safeRelativePath(qualification.path),
+ !qualification.manifestIdentity.isEmpty,
+ qualification.manifestFormatVersion > 0,
+ !qualification.signingKeyID.isEmpty,
+ catalog.component(qualification.component)?.assets.contains(where: {
+ $0.path == qualification.path
+ }) == true else {
+ throw DoryComponentError.invalidCatalog(
+ "VM qualification authority is incomplete or is not a declared component asset"
+ )
+ }
+ }
try validateDependencyGraph(catalog.components)
}
@@ -749,6 +801,37 @@ public struct DoryComponentStore: Sendable {
}
}
+ /// Reads one immutable installed asset only after revalidating its complete component
+ /// generation and the bytes returned by this read. This closes the verify/use race for
+ /// security metadata such as VM qualification manifests.
+ public func verifiedAssetData(
+ component id: DoryComponentID,
+ path: String,
+ maximumBytes: Int
+ ) throws -> Data {
+ guard maximumBytes > 0,
+ DoryComponentCatalogVerifier.safeRelativePath(path) else {
+ throw DoryComponentError.invalidAsset(path)
+ }
+ let installed = try verify(id)
+ guard let asset = installed.assets.first(where: { $0.path == path }),
+ asset.installedBytes <= UInt64(maximumBytes),
+ let assetPath = assetPath(component: id, path: path) else {
+ throw DoryComponentError.invalidAsset(path)
+ }
+ let data: Data
+ do {
+ data = try PrivateRecordFile.read(at: assetPath, maximumBytes: maximumBytes)
+ } catch {
+ throw DoryComponentError.invalidAsset(path)
+ }
+ guard UInt64(data.count) == asset.installedBytes,
+ DoryComponentCatalogVerifier.digest(data) == asset.installedSHA256 else {
+ throw DoryComponentError.digestMismatch(path)
+ }
+ return data
+ }
+
public func isInstalledAndValid(_ id: DoryComponentID) -> Bool {
guard let installed = try? installedComponent(id) else { return false }
return (try? validateInstalledAssets(installed)) != nil
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
new file mode 100644
index 00000000..9cd55f76
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
@@ -0,0 +1,506 @@
+import CryptoKit
+import Foundation
+
+public struct DoryVirtualMachineQualifiedComponent: Codable, Sendable, Equatable, Hashable {
+ public var componentIdentifier: String
+ public var buildIdentifier: String
+ public var artifactSHA256: String
+
+ public init(componentIdentifier: String, buildIdentifier: String, artifactSHA256: String) {
+ self.componentIdentifier = componentIdentifier
+ self.buildIdentifier = buildIdentifier
+ self.artifactSHA256 = artifactSHA256.lowercased()
+ }
+}
+
+/// One signed, exact result from Dory's backend conformance matrix. This is data inside a
+/// catalog-authenticated asset; decoding this type alone never creates trusted capability facts.
+public struct DoryVirtualMachineQualificationRecord: Codable, Sendable, Equatable, Hashable {
+ public var qualificationIdentity: String
+ public var guest: DoryGuestPlatform
+ public var bootMediaKind: DoryBootMediaKind
+ public var bootMediaSource: DoryBootMediaSource
+ public var immutableArtifactSHA256: String?
+ public var mutableProvenance: DoryMutableBootMediaProvenanceReference?
+ public var backend: DoryVirtualizationBackendIdentity
+ public var backendImplementationIdentifier: String
+ public var backendRuntimeBuildIdentifier: String
+ public var virtualHardwareABIVersion: UInt16
+ public var graphics: DoryGraphicsAccelerationLevel
+ public var devices: DoryVirtualMachineDeviceCapabilityRequest
+ public var hostHardwareModelIdentifier: String
+ public var hostOperatingSystemBuild: String
+ public var components: [DoryVirtualMachineQualifiedComponent]
+ public var virtioGPUKernelAndDeviceSupportQualified: Bool
+ public var venusVulkanGuestRuntimeQualified: Bool
+
+ public init(
+ qualificationIdentity: String,
+ guest: DoryGuestPlatform,
+ bootMediaKind: DoryBootMediaKind,
+ bootMediaSource: DoryBootMediaSource,
+ immutableArtifactSHA256: String? = nil,
+ mutableProvenance: DoryMutableBootMediaProvenanceReference? = nil,
+ backend: DoryVirtualizationBackendIdentity,
+ backendImplementationIdentifier: String,
+ backendRuntimeBuildIdentifier: String,
+ virtualHardwareABIVersion: UInt16,
+ graphics: DoryGraphicsAccelerationLevel,
+ devices: DoryVirtualMachineDeviceCapabilityRequest,
+ hostHardwareModelIdentifier: String,
+ hostOperatingSystemBuild: String,
+ components: [DoryVirtualMachineQualifiedComponent],
+ virtioGPUKernelAndDeviceSupportQualified: Bool = false,
+ venusVulkanGuestRuntimeQualified: Bool = false
+ ) {
+ self.qualificationIdentity = qualificationIdentity
+ self.guest = guest
+ self.bootMediaKind = bootMediaKind
+ self.bootMediaSource = bootMediaSource
+ self.immutableArtifactSHA256 = immutableArtifactSHA256?.lowercased()
+ self.mutableProvenance = mutableProvenance
+ self.backend = backend
+ self.backendImplementationIdentifier = backendImplementationIdentifier
+ self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.graphics = graphics
+ self.devices = devices
+ self.hostHardwareModelIdentifier = hostHardwareModelIdentifier
+ self.hostOperatingSystemBuild = hostOperatingSystemBuild
+ self.components = components.sorted { $0.componentIdentifier < $1.componentIdentifier }
+ self.virtioGPUKernelAndDeviceSupportQualified =
+ virtioGPUKernelAndDeviceSupportQualified
+ self.venusVulkanGuestRuntimeQualified = venusVulkanGuestRuntimeQualified
+ }
+}
+
+public struct DoryVirtualMachineQualificationManifest: Codable, Sendable, Equatable {
+ public static let kind = "dev.dory.virtual-machine-qualification-manifest"
+ public static let schemaVersion: UInt16 = 1
+
+ public var kind: String
+ public var schemaVersion: UInt16
+ public var manifestIdentity: String
+ public var catalogReleaseVersion: String
+ public var architecture: String
+ public var signingKeyID: String
+ public var records: [DoryVirtualMachineQualificationRecord]
+
+ public init(
+ manifestIdentity: String,
+ catalogReleaseVersion: String,
+ architecture: String,
+ signingKeyID: String,
+ records: [DoryVirtualMachineQualificationRecord]
+ ) {
+ kind = Self.kind
+ schemaVersion = Self.schemaVersion
+ self.manifestIdentity = manifestIdentity
+ self.catalogReleaseVersion = catalogReleaseVersion
+ self.architecture = architecture
+ self.signingKeyID = signingKeyID
+ self.records = records
+ }
+}
+
+public enum DoryVirtualMachineQualificationAuthorityError:
+ Error, Sendable, Equatable, CustomStringConvertible
+{
+ case catalogUnavailable
+ case catalogSchemaUnsupported(Int)
+ case authorityUndeclared
+ case declarationInvalid
+ case installedComponentUnavailable
+ case installedComponentCatalogMismatch
+ case manifestUnreadable
+ case manifestInvalid(String)
+ case qualificationUnavailable
+ case mediaInspectionFailed
+
+ public var description: String {
+ switch self {
+ case .catalogUnavailable: "no signature-verified component catalog is cached"
+ case let .catalogSchemaUnsupported(version):
+ "component catalog schema \(version) cannot authorize VM qualifications"
+ case .authorityUndeclared: "component catalog does not declare a VM qualification asset"
+ case .declarationInvalid: "VM qualification declaration is invalid"
+ case .installedComponentUnavailable: "qualification component is not installed and verified"
+ case .installedComponentCatalogMismatch:
+ "installed qualification component does not belong to the verified catalog"
+ case .manifestUnreadable: "qualification manifest asset cannot be read and verified"
+ case let .manifestInvalid(detail): "qualification manifest is invalid: \(detail)"
+ case .qualificationUnavailable: "no exact signed VM qualification matches the request"
+ case .mediaInspectionFailed: "boot media does not match its signed qualification"
+ }
+ }
+}
+
+/// Non-Codable authority returned only after the entire catalog -> installed generation -> asset
+/// digest chain has been checked. It is safe to pass inside the daemon, never through API intent.
+public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable {
+ public let catalogDigest: String
+ public let catalogReleaseVersion: String
+ public let componentIdentifier: String
+ public let componentVersion: String
+ public let manifestSHA256: String
+ public let manifestIdentity: String
+ public let signingKeyID: String
+
+ fileprivate let manifest: DoryVirtualMachineQualificationManifest
+
+ fileprivate init(
+ catalogDigest: String,
+ catalogReleaseVersion: String,
+ componentIdentifier: String,
+ componentVersion: String,
+ manifestSHA256: String,
+ manifestIdentity: String,
+ signingKeyID: String,
+ manifest: DoryVirtualMachineQualificationManifest
+ ) {
+ self.catalogDigest = catalogDigest
+ self.catalogReleaseVersion = catalogReleaseVersion
+ self.componentIdentifier = componentIdentifier
+ self.componentVersion = componentVersion
+ self.manifestSHA256 = manifestSHA256
+ self.manifestIdentity = manifestIdentity
+ self.signingKeyID = signingKeyID
+ self.manifest = manifest
+ }
+
+ public func resolve(
+ request: DoryVirtualMachineCapabilityRequest,
+ backendImplementationIdentifier: String,
+ backendRuntimeBuildIdentifier: String,
+ hostHardwareModelIdentifier: String,
+ hostOperatingSystemBuild: String,
+ installedComponents: [DoryVirtualMachineQualifiedComponent]
+ ) throws -> DoryResolvedTrustedVirtualMachineQualification {
+ let components = installedComponents.sorted {
+ $0.componentIdentifier < $1.componentIdentifier
+ }
+ let matches = manifest.records.filter {
+ $0.guest == request.guest
+ && $0.bootMediaKind == request.bootMedia.kind
+ && $0.bootMediaSource == request.bootMedia.source
+ && $0.immutableArtifactSHA256?.lowercased()
+ == request.bootMedia.artifactSHA256?.lowercased()
+ && $0.mutableProvenance == request.bootMedia.mutableProvenance
+ && $0.backend == request.backend
+ && $0.backendImplementationIdentifier == backendImplementationIdentifier
+ && $0.backendRuntimeBuildIdentifier == backendRuntimeBuildIdentifier
+ && $0.virtualHardwareABIVersion == request.virtualHardwareABIVersion
+ && $0.graphics == request.graphics
+ && $0.devices == request.devices
+ && $0.hostHardwareModelIdentifier == hostHardwareModelIdentifier
+ && $0.hostOperatingSystemBuild == hostOperatingSystemBuild
+ && $0.components == components
+ }
+ guard matches.count == 1, let record = matches.first else {
+ throw DoryVirtualMachineQualificationAuthorityError.qualificationUnavailable
+ }
+
+ let evidence = DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: record.qualificationIdentity,
+ qualificationReportSHA256: Self.digest(Self.canonicalData(record)),
+ signingKeyID: signingKeyID,
+ qualificationFormatVersion: manifest.schemaVersion,
+ guest: record.guest,
+ bootMediaKind: record.bootMediaKind,
+ immutableArtifactSHA256: record.immutableArtifactSHA256,
+ mutableProvenance: record.mutableProvenance,
+ backend: record.backend,
+ backendRuntimeBuildID: record.backendRuntimeBuildIdentifier,
+ virtualHardwareABIVersion: record.virtualHardwareABIVersion,
+ graphics: record.graphics,
+ devices: record.devices
+ )
+ let runtime = DoryTrustedVirtualMachineRuntimeQualification(
+ auditEvidence: evidence,
+ runtimeQualified: true
+ )
+ let graphics: DoryTrustedGuestImageGraphicsQualification?
+ if record.guest.family == .linux,
+ record.backend == .doryHypervisor,
+ record.graphics != .none {
+ graphics = DoryTrustedGuestImageGraphicsQualification(
+ auditEvidence: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: manifestIdentity,
+ artifactSHA256: request.bootMedia.artifactSHA256 ?? "",
+ manifestSHA256: manifestSHA256,
+ signingKeyID: signingKeyID,
+ manifestFormatVersion: manifest.schemaVersion
+ ),
+ virtioGPUKernelAndDeviceSupportQualified:
+ record.virtioGPUKernelAndDeviceSupportQualified,
+ venusVulkanGuestRuntimeQualified:
+ record.venusVulkanGuestRuntimeQualified
+ )
+ } else {
+ graphics = nil
+ }
+ return DoryResolvedTrustedVirtualMachineQualification(
+ record: record,
+ runtime: runtime,
+ graphics: graphics,
+ manifestIdentity: manifestIdentity,
+ manifestSHA256: manifestSHA256,
+ signingKeyID: signingKeyID
+ )
+ }
+
+ private static func canonicalData(_ value: T) -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return (try? encoder.encode(value)) ?? Data()
+ }
+
+ private static func digest(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+public struct DoryResolvedTrustedVirtualMachineQualification: Sendable {
+ public let record: DoryVirtualMachineQualificationRecord
+ public let runtime: DoryTrustedVirtualMachineRuntimeQualification
+ public let graphics: DoryTrustedGuestImageGraphicsQualification?
+ fileprivate let manifestIdentity: String
+ fileprivate let manifestSHA256: String
+ fileprivate let signingKeyID: String
+}
+
+public enum DoryVirtualMachineQualificationAuthorityResolver {
+ public static let maximumManifestBytes = 4 * 1_024 * 1_024
+
+ public static func resolve(
+ store: DoryComponentStore,
+ publicKey: String,
+ expectedArchitecture: String,
+ appVersion: String
+ ) throws -> DoryVerifiedVirtualMachineQualificationAuthority {
+ guard let cached = try store.cachedCatalog(
+ publicKey: publicKey,
+ expectedArchitecture: expectedArchitecture,
+ appVersion: appVersion
+ ) else {
+ throw DoryVirtualMachineQualificationAuthorityError.catalogUnavailable
+ }
+ guard cached.catalog.schemaVersion == DoryComponentCatalog.schemaVersion else {
+ throw DoryVirtualMachineQualificationAuthorityError.catalogSchemaUnsupported(
+ cached.catalog.schemaVersion
+ )
+ }
+ guard let declaration = cached.catalog.virtualMachineQualification else {
+ throw DoryVirtualMachineQualificationAuthorityError.authorityUndeclared
+ }
+ guard let release = cached.catalog.component(declaration.component),
+ let declaredAsset = release.assets.first(where: {
+ $0.path == declaration.path
+ }) else {
+ throw DoryVirtualMachineQualificationAuthorityError.declarationInvalid
+ }
+ let installed: DoryInstalledComponent
+ do {
+ installed = try store.verify(declaration.component)
+ } catch {
+ throw DoryVirtualMachineQualificationAuthorityError.installedComponentUnavailable
+ }
+ let catalogDigest = DoryComponentCatalogVerifier.digest(cached.data)
+ guard installed.catalogDigest == catalogDigest,
+ installed.version == release.version,
+ installed.assets == release.assets,
+ installed.assets.contains(declaredAsset) else {
+ throw DoryVirtualMachineQualificationAuthorityError
+ .installedComponentCatalogMismatch
+ }
+ let data: Data
+ do {
+ data = try store.verifiedAssetData(
+ component: declaration.component,
+ path: declaration.path,
+ maximumBytes: maximumManifestBytes
+ )
+ } catch {
+ throw DoryVirtualMachineQualificationAuthorityError.manifestUnreadable
+ }
+ let manifest: DoryVirtualMachineQualificationManifest
+ do {
+ manifest = try JSONDecoder().decode(
+ DoryVirtualMachineQualificationManifest.self,
+ from: data
+ )
+ } catch {
+ throw DoryVirtualMachineQualificationAuthorityError.manifestInvalid(
+ "JSON could not be decoded"
+ )
+ }
+ try validate(
+ manifest,
+ declaration: declaration,
+ catalog: cached.catalog,
+ manifestSHA256: declaredAsset.installedSHA256,
+ expectedSigningKeyID: signingKeyID(publicKey)
+ )
+ return DoryVerifiedVirtualMachineQualificationAuthority(
+ catalogDigest: catalogDigest,
+ catalogReleaseVersion: cached.catalog.releaseVersion,
+ componentIdentifier: installed.id.rawValue,
+ componentVersion: installed.version,
+ manifestSHA256: declaredAsset.installedSHA256,
+ manifestIdentity: manifest.manifestIdentity,
+ signingKeyID: manifest.signingKeyID,
+ manifest: manifest
+ )
+ }
+
+ private static func validate(
+ _ manifest: DoryVirtualMachineQualificationManifest,
+ declaration: DoryComponentVirtualMachineQualificationAsset,
+ catalog: DoryComponentCatalog,
+ manifestSHA256: String,
+ expectedSigningKeyID: String
+ ) throws {
+ guard manifest.kind == DoryVirtualMachineQualificationManifest.kind,
+ manifest.schemaVersion == DoryVirtualMachineQualificationManifest.schemaVersion,
+ declaration.manifestFormatVersion == manifest.schemaVersion,
+ declaration.manifestIdentity == manifest.manifestIdentity,
+ declaration.signingKeyID == manifest.signingKeyID,
+ manifest.signingKeyID == expectedSigningKeyID,
+ manifest.catalogReleaseVersion == catalog.releaseVersion,
+ manifest.architecture == catalog.architecture,
+ !manifest.records.isEmpty,
+ isSHA256(manifestSHA256),
+ Set(manifest.records.map(\.qualificationIdentity)).count
+ == manifest.records.count,
+ manifest.records.allSatisfy(recordIsValid) else {
+ throw DoryVirtualMachineQualificationAuthorityError.manifestInvalid(
+ "header, key, catalog, or record binding is incomplete"
+ )
+ }
+ }
+
+ private static func recordIsValid(_ record: DoryVirtualMachineQualificationRecord) -> Bool {
+ let mediaBindingCount = (record.immutableArtifactSHA256 == nil ? 0 : 1)
+ + (record.mutableProvenance == nil ? 0 : 1)
+ return !record.qualificationIdentity.isEmpty
+ && mediaBindingCount == 1
+ && (record.immutableArtifactSHA256.map(isSHA256) ?? true)
+ && record.mutableProvenance.map {
+ !$0.repositoryIdentity.isEmpty && !$0.mediaIdentity.isEmpty && $0.revision > 0
+ } ?? true
+ && !record.backendImplementationIdentifier.isEmpty
+ && !record.backendRuntimeBuildIdentifier.isEmpty
+ && record.virtualHardwareABIVersion > 0
+ && !record.hostHardwareModelIdentifier.isEmpty
+ && !record.hostOperatingSystemBuild.isEmpty
+ && !record.components.isEmpty
+ && Set(record.components.map(\.componentIdentifier)).count
+ == record.components.count
+ && record.components.allSatisfy {
+ !$0.componentIdentifier.isEmpty
+ && !$0.buildIdentifier.isEmpty
+ && isSHA256($0.artifactSHA256)
+ }
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy { byte in
+ (48...57).contains(byte) || (65...70).contains(byte)
+ || (97...102).contains(byte)
+ }
+ }
+
+ private static func signingKeyID(_ publicKeyBase64: String) -> String {
+ guard let bytes = Data(base64Encoded: publicKeyBase64) else { return "" }
+ return SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+public enum DoryQualifiedBootMediaInspector {
+ public static let inspectorID = "dory.iso9660-efi-inspector"
+ public static let inspectorVersion: UInt16 = 1
+
+ /// Structural inspection is bound to a catalog-authenticated exact qualification. This avoids
+ /// treating a caller-declared guest family as a fact about arbitrary ISO bytes.
+ public static func inspectInstallerISO(
+ atPath path: String,
+ qualification: DoryResolvedTrustedVirtualMachineQualification
+ ) throws -> (media: DoryBootMedia, inspection: DoryTrustedBootMediaInspection) {
+ let record = qualification.record
+ guard record.bootMediaKind == .installerISO,
+ let expectedDigest = record.immutableArtifactSHA256 else {
+ throw DoryVirtualMachineQualificationAuthorityError.mediaInspectionFailed
+ }
+ let identity: DoryInstallerISOMediaIdentity
+ do { identity = try DoryInstallerISOInspector.mediaIdentity(atPath: path) }
+ catch {
+ throw DoryVirtualMachineQualificationAuthorityError.mediaInspectionFailed
+ }
+ let architecture: DoryGuestArchitecture
+ switch identity.architecture {
+ case .arm64: architecture = .arm64
+ case .x86_64: architecture = .x86_64
+ case .multiArchitecture: architecture = record.guest.architecture
+ case .unknown:
+ throw DoryVirtualMachineQualificationAuthorityError.mediaInspectionFailed
+ }
+ guard identity.sha256 == expectedDigest.lowercased(),
+ architecture == record.guest.architecture else {
+ throw DoryVirtualMachineQualificationAuthorityError.mediaInspectionFailed
+ }
+ let report = ISOInspectionReport(
+ artifactSHA256: identity.sha256,
+ byteCount: identity.byteCount,
+ architecture: identity.architecture.rawValue,
+ guest: record.guest,
+ efiBootable: true
+ )
+ let evidence = DoryBootMediaInspectionAuditEvidence(
+ inspectionIdentity: "\(inspectorID):\(identity.sha256)",
+ artifactSHA256: identity.sha256,
+ inspectionReportSHA256: digest(canonicalData(report)),
+ inspectorID: inspectorID,
+ inspectorVersion: inspectorVersion,
+ catalogManifestEvidence: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: qualification.manifestIdentity,
+ artifactSHA256: identity.sha256,
+ manifestSHA256: qualification.manifestSHA256,
+ signingKeyID: qualification.signingKeyID,
+ manifestFormatVersion:
+ DoryVirtualMachineQualificationManifest.schemaVersion
+ )
+ )
+ return (
+ DoryBootMedia(
+ kind: .installerISO,
+ source: record.bootMediaSource,
+ artifactSHA256: identity.sha256
+ ),
+ DoryTrustedBootMediaInspection(
+ auditEvidence: evidence,
+ detectedKind: .installerISO,
+ detectedGuestFamily: record.guest.family,
+ detectedArchitecture: architecture,
+ isEFIBootable: true
+ )
+ )
+ }
+
+ private struct ISOInspectionReport: Codable {
+ var artifactSHA256: String
+ var byteCount: UInt64
+ var architecture: String
+ var guest: DoryGuestPlatform
+ var efiBootable: Bool
+ }
+
+ private static func canonicalData(_ value: T) -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return (try? encoder.encode(value)) ?? Data()
+ }
+
+ private static func digest(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift
new file mode 100644
index 00000000..71bcf487
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift
@@ -0,0 +1,338 @@
+@testable import DoryOperations
+import CryptoKit
+import Foundation
+import XCTest
+
+final class DoryVirtualMachineQualificationManifestTests: XCTestCase {
+ func testSignedCatalogInstalledManifestMintsOnlyExactTrustedQualification() throws {
+ let fixture = try QualificationFixture()
+ defer { fixture.cleanup() }
+ let installed = try fixture.installAuthority()
+ let authority = try fixture.resolveAuthority()
+
+ XCTAssertEqual(authority.catalogDigest, installed.catalogDigest)
+ XCTAssertEqual(authority.manifestIdentity, fixture.manifest.manifestIdentity)
+ let resolved = try authority.resolve(
+ request: fixture.request,
+ backendImplementationIdentifier:
+ RawBackendContract.implementationIdentifier,
+ backendRuntimeBuildIdentifier: fixture.runtimeBuild,
+ hostHardwareModelIdentifier: fixture.hostModel,
+ hostOperatingSystemBuild: fixture.hostBuild,
+ installedComponents: fixture.runtimeComponents
+ )
+ let descriptor = DoryAppleSiliconCapabilityEvaluator.evaluate(
+ fixture.request,
+ host: fixture.hostFacts,
+ trustedGuestImageGraphicsQualification: resolved.graphics,
+ trustedRuntimeQualification: resolved.runtime
+ )
+ XCTAssertEqual(descriptor.availability.supportTier, .supported)
+ XCTAssertEqual(descriptor.availability.state, .available)
+ XCTAssertEqual(
+ descriptor.runtimeQualificationEvidence?.qualificationIdentity,
+ fixture.record.qualificationIdentity
+ )
+ }
+
+ func testCatalogV1CannotBecomeQualificationAuthority() throws {
+ let fixture = try QualificationFixture(catalogSchemaVersion: 1, declaresAuthority: false)
+ defer { fixture.cleanup() }
+ try fixture.installAuthority()
+
+ XCTAssertThrowsError(try fixture.resolveAuthority()) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineQualificationAuthorityError,
+ .catalogSchemaUnsupported(1)
+ )
+ }
+ }
+
+ func testMissingDeclarationFailsClosed() throws {
+ let fixture = try QualificationFixture(declaresAuthority: false)
+ defer { fixture.cleanup() }
+ try fixture.installAuthority()
+
+ XCTAssertThrowsError(try fixture.resolveAuthority()) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineQualificationAuthorityError,
+ .authorityUndeclared
+ )
+ }
+ }
+
+ func testTamperedCatalogSignatureAndManifestFailClosed() throws {
+ do {
+ let fixture = try QualificationFixture()
+ defer { fixture.cleanup() }
+ try fixture.installAuthority()
+ let signaturePath = fixture.store.root + "/catalog.sig"
+ try Data("not-a-signature".utf8).write(to: URL(fileURLWithPath: signaturePath))
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: signaturePath
+ )
+ XCTAssertThrowsError(try fixture.resolveAuthority()) { error in
+ XCTAssertEqual(error as? DoryComponentError, .invalidSignature)
+ }
+ }
+
+ do {
+ let fixture = try QualificationFixture()
+ defer { fixture.cleanup() }
+ let installed = try fixture.installAuthority()
+ let path = try XCTUnwrap(fixture.store.assetPath(
+ component: .linuxMachines,
+ path: fixture.manifestPath
+ ))
+ var data = try Data(contentsOf: URL(fileURLWithPath: path))
+ data.append(0x20)
+ try data.write(to: URL(fileURLWithPath: path))
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ XCTAssertFalse(installed.assets.isEmpty)
+ XCTAssertThrowsError(try fixture.resolveAuthority()) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineQualificationAuthorityError,
+ .installedComponentUnavailable
+ )
+ }
+ }
+ }
+
+ func testStaleRuntimeBuildAndComponentDigestCannotMintTrust() throws {
+ let fixture = try QualificationFixture()
+ defer { fixture.cleanup() }
+ try fixture.installAuthority()
+ let authority = try fixture.resolveAuthority()
+
+ XCTAssertThrowsError(try authority.resolve(
+ request: fixture.request,
+ backendImplementationIdentifier: RawBackendContract.implementationIdentifier,
+ backendRuntimeBuildIdentifier: "sha256:stale",
+ hostHardwareModelIdentifier: fixture.hostModel,
+ hostOperatingSystemBuild: fixture.hostBuild,
+ installedComponents: fixture.runtimeComponents
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineQualificationAuthorityError,
+ .qualificationUnavailable
+ )
+ }
+
+ var components = fixture.runtimeComponents
+ components[0].artifactSHA256 = String(repeating: "f", count: 64)
+ XCTAssertThrowsError(try authority.resolve(
+ request: fixture.request,
+ backendImplementationIdentifier: RawBackendContract.implementationIdentifier,
+ backendRuntimeBuildIdentifier: fixture.runtimeBuild,
+ hostHardwareModelIdentifier: fixture.hostModel,
+ hostOperatingSystemBuild: fixture.hostBuild,
+ installedComponents: components
+ ))
+ }
+}
+
+private enum RawBackendContract {
+ static let implementationIdentifier = "dory.raw-hv-linux.compatibility.v1"
+}
+
+private final class QualificationFixture {
+ let root: URL
+ let store: DoryComponentStore
+ let privateKey = Curve25519.Signing.PrivateKey()
+ let appVersion = "1.0.0"
+ let manifestPath = "vm-qualifications.json"
+ let hostModel = "Mac15,3"
+ let hostBuild = "26A5406c"
+ let mediaDigest = String(repeating: "a", count: 64)
+ let helperDigest = String(repeating: "b", count: 64)
+ let runtimeBuild: String
+ let runtimeComponents: [DoryVirtualMachineQualifiedComponent]
+ let request: DoryVirtualMachineCapabilityRequest
+ let record: DoryVirtualMachineQualificationRecord
+ let manifest: DoryVirtualMachineQualificationManifest
+ let catalog: DoryComponentCatalog
+ let catalogData: Data
+ let catalogSignature: String
+ let manifestData: Data
+
+ var hostFacts: DoryAppleSiliconHostFacts {
+ DoryAppleSiliconHostFacts(
+ macOSMajorVersion: 26,
+ virtualizationFrameworkAvailable: true,
+ hypervisorFrameworkAvailable: true,
+ doryHypervisorAvailable: true,
+ qemuHypervisorFrameworkAvailable: false,
+ windowsUEFIFirmwareAvailable: false,
+ windowsSecureBootAvailable: false,
+ windowsSBSADeviceModelAvailable: false,
+ virtualTPM20Available: false,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: false,
+ networkAvailable: false,
+ displayAvailable: false,
+ inputAvailable: false
+ ),
+ macOSGuestVirtualizationSupported: false,
+ macOSRestoreImageInstallationSupported: false,
+ doryMacOSBackendAvailable: false,
+ doryMacOSBackendQualified: false,
+ metalAvailable: true,
+ doryAcceleratedRendererAvailable: true,
+ runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: runtimeBuild,
+ virtualizationFrameworkAdapterBuildID: "",
+ qemuRuntimeBuildID: ""
+ )
+ )
+ }
+
+ init(catalogSchemaVersion: Int = 2, declaresAuthority: Bool = true) throws {
+ root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-vm-qualification-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let drive = try DoryDataDrive(home: root.path)
+ try drive.prepare()
+ store = DoryComponentStore(drive: drive)
+ try store.prepare()
+
+ runtimeBuild = "sha256:\(helperDigest)"
+ runtimeComponents = [DoryVirtualMachineQualifiedComponent(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: runtimeBuild,
+ artifactSHA256: helperDigest
+ )]
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ request = DoryVirtualMachineCapabilityRequest(
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ bootMedia: DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifactSHA256: mediaDigest
+ ),
+ backend: .doryHypervisor,
+ graphics: .none,
+ devices: devices
+ )
+ record = DoryVirtualMachineQualificationRecord(
+ qualificationIdentity: "raw-linux-arm64-none-v1",
+ guest: request.guest,
+ bootMediaKind: request.bootMedia.kind,
+ bootMediaSource: request.bootMedia.source,
+ immutableArtifactSHA256: mediaDigest,
+ backend: request.backend,
+ backendImplementationIdentifier: RawBackendContract.implementationIdentifier,
+ backendRuntimeBuildIdentifier: runtimeBuild,
+ virtualHardwareABIVersion: request.virtualHardwareABIVersion,
+ graphics: request.graphics,
+ devices: devices,
+ hostHardwareModelIdentifier: hostModel,
+ hostOperatingSystemBuild: hostBuild,
+ components: runtimeComponents
+ )
+ let signingKeyID = Self.digest(privateKey.publicKey.rawRepresentation)
+ manifest = DoryVirtualMachineQualificationManifest(
+ manifestIdentity: "dory-vm-qualifications-2026.8",
+ catalogReleaseVersion: "1.0.0",
+ architecture: "arm64",
+ signingKeyID: signingKeyID,
+ records: [record]
+ )
+ manifestData = try Self.encoded(manifest)
+ let asset = DoryComponentAsset(
+ path: manifestPath,
+ url: "https://example.invalid/\(manifestPath)",
+ downloadBytes: UInt64(manifestData.count),
+ installedBytes: UInt64(manifestData.count),
+ sha256: Self.digest(manifestData),
+ installedSHA256: Self.digest(manifestData)
+ )
+ let core = DoryComponentRelease(
+ id: .dockerCore,
+ version: "1.0.0",
+ displayName: "Docker Core",
+ summary: "Core",
+ dependencies: [],
+ downloadBytes: 1,
+ installedBytes: 1,
+ assets: []
+ )
+ let machines = DoryComponentRelease(
+ id: .linuxMachines,
+ version: "1.0.0",
+ displayName: "Linux Machines",
+ summary: "Qualified Linux runtimes",
+ dependencies: [.dockerCore],
+ downloadBytes: UInt64(manifestData.count),
+ installedBytes: UInt64(manifestData.count),
+ assets: [asset]
+ )
+ catalog = DoryComponentCatalog(
+ schemaVersion: catalogSchemaVersion,
+ releaseVersion: "1.0.0",
+ generatedAt: "2026-08-20T12:00:00.000Z",
+ minimumAppVersion: appVersion,
+ architecture: "arm64",
+ components: [core, machines],
+ virtualMachineQualification: declaresAuthority
+ ? DoryComponentVirtualMachineQualificationAsset(
+ component: .linuxMachines,
+ path: manifestPath,
+ manifestIdentity: manifest.manifestIdentity,
+ manifestFormatVersion: manifest.schemaVersion,
+ signingKeyID: signingKeyID
+ ) : nil
+ )
+ catalogData = try Self.encoded(catalog)
+ catalogSignature = try privateKey.signature(for: catalogData).base64EncodedString()
+ }
+
+ @discardableResult
+ func installAuthority() throws -> DoryInstalledComponent {
+ _ = try store.cacheCatalog(
+ data: catalogData,
+ signature: catalogSignature,
+ publicKey: privateKey.publicKey.rawRepresentation.base64EncodedString(),
+ expectedArchitecture: "arm64",
+ appVersion: appVersion
+ )
+ let source = root.appendingPathComponent("qualification-source.json")
+ try manifestData.write(to: source)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: source.path
+ )
+ return try store.install(
+ try XCTUnwrap(catalog.component(.linuxMachines)),
+ catalogDigest: DoryComponentCatalogVerifier.digest(catalogData),
+ downloadedAssets: [manifestPath: source.path]
+ )
+ }
+
+ func resolveAuthority() throws -> DoryVerifiedVirtualMachineQualificationAuthority {
+ try DoryVirtualMachineQualificationAuthorityResolver.resolve(
+ store: store,
+ publicKey: privateKey.publicKey.rawRepresentation.base64EncodedString(),
+ expectedArchitecture: "arm64",
+ appVersion: appVersion
+ )
+ }
+
+ func cleanup() { try? FileManager.default.removeItem(at: root) }
+
+ private static func encoded(_ value: T) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ return try encoder.encode(value) + Data("\n".utf8)
+ }
+
+ private static func digest(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
From c5501d828288bb1e69b85c9776c51c9f98066b89 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 13:30:26 +0000
Subject: [PATCH 019/338] feat(vm): expose immutable runtime identity
---
Dory/Models/AppStore.swift | 5 +-
Dory/Models/Models.swift | 1 +
Dory/Runtime/Doryd/DorydClient.swift | 318 +++++++++++++-
Dory/Runtime/Machines/MachineSnapshot.swift | 8 +-
DoryTests/DorydClientTests.swift | 188 +++++++-
.../DorydKit/DoryMachineRuntimeIdentity.swift | 324 ++++++++++++++
.../Sources/DorydKit/DorydService.swift | 110 ++++-
.../Sources/DorydKit/MachineManager.swift | 406 +++++++++++++++++-
.../DoryMachineRuntimeIdentityTests.swift | 108 +++++
.../DorydKitTests/DorydServiceTests.swift | 12 +
...eManagerResolvedPlanIntegrationTests.swift | 147 +++++++
11 files changed, 1604 insertions(+), 23 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityTests.swift
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index cff78697..68bbdba5 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5184,6 +5184,7 @@ final class AppStore {
displayMode: status.displayMode,
bootMode: status.bootMode,
installerMediaAttached: status.installerMediaAttached,
+ runtimeIdentity: status.runtimeIdentity,
mounts: status.shares.map(Self.mountPair(fromDoryd:))
)
}
@@ -6004,7 +6005,9 @@ final class AppStore {
version: "disk",
arch: snapshot.architecture,
boot: "vz",
- recipe: "doryd"
+ recipe: "doryd",
+ runtimeIdentity: snapshot.runtimeIdentity,
+ artifactEvidence: snapshot.artifactEvidence
)
}
diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift
index 0ea45800..07022f82 100644
--- a/Dory/Models/Models.swift
+++ b/Dory/Models/Models.swift
@@ -301,6 +301,7 @@ struct Machine: Identifiable, Hashable, Sendable {
var displayMode: MachineDisplayMode = .headless
var bootMode: MachineBootMode = .linuxKernel
var installerMediaAttached: Bool = false
+ var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
var mounts: [MountPair] = []
var id: String { name }
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index 0c7d4f3c..243bcf45 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -131,6 +131,265 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable {
}
}
+nonisolated struct DorydMachineRuntimeComponentIdentity: Codable, Sendable, Equatable, Hashable {
+ var componentIdentifier: String
+ var buildIdentifier: String
+ var artifactSHA256: String
+}
+
+nonisolated struct DorydMachineRuntimeBootMediaIdentity: Codable, Sendable, Equatable, Hashable {
+ var kind: String
+ var source: String
+ var artifactSHA256: String? = nil
+ var resolverNamespace: String? = nil
+ var resolverIdentifier: String? = nil
+ var inspectionIdentity: String? = nil
+ var inspectionReportSHA256: String? = nil
+ var provenanceReceiptIdentity: String? = nil
+ var provenanceReceiptSHA256: String? = nil
+ var provenanceRevision: UInt64? = nil
+}
+
+nonisolated struct DorydMachineRuntimeQualificationReference: Codable, Sendable, Equatable, Hashable {
+ var manifestIdentity: String? = nil
+ var artifactSHA256: String? = nil
+ var manifestSHA256: String? = nil
+ var qualificationIdentity: String? = nil
+ var qualificationReportSHA256: String? = nil
+ var signingKeyID: String? = nil
+ var qualifierIdentifier: String? = nil
+
+ var isValidGraphicsReference: Bool {
+ manifestIdentity?.isSafeEvidenceIdentifier == true
+ && artifactSHA256?.isLowercaseSHA256 == true
+ && manifestSHA256?.isLowercaseSHA256 == true
+ && signingKeyID?.isSafeEvidenceIdentifier == true
+ && qualificationIdentity == nil
+ && qualificationReportSHA256 == nil
+ && qualifierIdentifier == nil
+ }
+
+ var isValidRuntimeReference: Bool {
+ qualificationIdentity?.isSafeEvidenceIdentifier == true
+ && qualificationReportSHA256?.isLowercaseSHA256 == true
+ && signingKeyID?.isSafeEvidenceIdentifier == true
+ && manifestIdentity == nil
+ && artifactSHA256 == nil
+ && manifestSHA256 == nil
+ && qualifierIdentifier == nil
+ }
+
+ var isValidHostReference: Bool {
+ qualificationIdentity?.isSafeEvidenceIdentifier == true
+ && qualificationReportSHA256?.isLowercaseSHA256 == true
+ && qualifierIdentifier?.isSafeEvidenceIdentifier == true
+ && manifestIdentity == nil
+ && artifactSHA256 == nil
+ && manifestSHA256 == nil
+ && signingKeyID == nil
+ }
+}
+
+nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Hashable {
+ static let currentSchemaVersion: UInt16 = 1
+ var schemaVersion: UInt16
+ var mode: String
+ var virtualHardwareABIVersion: UInt16
+ var invalidationReason: String? = nil
+ var definitionRevision: UInt64? = nil
+ var definitionSHA256: String? = nil
+ var planRevision: UInt64? = nil
+ var planSHA256: String? = nil
+ var backend: String? = nil
+ var backendImplementationIdentifier: String? = nil
+ var backendRuntimeBuildIdentifier: String? = nil
+ var supportTier: String? = nil
+ var selectionDisposition: String? = nil
+ var fallbackAuthorizationIdentity: String? = nil
+ var experimentalAuthorizationIdentity: String? = nil
+ var graphicsQualification: DorydMachineRuntimeQualificationReference? = nil
+ var runtimeQualification: DorydMachineRuntimeQualificationReference? = nil
+ var hostQualification: DorydMachineRuntimeQualificationReference? = nil
+ var components: [DorydMachineRuntimeComponentIdentity]? = nil
+ var bootMedia: DorydMachineRuntimeBootMediaIdentity? = nil
+
+ static let legacyCompatibility = Self(
+ schemaVersion: currentSchemaVersion,
+ mode: "legacy-compatibility",
+ virtualHardwareABIVersion: 1
+ )
+
+ var isValid: Bool {
+ guard schemaVersion == Self.currentSchemaVersion,
+ virtualHardwareABIVersion > 0 else {
+ return false
+ }
+ switch mode {
+ case "legacy-compatibility":
+ return invalidationReason == nil && hasNoResolvedEvidence
+ case "requires-replanning":
+ return invalidationReason?.isSafeEvidenceIdentifier == true
+ && hasNoResolvedEvidence
+ case "resolved-plan":
+ return isValidResolvedEvidence
+ default:
+ return false
+ }
+ }
+
+ private var hasNoResolvedEvidence: Bool {
+ definitionRevision == nil
+ && definitionSHA256 == nil
+ && planRevision == nil
+ && planSHA256 == nil
+ && backend == nil
+ && backendImplementationIdentifier == nil
+ && backendRuntimeBuildIdentifier == nil
+ && supportTier == nil
+ && selectionDisposition == nil
+ && fallbackAuthorizationIdentity == nil
+ && experimentalAuthorizationIdentity == nil
+ && graphicsQualification == nil
+ && runtimeQualification == nil
+ && hostQualification == nil
+ && components == nil
+ && bootMedia == nil
+ }
+
+ private var isValidResolvedEvidence: Bool {
+ guard invalidationReason == nil,
+ let definitionRevision, definitionRevision > 0,
+ definitionSHA256?.isLowercaseSHA256 == true,
+ let planRevision, planRevision > 0,
+ planSHA256?.isLowercaseSHA256 == true,
+ backend?.isSafeEvidenceIdentifier == true,
+ backendImplementationIdentifier?.isSafeEvidenceIdentifier == true,
+ backendRuntimeBuildIdentifier?.isSafeEvidenceIdentifier == true,
+ let supportTier,
+ let selectionDisposition,
+ ["primary", "explicit-alternative", "approved-fallback"]
+ .contains(selectionDisposition),
+ let components, !components.isEmpty,
+ Set(components.map(\.componentIdentifier)).count == components.count,
+ components.allSatisfy({ component in
+ component.componentIdentifier.isSafeEvidenceIdentifier
+ && component.buildIdentifier.isSafeEvidenceIdentifier
+ && component.artifactSHA256.isLowercaseSHA256
+ }),
+ let bootMedia, bootMedia.isValid else {
+ return false
+ }
+ if let graphicsQualification, !graphicsQualification.isValidGraphicsReference {
+ return false
+ }
+ guard hostQualification?.isValidHostReference == true else { return false }
+ switch supportTier {
+ case "supported":
+ guard runtimeQualification?.isValidRuntimeReference == true,
+ experimentalAuthorizationIdentity == nil else {
+ return false
+ }
+ case "experimental":
+ guard experimentalAuthorizationIdentity?.isSafeEvidenceIdentifier == true,
+ runtimeQualification.map(\.isValidRuntimeReference) ?? true else {
+ return false
+ }
+ default:
+ return false
+ }
+ switch selectionDisposition {
+ case "approved-fallback":
+ return fallbackAuthorizationIdentity?.isSafeEvidenceIdentifier == true
+ case "primary", "explicit-alternative":
+ return fallbackAuthorizationIdentity == nil
+ default:
+ return false
+ }
+ }
+}
+
+nonisolated private extension String {
+ var isLowercaseSHA256: Bool {
+ utf8.count == 64 && utf8.allSatisfy { byte in
+ (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
+ }
+ }
+
+ var isSafeEvidenceIdentifier: Bool {
+ let bytes = Array(utf8)
+ guard (1...256).contains(bytes.count) else { return false }
+ return bytes.allSatisfy { byte in
+ (byte >= 48 && byte <= 57)
+ || (byte >= 65 && byte <= 90)
+ || (byte >= 97 && byte <= 122)
+ || byte == 45 || byte == 46 || byte == 47
+ || byte == 58 || byte == 64 || byte == 95
+ }
+ }
+}
+
+nonisolated private extension DorydMachineRuntimeBootMediaIdentity {
+ var isValid: Bool {
+ let immutableKinds = [
+ "installer-iso",
+ "installed-linux-boot-bundle",
+ "macos-restore-image",
+ ]
+ guard immutableKinds.contains(kind) || kind == "virtual-disk",
+ ["dory-bundled", "vendor-download", "user-provided"].contains(source) else {
+ return false
+ }
+ let immutable = artifactSHA256?.isLowercaseSHA256 == true
+ let mutable = provenanceReceiptIdentity?.isSafeEvidenceIdentifier == true
+ && provenanceReceiptSHA256?.isLowercaseSHA256 == true
+ && (provenanceRevision ?? 0) > 0
+ let hasAnyMutableField = provenanceReceiptIdentity != nil
+ || provenanceReceiptSHA256 != nil
+ || provenanceRevision != nil
+ guard immutable != mutable,
+ hasAnyMutableField == mutable,
+ (kind == "virtual-disk") == mutable else {
+ return false
+ }
+ guard (resolverNamespace == nil) == (resolverIdentifier == nil),
+ resolverNamespace.map(\.isSafeEvidenceIdentifier) ?? true,
+ resolverIdentifier.map(\.isSafeEvidenceIdentifier) ?? true,
+ (inspectionIdentity == nil) == (inspectionReportSHA256 == nil),
+ inspectionIdentity.map(\.isSafeEvidenceIdentifier) ?? true,
+ inspectionReportSHA256.map(\.isLowercaseSHA256) ?? true else {
+ return false
+ }
+ if kind == "installer-iso" || kind == "macos-restore-image" {
+ guard inspectionIdentity != nil else { return false }
+ }
+ return true
+ }
+}
+
+nonisolated struct DorydMachineSnapshotArtifact: Codable, Sendable, Equatable, Hashable {
+ var byteCount: UInt64
+ var sha256: String
+
+ var isValid: Bool { byteCount > 0 && sha256.isLowercaseSHA256 }
+}
+
+nonisolated struct DorydMachineSnapshotArtifactEvidence: Codable, Sendable, Equatable, Hashable {
+ var schemaVersion: UInt16
+ var rootfs: DorydMachineSnapshotArtifact
+ var kernel: DorydMachineSnapshotArtifact
+ var machineIdentifier: DorydMachineSnapshotArtifact?
+ var nvram: DorydMachineSnapshotArtifact?
+
+ var isValid: Bool {
+ schemaVersion == 1
+ && rootfs.isValid
+ && kernel.isValid
+ && (machineIdentifier?.isValid ?? true)
+ && (nvram?.isValid ?? true)
+ && ((machineIdentifier == nil) == (nvram == nil))
+ }
+}
+
nonisolated struct DorydMachineStatus: Sendable, Equatable {
var id: String
var state: String
@@ -154,6 +413,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable {
var installerMediaAttached: Bool = false
var shares: [DorydMachineShareConfiguration] = []
var environment: [String: String] = [:]
+ var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
}
nonisolated struct DorydMachineExecResult: Sendable, Equatable {
@@ -205,6 +465,8 @@ nonisolated struct DorydMachineSnapshot: Sendable, Equatable {
var architecture: String
var memoryMB: UInt64
var cpuCount: Int
+ var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
+ var artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil
}
nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable {
@@ -1186,7 +1448,8 @@ nonisolated final class DorydClient: @unchecked Sendable {
nonisolated private static func machineStatus(from dictionary: NSDictionary) -> DorydMachineStatus? {
guard let id = dictionary["id"] as? String,
- let state = dictionary["state"] as? String else {
+ let state = dictionary["state"] as? String,
+ let runtimeIdentity = machineRuntimeIdentity(from: dictionary) else {
return nil
}
return DorydMachineStatus(
@@ -1213,10 +1476,26 @@ nonisolated final class DorydClient: @unchecked Sendable {
?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue
?? false,
shares: machineShares(from: dictionary["shares"]),
- environment: machineEnvironment(from: dictionary["env"])
+ environment: machineEnvironment(from: dictionary["env"]),
+ runtimeIdentity: runtimeIdentity
)
}
+ /// Only an absent key is compatible with an older daemon. A present identity is an evidence
+ /// claim and must decode and validate exactly; malformed or future-schema claims fail closed.
+ nonisolated private static func machineRuntimeIdentity(
+ from dictionary: NSDictionary
+ ) -> DorydMachineRuntimeIdentity? {
+ guard let encoded = dictionary["runtimeIdentity"] else {
+ return .legacyCompatibility
+ }
+ guard let identity = decoded(DorydMachineRuntimeIdentity.self, from: encoded),
+ identity.isValid else {
+ return nil
+ }
+ return identity
+ }
+
nonisolated private static func machineShares(from value: Any?) -> [DorydMachineShareConfiguration] {
let rows: [NSDictionary]
if let swiftRows = value as? [NSDictionary] {
@@ -1362,7 +1641,12 @@ nonisolated final class DorydClient: @unchecked Sendable {
let kernelPath = dictionary["kernelPath"] as? String,
let architecture = dictionary["architecture"] as? String,
let memoryMB = uint64(dictionary["memoryMB"]),
- let cpuCount = int(dictionary["cpuCount"]) else {
+ let cpuCount = int(dictionary["cpuCount"]),
+ let runtimeIdentity = machineRuntimeIdentity(from: dictionary),
+ let artifactEvidence = machineSnapshotArtifactEvidence(
+ from: dictionary,
+ runtimeIdentity: runtimeIdentity
+ ) else {
return nil
}
return DorydMachineSnapshot(
@@ -1375,10 +1659,36 @@ nonisolated final class DorydClient: @unchecked Sendable {
kernelPath: kernelPath,
architecture: architecture,
memoryMB: memoryMB,
- cpuCount: cpuCount
+ cpuCount: cpuCount,
+ runtimeIdentity: runtimeIdentity,
+ artifactEvidence: artifactEvidence.value
)
}
+ private struct ParsedMachineSnapshotArtifactEvidence {
+ var value: DorydMachineSnapshotArtifactEvidence?
+ }
+
+ /// Artifact evidence follows the same compatibility rule as runtime identity: absence is
+ /// accepted only for snapshots from a legacy daemon. A present claim must be well formed,
+ /// and every non-legacy identity requires evidence.
+ nonisolated private static func machineSnapshotArtifactEvidence(
+ from dictionary: NSDictionary,
+ runtimeIdentity: DorydMachineRuntimeIdentity
+ ) -> ParsedMachineSnapshotArtifactEvidence? {
+ guard let encoded = dictionary["artifactEvidence"] else {
+ guard runtimeIdentity.mode == "legacy-compatibility" else { return nil }
+ return ParsedMachineSnapshotArtifactEvidence(value: nil)
+ }
+ guard let evidence = decoded(
+ DorydMachineSnapshotArtifactEvidence.self,
+ from: encoded
+ ), evidence.isValid else {
+ return nil
+ }
+ return ParsedMachineSnapshotArtifactEvidence(value: evidence)
+ }
+
nonisolated private static func machineSnapshots(from rows: NSArray) -> [DorydMachineSnapshot]? {
let dictionaries = rows.compactMap { $0 as? NSDictionary }
guard dictionaries.count == rows.count else { return nil }
diff --git a/Dory/Runtime/Machines/MachineSnapshot.swift b/Dory/Runtime/Machines/MachineSnapshot.swift
index 600916c4..aee8238c 100644
--- a/Dory/Runtime/Machines/MachineSnapshot.swift
+++ b/Dory/Runtime/Machines/MachineSnapshot.swift
@@ -16,11 +16,15 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable {
let uid: Int?
let homePath: String?
let loginShell: String
+ let runtimeIdentity: DorydMachineRuntimeIdentity
+ let artifactEvidence: DorydMachineSnapshotArtifactEvidence?
nonisolated init(id: String, imageRef: String, machineName: String, note: String, createdISO: String,
sizeBytes: Int64, distro: String, version: String, arch: String, boot: String,
recipe: String, username: String = "root", uid: Int? = nil, homePath: String? = nil,
- loginShell: String = "/bin/sh") {
+ loginShell: String = "/bin/sh",
+ runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility,
+ artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil) {
self.id = id
self.imageRef = imageRef
self.machineName = machineName
@@ -36,6 +40,8 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable {
self.uid = uid
self.homePath = homePath
self.loginShell = loginShell
+ self.runtimeIdentity = runtimeIdentity
+ self.artifactEvidence = artifactEvidence
}
}
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index cdb4c8ed..560e67c1 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -243,6 +243,7 @@ struct DorydClientTests {
#expect(startedMachine.agentSocketPath == "/tmp/agent.sock")
#expect(startedMachine.address == "192.168.215.40")
#expect(startedMachine.configuredAddress == "192.168.215.40")
+ #expect(startedMachine.runtimeIdentity == .legacyCompatibility)
#expect(startedMachine.shares == [
DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true),
])
@@ -258,6 +259,7 @@ struct DorydClientTests {
#expect(provisionedMachine.verify.stdout == "cargo 1.0\n")
#expect(snapshot.id == "s1")
#expect(snapshot.machineID == "dev")
+ #expect(snapshot.runtimeIdentity == .legacyCompatibility)
#expect(snapshots.map(\.id).contains("s1"))
#expect(clonedSnapshot.id == "dev-copy")
#expect(restoredSnapshot.id == "dev")
@@ -321,6 +323,162 @@ struct DorydClientTests {
])
}
+ @Test func presentInvalidRuntimeIdentityFailsStatusAndSnapshotClosed() async throws {
+ let resolvedWithoutComponentsOrMedia = NSMutableDictionary(
+ dictionary: validResolvedRuntimeIdentity()
+ )
+ resolvedWithoutComponentsOrMedia.removeObject(forKey: "components")
+ resolvedWithoutComponentsOrMedia.removeObject(forKey: "bootMedia")
+ let mixedQualification = NSMutableDictionary(
+ dictionary: validResolvedRuntimeIdentity()
+ )
+ mixedQualification["runtimeQualification"] = [
+ "qualificationIdentity": "runtime-qualification-1",
+ "qualificationReportSHA256": String(repeating: "3", count: 64),
+ "signingKeyID": "dory-runtime-1",
+ "manifestIdentity": "wrong-shape-field",
+ ]
+ let orphanProvenance = NSMutableDictionary(
+ dictionary: validResolvedRuntimeIdentity()
+ )
+ orphanProvenance["bootMedia"] = [
+ "kind": "installed-linux-boot-bundle",
+ "source": "user-provided",
+ "artifactSHA256": String(repeating: "6", count: 64),
+ "provenanceReceiptIdentity": "orphan-receipt",
+ ]
+ for identity in [
+ [
+ "schemaVersion": 2,
+ "mode": "legacy-compatibility",
+ "virtualHardwareABIVersion": 1,
+ ] as NSDictionary,
+ [
+ "schemaVersion": 1,
+ "mode": "legacy-compatibility",
+ "virtualHardwareABIVersion": 1,
+ "components": [[
+ "componentIdentifier": "dory-hv",
+ "buildIdentifier": "runtime-1",
+ "artifactSHA256": String(repeating: "a", count: 64),
+ ]],
+ ] as NSDictionary,
+ resolvedWithoutComponentsOrMedia,
+ mixedQualification,
+ orphanProvenance,
+ ] {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService(runtimeIdentityOverride: identity)
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+ let client = DorydClient(endpoint: listener.endpoint)
+
+ do {
+ _ = try await client.machineList()
+ Issue.record("present invalid status identity must fail closed")
+ } catch let error as DorydClientError {
+ #expect(error.description.contains("invalid machine list"))
+ }
+
+ do {
+ _ = try await client.machineSnapshot(
+ "dev",
+ note: "invalid identity",
+ createdISO: "2026-07-07T00:00:00Z",
+ snapshotID: "invalid-identity"
+ )
+ Issue.record("present invalid snapshot identity must fail closed")
+ } catch let error as DorydClientError {
+ #expect(error.description.contains("invalid doryd response"))
+ }
+ }
+ }
+
+ @Test func snapshotArtifactEvidenceIsAbsentOnlyForLegacyAndOtherwiseExact() async throws {
+ let malformedEvidence = [
+ "schemaVersion": 1,
+ "rootfs": [
+ "byteCount": 0,
+ "sha256": String(repeating: "a", count: 64),
+ ],
+ "kernel": [
+ "byteCount": 1,
+ "sha256": String(repeating: "b", count: 64),
+ ],
+ ] as NSDictionary
+ for fixture in [
+ (runtime: [
+ "schemaVersion": 1,
+ "mode": "legacy-compatibility",
+ "virtualHardwareABIVersion": 1,
+ ] as NSDictionary,
+ artifacts: malformedEvidence),
+ (runtime: validResolvedRuntimeIdentity(), artifacts: nil),
+ ] as [(runtime: NSDictionary, artifacts: NSDictionary?)] {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService(
+ runtimeIdentityOverride: fixture.runtime,
+ artifactEvidenceOverride: fixture.artifacts
+ )
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+ let client = DorydClient(endpoint: listener.endpoint)
+
+ do {
+ _ = try await client.machineSnapshot(
+ "dev",
+ note: "invalid artifacts",
+ createdISO: "2026-07-07T00:00:00Z",
+ snapshotID: "invalid-artifacts"
+ )
+ Issue.record("invalid or missing non-legacy artifact evidence must fail closed")
+ } catch let error as DorydClientError {
+ #expect(error.description.contains("invalid doryd response"))
+ }
+ }
+ }
+
+ private func validResolvedRuntimeIdentity() -> NSDictionary {
+ [
+ "schemaVersion": 1,
+ "mode": "resolved-plan",
+ "virtualHardwareABIVersion": 1,
+ "definitionRevision": UInt64(1),
+ "definitionSHA256": String(repeating: "1", count: 64),
+ "planRevision": UInt64(1),
+ "planSHA256": String(repeating: "2", count: 64),
+ "backend": "dory-hypervisor",
+ "backendImplementationIdentifier": "dev.dory.raw-hv-linux",
+ "backendRuntimeBuildIdentifier": "runtime-1",
+ "supportTier": "supported",
+ "selectionDisposition": "primary",
+ "runtimeQualification": [
+ "qualificationIdentity": "runtime-qualification-1",
+ "qualificationReportSHA256": String(repeating: "3", count: 64),
+ "signingKeyID": "dory-runtime-1",
+ ],
+ "hostQualification": [
+ "qualificationIdentity": "host-qualification-1",
+ "qualificationReportSHA256": String(repeating: "4", count: 64),
+ "qualifierIdentifier": "dory-host-qualifier",
+ ],
+ "components": [[
+ "componentIdentifier": "dory-hv",
+ "buildIdentifier": "runtime-1",
+ "artifactSHA256": String(repeating: "5", count: 64),
+ ]],
+ "bootMedia": [
+ "kind": "installed-linux-boot-bundle",
+ "source": "user-provided",
+ "artifactSHA256": String(repeating: "6", count: 64),
+ ],
+ ] as NSDictionary
+ }
+
@MainActor
@Test func healthRecoveryUsesDaemonOwnedSubsystemRepair() async {
let listener = NSXPCListener.anonymous()
@@ -1790,6 +1948,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
private var _latestMachineCreateConfig: NSDictionary?
private var _latestMachineUpdateConfig: NSDictionary?
private var _latestMachineProvisionRecipe: String?
+ private var runtimeIdentityOverride: NSDictionary?
+ private var artifactEvidenceOverride: NSDictionary?
private var snapshots: [String: [NSDictionary]] = [:]
private var backupStatuses: [String: NSDictionary] = [:]
var engineStartCount: Int {
@@ -1871,10 +2031,19 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
init(
socketPath: String = "/tmp/doryd-test.sock",
- engineShutdownReplyDelay: TimeInterval = 0
+ engineShutdownReplyDelay: TimeInterval = 0,
+ runtimeIdentityOverride: NSDictionary? = nil,
+ artifactEvidenceOverride: NSDictionary? = nil
) {
self.socketPath = socketPath
self.engineShutdownReplyDelay = engineShutdownReplyDelay
+ self.runtimeIdentityOverride = runtimeIdentityOverride
+ self.artifactEvidenceOverride = artifactEvidenceOverride
+ if let runtimeIdentityOverride,
+ let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary {
+ existing["runtimeIdentity"] = runtimeIdentityOverride
+ machines["dev"] = existing.copy() as? NSDictionary
+ }
}
func setEngineStatus(_ state: String, detail: String = "ok") {
@@ -2182,13 +2351,28 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) {
let id = request["snapshotID"] as? String ?? "s\(UUID().uuidString.prefix(8).lowercased())"
- let row = Self.snapshotRow(
+ let baseRow = Self.snapshotRow(
id: id,
machineID: machineID,
note: request["note"] as? String ?? "",
createdISO: request["createdISO"] as? String ?? "2026-07-07T00:00:00Z"
)
lock.lock()
+ let row: NSDictionary
+ if let runtimeIdentityOverride,
+ let mutable = baseRow.mutableCopy() as? NSMutableDictionary {
+ mutable["runtimeIdentity"] = runtimeIdentityOverride
+ if let artifactEvidenceOverride {
+ mutable["artifactEvidence"] = artifactEvidenceOverride
+ }
+ row = mutable.copy() as? NSDictionary ?? baseRow
+ } else if let artifactEvidenceOverride,
+ let mutable = baseRow.mutableCopy() as? NSMutableDictionary {
+ mutable["artifactEvidence"] = artifactEvidenceOverride
+ row = mutable.copy() as? NSDictionary ?? baseRow
+ } else {
+ row = baseRow
+ }
_machineSnapshotCount += 1
snapshots[machineID, default: []].insert(row, at: 0)
lock.unlock()
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift
new file mode 100644
index 00000000..8014b3e6
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift
@@ -0,0 +1,324 @@
+import CryptoKit
+import DoryOperations
+import Foundation
+
+public enum DoryMachineRuntimeIdentityMode: String, Codable, Sendable, Hashable {
+ case legacyCompatibility = "legacy-compatibility"
+ case resolvedPlan = "resolved-plan"
+ /// The machine is governed by the resolved-plan launch policy, but its current desired
+ /// state or mutable artifacts are not covered by a launch-authorizing plan yet.
+ case requiresReplanning = "requires-replanning"
+}
+
+public enum DoryMachineRuntimeIdentityInvalidationReason: String, Codable, Sendable, Hashable {
+ case definitionChanged = "definition-changed"
+ case restoredSnapshot = "restored-snapshot"
+ case planRecoveryFailed = "plan-recovery-failed"
+ case planNotInstalled = "plan-not-installed"
+}
+
+public enum DoryMachineRuntimeIdentityValidationCode: String, Codable, Sendable, Hashable {
+ case unsupportedSchemaVersion = "unsupported-schema-version"
+ case legacyCarriesResolvedEvidence = "legacy-carries-resolved-evidence"
+ case missingResolvedEvidence = "missing-resolved-evidence"
+ case invalidResolvedPlan = "invalid-resolved-plan"
+ case invalidPlanDigest = "invalid-plan-digest"
+ case planDigestMismatch = "plan-digest-mismatch"
+ case invalidVirtualHardwareABI = "invalid-virtual-hardware-abi"
+ case invalidInvalidationReason = "invalid-invalidation-reason"
+}
+
+public struct DoryMachineRuntimeIdentityValidationIssue:
+ Codable,
+ Sendable,
+ Equatable,
+ Hashable
+{
+ public var code: DoryMachineRuntimeIdentityValidationCode
+ public var field: String
+
+ public init(code: DoryMachineRuntimeIdentityValidationCode, field: String) {
+ self.code = code
+ self.field = field
+ }
+}
+
+/// Immutable, non-secret evidence identifying the exact runtime selected for a machine launch.
+/// The resolved plan is retained whole so status, snapshots, exports, and diagnostics cannot drift
+/// into mutually inconsistent subsets of definition, backend, media, component, or qualification
+/// evidence. Legacy launches are represented explicitly and never masquerade as resolved evidence.
+public struct DoryMachineRuntimeIdentity: Codable, Sendable, Equatable, Hashable {
+ public static let currentSchemaVersion: UInt16 = 1
+ /// The virtual-hardware ABI used by metadata written before runtime identity became
+ /// explicit. This value is deliberately independent of the current ABI so a future ABI
+ /// bump cannot relabel historical snapshots as newly compatible.
+ public static let oldestLegacyVirtualHardwareABIVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var mode: DoryMachineRuntimeIdentityMode
+ public var virtualHardwareABIVersion: UInt16
+ public var invalidationReason: DoryMachineRuntimeIdentityInvalidationReason?
+ public var resolvedPlanSHA256: String?
+ public var resolvedPlan: DoryResolvedMachinePlan?
+
+ public static func legacyCompatibility(virtualHardwareABIVersion: UInt16 = 1) -> Self {
+ Self(
+ schemaVersion: currentSchemaVersion,
+ mode: .legacyCompatibility,
+ virtualHardwareABIVersion: virtualHardwareABIVersion,
+ invalidationReason: nil,
+ resolvedPlanSHA256: nil,
+ resolvedPlan: nil
+ )
+ }
+
+ public static func requiresReplanning(
+ virtualHardwareABIVersion: UInt16 = 1,
+ reason: DoryMachineRuntimeIdentityInvalidationReason
+ ) -> Self {
+ Self(
+ schemaVersion: currentSchemaVersion,
+ mode: .requiresReplanning,
+ virtualHardwareABIVersion: virtualHardwareABIVersion,
+ invalidationReason: reason,
+ resolvedPlanSHA256: nil,
+ resolvedPlan: nil
+ )
+ }
+
+ public init(
+ schemaVersion: UInt16 = Self.currentSchemaVersion,
+ mode: DoryMachineRuntimeIdentityMode,
+ virtualHardwareABIVersion: UInt16,
+ invalidationReason: DoryMachineRuntimeIdentityInvalidationReason? = nil,
+ resolvedPlanSHA256: String?,
+ resolvedPlan: DoryResolvedMachinePlan?
+ ) {
+ self.schemaVersion = schemaVersion
+ self.mode = mode
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.invalidationReason = invalidationReason
+ self.resolvedPlanSHA256 = resolvedPlanSHA256
+ self.resolvedPlan = resolvedPlan
+ }
+
+ public init(resolvedPlan: DoryResolvedMachinePlan, planSHA256: String) throws {
+ self.init(
+ mode: .resolvedPlan,
+ virtualHardwareABIVersion: resolvedPlan.virtualHardwareABIVersion,
+ invalidationReason: nil,
+ resolvedPlanSHA256: planSHA256.lowercased(),
+ resolvedPlan: resolvedPlan
+ )
+ guard validate().isEmpty else {
+ throw MachineManagerError.persistence("invalid immutable runtime identity")
+ }
+ }
+
+ public var definitionRevision: UInt64? { resolvedPlan?.definitionRevision }
+ public var definitionSHA256: String? { resolvedPlan?.definitionSHA256 }
+ public var planRevision: UInt64? { resolvedPlan?.planRevision }
+ public var backend: DoryVirtualizationBackendIdentity? { resolvedPlan?.backend }
+ public var backendImplementationIdentifier: String? {
+ resolvedPlan?.backendImplementationIdentifier
+ }
+ public var backendRuntimeBuildIdentifier: String? {
+ resolvedPlan?.backendRuntimeBuildIdentifier
+ }
+ public var supportTier: DoryCapabilitySupportTier? { resolvedPlan?.supportTier }
+ public var selectionDisposition: DoryResolvedMachineSelectionDisposition? {
+ resolvedPlan?.selectionEvidence?.disposition
+ }
+ public var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization? {
+ resolvedPlan?.selectionEvidence?.fallbackAuthorization
+ }
+ public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization? {
+ resolvedPlan?.experimentalAuthorization
+ }
+ public var qualificationEvidence: DoryResolvedMachineQualificationEvidence? {
+ resolvedPlan?.qualificationEvidence
+ }
+ public var hostQualification: DoryResolvedHostQualificationEvidence? {
+ resolvedPlan?.hostQualification
+ }
+ public var components: [DoryResolvedBackendComponentEvidence] {
+ resolvedPlan?.components ?? []
+ }
+ public var bootMedia: DoryResolvedMachineBootMedia? { resolvedPlan?.bootMedia }
+
+ public func validate() -> [DoryMachineRuntimeIdentityValidationIssue] {
+ guard schemaVersion == Self.currentSchemaVersion else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .unsupportedSchemaVersion,
+ field: "schemaVersion"
+ )]
+ }
+ guard virtualHardwareABIVersion > 0 else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .invalidVirtualHardwareABI,
+ field: "virtualHardwareABIVersion"
+ )]
+ }
+ switch mode {
+ case .legacyCompatibility:
+ guard invalidationReason == nil,
+ resolvedPlanSHA256 == nil, resolvedPlan == nil else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .legacyCarriesResolvedEvidence,
+ field: "resolvedPlan"
+ )]
+ }
+ return []
+ case .requiresReplanning:
+ guard invalidationReason != nil else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .invalidInvalidationReason,
+ field: "invalidationReason"
+ )]
+ }
+ guard resolvedPlanSHA256 == nil, resolvedPlan == nil else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .legacyCarriesResolvedEvidence,
+ field: "resolvedPlan"
+ )]
+ }
+ return []
+ case .resolvedPlan:
+ guard invalidationReason == nil else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .invalidInvalidationReason,
+ field: "invalidationReason"
+ )]
+ }
+ guard let digest = resolvedPlanSHA256, let plan = resolvedPlan else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .missingResolvedEvidence,
+ field: resolvedPlan == nil ? "resolvedPlan" : "resolvedPlanSHA256"
+ )]
+ }
+ guard Self.isSHA256(digest) else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .invalidPlanDigest,
+ field: "resolvedPlanSHA256"
+ )]
+ }
+ guard plan.validate().isEmpty else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .invalidResolvedPlan,
+ field: "resolvedPlan"
+ )]
+ }
+ guard plan.virtualHardwareABIVersion == virtualHardwareABIVersion else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .invalidVirtualHardwareABI,
+ field: "virtualHardwareABIVersion"
+ )]
+ }
+ guard digest == Self.planSHA256(plan) else {
+ return [DoryMachineRuntimeIdentityValidationIssue(
+ code: .planDigestMismatch,
+ field: "resolvedPlanSHA256"
+ )]
+ }
+ return []
+ }
+ }
+
+ public static func planSHA256(_ plan: DoryResolvedMachinePlan) -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ guard let data = try? encoder.encode(plan) else { return "" }
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.count == 64 && value.allSatisfy { $0.isHexDigit && !$0.isUppercase }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaVersion
+ case mode
+ case virtualHardwareABIVersion
+ case invalidationReason
+ case resolvedPlanSHA256
+ case resolvedPlan
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.init(
+ schemaVersion: try container.decodeIfPresent(
+ UInt16.self,
+ forKey: .schemaVersion
+ ) ?? Self.currentSchemaVersion,
+ mode: try container.decode(DoryMachineRuntimeIdentityMode.self, forKey: .mode),
+ virtualHardwareABIVersion: try container.decodeIfPresent(
+ UInt16.self,
+ forKey: .virtualHardwareABIVersion
+ ) ?? Self.oldestLegacyVirtualHardwareABIVersion,
+ invalidationReason: try container.decodeIfPresent(
+ DoryMachineRuntimeIdentityInvalidationReason.self,
+ forKey: .invalidationReason
+ ),
+ resolvedPlanSHA256: try container.decodeIfPresent(
+ String.self,
+ forKey: .resolvedPlanSHA256
+ ),
+ resolvedPlan: try container.decodeIfPresent(
+ DoryResolvedMachinePlan.self,
+ forKey: .resolvedPlan
+ )
+ )
+ }
+}
+
+public struct DoryMachineSnapshotArtifact: Codable, Sendable, Equatable, Hashable {
+ public var byteCount: UInt64
+ public var sha256: String
+
+ public init(byteCount: UInt64, sha256: String) {
+ self.byteCount = byteCount
+ self.sha256 = sha256.lowercased()
+ }
+
+ public var isValid: Bool {
+ byteCount > 0
+ && sha256.count == 64
+ && sha256.allSatisfy { $0.isHexDigit && !$0.isUppercase }
+ }
+}
+
+/// Content identity for the mutable artifacts captured by a snapshot. This deliberately carries
+/// no daemon-local paths: exports can move across hosts without leaking or trusting host layout.
+public struct DoryMachineSnapshotArtifactEvidence: Codable, Sendable, Equatable, Hashable {
+ public static let currentSchemaVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var rootfs: DoryMachineSnapshotArtifact
+ public var kernel: DoryMachineSnapshotArtifact
+ public var machineIdentifier: DoryMachineSnapshotArtifact?
+ public var nvram: DoryMachineSnapshotArtifact?
+
+ public init(
+ schemaVersion: UInt16 = Self.currentSchemaVersion,
+ rootfs: DoryMachineSnapshotArtifact,
+ kernel: DoryMachineSnapshotArtifact,
+ machineIdentifier: DoryMachineSnapshotArtifact? = nil,
+ nvram: DoryMachineSnapshotArtifact? = nil
+ ) {
+ self.schemaVersion = schemaVersion
+ self.rootfs = rootfs
+ self.kernel = kernel
+ self.machineIdentifier = machineIdentifier
+ self.nvram = nvram
+ }
+
+ public var isValid: Bool {
+ schemaVersion == Self.currentSchemaVersion
+ && rootfs.isValid
+ && kernel.isValid
+ && (machineIdentifier?.isValid ?? true)
+ && (nvram?.isValid ?? true)
+ && ((machineIdentifier == nil) == (nvram == nil))
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index ab12e3b9..014db761 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -1601,6 +1601,109 @@ private extension DoryMachineStatus {
dictionary["displayMode"] = displayMode.rawValue
dictionary["bootMode"] = bootMode.rawValue
dictionary["installerMediaAttached"] = installerMediaAttached
+ dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary
+ return dictionary as NSDictionary
+ }
+}
+
+private extension DoryMachineRuntimeIdentity {
+ var xpcDictionary: NSDictionary {
+ var dictionary: [String: Any] = [
+ "schemaVersion": schemaVersion,
+ "mode": mode.rawValue,
+ "virtualHardwareABIVersion": virtualHardwareABIVersion,
+ ]
+ if let invalidationReason {
+ dictionary["invalidationReason"] = invalidationReason.rawValue
+ }
+ guard let plan = resolvedPlan, let resolvedPlanSHA256 else {
+ return dictionary as NSDictionary
+ }
+ dictionary["definitionRevision"] = plan.definitionRevision
+ if let definitionSHA256 = plan.definitionSHA256 {
+ dictionary["definitionSHA256"] = definitionSHA256
+ }
+ dictionary["planRevision"] = plan.planRevision
+ dictionary["planSHA256"] = resolvedPlanSHA256
+ dictionary["backend"] = plan.backend.rawValue
+ dictionary["backendImplementationIdentifier"] = plan.backendImplementationIdentifier
+ dictionary["backendRuntimeBuildIdentifier"] = plan.backendRuntimeBuildIdentifier
+ dictionary["supportTier"] = plan.supportTier.rawValue
+ if let selectionDisposition = plan.selectionEvidence?.disposition {
+ dictionary["selectionDisposition"] = selectionDisposition.rawValue
+ }
+ if let fallback = plan.selectionEvidence?.fallbackAuthorization {
+ dictionary["fallbackAuthorizationIdentity"] = fallback.authorizationIdentity
+ }
+ if let experimental = plan.experimentalAuthorization {
+ dictionary["experimentalAuthorizationIdentity"] = experimental.authorizationIdentity
+ }
+ if let graphics = plan.qualificationEvidence.graphics {
+ dictionary["graphicsQualification"] = [
+ "manifestIdentity": graphics.manifestIdentity,
+ "artifactSHA256": graphics.artifactSHA256,
+ "manifestSHA256": graphics.manifestSHA256,
+ "signingKeyID": graphics.signingKeyID,
+ ] as NSDictionary
+ }
+ if let runtime = plan.qualificationEvidence.runtime {
+ dictionary["runtimeQualification"] = [
+ "qualificationIdentity": runtime.qualificationIdentity,
+ "qualificationReportSHA256": runtime.qualificationReportSHA256,
+ "signingKeyID": runtime.signingKeyID,
+ ] as NSDictionary
+ }
+ if let host = plan.hostQualification {
+ dictionary["hostQualification"] = [
+ "qualificationIdentity": host.qualificationIdentity,
+ "qualificationReportSHA256": host.qualificationReportSHA256,
+ "qualifierIdentifier": host.qualifierIdentifier,
+ ] as NSDictionary
+ }
+ dictionary["components"] = plan.components.map { component in
+ [
+ "componentIdentifier": component.componentIdentifier,
+ "buildIdentifier": component.buildIdentifier,
+ "artifactSHA256": component.artifactSHA256,
+ ] as NSDictionary
+ }
+ var media: [String: Any] = [
+ "kind": plan.bootMedia.media.kind.rawValue,
+ "source": plan.bootMedia.media.source.rawValue,
+ ]
+ if let digest = plan.bootMedia.media.artifactSHA256 {
+ media["artifactSHA256"] = digest
+ }
+ if let reference = plan.bootMedia.resolverReference {
+ media["resolverNamespace"] = reference.namespace
+ media["resolverIdentifier"] = reference.identifier
+ }
+ if let inspection = plan.bootMedia.inspectionEvidence {
+ media["inspectionIdentity"] = inspection.inspectionIdentity
+ media["inspectionReportSHA256"] = inspection.inspectionReportSHA256
+ }
+ if let provenance = plan.bootMedia.mutableProvenanceEvidence {
+ media["provenanceReceiptIdentity"] = provenance.receiptIdentity
+ media["provenanceReceiptSHA256"] = provenance.receiptSHA256
+ media["provenanceRevision"] = provenance.provenance.revision
+ }
+ dictionary["bootMedia"] = media as NSDictionary
+ return dictionary as NSDictionary
+ }
+}
+
+private extension DoryMachineSnapshotArtifactEvidence {
+ var xpcDictionary: NSDictionary {
+ func artifact(_ value: DoryMachineSnapshotArtifact) -> NSDictionary {
+ ["byteCount": value.byteCount, "sha256": value.sha256]
+ }
+ var dictionary: [String: Any] = [
+ "schemaVersion": schemaVersion,
+ "rootfs": artifact(rootfs),
+ "kernel": artifact(kernel),
+ ]
+ if let machineIdentifier { dictionary["machineIdentifier"] = artifact(machineIdentifier) }
+ if let nvram { dictionary["nvram"] = artifact(nvram) }
return dictionary as NSDictionary
}
}
@@ -1683,7 +1786,7 @@ private extension MachineRecipeProvisionResult {
private extension DoryMachineSnapshot {
var xpcDictionary: NSDictionary {
- [
+ var dictionary: [String: Any] = [
"id": id,
"machineID": machineID,
"note": note,
@@ -1695,7 +1798,12 @@ private extension DoryMachineSnapshot {
"memoryMB": memoryMB,
"cpuCount": cpuCount,
"displayMode": displayMode.rawValue,
+ "runtimeIdentity": runtimeIdentity.xpcDictionary,
]
+ if let artifactEvidence {
+ dictionary["artifactEvidence"] = artifactEvidence.xpcDictionary
+ }
+ return dictionary as NSDictionary
}
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index d90f4d17..7533357f 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -308,6 +308,7 @@ public struct DoryMachineStatus: Sendable, Equatable {
public var installerMediaAttached: Bool
public var shares: [DoryMachineShareConfiguration]
public var environment: [String: String]
+ public var runtimeIdentity: DoryMachineRuntimeIdentity
public init(
id: String,
@@ -331,7 +332,11 @@ public struct DoryMachineStatus: Sendable, Equatable {
bootMode: DoryMachineBootMode = .linuxKernel,
installerMediaAttached: Bool = false,
shares: [DoryMachineShareConfiguration] = [],
- environment: [String: String] = [:]
+ environment: [String: String] = [:],
+ runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ )
) {
self.id = id
self.state = state
@@ -355,6 +360,7 @@ public struct DoryMachineStatus: Sendable, Equatable {
self.installerMediaAttached = installerMediaAttached
self.shares = shares
self.environment = environment
+ self.runtimeIdentity = runtimeIdentity
}
}
@@ -376,6 +382,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
public var bootMode: DoryMachineBootMode
public var machineIdentifierPath: String?
public var nvramPath: String?
+ public var runtimeIdentity: DoryMachineRuntimeIdentity
+ public var artifactEvidence: DoryMachineSnapshotArtifactEvidence?
public init(
id: String,
@@ -394,7 +402,12 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
environment: [String: String] = [:],
bootMode: DoryMachineBootMode = .linuxKernel,
machineIdentifierPath: String? = nil,
- nvramPath: String? = nil
+ nvramPath: String? = nil,
+ runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ ),
+ artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil
) {
self.id = id
self.machineID = machineID
@@ -413,6 +426,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
self.bootMode = bootMode
self.machineIdentifierPath = machineIdentifierPath
self.nvramPath = nvramPath
+ self.runtimeIdentity = runtimeIdentity
+ self.artifactEvidence = artifactEvidence
}
private enum CodingKeys: String, CodingKey {
@@ -433,6 +448,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
case bootMode
case machineIdentifierPath
case nvramPath
+ case runtimeIdentity
+ case artifactEvidence
}
public init(from decoder: Decoder) throws {
@@ -454,7 +471,18 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:],
bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel,
machineIdentifierPath: try container.decodeIfPresent(String.self, forKey: .machineIdentifierPath),
- nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath)
+ nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath),
+ runtimeIdentity: try container.decodeIfPresent(
+ DoryMachineRuntimeIdentity.self,
+ forKey: .runtimeIdentity
+ ) ?? .legacyCompatibility(
+ virtualHardwareABIVersion:
+ DoryMachineRuntimeIdentity.oldestLegacyVirtualHardwareABIVersion
+ ),
+ artifactEvidence: try container.decodeIfPresent(
+ DoryMachineSnapshotArtifactEvidence.self,
+ forKey: .artifactEvidence
+ )
)
}
}
@@ -695,7 +723,10 @@ public final class MachineManager: @unchecked Sendable {
Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory)
Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory)
Self.restrictWorkspaceProjectionRootIfOwned(configuration.stateDirectory)
- self.machines = Self.loadPersistedMachines(configuration: configuration)
+ self.machines = Self.loadPersistedMachines(
+ configuration: configuration,
+ launchPolicy: launchPolicy
+ )
reconcileLoadedWorkspaceProjections()
recoverInterruptedDesktopUpdates()
}
@@ -728,6 +759,10 @@ public final class MachineManager: @unchecked Sendable {
resolvedLaunchPlanResolver = resolver
resolvedLaunchPlanStore = plans
resolvedPlanRevisionProvider = expectedPlanRevision
+ recoverResolvedRuntimeIdentities(
+ plans: plans,
+ expectedPlanRevision: expectedPlanRevision
+ )
}
/// Operations for the current Linux compatibility adapters. Start is protected by a
@@ -834,7 +869,12 @@ public final class MachineManager: @unchecked Sendable {
try validateManagedMachineArtifacts(preparedMachine)
try persist(preparedMachine)
lock.lock()
- machines[machine.id] = MachineEntry(configuration: preparedMachine, state: .created)
+ let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine()
+ machines[machine.id] = MachineEntry(
+ configuration: preparedMachine,
+ state: .created,
+ runtimeIdentity: initialRuntimeIdentity
+ )
lock.unlock()
committed = true
return DoryMachineStatus(
@@ -848,7 +888,8 @@ public final class MachineManager: @unchecked Sendable {
bootMode: preparedMachine.bootMode,
installerMediaAttached: preparedMachine.installerISOPath != nil,
shares: preparedMachine.shares,
- environment: preparedMachine.environment
+ environment: preparedMachine.environment,
+ runtimeIdentity: initialRuntimeIdentity
)
}
@@ -927,6 +968,10 @@ public final class MachineManager: @unchecked Sendable {
resolved,
planStore: planStore
)
+ let runtimeIdentity = try DoryMachineRuntimeIdentity(
+ resolvedPlan: resolved.resolvedPlan,
+ planSHA256: resolved.resolvedPlanSHA256
+ )
pendingResolvedStart = PendingResolvedMachineStart(
machine: prepared.machine,
@@ -944,6 +989,12 @@ public final class MachineManager: @unchecked Sendable {
let failure = operation.failure?.message ?? "backend adapter returned no observation"
throw MachineManagerError.persistence("resolved backend start failed: \(failure)")
}
+ lock.lock()
+ if var entry = machines[id] {
+ entry.runtimeIdentity = runtimeIdentity
+ machines[id] = entry
+ }
+ lock.unlock()
guard let status = status(id: id), [.starting, .running].contains(status.state) else {
throw MachineManagerError.persistence(
"resolved backend did not enter MachineManager's prepared spawn path"
@@ -1564,6 +1615,9 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.invalidID(snapshotID)
}
let (machine, wasRunning) = try configurationAndRunningState(id: id)
+ guard let snapshotRuntimeIdentity = runtimeIdentity(id: id) else {
+ throw MachineManagerError.unknownMachine(id)
+ }
try Self.validateLaunchConfiguration(machine)
try ensurePrivateSnapshotDirectory(machineID: id)
let rootfsPath = snapshotRootfsPath(machineID: id, snapshotID: snapshotID)
@@ -1608,6 +1662,12 @@ public final class MachineManager: @unchecked Sendable {
try Self.cloneOrCopyFile(source: liveNVRAMPath, destination: nvramPath)
publishedNVRAM = true
}
+ let artifactEvidence = try Self.snapshotArtifactEvidence(
+ rootfsPath: rootfsPath,
+ kernelPath: kernelPath,
+ machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil,
+ nvramPath: machine.bootMode == .efi ? nvramPath : nil
+ )
snapshot = DoryMachineSnapshot(
id: snapshotID,
machineID: id,
@@ -1625,7 +1685,9 @@ public final class MachineManager: @unchecked Sendable {
environment: machine.environment,
bootMode: machine.bootMode,
machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil,
- nvramPath: machine.bootMode == .efi ? nvramPath : nil
+ nvramPath: machine.bootMode == .efi ? nvramPath : nil,
+ runtimeIdentity: snapshotRuntimeIdentity,
+ artifactEvidence: artifactEvidence
)
try persistSnapshot(snapshot)
} catch {
@@ -1897,6 +1959,7 @@ public final class MachineManager: @unchecked Sendable {
operationLock.lock()
defer { operationLock.unlock() }
let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID)
+ try validateSnapshotRuntimeCompatibility(snapshot, machine: nil, cloning: true)
let machine = DoryMachineConfiguration(
id: newID,
kernelPath: snapshot.kernelPath,
@@ -1940,6 +2003,7 @@ public final class MachineManager: @unchecked Sendable {
defer { operationLock.unlock() }
let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID)
let (machine, wasRunning) = try configurationAndRunningState(id: machineID)
+ try validateSnapshotRuntimeCompatibility(snapshot, machine: machine, cloning: false)
guard machine.bootMode == snapshot.bootMode else {
throw MachineManagerError.persistence("snapshot boot mode does not match the machine")
}
@@ -1964,13 +2028,20 @@ public final class MachineManager: @unchecked Sendable {
if var entry = machines[machineID] {
entry.configuration = restoredMachine
entry.currentBalloonTargetMB = nil
+ entry.runtimeIdentity = runtimeIdentityAfterSnapshotRestore(
+ snapshot.runtimeIdentity
+ )
machines[machineID] = entry
}
lock.unlock()
}
- let status = wasRunning
- ? try start(id: machineID)
- : (status(id: machineID) ?? DoryMachineStatus(id: machineID, state: .stopped))
+ let status: DoryMachineStatus
+ if wasRunning, launchPolicy == .legacyCompatibility {
+ status = try start(id: machineID)
+ } else {
+ status = self.status(id: machineID)
+ ?? DoryMachineStatus(id: machineID, state: .stopped)
+ }
return status
} catch let error as MachineManagerError {
if wasRunning {
@@ -2069,6 +2140,7 @@ public final class MachineManager: @unchecked Sendable {
)
}
try Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount)
+ try Self.validateSnapshotRuntimeIdentity(snapshot)
snapshot.address = nil
snapshot.shares = []
snapshot.environment = [:]
@@ -2149,6 +2221,12 @@ public final class MachineManager: @unchecked Sendable {
return statusLocked(id: id, entry: entry)
}
+ public func runtimeIdentity(id: String) -> DoryMachineRuntimeIdentity? {
+ lock.lock()
+ defer { lock.unlock() }
+ return machines[id]?.runtimeIdentity
+ }
+
public func list() -> [DoryMachineStatus] {
lock.lock()
let statuses = machines.keys.sorted().compactMap { id in
@@ -2190,7 +2268,8 @@ public final class MachineManager: @unchecked Sendable {
bootMode: entry.configuration.bootMode,
installerMediaAttached: entry.configuration.installerISOPath != nil,
shares: entry.configuration.shares,
- environment: entry.configuration.environment
+ environment: entry.configuration.environment,
+ runtimeIdentity: entry.runtimeIdentity
)
}
return DoryMachineStatus(
@@ -2215,7 +2294,8 @@ public final class MachineManager: @unchecked Sendable {
bootMode: entry.configuration.bootMode,
installerMediaAttached: entry.configuration.installerISOPath != nil,
shares: entry.configuration.shares,
- environment: entry.configuration.environment
+ environment: entry.configuration.environment,
+ runtimeIdentity: entry.runtimeIdentity
)
}
@@ -2869,9 +2949,92 @@ public final class MachineManager: @unchecked Sendable {
}
entry.configuration = configuration
entry.currentBalloonTargetMB = nil
+ entry.runtimeIdentity = runtimeIdentityForUnplannedMachine(
+ reason: .definitionChanged
+ )
machines[configuration.id] = entry
}
+ private func runtimeIdentityForUnplannedMachine(
+ reason: DoryMachineRuntimeIdentityInvalidationReason = .planNotInstalled
+ ) -> DoryMachineRuntimeIdentity {
+ switch launchPolicy {
+ case .legacyCompatibility:
+ return .legacyCompatibility(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ )
+ case .requireResolvedPlan:
+ return .requiresReplanning(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
+ reason: reason
+ )
+ }
+ }
+
+ private func runtimeIdentityAfterSnapshotRestore(
+ _ snapshotIdentity: DoryMachineRuntimeIdentity
+ ) -> DoryMachineRuntimeIdentity {
+ switch launchPolicy {
+ case .legacyCompatibility:
+ return .legacyCompatibility(
+ virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion
+ )
+ case .requireResolvedPlan:
+ return .requiresReplanning(
+ virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion,
+ reason: .restoredSnapshot
+ )
+ }
+ }
+
+ /// Reconstructs durable identity only when the persisted plan still binds the exact current
+ /// authoritative definition. Missing, stale, or migration-only plans remain visibly blocked
+ /// instead of being mislabeled as legacy compatibility after a daemon restart.
+ private func recoverResolvedRuntimeIdentities(
+ plans: any DoryResolvedMachinePlanStoring,
+ expectedPlanRevision: ResolvedPlanRevisionProvider
+ ) {
+ let entries: [(String, DoryMachineConfiguration)] = {
+ lock.lock()
+ defer { lock.unlock() }
+ return machines.map { ($0.key, $0.value.configuration) }
+ }()
+ for (id, machine) in entries {
+ var recovered = DoryMachineRuntimeIdentity.requiresReplanning(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
+ reason: .planRecoveryFailed
+ )
+ if let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: id)),
+ let definition = reconcileWorkspaceProjection(
+ machine: machine,
+ authoritativeLegacyData: legacyData
+ ),
+ let canonicalData = try? Self.canonicalDefinitionData(definition),
+ let plan = try? plans.read(id: id),
+ expectedPlanRevision(id) == plan.planRevision,
+ plan.machineID == id,
+ plan.definitionRevision == definition.lifecycle.revision,
+ plan.definitionSHA256 == Self.sha256(data: canonicalData),
+ plan.migrationDisposition == .current,
+ plan.validate().isEmpty,
+ let identity = try? DoryMachineRuntimeIdentity(
+ resolvedPlan: plan,
+ planSHA256: DoryMachineRuntimeIdentity.planSHA256(plan)
+ ) {
+ recovered = identity
+ }
+ lock.lock()
+ if var entry = machines[id] {
+ entry.runtimeIdentity = recovered
+ machines[id] = entry
+ }
+ lock.unlock()
+ }
+ }
+
private func prepareMachineArtifacts(_ machine: DoryMachineConfiguration) throws -> DoryMachineConfiguration {
let rootfsDestination = machineRootfsPath(id: machine.id)
let kernelDestination = machineKernelPath(id: machine.id)
@@ -3311,6 +3474,8 @@ public final class MachineManager: @unchecked Sendable {
let directory = snapshotDirectory(machineID: snapshot.machineID)
let temporaryPath = "\(directory)/\(Self.snapshotMetadataTemporaryPrefix)\(snapshot.id)-\(UUID().uuidString)"
do {
+ try Self.validateSnapshotRuntimeIdentity(snapshot)
+ try Self.validateSnapshotArtifactEvidence(snapshot)
guard Self.isPrivateDirectory(path: directory) else {
throw MachineManagerError.persistence("machine snapshot path is not a private directory")
}
@@ -3355,6 +3520,7 @@ public final class MachineManager: @unchecked Sendable {
snapshot.rootfsPath == expectedRootfsPath,
snapshot.kernelPath == expectedKernelPath,
snapshot.architecture == configuration.guestArchitecture,
+ snapshot.runtimeIdentity.validate().isEmpty,
(try? Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount)) != nil,
Self.isPrivateRegularFile(path: expectedRootfsPath),
Self.isPrivateRegularFile(path: expectedKernelPath) else {
@@ -3373,11 +3539,169 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.unknownSnapshot(snapshotID)
}
}
+ try Self.validateSnapshotArtifactEvidence(snapshot)
var validated = snapshot
validated.sizeBytes = Self.fileSize(path: expectedRootfsPath)
return validated
}
+ fileprivate static func validateSnapshotRuntimeIdentity(
+ _ snapshot: DoryMachineSnapshot
+ ) throws {
+ guard snapshot.runtimeIdentity.validate().isEmpty,
+ snapshot.runtimeIdentity.virtualHardwareABIVersion
+ == DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion else {
+ throw MachineManagerError.persistence("invalid machine snapshot runtime identity")
+ }
+ if snapshot.runtimeIdentity.mode == .resolvedPlan,
+ snapshot.artifactEvidence == nil {
+ throw MachineManagerError.persistence(
+ "resolved machine snapshot is missing mutable artifact evidence"
+ )
+ }
+ guard let plan = snapshot.runtimeIdentity.resolvedPlan else {
+ return
+ }
+ let memoryBytes = snapshot.memoryMB.multipliedReportingOverflow(by: 1_048_576)
+ guard let artifactEvidence = snapshot.artifactEvidence,
+ snapshot.sizeBytes > 0,
+ let snapshotSizeBytes = UInt64(exactly: snapshot.sizeBytes),
+ !memoryBytes.overflow,
+ plan.machineID == snapshot.machineID,
+ plan.guest.architecture.rawValue == snapshot.architecture,
+ plan.resourceAdmission?.admittedVirtualCPUCount == UInt64(snapshot.cpuCount),
+ plan.resourceAdmission?.admittedMemoryBytes == memoryBytes.partialValue,
+ plan.resourceAdmission?.admittedStorageBytes == snapshotSizeBytes,
+ artifactEvidence.rootfs.byteCount == snapshotSizeBytes else {
+ throw MachineManagerError.persistence(
+ "machine snapshot resources do not match immutable runtime evidence"
+ )
+ }
+ }
+
+ private static func snapshotArtifactEvidence(
+ rootfsPath: String,
+ kernelPath: String,
+ machineIdentifierPath: String?,
+ nvramPath: String?
+ ) throws -> DoryMachineSnapshotArtifactEvidence {
+ DoryMachineSnapshotArtifactEvidence(
+ rootfs: try snapshotArtifact(path: rootfsPath),
+ kernel: try snapshotArtifact(path: kernelPath),
+ machineIdentifier: try machineIdentifierPath.map(snapshotArtifact(path:)),
+ nvram: try nvramPath.map(snapshotArtifact(path:))
+ )
+ }
+
+ private static func snapshotArtifact(path: String) throws -> DoryMachineSnapshotArtifact {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw MachineManagerError.persistence("could not open machine snapshot artifact")
+ }
+ let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
+ let byteCount = try handle.seekToEnd()
+ guard byteCount > 0 else {
+ throw MachineManagerError.persistence("machine snapshot artifact is empty")
+ }
+ try handle.seek(toOffset: 0)
+ var hasher = SHA256()
+ while true {
+ let chunk = try handle.read(upToCount: 4 * 1_024 * 1_024) ?? Data()
+ if chunk.isEmpty { break }
+ hasher.update(data: chunk)
+ }
+ return DoryMachineSnapshotArtifact(
+ byteCount: byteCount,
+ sha256: hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ )
+ }
+
+ fileprivate static func validateSnapshotArtifactEvidence(
+ _ snapshot: DoryMachineSnapshot
+ ) throws {
+ guard let expected = snapshot.artifactEvidence else {
+ // Old legacy snapshots remain decodable and usable under the explicitly selected
+ // compatibility policy. New snapshots always persist evidence.
+ guard snapshot.runtimeIdentity.mode == .legacyCompatibility else {
+ throw MachineManagerError.persistence(
+ "machine snapshot artifact evidence requires migration"
+ )
+ }
+ return
+ }
+ guard expected.isValid,
+ (snapshot.bootMode == .efi) == (expected.machineIdentifier != nil) else {
+ throw MachineManagerError.persistence("invalid machine snapshot artifact evidence")
+ }
+ let actual = try snapshotArtifactEvidence(
+ rootfsPath: snapshot.rootfsPath,
+ kernelPath: snapshot.kernelPath,
+ machineIdentifierPath: snapshot.machineIdentifierPath,
+ nvramPath: snapshot.nvramPath
+ )
+ guard actual == expected else {
+ throw MachineManagerError.persistence(
+ "machine snapshot artifacts do not match immutable evidence"
+ )
+ }
+ }
+
+ private func validateSnapshotRuntimeCompatibility(
+ _ snapshot: DoryMachineSnapshot,
+ machine: DoryMachineConfiguration?,
+ cloning: Bool
+ ) throws {
+ try Self.validateSnapshotRuntimeIdentity(snapshot)
+ switch snapshot.runtimeIdentity.mode {
+ case .legacyCompatibility:
+ guard launchPolicy == .legacyCompatibility else {
+ throw MachineManagerError.persistence(
+ "legacy snapshot requires explicit legacy compatibility launch policy"
+ )
+ }
+ case .resolvedPlan:
+ guard !cloning else {
+ throw MachineManagerError.persistence(
+ "resolved snapshot identity is machine-bound and must be replanned before cloning"
+ )
+ }
+ guard launchPolicy == .requireResolvedPlan,
+ let plan = snapshot.runtimeIdentity.resolvedPlan,
+ let registry = resolvedLaunchRegistry,
+ let planStore = resolvedLaunchPlanStore,
+ let descriptor = registry.backend(for: plan.backend)?.descriptor,
+ descriptor.identity == plan.backend,
+ descriptor.implementationIdentifier == plan.backendImplementationIdentifier,
+ let machine,
+ machine.id == plan.machineID else {
+ throw MachineManagerError.persistence(
+ "snapshot runtime is incompatible with installed resolved-launch infrastructure"
+ )
+ }
+ let currentPlan: DoryResolvedMachinePlan
+ do {
+ currentPlan = try planStore.read(id: machine.id)
+ } catch {
+ throw MachineManagerError.persistence(
+ "snapshot resolved plan is unavailable: \(error)"
+ )
+ }
+ guard currentPlan == plan,
+ snapshot.runtimeIdentity.resolvedPlanSHA256
+ == DoryMachineRuntimeIdentity.planSHA256(currentPlan) else {
+ throw MachineManagerError.persistence(
+ "snapshot resolved plan no longer matches durable launch authority"
+ )
+ }
+ case .requiresReplanning:
+ guard launchPolicy == .requireResolvedPlan, !cloning else {
+ throw MachineManagerError.persistence(
+ "snapshot requires a new resolved plan before it can launch"
+ )
+ }
+ }
+ }
+
private func handleHandoff(
machineID: String,
launchID: UUID,
@@ -3869,6 +4193,10 @@ public final class MachineManager: @unchecked Sendable {
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
+ private static func sha256(data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
private static func isPrivateDirectory(info: stat) -> Bool {
(info.st_mode & S_IFMT) == S_IFDIR
&& info.st_uid == getuid()
@@ -3882,7 +4210,10 @@ public final class MachineManager: @unchecked Sendable {
&& info.st_size > 0
}
- private static func loadPersistedMachines(configuration: MachineManagerConfiguration) -> [String: MachineEntry] {
+ private static func loadPersistedMachines(
+ configuration: MachineManagerConfiguration,
+ launchPolicy: DoryMachineLaunchPolicy
+ ) -> [String: MachineEntry] {
let root = configuration.stateDirectory
guard let ids = try? FileManager.default.contentsOfDirectory(atPath: root) else {
return [:]
@@ -3910,7 +4241,24 @@ public final class MachineManager: @unchecked Sendable {
) else {
continue
}
- loaded[id] = MachineEntry(configuration: machine, state: .stopped)
+ let identity: DoryMachineRuntimeIdentity = switch launchPolicy {
+ case .legacyCompatibility:
+ .legacyCompatibility(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ )
+ case .requireResolvedPlan:
+ .requiresReplanning(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
+ reason: .planRecoveryFailed
+ )
+ }
+ loaded[id] = MachineEntry(
+ configuration: machine,
+ state: .stopped,
+ runtimeIdentity: identity
+ )
}
return loaded
}
@@ -4001,6 +4349,8 @@ private enum MachineSnapshotBundle {
private static let copyChunkSize = 4 * 1024 * 1024
static func write(snapshot: DoryMachineSnapshot, toPath path: String) throws {
+ try MachineManager.validateSnapshotRuntimeIdentity(snapshot)
+ try MachineManager.validateSnapshotArtifactEvidence(snapshot)
let rootfs = try openRegularFileForReading(path: snapshot.rootfsPath, requirePrivateOwnership: true)
defer { try? rootfs.close() }
let kernel = try openRegularFileForReading(path: snapshot.kernelPath, requirePrivateOwnership: true)
@@ -4343,12 +4693,32 @@ private enum MachineSnapshotBundle {
throw MachineManagerError.persistence("corrupt dory machine bundle metadata")
}
let snapshot = try JSONDecoder().decode(DoryMachineSnapshot.self, from: metadata)
+ try MachineManager.validateSnapshotRuntimeIdentity(snapshot)
guard snapshot.sizeBytes == Int64(rootfsLength) else {
throw MachineManagerError.persistence("machine bundle rootfs size does not match metadata")
}
guard (snapshot.bootMode == .efi) == includesFirmware else {
throw MachineManagerError.persistence("machine bundle boot mode does not match its artifacts")
}
+ if let evidence = snapshot.artifactEvidence {
+ guard evidence.isValid,
+ evidence.rootfs.byteCount == rootfsLength,
+ evidence.rootfs.sha256 == digestHex(rootfsDigest),
+ evidence.kernel.byteCount == kernelLength,
+ evidence.kernel.sha256 == digestHex(kernelDigest),
+ evidence.machineIdentifier?.byteCount == machineIdentifierLength,
+ evidence.machineIdentifier?.sha256 == machineIdentifierDigest.map(digestHex),
+ evidence.nvram?.byteCount == nvramLength,
+ evidence.nvram?.sha256 == nvramDigest.map(digestHex) else {
+ throw MachineManagerError.persistence(
+ "machine bundle artifacts do not match immutable snapshot evidence"
+ )
+ }
+ } else if snapshot.runtimeIdentity.mode != .legacyCompatibility {
+ throw MachineManagerError.persistence(
+ "machine bundle artifact evidence requires migration"
+ )
+ }
let artifactCount = includesFirmware ? 5 : 3
let fixedHeaderLength = UInt64(
v3Magic.count + (lengthByteCount * artifactCount) + (digestByteCount * artifactCount)
@@ -4407,6 +4777,10 @@ private enum MachineSnapshotBundle {
)
}
+ private static func digestHex(_ digest: Data) -> String {
+ digest.map { String(format: "%02x", $0) }.joined()
+ }
+
private static func openRegularFileForReading(
path: String,
requirePrivateOwnership: Bool = false
@@ -4535,6 +4909,10 @@ private struct MachineEntry {
var runtimeAddress: String?
var currentBalloonTargetMB: UInt64?
var lastError: String?
+ var runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ )
}
extension MachineManager: WakeClockSyncing {
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityTests.swift
new file mode 100644
index 00000000..c7534409
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityTests.swift
@@ -0,0 +1,108 @@
+import DoryOperations
+import Foundation
+import Testing
+@testable import DorydKit
+
+@Suite("Machine runtime identity", .serialized)
+struct DoryMachineRuntimeIdentityTests {
+ @Test("legacy and replanning identities round trip with an explicit hardware ABI")
+ func compatibilityIdentityRoundTrip() throws {
+ let identities: [DoryMachineRuntimeIdentity] = [
+ .legacyCompatibility(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ ),
+ .requiresReplanning(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
+ reason: .definitionChanged
+ ),
+ ]
+ for identity in identities {
+ #expect(identity.validate().isEmpty)
+ let data = try JSONEncoder().encode(identity)
+ #expect(try JSONDecoder().decode(DoryMachineRuntimeIdentity.self, from: data) == identity)
+ #expect(identity.virtualHardwareABIVersion == 1)
+ }
+ }
+
+ @Test("old snapshot metadata decodes as explicit legacy compatibility")
+ func oldestSnapshotGoldenDecodes() throws {
+ let data = Data(#"""
+ {
+ "id":"s1","machineID":"dev","note":"","createdISO":"2026-01-01T00:00:00Z",
+ "rootfsPath":"/state/dev/snapshots/s1.ext4","sizeBytes":4,
+ "kernelPath":"/state/dev/snapshots/s1.kernel","architecture":"arm64",
+ "memoryMB":2048,"cpuCount":2,"displayMode":"headless","shares":[],
+ "environment":{},"bootMode":"linux-kernel"
+ }
+ """#.utf8)
+ let snapshot = try JSONDecoder().decode(DoryMachineSnapshot.self, from: data)
+ #expect(snapshot.runtimeIdentity.mode == .legacyCompatibility)
+ #expect(
+ snapshot.runtimeIdentity.virtualHardwareABIVersion
+ == DoryMachineRuntimeIdentity.oldestLegacyVirtualHardwareABIVersion
+ )
+ #expect(
+ snapshot.runtimeIdentity.virtualHardwareABIVersion
+ != DoryMachineRuntimeIdentity.oldestLegacyVirtualHardwareABIVersion + 1
+ )
+ #expect(snapshot.artifactEvidence == nil)
+ }
+
+ @Test("new snapshots bind mutable artifacts and reject later disk tampering")
+ func snapshotArtifactTamperingIsRejected() throws {
+ let root = "/tmp/dory-runtime-identity-\(UUID().uuidString.lowercased())"
+ let manager = MachineManager(configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ stateDirectory: root,
+ baseArguments: ["30"],
+ passMachineArguments: false,
+ requiresReadyHandoff: false
+ ))
+ defer { try? FileManager.default.removeItem(atPath: root) }
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath
+ ))
+ let snapshot = try manager.snapshot(id: "dev", snapshotID: "s1")
+ let evidence = try #require(snapshot.artifactEvidence)
+ #expect(evidence.isValid)
+ #expect(evidence.rootfs.sha256.count == 64)
+ #expect(snapshot.runtimeIdentity.mode == .legacyCompatibility)
+ #expect(snapshot.runtimeIdentity.virtualHardwareABIVersion == 1)
+
+ let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: snapshot.rootfsPath))
+ try handle.seek(toOffset: 0)
+ try handle.write(contentsOf: Data("tampered".utf8))
+ try handle.close()
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.restoreSnapshot(machineID: "dev", snapshotID: "s1")
+ }
+ }
+
+ @Test("resolved-plan policy never labels an unplanned machine as legacy")
+ func requiredPolicyStartsAsRequiresReplanning() throws {
+ let root = "/tmp/dory-runtime-identity-required-\(UUID().uuidString.lowercased())"
+ let manager = MachineManager(
+ configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ stateDirectory: root,
+ baseArguments: ["30"],
+ passMachineArguments: false,
+ requiresReadyHandoff: false
+ ),
+ launchPolicy: .requireResolvedPlan
+ )
+ defer { try? FileManager.default.removeItem(atPath: root) }
+ let created = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath
+ ))
+ #expect(created.runtimeIdentity.mode == .requiresReplanning)
+ #expect(created.runtimeIdentity.invalidationReason == .planNotInstalled)
+ #expect(created.runtimeIdentity.virtualHardwareABIVersion == 1)
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 590d8678..36b9bf35 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -1098,6 +1098,10 @@ final class DorydServiceTests: XCTestCase {
XCTAssertTrue(ok, message)
XCTAssertEqual(body["state"] as? String, "running")
XCTAssertNotNil(body["pid"])
+ let runtime = body["runtimeIdentity"] as? NSDictionary
+ XCTAssertEqual(runtime?["mode"] as? String, "legacy-compatibility")
+ XCTAssertEqual((runtime?["virtualHardwareABIVersion"] as? NSNumber)?.uint16Value, 1)
+ XCTAssertNil(runtime?["resolvedPlan"])
start.fulfill()
}
wait(for: [start], timeout: 5)
@@ -1376,6 +1380,14 @@ final class DorydServiceTests: XCTestCase {
XCTAssertEqual(body["machineID"] as? String, "dev")
XCTAssertEqual(body["note"] as? String, "before")
XCTAssertEqual(body["architecture"] as? String, doryTestGuestArchitecture)
+ let runtime = body["runtimeIdentity"] as? NSDictionary
+ XCTAssertEqual(runtime?["mode"] as? String, "legacy-compatibility")
+ XCTAssertEqual((runtime?["virtualHardwareABIVersion"] as? NSNumber)?.uint16Value, 1)
+ let artifacts = body["artifactEvidence"] as? NSDictionary
+ XCTAssertEqual(
+ ((artifacts?["rootfs"] as? NSDictionary)?["sha256"] as? String)?.count,
+ 64
+ )
snapshotReply.fulfill()
}
wait(for: [snapshotReply], timeout: 5)
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
index bd52bb01..e1ae95d2 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -37,8 +37,155 @@ struct MachineManagerResolvedPlanIntegrationTests {
#expect(identity.planRevision == 1)
#expect(identity.backend == .doryHypervisor)
#expect(identity.planSHA256.count == 64)
+ #expect(status.runtimeIdentity.mode == .resolvedPlan)
+ #expect(status.runtimeIdentity.planRevision == 1)
+ #expect(status.runtimeIdentity.definitionSHA256?.count == 64)
+ #expect(
+ status.runtimeIdentity.backendImplementationIdentifier
+ == RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier
+ )
+ #expect(status.runtimeIdentity.virtualHardwareABIVersion == 1)
+ #expect(status.runtimeIdentity.components.count == 1)
+ let service = DorydService(
+ socketPath: state + "/service.sock",
+ machineManager: manager
+ )
+ var xpcRows: NSArray = []
+ service.machineList { rows, message in
+ #expect(message.isEmpty)
+ xpcRows = rows
+ }
+ let xpcStatus = try #require(xpcRows.firstObject as? NSDictionary)
+ let xpcIdentity = try #require(
+ xpcStatus["runtimeIdentity"] as? NSDictionary
+ )
+ #expect(xpcIdentity["mode"] as? String == "resolved-plan")
+ #expect(xpcIdentity["planSHA256"] as? String == identity.planSHA256)
+ #expect(xpcIdentity["backend"] as? String == "dory-hypervisor")
+ #expect(xpcIdentity["resolvedPlan"] == nil)
+ let encodedXPC = try JSONSerialization.data(withJSONObject: xpcIdentity)
+ let xpcText = String(decoding: encodedXPC, as: UTF8.self)
+ #expect(!xpcText.contains("/bin/sleep"))
+ #expect(!xpcText.contains(state))
#expect(FileManager.default.fileExists(atPath: state + "/dev/machine.json"))
_ = try manager.stop(id: "dev")
+ let snapshot = try manager.snapshot(id: "dev", snapshotID: "evidence")
+ #expect(snapshot.runtimeIdentity == status.runtimeIdentity)
+ #expect(snapshot.artifactEvidence?.rootfs.sha256.count == 64)
+ #expect(snapshot.artifactEvidence?.kernel.sha256.count == 64)
+ var tamperedIdentity = snapshot.runtimeIdentity
+ tamperedIdentity.resolvedPlanSHA256 = String(repeating: "0", count: 64)
+ #expect(tamperedIdentity.validate().contains { $0.code == .planDigestMismatch })
+
+ let restored = try manager.restoreSnapshot(
+ machineID: "dev",
+ snapshotID: "evidence"
+ )
+ #expect(restored.state == .stopped)
+ #expect(restored.runtimeIdentity.mode == .requiresReplanning)
+ #expect(restored.runtimeIdentity.invalidationReason == .restoredSnapshot)
+ }
+ }
+
+ @Test("resolved snapshot disk capacity is bound to resource admission")
+ func resolvedSnapshotRejectsSelfConsistentDifferentCapacityDisk() throws {
+ try withHarness("snapshot-storage-evidence") { manager, _, state in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(request: request)
+ plans.set(resolution.resolvedPlan)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+ _ = try manager.start(id: "dev")
+ _ = try manager.stop(id: "dev")
+ var snapshot = try manager.snapshot(id: "dev", snapshotID: "capacity")
+
+ let rootfsURL = URL(fileURLWithPath: snapshot.rootfsPath)
+ let handle = try FileHandle(forWritingTo: rootfsURL)
+ try handle.seekToEnd()
+ try handle.write(contentsOf: Data([0]))
+ try handle.close()
+ let rootfsData = try Data(contentsOf: rootfsURL)
+ let rootfsSHA256 = SHA256.hash(data: rootfsData)
+ .map { String(format: "%02x", $0) }.joined()
+ snapshot.sizeBytes = Int64(rootfsData.count)
+ snapshot.artifactEvidence?.rootfs = DoryMachineSnapshotArtifact(
+ byteCount: UInt64(rootfsData.count),
+ sha256: rootfsSHA256
+ )
+
+ let metadataPath = state + "/dev/snapshots/capacity.json"
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ try encoder.encode(snapshot).write(
+ to: URL(fileURLWithPath: metadataPath),
+ options: .atomic
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: metadataPath
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.restoreSnapshot(machineID: "dev", snapshotID: "capacity")
+ }
+ }
+ }
+
+ @Test("daemon restart recovers exact plan identity and definition changes invalidate it")
+ func restartRecoveryAndConfigurationInvalidation() throws {
+ try withHarness("restart-recovery") { manager, _, state in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(request: request)
+ plans.set(resolution.resolvedPlan)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+ _ = try manager.start(id: "dev")
+ _ = try manager.stop(id: "dev")
+
+ let restarted = MachineManager(
+ configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ acceleratedDesktopExecutablePath: "/bin/sleep",
+ stateDirectory: state,
+ baseArguments: ["30"],
+ acceleratedDesktopBaseArguments: ["30"],
+ passMachineArguments: false,
+ requiresReadyHandoff: false
+ ),
+ launchPolicy: .requireResolvedPlan
+ )
+ #expect(restarted.status(id: "dev")?.runtimeIdentity.mode == .requiresReplanning)
+ let restartedOperations = restarted.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ try restarted.installResolvedLaunchInfrastructure(
+ registry: rawRegistry(operations: restartedOperations),
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+ #expect(restarted.status(id: "dev")?.runtimeIdentity.mode == .resolvedPlan)
+ let updated = try restarted.update(id: "dev", memoryMB: 4_096)
+ #expect(updated.runtimeIdentity.mode == .requiresReplanning)
+ #expect(updated.runtimeIdentity.invalidationReason == .definitionChanged)
}
}
From 2462e18c34d4b66b998143d353cffc0a10d98373 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 13:54:02 +0000
Subject: [PATCH 020/338] feat(vm): add durable artifact authority
---
.../DoryVMResolverReferenceValidation.swift | 38 +
.../DoryVirtualMachineArtifactAuthority.swift | 690 ++++++++++++++++++
.../DoryVirtualMachineDefinition.swift | 25 +-
...VirtualMachineArtifactAuthorityTests.swift | 505 +++++++++++++
4 files changed, 1234 insertions(+), 24 deletions(-)
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVMResolverReferenceValidation.swift
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift
create mode 100644 dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVMResolverReferenceValidation.swift b/dory-core-swift/Sources/DoryOperations/DoryVMResolverReferenceValidation.swift
new file mode 100644
index 00000000..991e3d13
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryVMResolverReferenceValidation.swift
@@ -0,0 +1,38 @@
+public extension DoryVMResolverReference {
+ /// Persistence-safe resolver key shared by WorkspaceSpec and daemon-private authorities.
+ /// Host paths, URLs, controls, and common credential/token shapes are intentionally excluded.
+ var isValidForPersistence: Bool {
+ let namespaceBytes = Array(namespace.utf8)
+ let identifierBytes = Array(identifier.utf8)
+ guard (1...32).contains(namespaceBytes.count),
+ let firstNamespaceByte = namespaceBytes.first,
+ firstNamespaceByte >= 97,
+ firstNamespaceByte <= 122,
+ namespaceBytes.dropFirst().allSatisfy({ byte in
+ (byte >= 97 && byte <= 122)
+ || (byte >= 48 && byte <= 57)
+ || byte == 45
+ }),
+ (1...64).contains(identifierBytes.count),
+ let firstIdentifierByte = identifierBytes.first,
+ Self.isASCIIAlphaNumeric(firstIdentifierByte),
+ identifierBytes.dropFirst().allSatisfy({ byte in
+ Self.isASCIIAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45
+ }) else {
+ return false
+ }
+ let lowercase = identifier.lowercased()
+ let secretPrefixes = [
+ "sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-", "xoxa-", "xoxr-",
+ "bearer-", "password-", "secret-", "token-", "akia",
+ ]
+ return !secretPrefixes.contains(where: lowercase.hasPrefix)
+ && !lowercase.hasPrefix("eyj")
+ }
+
+ private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool {
+ (byte >= 48 && byte <= 57)
+ || (byte >= 65 && byte <= 90)
+ || (byte >= 97 && byte <= 122)
+ }
+}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift
new file mode 100644
index 00000000..d533bcc5
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineArtifactAuthority.swift
@@ -0,0 +1,690 @@
+import CryptoKit
+import Darwin
+import Foundation
+
+public enum DoryVirtualMachineArtifactAuthorityError:
+ Error, Sendable, Equatable, CustomStringConvertible
+{
+ case invalidReference
+ case invalidPath
+ case insecureArtifact
+ case artifactMissing
+ case artifactChanged
+ case staleRevision(expected: UInt64, actual: UInt64)
+ case invalidRecord
+ case filesystem(String)
+
+ public var description: String {
+ switch self {
+ case .invalidReference: "artifact resolver reference is invalid"
+ case .invalidPath: "artifact path must be absolute and canonical"
+ case .insecureArtifact: "artifact must be a private owner-controlled single-link file"
+ case .artifactMissing: "artifact authority record does not exist"
+ case .artifactChanged: "artifact bytes or file identity changed after publication"
+ case let .staleRevision(expected, actual):
+ "stale artifact revision: expected \(expected), found \(actual)"
+ case .invalidRecord: "artifact authority record is invalid or tampered"
+ case let .filesystem(message): message
+ }
+ }
+}
+
+public enum DoryVirtualMachineArtifactIdentity: Codable, Sendable, Equatable, Hashable {
+ case immutable(sha256: String, byteCount: UInt64)
+ case mutable(
+ provenance: DoryMutableBootMediaProvenanceReference,
+ sha256: String,
+ byteCount: UInt64,
+ device: UInt64,
+ inode: UInt64,
+ modifiedSeconds: Int64,
+ modifiedNanoseconds: Int64,
+ changedSeconds: Int64,
+ changedNanoseconds: Int64
+ )
+}
+
+/// Daemon-private durable mapping. Host paths intentionally remain outside WorkspaceSpec and the
+/// public API, while the exact non-secret identity is copied into resolved launch evidence.
+public struct DoryVirtualMachineArtifactAuthorityRecord: Codable, Sendable, Equatable {
+ public static let schemaVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var reference: DoryVMResolverReference
+ public var path: String
+ public var kind: DoryBootMediaKind
+ public var source: DoryBootMediaSource
+ public var identity: DoryVirtualMachineArtifactIdentity
+ public var authorityRevision: UInt64
+
+ public init(
+ reference: DoryVMResolverReference,
+ path: String,
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ identity: DoryVirtualMachineArtifactIdentity,
+ authorityRevision: UInt64
+ ) {
+ schemaVersion = Self.schemaVersion
+ self.reference = reference
+ self.path = path
+ self.kind = kind
+ self.source = source
+ self.identity = identity
+ self.authorityRevision = authorityRevision
+ }
+}
+
+public struct DoryVerifiedVirtualMachineArtifact: Sendable {
+ public let reference: DoryVMResolverReference
+ public let path: String
+ public let media: DoryBootMedia
+ public let authorityRevision: UInt64
+ public let mutableProvenance: DoryTrustedMutableBootMediaProvenance?
+
+ fileprivate init(record: DoryVirtualMachineArtifactAuthorityRecord) {
+ reference = record.reference
+ path = record.path
+ authorityRevision = record.authorityRevision
+ switch record.identity {
+ case let .immutable(sha256, _):
+ media = DoryBootMedia(
+ kind: record.kind,
+ source: record.source,
+ artifactSHA256: sha256
+ )
+ mutableProvenance = nil
+ case let .mutable(provenance, _, _, _, _, _, _, _, _):
+ let evidence = DoryMutableBootMediaProvenanceAuditEvidence(
+ receiptIdentity: "dory-artifact-authority:\(record.reference.namespace):"
+ + "\(record.reference.identifier):\(provenance.revision)",
+ provenance: provenance,
+ receiptSHA256: Self.digest(Self.canonicalData(record)),
+ resolverID: DoryVirtualMachineArtifactAuthority.resolverID,
+ resolverVersion: DoryVirtualMachineArtifactAuthority.resolverVersion
+ )
+ media = DoryBootMedia(
+ kind: record.kind,
+ source: record.source,
+ mutableProvenance: provenance
+ )
+ mutableProvenance = DoryTrustedMutableBootMediaProvenance(
+ auditEvidence: evidence
+ )
+ }
+ }
+
+ private static func canonicalData(_ value: T) -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return (try? encoder.encode(value)) ?? Data()
+ }
+
+ private static func digest(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+/// Crash-safe daemon authority for path-hostile workspace references. Every artifact, including a
+/// mutable disk revision, is rehashed on resolution. Mutable disks require an explicit publication
+/// after any write; changed bytes are rejected instead of silently advancing provenance at start.
+///
+/// A successful resolution is a point-in-time verification snapshot. A launcher must still close
+/// the path/exec TOCTOU window with descriptor-bound or immediate pre-spawn verification.
+public final class DoryVirtualMachineArtifactAuthority: @unchecked Sendable {
+ public static let resolverID = "dory.artifact-authority"
+ public static let resolverVersion: UInt16 = 1
+
+ private static let maximumRecordBytes = 1 * 1_024 * 1_024
+ private static let temporaryPrefix = ".artifact-authority."
+ private static let lockFilename = ".artifact-authority.lock"
+
+ public let root: String
+ private let lock = NSLock()
+ private let publicationFaultInjector: (@Sendable (PublicationStage) throws -> Void)?
+ private let inspectionProgressInjector: (@Sendable (InspectionStage) throws -> Void)?
+
+ enum PublicationStage: Sendable {
+ case temporaryFileSynced
+ }
+
+ enum InspectionStage: Sendable {
+ case firstChunkHashed(path: String)
+ }
+
+ public init(root: String) {
+ self.root = URL(fileURLWithPath: root).standardizedFileURL.path
+ publicationFaultInjector = nil
+ inspectionProgressInjector = nil
+ }
+
+ init(
+ root: String,
+ publicationFaultInjector: @escaping @Sendable (PublicationStage) throws -> Void
+ ) {
+ self.root = URL(fileURLWithPath: root).standardizedFileURL.path
+ self.publicationFaultInjector = publicationFaultInjector
+ inspectionProgressInjector = nil
+ }
+
+ init(
+ root: String,
+ inspectionProgressInjector: @escaping @Sendable (InspectionStage) throws -> Void
+ ) {
+ self.root = URL(fileURLWithPath: root).standardizedFileURL.path
+ publicationFaultInjector = nil
+ self.inspectionProgressInjector = inspectionProgressInjector
+ }
+
+ @discardableResult
+ public func publishImmutable(
+ reference: DoryVMResolverReference,
+ path: String,
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ expectedAuthorityRevision: UInt64? = nil
+ ) throws -> DoryVerifiedVirtualMachineArtifact {
+ try withExclusiveAccess {
+ let path = try Self.validatedPath(path)
+ let file = try Self.inspect(
+ path,
+ hashContents: true,
+ progressInjector: inspectionProgressInjector
+ )
+ let current = try readIfPresent(reference)
+ try Self.validateExpectedRevision(expectedAuthorityRevision, current: current)
+ let revision = try Self.nextRevision(current)
+ let record = DoryVirtualMachineArtifactAuthorityRecord(
+ reference: reference,
+ path: path,
+ kind: kind,
+ source: source,
+ identity: .immutable(
+ sha256: try Self.requiredDigest(file),
+ byteCount: file.byteCount
+ ),
+ authorityRevision: revision
+ )
+ try publish(record)
+ return DoryVerifiedVirtualMachineArtifact(record: record)
+ }
+ }
+
+ @discardableResult
+ public func publishMutable(
+ reference: DoryVMResolverReference,
+ path: String,
+ kind: DoryBootMediaKind = .virtualDisk,
+ source: DoryBootMediaSource,
+ expectedAuthorityRevision: UInt64? = nil
+ ) throws -> DoryVerifiedVirtualMachineArtifact {
+ try withExclusiveAccess {
+ guard kind == .virtualDisk else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ let path = try Self.validatedPath(path)
+ let file = try Self.inspect(
+ path,
+ hashContents: true,
+ progressInjector: inspectionProgressInjector
+ )
+ let current = try readIfPresent(reference)
+ try Self.validateExpectedRevision(expectedAuthorityRevision, current: current)
+ let revision = try Self.nextRevision(current)
+ let provenance = DoryMutableBootMediaProvenanceReference(
+ repositoryIdentity: Self.digest(Data(root.utf8)),
+ mediaIdentity: Self.referenceIdentity(reference),
+ revision: revision
+ )
+ let record = DoryVirtualMachineArtifactAuthorityRecord(
+ reference: reference,
+ path: path,
+ kind: kind,
+ source: source,
+ identity: .mutable(
+ provenance: provenance,
+ sha256: try Self.requiredDigest(file),
+ byteCount: file.byteCount,
+ device: file.device,
+ inode: file.inode,
+ modifiedSeconds: file.modifiedSeconds,
+ modifiedNanoseconds: file.modifiedNanoseconds,
+ changedSeconds: file.changedSeconds,
+ changedNanoseconds: file.changedNanoseconds
+ ),
+ authorityRevision: revision
+ )
+ try publish(record)
+ return DoryVerifiedVirtualMachineArtifact(record: record)
+ }
+ }
+
+ public func resolve(
+ reference: DoryVMResolverReference,
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource
+ ) throws -> DoryVerifiedVirtualMachineArtifact {
+ try withExclusiveAccess {
+ guard let record = try readIfPresent(reference) else {
+ throw DoryVirtualMachineArtifactAuthorityError.artifactMissing
+ }
+ guard record.kind == kind, record.source == source else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ let file: InspectedFile
+ switch record.identity {
+ case .immutable:
+ file = try Self.inspect(
+ record.path,
+ hashContents: true,
+ progressInjector: inspectionProgressInjector
+ )
+ case .mutable:
+ file = try Self.inspect(
+ record.path,
+ hashContents: true,
+ progressInjector: inspectionProgressInjector
+ )
+ }
+ guard Self.file(file, matches: record.identity) else {
+ throw DoryVirtualMachineArtifactAuthorityError.artifactChanged
+ }
+ return DoryVerifiedVirtualMachineArtifact(record: record)
+ }
+ }
+
+ /// Returns daemon-private publication metadata for optimistic reconciliation. This does not
+ /// verify current artifact bytes and therefore never returns an opaque trusted receipt.
+ public func authorityRecord(
+ reference: DoryVMResolverReference
+ ) throws -> DoryVirtualMachineArtifactAuthorityRecord? {
+ try withExclusiveAccess { try readIfPresent(reference) }
+ }
+
+ private func publish(_ record: DoryVirtualMachineArtifactAuthorityRecord) throws {
+ try validate(record)
+ let directory = root
+ try Self.ensurePrivateDirectory(directory)
+ let path = recordPath(record.reference)
+ let envelope = RecordEnvelope(
+ recordSHA256: Self.digest(Self.canonicalData(record)),
+ record: record
+ )
+ let data = Self.canonicalData(envelope) + Data("\n".utf8)
+ let temporary = directory + "/\(Self.temporaryPrefix)\(UUID().uuidString)"
+ let descriptor = temporary.withCString {
+ open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600))
+ }
+ guard descriptor >= 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "create artifact authority record: errno \(errno)"
+ )
+ }
+ var descriptorIsOpen = true
+ do {
+ try data.withUnsafeBytes { bytes in
+ var offset = 0
+ while offset < bytes.count {
+ let count = Darwin.write(
+ descriptor,
+ bytes.baseAddress!.advanced(by: offset),
+ bytes.count - offset
+ )
+ guard count > 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "write artifact authority record: errno \(errno)"
+ )
+ }
+ offset += count
+ }
+ }
+ guard fsync(descriptor) == 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "sync artifact authority record: errno \(errno)"
+ )
+ }
+ close(descriptor)
+ descriptorIsOpen = false
+ try publicationFaultInjector?(.temporaryFileSynced)
+ guard rename(temporary, path) == 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "publish artifact authority record: errno \(errno)"
+ )
+ }
+ try Self.syncDirectory(directory)
+ } catch {
+ if descriptorIsOpen { close(descriptor) }
+ unlink(temporary)
+ throw error
+ }
+ }
+
+ private func readIfPresent(
+ _ reference: DoryVMResolverReference
+ ) throws -> DoryVirtualMachineArtifactAuthorityRecord? {
+ try Self.validate(reference)
+ let path = recordPath(reference)
+ guard FileManager.default.fileExists(atPath: path) else { return nil }
+ let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) }
+ guard descriptor >= 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ defer { close(descriptor) }
+ var status = stat()
+ guard fstat(descriptor, &status) == 0,
+ status.st_mode & S_IFMT == S_IFREG,
+ status.st_uid == geteuid(),
+ status.st_nlink == 1,
+ status.st_mode & 0o077 == 0,
+ status.st_size > 0,
+ status.st_size <= Self.maximumRecordBytes else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ var data = Data()
+ var buffer = [UInt8](repeating: 0, count: 16 * 1_024)
+ while true {
+ let count = buffer.withUnsafeMutableBytes {
+ Darwin.read(descriptor, $0.baseAddress, $0.count)
+ }
+ if count < 0, errno == EINTR { continue }
+ guard count >= 0, data.count + count <= Self.maximumRecordBytes else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ if count == 0 { break }
+ data.append(contentsOf: buffer.prefix(count))
+ }
+ guard let envelope = try? JSONDecoder().decode(RecordEnvelope.self, from: data),
+ data == Self.canonicalData(envelope) + Data("\n".utf8),
+ envelope.recordSHA256 == Self.digest(Self.canonicalData(envelope.record)),
+ envelope.record.reference == reference else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ try validate(envelope.record)
+ return envelope.record
+ }
+
+ private func recordPath(_ reference: DoryVMResolverReference) -> String {
+ root + "/" + Self.referenceIdentity(reference) + ".json"
+ }
+
+ private struct RecordEnvelope: Codable {
+ var recordSHA256: String
+ var record: DoryVirtualMachineArtifactAuthorityRecord
+ }
+
+ private struct InspectedFile {
+ var byteCount: UInt64
+ var device: UInt64
+ var inode: UInt64
+ var modifiedSeconds: Int64
+ var modifiedNanoseconds: Int64
+ var changedSeconds: Int64
+ var changedNanoseconds: Int64
+ var sha256: String?
+ }
+
+ private static func inspect(
+ _ path: String,
+ hashContents: Bool,
+ progressInjector: (@Sendable (InspectionStage) throws -> Void)?
+ ) throws -> InspectedFile {
+ let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) }
+ guard descriptor >= 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.insecureArtifact
+ }
+ defer { close(descriptor) }
+ var before = stat()
+ guard fstat(descriptor, &before) == 0,
+ isPrivateArtifact(before) else {
+ throw DoryVirtualMachineArtifactAuthorityError.insecureArtifact
+ }
+ var digest: String?
+ if hashContents {
+ var hasher = SHA256()
+ var buffer = [UInt8](repeating: 0, count: 1 * 1_024 * 1_024)
+ var reportedProgress = false
+ while true {
+ let count = buffer.withUnsafeMutableBytes {
+ Darwin.read(descriptor, $0.baseAddress, $0.count)
+ }
+ if count < 0, errno == EINTR { continue }
+ guard count >= 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.artifactChanged
+ }
+ if count == 0 { break }
+ hasher.update(data: Data(buffer.prefix(count)))
+ if !reportedProgress {
+ reportedProgress = true
+ try progressInjector?(.firstChunkHashed(path: path))
+ }
+ }
+ digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ }
+ var after = stat()
+ guard fstat(descriptor, &after) == 0,
+ isPrivateArtifact(after),
+ sameArtifactSnapshot(before, after) else {
+ throw DoryVirtualMachineArtifactAuthorityError.artifactChanged
+ }
+ return InspectedFile(
+ byteCount: UInt64(after.st_size),
+ device: UInt64(after.st_dev),
+ inode: UInt64(after.st_ino),
+ modifiedSeconds: Int64(after.st_mtimespec.tv_sec),
+ modifiedNanoseconds: Int64(after.st_mtimespec.tv_nsec),
+ changedSeconds: Int64(after.st_ctimespec.tv_sec),
+ changedNanoseconds: Int64(after.st_ctimespec.tv_nsec),
+ sha256: digest
+ )
+ }
+
+ private static func isPrivateArtifact(_ status: stat) -> Bool {
+ status.st_mode & S_IFMT == S_IFREG
+ && status.st_uid == geteuid()
+ && status.st_nlink == 1
+ && status.st_mode & 0o077 == 0
+ && status.st_size > 0
+ }
+
+ private static func sameArtifactSnapshot(_ lhs: stat, _ rhs: stat) -> Bool {
+ lhs.st_dev == rhs.st_dev
+ && lhs.st_ino == rhs.st_ino
+ && lhs.st_size == rhs.st_size
+ && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec
+ && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec
+ && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec
+ && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec
+ }
+
+ private static func file(
+ _ file: InspectedFile,
+ matches identity: DoryVirtualMachineArtifactIdentity
+ ) -> Bool {
+ switch identity {
+ case let .immutable(sha256, byteCount):
+ return file.byteCount == byteCount && file.sha256 == sha256
+ case let .mutable(
+ _, sha256, byteCount, device, inode, modifiedSeconds, modifiedNanoseconds,
+ changedSeconds, changedNanoseconds
+ ):
+ return file.byteCount == byteCount
+ && file.sha256 == sha256
+ && file.device == device
+ && file.inode == inode
+ && file.modifiedSeconds == modifiedSeconds
+ && file.modifiedNanoseconds == modifiedNanoseconds
+ && file.changedSeconds == changedSeconds
+ && file.changedNanoseconds == changedNanoseconds
+ }
+ }
+
+ private static func requiredDigest(_ file: InspectedFile) throws -> String {
+ guard let digest = file.sha256 else {
+ throw DoryVirtualMachineArtifactAuthorityError.artifactChanged
+ }
+ return digest
+ }
+
+ private static func validatedPath(_ value: String) throws -> String {
+ guard value.hasPrefix("/"), !value.contains("\0") else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidPath
+ }
+ let standardized = URL(fileURLWithPath: value).standardizedFileURL.path
+ guard standardized == value, standardized != "/" else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidPath
+ }
+ return standardized
+ }
+
+ private static func validate(_ reference: DoryVMResolverReference) throws {
+ guard reference.isValidForPersistence else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidReference
+ }
+ }
+
+ private func validate(_ record: DoryVirtualMachineArtifactAuthorityRecord) throws {
+ try Self.validate(record.reference)
+ guard record.schemaVersion == DoryVirtualMachineArtifactAuthorityRecord.schemaVersion,
+ record.authorityRevision > 0,
+ (try? Self.validatedPath(record.path)) == record.path else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ switch record.identity {
+ case let .immutable(sha256, byteCount):
+ guard record.kind != .virtualDisk, Self.isSHA256(sha256), byteCount > 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ case let .mutable(provenance, sha256, byteCount, _, _, _, _, _, _):
+ guard record.kind == .virtualDisk,
+ byteCount > 0,
+ Self.isSHA256(sha256),
+ provenance.revision == record.authorityRevision,
+ provenance.mediaIdentity == Self.referenceIdentity(record.reference),
+ provenance.repositoryIdentity == Self.digest(Data(root.utf8)) else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ }
+ }
+
+ private static func validateExpectedRevision(
+ _ expected: UInt64?,
+ current: DoryVirtualMachineArtifactAuthorityRecord?
+ ) throws {
+ guard let expected else {
+ guard current == nil else {
+ throw DoryVirtualMachineArtifactAuthorityError.staleRevision(
+ expected: 0,
+ actual: current!.authorityRevision
+ )
+ }
+ return
+ }
+ let actual = current?.authorityRevision ?? 0
+ guard expected == actual else {
+ throw DoryVirtualMachineArtifactAuthorityError.staleRevision(
+ expected: expected,
+ actual: actual
+ )
+ }
+ }
+
+ private static func nextRevision(
+ _ current: DoryVirtualMachineArtifactAuthorityRecord?
+ ) throws -> UInt64 {
+ guard let current else { return 1 }
+ guard current.authorityRevision < UInt64.max else {
+ throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
+ }
+ return current.authorityRevision + 1
+ }
+
+ private static func referenceIdentity(_ reference: DoryVMResolverReference) -> String {
+ digest(Data("\(reference.namespace)\u{0}\(reference.identifier)".utf8))
+ }
+
+ private static func canonicalData(_ value: T) -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return (try? encoder.encode(value)) ?? Data()
+ }
+
+ private static func digest(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy { byte in
+ (48...57).contains(byte) || (65...70).contains(byte)
+ || (97...102).contains(byte)
+ }
+ }
+
+ private static func ensurePrivateDirectory(_ path: String) throws {
+ if mkdir(path, mode_t(0o700)) != 0, errno != EEXIST {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "create artifact authority directory: errno \(errno)"
+ )
+ }
+ var status = stat()
+ guard lstat(path, &status) == 0,
+ status.st_mode & S_IFMT == S_IFDIR,
+ status.st_uid == geteuid(),
+ status.st_mode & 0o077 == 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "artifact authority directory is not private"
+ )
+ }
+ }
+
+ private static func syncDirectory(_ path: String) throws {
+ let descriptor = path.withCString {
+ open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
+ }
+ guard descriptor >= 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "open artifact authority directory: errno \(errno)"
+ )
+ }
+ defer { close(descriptor) }
+ guard fsync(descriptor) == 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "sync artifact authority directory: errno \(errno)"
+ )
+ }
+ }
+
+ private func withExclusiveAccess(_ body: () throws -> T) throws -> T {
+ try lock.withLock {
+ try Self.ensurePrivateDirectory(root)
+ let lockPath = root + "/" + Self.lockFilename
+ let descriptor = lockPath.withCString {
+ open($0, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600))
+ }
+ guard descriptor >= 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "open artifact authority lock: errno \(errno)"
+ )
+ }
+ defer { close(descriptor) }
+ var status = stat()
+ guard fstat(descriptor, &status) == 0,
+ status.st_mode & S_IFMT == S_IFREG,
+ status.st_uid == geteuid(),
+ status.st_nlink == 1,
+ status.st_mode & 0o077 == 0 else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "artifact authority lock is not private"
+ )
+ }
+ while flock(descriptor, LOCK_EX) != 0 {
+ guard errno == EINTR else {
+ throw DoryVirtualMachineArtifactAuthorityError.filesystem(
+ "lock artifact authority: errno \(errno)"
+ )
+ }
+ }
+ defer { _ = flock(descriptor, LOCK_UN) }
+ return try body()
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
index 0a42efa6..48d70d60 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
@@ -846,30 +846,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
}
private static func isSafeResolverReference(_ reference: DoryVMResolverReference) -> Bool {
- let namespace = Array(reference.namespace.utf8)
- let identifier = Array(reference.identifier.utf8)
- guard (1...32).contains(namespace.count),
- namespace[0] >= 97,
- namespace[0] <= 122,
- namespace.dropFirst().allSatisfy({ byte in
- (byte >= 97 && byte <= 122)
- || (byte >= 48 && byte <= 57)
- || byte == 45
- }),
- (1...64).contains(identifier.count),
- isASCIIAlphaNumeric(identifier[0]),
- identifier.dropFirst().allSatisfy({ byte in
- isASCIIAlphaNumeric(byte) || byte == 95 || byte == 46 || byte == 45
- }) else {
- return false
- }
- let lowercase = reference.identifier.lowercased()
- let secretPrefixes = [
- "sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-", "xoxa-", "xoxr-",
- "bearer-", "password-", "secret-", "token-", "akia",
- ]
- return !secretPrefixes.contains(where: lowercase.hasPrefix)
- && !lowercase.hasPrefix("eyj")
+ reference.isValidForPersistence
}
private static func bootMediaIsCompatible(
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift
new file mode 100644
index 00000000..b3c05d01
--- /dev/null
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift
@@ -0,0 +1,505 @@
+@testable import DoryOperations
+import Darwin
+import Foundation
+import XCTest
+
+final class DoryVirtualMachineArtifactAuthorityTests: XCTestCase {
+ func testImmutableArtifactIsContentAddressedAndRevalidated() throws {
+ try withFixture("immutable") { fixture in
+ let artifact = try fixture.file("installer.iso", data: Data("efi-image".utf8))
+ let published = try fixture.authority.publishImmutable(
+ reference: fixture.reference,
+ path: artifact,
+ kind: .installerISO,
+ source: .userProvided
+ )
+ XCTAssertEqual(published.authorityRevision, 1)
+ XCTAssertNotNil(published.media.artifactSHA256)
+ XCTAssertEqual(
+ try fixture.authority.resolve(
+ reference: fixture.reference,
+ kind: .installerISO,
+ source: .userProvided
+ ).media,
+ published.media
+ )
+
+ try Data("changed-image".utf8).write(to: URL(fileURLWithPath: artifact))
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: artifact
+ )
+ XCTAssertThrowsError(try fixture.authority.resolve(
+ reference: fixture.reference,
+ kind: .installerISO,
+ source: .userProvided
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineArtifactAuthorityError,
+ .artifactChanged
+ )
+ }
+ }
+ }
+
+ func testMutableArtifactRequiresExplicitRevisionPublication() throws {
+ try withFixture("mutable") { fixture in
+ let disk = try fixture.file("system.raw", data: Data(repeating: 0, count: 4_096))
+ let first = try fixture.authority.publishMutable(
+ reference: fixture.reference,
+ path: disk,
+ source: .userProvided
+ )
+ XCTAssertEqual(first.media.mutableProvenance?.revision, 1)
+ XCTAssertNotNil(first.mutableProvenance)
+
+ let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: disk))
+ try handle.seek(toOffset: 10)
+ try handle.write(contentsOf: Data([1]))
+ try handle.synchronize()
+ try handle.close()
+ XCTAssertThrowsError(try fixture.authority.resolve(
+ reference: fixture.reference,
+ kind: .virtualDisk,
+ source: .userProvided
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineArtifactAuthorityError,
+ .artifactChanged
+ )
+ }
+
+ let second = try fixture.authority.publishMutable(
+ reference: fixture.reference,
+ path: disk,
+ source: .userProvided,
+ expectedAuthorityRevision: 1
+ )
+ XCTAssertEqual(second.media.mutableProvenance?.revision, 2)
+ XCTAssertEqual(
+ try fixture.authority.resolve(
+ reference: fixture.reference,
+ kind: .virtualDisk,
+ source: .userProvided
+ ).authorityRevision,
+ 2
+ )
+ }
+ }
+
+ func testMutableArtifactDigestRejectsSameSizeMutationWithRestoredMtime() throws {
+ try withFixture("mutable-adversarial") { fixture in
+ let disk = try fixture.file(
+ "system.raw",
+ data: Data(repeating: 0x41, count: 4_096)
+ )
+ _ = try fixture.authority.publishMutable(
+ reference: fixture.reference,
+ path: disk,
+ source: .userProvided
+ )
+
+ var publishedStatus = stat()
+ XCTAssertEqual(lstat(disk, &publishedStatus), 0)
+ let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: disk))
+ try handle.seek(toOffset: 2_048)
+ try handle.write(contentsOf: Data([0x42]))
+ try handle.synchronize()
+ try handle.close()
+ let timestamps = [publishedStatus.st_atimespec, publishedStatus.st_mtimespec]
+ let restoreResult = timestamps.withUnsafeBufferPointer { buffer in
+ utimensat(AT_FDCWD, disk, buffer.baseAddress, AT_SYMLINK_NOFOLLOW)
+ }
+ XCTAssertEqual(restoreResult, 0)
+
+ var restoredStatus = stat()
+ XCTAssertEqual(lstat(disk, &restoredStatus), 0)
+ XCTAssertEqual(restoredStatus.st_size, publishedStatus.st_size)
+ XCTAssertEqual(
+ restoredStatus.st_mtimespec.tv_sec,
+ publishedStatus.st_mtimespec.tv_sec
+ )
+ XCTAssertEqual(
+ restoredStatus.st_mtimespec.tv_nsec,
+ publishedStatus.st_mtimespec.tv_nsec
+ )
+ XCTAssertThrowsError(try fixture.authority.resolve(
+ reference: fixture.reference,
+ kind: .virtualDisk,
+ source: .userProvided
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineArtifactAuthorityError,
+ .artifactChanged
+ )
+ }
+ }
+ }
+
+ func testMutationDuringHashWithRestoredMtimeIsRejectedByChangeTime() throws {
+ try withFixture("mutable-in-hash") { fixture in
+ let disk = try fixture.file(
+ "system.raw",
+ data: Data(repeating: 0x41, count: 2 * 1_024 * 1_024)
+ )
+ _ = try fixture.authority.publishMutable(
+ reference: fixture.reference,
+ path: disk,
+ source: .userProvided
+ )
+ let mutation = try InHashMutation(path: disk)
+ let inspectingAuthority = DoryVirtualMachineArtifactAuthority(
+ root: fixture.authority.root
+ ) { stage in
+ guard case let .firstChunkHashed(path) = stage else { return }
+ try mutation.apply(to: path)
+ }
+
+ XCTAssertThrowsError(try inspectingAuthority.resolve(
+ reference: fixture.reference,
+ kind: .virtualDisk,
+ source: .userProvided
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineArtifactAuthorityError,
+ .artifactChanged
+ )
+ }
+ XCTAssertTrue(mutation.didApply)
+ }
+ }
+
+ func testOptimisticRevisionAndRecordTamperFailClosed() throws {
+ try withFixture("revision") { fixture in
+ let artifact = try fixture.file("bundle", data: Data("bundle".utf8))
+ _ = try fixture.authority.publishImmutable(
+ reference: fixture.reference,
+ path: artifact,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory
+ )
+ XCTAssertThrowsError(try fixture.authority.publishImmutable(
+ reference: fixture.reference,
+ path: artifact,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ expectedAuthorityRevision: 0
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineArtifactAuthorityError,
+ .staleRevision(expected: 0, actual: 1)
+ )
+ }
+
+ let records = try FileManager.default.contentsOfDirectory(
+ atPath: fixture.authority.root
+ ).filter { $0.hasSuffix(".json") }
+ let record = try XCTUnwrap(records.first)
+ let path = fixture.authority.root + "/" + record
+ var data = try Data(contentsOf: URL(fileURLWithPath: path))
+ data.append(0x20)
+ try data.write(to: URL(fileURLWithPath: path))
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ XCTAssertThrowsError(try fixture.authority.resolve(
+ reference: fixture.reference,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineArtifactAuthorityError,
+ .invalidRecord
+ )
+ }
+ }
+ }
+
+ func testSymlinksHardlinksAndWorldReadableArtifactsAreRejected() throws {
+ try withFixture("unsafe") { fixture in
+ let target = try fixture.file("target", data: Data("artifact".utf8))
+ let symlink = fixture.root + "/link"
+ try FileManager.default.createSymbolicLink(
+ atPath: symlink,
+ withDestinationPath: target
+ )
+ XCTAssertThrowsError(try fixture.authority.publishImmutable(
+ reference: fixture.reference,
+ path: symlink,
+ kind: .installerISO,
+ source: .userProvided
+ ))
+
+ let hardlink = fixture.root + "/hardlink"
+ XCTAssertEqual(link(target, hardlink), 0)
+ XCTAssertThrowsError(try fixture.authority.publishImmutable(
+ reference: fixture.reference,
+ path: target,
+ kind: .installerISO,
+ source: .userProvided
+ ))
+ try FileManager.default.removeItem(atPath: hardlink)
+
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o644],
+ ofItemAtPath: target
+ )
+ XCTAssertThrowsError(try fixture.authority.publishImmutable(
+ reference: fixture.reference,
+ path: target,
+ kind: .installerISO,
+ source: .userProvided
+ ))
+ }
+ }
+
+ func testResolverReferencesUseTheWorkspacePersistenceContract() throws {
+ try withFixture("hostile-reference") { fixture in
+ let artifact = try fixture.file("image.iso", data: Data("image".utf8))
+ let hostile = [
+ DoryVMResolverReference(namespace: "artifact", identifier: "/tmp/image.iso"),
+ DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "https://example.com/image.iso"
+ ),
+ DoryVMResolverReference(namespace: "artifact", identifier: "sk-secret"),
+ DoryVMResolverReference(namespace: "Bad.Namespace", identifier: "image"),
+ DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: String(repeating: "a", count: 65)
+ ),
+ ]
+ for reference in hostile {
+ XCTAssertFalse(reference.isValidForPersistence)
+ XCTAssertThrowsError(try fixture.authority.publishImmutable(
+ reference: reference,
+ path: artifact,
+ kind: .installerISO,
+ source: .userProvided
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineArtifactAuthorityError,
+ .invalidReference
+ )
+ }
+ }
+ }
+ }
+
+ func testTwoAuthorityInstancesSerializeOptimisticRevision() async throws {
+ let fixture = try ArtifactFixture("cross-instance")
+ defer { fixture.cleanup() }
+ let disk = try fixture.file("system.raw", data: Data(repeating: 0, count: 4_096))
+ _ = try fixture.authority.publishMutable(
+ reference: fixture.reference,
+ path: disk,
+ source: .userProvided
+ )
+ let secondAuthority = DoryVirtualMachineArtifactAuthority(root: fixture.authority.root)
+ let reference = fixture.reference
+
+ let results = await withTaskGroup(
+ of: Result.self,
+ returning: [Result].self
+ ) { group in
+ for authority in [fixture.authority, secondAuthority] {
+ group.addTask {
+ do {
+ let result = try authority.publishMutable(
+ reference: reference,
+ path: disk,
+ source: .userProvided,
+ expectedAuthorityRevision: 1
+ )
+ return .success(result.authorityRevision)
+ } catch let error as DoryVirtualMachineArtifactAuthorityError {
+ return .failure(error)
+ } catch {
+ return .failure(.filesystem("unexpected error"))
+ }
+ }
+ }
+ var values: [Result] = []
+ for await result in group { values.append(result) }
+ return values
+ }
+
+ XCTAssertEqual(results.compactMap { try? $0.get() }, [2])
+ XCTAssertEqual(results.compactMap { result -> DoryVirtualMachineArtifactAuthorityError? in
+ guard case let .failure(error) = result else { return nil }
+ return error
+ }, [.staleRevision(expected: 1, actual: 2)])
+ }
+
+ func testMutableRecordCannotBeSubstitutedIntoAnotherAuthorityRoot() throws {
+ try withFixture("root-binding-source") { source in
+ try withFixture("root-binding-destination") { destination in
+ let disk = try source.file(
+ "system.raw",
+ data: Data(repeating: 0, count: 4_096)
+ )
+ _ = try source.authority.publishMutable(
+ reference: source.reference,
+ path: disk,
+ source: .userProvided
+ )
+ _ = try destination.authority.authorityRecord(reference: destination.reference)
+ let recordName = try XCTUnwrap(try FileManager.default.contentsOfDirectory(
+ atPath: source.authority.root
+ ).first { $0.hasSuffix(".json") })
+ let destinationRecord = destination.authority.root + "/" + recordName
+ try FileManager.default.copyItem(
+ atPath: source.authority.root + "/" + recordName,
+ toPath: destinationRecord
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: destinationRecord
+ )
+
+ XCTAssertThrowsError(try destination.authority.resolve(
+ reference: destination.reference,
+ kind: .virtualDisk,
+ source: .userProvided
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineArtifactAuthorityError,
+ .invalidRecord
+ )
+ }
+ }
+ }
+ }
+
+ func testInterruptedReplacementPreservesOldRecordAndCleansTemporaryFile() throws {
+ try withFixture("interrupted-publication") { fixture in
+ let artifact = try fixture.file("bundle", data: Data("bundle".utf8))
+ _ = try fixture.authority.publishImmutable(
+ reference: fixture.reference,
+ path: artifact,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory
+ )
+ let interrupted = DoryVirtualMachineArtifactAuthority(
+ root: fixture.authority.root
+ ) { stage in
+ guard case .temporaryFileSynced = stage else { return }
+ throw PublicationInterruption.injected
+ }
+ XCTAssertThrowsError(try interrupted.publishImmutable(
+ reference: fixture.reference,
+ path: artifact,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ expectedAuthorityRevision: 1
+ )) { error in
+ XCTAssertEqual(error as? PublicationInterruption, .injected)
+ }
+ XCTAssertEqual(
+ try fixture.authority.resolve(
+ reference: fixture.reference,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory
+ ).authorityRevision,
+ 1
+ )
+ XCTAssertFalse(try FileManager.default.contentsOfDirectory(
+ atPath: fixture.authority.root
+ ).contains { $0.hasPrefix(".artifact-authority.") && $0 != ".artifact-authority.lock" })
+ }
+ }
+
+ private func withFixture(
+ _ name: String,
+ body: (ArtifactFixture) throws -> Void
+ ) throws {
+ let fixture = try ArtifactFixture(name)
+ defer { fixture.cleanup() }
+ try body(fixture)
+ }
+}
+
+private enum PublicationInterruption: Error, Equatable {
+ case injected
+}
+
+private final class InHashMutation: @unchecked Sendable {
+ private let expectedPath: String
+ private let timestamps: [timespec]
+ private let lock = NSLock()
+ private var applied = false
+
+ init(path: String) throws {
+ var status = stat()
+ guard lstat(path, &status) == 0 else { throw InHashMutationError.statFailed }
+ expectedPath = path
+ timestamps = [status.st_atimespec, status.st_mtimespec]
+ }
+
+ var didApply: Bool { lock.withLock { applied } }
+
+ func apply(to path: String) throws {
+ try lock.withLock {
+ guard !applied else { return }
+ guard path == expectedPath else { throw InHashMutationError.unexpectedPath }
+ let descriptor = path.withCString {
+ open($0, O_WRONLY | O_CLOEXEC | O_NOFOLLOW)
+ }
+ guard descriptor >= 0 else { throw InHashMutationError.openFailed }
+ defer { close(descriptor) }
+ var byte: UInt8 = 0x42
+ guard pwrite(descriptor, &byte, 1, 0) == 1,
+ fsync(descriptor) == 0 else {
+ throw InHashMutationError.writeFailed
+ }
+ let result = timestamps.withUnsafeBufferPointer { buffer in
+ utimensat(AT_FDCWD, path, buffer.baseAddress, AT_SYMLINK_NOFOLLOW)
+ }
+ guard result == 0 else { throw InHashMutationError.restoreTimeFailed }
+ applied = true
+ }
+ }
+}
+
+private enum InHashMutationError: Error {
+ case statFailed
+ case unexpectedPath
+ case openFailed
+ case writeFailed
+ case restoreTimeFailed
+}
+
+private final class ArtifactFixture {
+ let root: String
+ let authority: DoryVirtualMachineArtifactAuthority
+ let reference = DoryVMResolverReference(
+ namespace: "legacy-artifact",
+ identifier: String(repeating: "a", count: 64)
+ )
+
+ init(_ name: String) throws {
+ root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-artifact-authority-\(name)-\(UUID().uuidString)",
+ isDirectory: true
+ ).path
+ try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o700],
+ ofItemAtPath: root
+ )
+ authority = DoryVirtualMachineArtifactAuthority(root: root + "/records")
+ }
+
+ func file(_ name: String, data: Data) throws -> String {
+ let path = root + "/" + name
+ try data.write(to: URL(fileURLWithPath: path))
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ return path
+ }
+
+ func cleanup() { try? FileManager.default.removeItem(atPath: root) }
+}
From a80448a51439fe63dd63dcdc2ef18a709006798c Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 13:54:40 +0000
Subject: [PATCH 021/338] feat(workspace): version lifecycle runtime bindings
---
.../DoryWorkspaceLifecycleOperation.swift | 391 +++++++++++++++++-
...DoryWorkspaceLifecycleOperationTests.swift | 204 ++++++++-
2 files changed, 572 insertions(+), 23 deletions(-)
diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
index 134d9874..224abda1 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
@@ -77,34 +77,232 @@ public struct DoryWorkspaceResolvedCondition: Codable, Sendable, Equatable {
}
}
+public enum DoryWorkspaceRuntimePolicy: String, Codable, CaseIterable, Sendable {
+ case legacyCompatibility = "legacy-compatibility"
+ case requireResolvedPlan = "require-resolved-plan"
+}
+
+public enum DoryWorkspaceRuntimeAuthorizationState: String, Codable, CaseIterable, Sendable {
+ case legacyCompatibility = "legacy-compatibility"
+ case resolvedPlan = "resolved-plan"
+ case requiresReplanning = "requires-replanning"
+}
+
+/// Exact runtime-policy authority for a lifecycle transition. Legacy compatibility is explicit
+/// and cannot carry plan claims; resolved operation bindings must carry the complete immutable
+/// plan identity used by the executor. `runtimeIdentityDigest` binds the full status identity,
+/// including a requires-replanning identity when a resolved-policy restore invalidates launch.
+public struct DoryWorkspaceRuntimeBinding: Codable, Sendable, Equatable {
+ public var policy: DoryWorkspaceRuntimePolicy
+ public var authorizationState: DoryWorkspaceRuntimeAuthorizationState
+ public var virtualHardwareABIVersion: UInt16
+ public var runtimeIdentityDigest: String
+ public var resolved: DoryWorkspaceResolvedCondition?
+
+ public init(
+ policy: DoryWorkspaceRuntimePolicy,
+ authorizationState: DoryWorkspaceRuntimeAuthorizationState,
+ virtualHardwareABIVersion: UInt16,
+ runtimeIdentityDigest: String,
+ resolved: DoryWorkspaceResolvedCondition? = nil
+ ) {
+ self.policy = policy
+ self.authorizationState = authorizationState
+ self.virtualHardwareABIVersion = virtualHardwareABIVersion
+ self.runtimeIdentityDigest = runtimeIdentityDigest
+ self.resolved = resolved
+ }
+
+ public static func legacyCompatibility(
+ virtualHardwareABIVersion: UInt16,
+ runtimeIdentityDigest: String
+ ) -> Self {
+ Self(
+ policy: .legacyCompatibility,
+ authorizationState: .legacyCompatibility,
+ virtualHardwareABIVersion: virtualHardwareABIVersion,
+ runtimeIdentityDigest: runtimeIdentityDigest
+ )
+ }
+
+ public static func resolvedPlan(
+ _ resolved: DoryWorkspaceResolvedCondition,
+ runtimeIdentityDigest: String
+ ) -> Self {
+ Self(
+ policy: .requireResolvedPlan,
+ authorizationState: .resolvedPlan,
+ virtualHardwareABIVersion: resolved.virtualHardwareABIVersion,
+ runtimeIdentityDigest: runtimeIdentityDigest,
+ resolved: resolved
+ )
+ }
+
+ public static func requiresReplanning(
+ virtualHardwareABIVersion: UInt16,
+ runtimeIdentityDigest: String
+ ) -> Self {
+ Self(
+ policy: .requireResolvedPlan,
+ authorizationState: .requiresReplanning,
+ virtualHardwareABIVersion: virtualHardwareABIVersion,
+ runtimeIdentityDigest: runtimeIdentityDigest
+ )
+ }
+
+ fileprivate var isValid: Bool {
+ guard virtualHardwareABIVersion > 0,
+ DoryOperationJournalStore.isDigest(runtimeIdentityDigest) else {
+ return false
+ }
+ switch (policy, authorizationState) {
+ case (.legacyCompatibility, .legacyCompatibility):
+ return resolved == nil
+ case (.requireResolvedPlan, .resolvedPlan):
+ return resolved?.isValid == true
+ && resolved?.virtualHardwareABIVersion == virtualHardwareABIVersion
+ case (.requireResolvedPlan, .requiresReplanning):
+ return resolved == nil
+ default:
+ return false
+ }
+ }
+}
+
+/// Content authority only; host paths and configuration values never enter the journal contract.
+public struct DoryWorkspaceConfigurationAuthority: Codable, Sendable, Equatable {
+ public var legacyConfigurationSHA256: String
+ public var canonicalDefinitionSHA256: String?
+
+ public init(
+ legacyConfigurationSHA256: String,
+ canonicalDefinitionSHA256: String? = nil
+ ) {
+ self.legacyConfigurationSHA256 = legacyConfigurationSHA256
+ self.canonicalDefinitionSHA256 = canonicalDefinitionSHA256
+ }
+
+ fileprivate var isValid: Bool {
+ DoryOperationJournalStore.isDigest(legacyConfigurationSHA256)
+ && (canonicalDefinitionSHA256.map(DoryOperationJournalStore.isDigest) ?? true)
+ }
+}
+
public struct DoryWorkspaceLifecycleCondition: Codable, Sendable, Equatable {
+ private var persistenceSchemaVersion: UInt16
public var workspaceID: String
public var state: DoryWorkspaceLifecycleState
public var definitionRevision: UInt64?
- public var resolved: DoryWorkspaceResolvedCondition?
+ public var runtime: DoryWorkspaceRuntimeBinding?
+ public var configurationAuthority: DoryWorkspaceConfigurationAuthority?
+
+ /// Read-only source compatibility for schema-v1 inspection. New construction must provide an
+ /// exact tagged runtime binding and cannot synthesize an identity digest from a plan digest.
+ public var resolved: DoryWorkspaceResolvedCondition? { runtime?.resolved }
public init(
workspaceID: String,
state: DoryWorkspaceLifecycleState,
definitionRevision: UInt64? = nil,
- resolved: DoryWorkspaceResolvedCondition? = nil
+ runtime: DoryWorkspaceRuntimeBinding? = nil,
+ configurationAuthority: DoryWorkspaceConfigurationAuthority? = nil
) {
+ persistenceSchemaVersion = DoryWorkspaceLifecycleOperation.schemaVersion
self.workspaceID = workspaceID
self.state = state
self.definitionRevision = definitionRevision
- self.resolved = resolved
+ self.runtime = runtime
+ self.configurationAuthority = configurationAuthority
}
fileprivate var isValid: Bool {
guard DoryOperationJournalStore.isToken(workspaceID) else { return false }
if state == .absent {
- return definitionRevision == nil && resolved == nil
+ return definitionRevision == nil && runtime == nil && configurationAuthority == nil
}
return definitionRevision.map { $0 > 0 } == true
- && (resolved?.isValid ?? true)
+ && (runtime?.isValid ?? true)
+ && (configurationAuthority?.isValid ?? true)
&& (!(state == .running || state == .paused || state == .suspended)
- || resolved != nil)
+ || (runtime != nil
+ && runtime?.authorizationState != .requiresReplanning))
}
+
+ private enum CodingKeys: String, CodingKey {
+ case conditionSchemaVersion
+ case workspaceID
+ case state
+ case definitionRevision
+ case resolved
+ case runtime
+ case configurationAuthority
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ workspaceID = try container.decode(String.self, forKey: .workspaceID)
+ state = try container.decode(DoryWorkspaceLifecycleState.self, forKey: .state)
+ definitionRevision = try container.decodeIfPresent(UInt64.self, forKey: .definitionRevision)
+ if let conditionSchemaVersion = try container.decodeIfPresent(
+ UInt16.self,
+ forKey: .conditionSchemaVersion
+ ) {
+ guard conditionSchemaVersion == DoryWorkspaceLifecycleOperation.schemaVersion,
+ !container.contains(.resolved) else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .conditionSchemaVersion,
+ in: container,
+ debugDescription: "unsupported workspace lifecycle condition schema"
+ )
+ }
+ persistenceSchemaVersion = DoryWorkspaceLifecycleOperation.schemaVersion
+ runtime = try container.decodeIfPresent(
+ DoryWorkspaceRuntimeBinding.self,
+ forKey: .runtime
+ )
+ configurationAuthority = try container.decodeIfPresent(
+ DoryWorkspaceConfigurationAuthority.self,
+ forKey: .configurationAuthority
+ )
+ } else {
+ guard !container.contains(.runtime), !container.contains(.configurationAuthority) else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .conditionSchemaVersion,
+ in: container,
+ debugDescription: "workspace lifecycle condition schema marker is required"
+ )
+ }
+ persistenceSchemaVersion = DoryWorkspaceLifecycleOperation.oldestSupportedSchemaVersion
+ configurationAuthority = nil
+ if let legacyResolved = try container.decodeIfPresent(
+ DoryWorkspaceResolvedCondition.self,
+ forKey: .resolved
+ ) {
+ runtime = .resolvedPlan(
+ legacyResolved,
+ runtimeIdentityDigest: legacyResolved.planDigest
+ )
+ } else {
+ runtime = nil
+ }
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(workspaceID, forKey: .workspaceID)
+ try container.encode(state, forKey: .state)
+ try container.encodeIfPresent(definitionRevision, forKey: .definitionRevision)
+ if persistenceSchemaVersion == DoryWorkspaceLifecycleOperation.oldestSupportedSchemaVersion {
+ try container.encodeIfPresent(runtime?.resolved, forKey: .resolved)
+ } else {
+ try container.encode(persistenceSchemaVersion, forKey: .conditionSchemaVersion)
+ try container.encodeIfPresent(runtime, forKey: .runtime)
+ try container.encodeIfPresent(configurationAuthority, forKey: .configurationAuthority)
+ }
+ }
+
+ fileprivate var encodedSchemaVersion: UInt16 { persistenceSchemaVersion }
}
public enum DoryWorkspaceOperationStage: String, Codable, CaseIterable, Sendable {
@@ -246,14 +444,18 @@ public struct DoryWorkspaceLifecycleJournalBinding: Sendable, Equatable {
/// actual progress to `DoryOperationJournalStore`; this specification is the exact transition,
/// deadline, retry, readiness, cancellation, and recovery contract they must follow.
public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
- public static let schemaVersion: UInt16 = 1
+ public static let oldestSupportedSchemaVersion: UInt16 = 1
+ public static let schemaVersion: UInt16 = 2
public var schemaVersion: UInt16
+ public var sourceSchemaVersion: UInt16
public var operationID: UUID
public var kind: DoryWorkspaceMutationKind
public var source: DoryWorkspaceLifecycleCondition
public var target: DoryWorkspaceLifecycleCondition
public var targetWorkspaceID: String?
+ /// Stable snapshot or clone identity needed for deterministic recovery; never a host path.
+ public var targetResourceID: String?
public var createdAtUnixMilliseconds: Int64
public var deadlineUnixMilliseconds: Int64
public var steps: [DoryWorkspaceOperationStep]
@@ -268,6 +470,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
source: DoryWorkspaceLifecycleCondition,
target: DoryWorkspaceLifecycleCondition,
targetWorkspaceID: String? = nil,
+ targetResourceID: String? = nil,
createdAtUnixMilliseconds: Int64,
deadlineUnixMilliseconds: Int64,
steps: [DoryWorkspaceOperationStep],
@@ -277,11 +480,13 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
recovery: DoryWorkspaceRecoveryRecipe
) {
schemaVersion = Self.schemaVersion
+ sourceSchemaVersion = Self.schemaVersion
self.operationID = operationID
self.kind = kind
self.source = source
self.target = target
self.targetWorkspaceID = targetWorkspaceID
+ self.targetResourceID = targetResourceID
self.createdAtUnixMilliseconds = createdAtUnixMilliseconds
self.deadlineUnixMilliseconds = deadlineUnixMilliseconds
self.steps = steps
@@ -297,7 +502,12 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
issues.append(DoryWorkspaceOperationValidationIssue(code: code, field: field))
}
- if schemaVersion != Self.schemaVersion { add(.invalidSchemaVersion, "schemaVersion") }
+ if !(Self.oldestSupportedSchemaVersion...Self.schemaVersion).contains(schemaVersion)
+ || sourceSchemaVersion != schemaVersion
+ || source.encodedSchemaVersion != schemaVersion
+ || target.encodedSchemaVersion != schemaVersion {
+ add(.invalidSchemaVersion, "schemaVersion")
+ }
if operationID == UUID.zero { add(.invalidOperationID, "operationID") }
if !source.isValid { add(.invalidCondition, "source") }
if !target.isValid { add(.invalidCondition, "target") }
@@ -308,14 +518,45 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
} else if source.workspaceID != target.workspaceID {
add(.invalidCondition, "target.workspaceID")
}
- if Self.requiresResolvedSource(kind), source.resolved == nil {
+ if Self.requiresRuntimeSource(kind), source.runtime == nil {
add(.invalidCondition, "source")
}
- if Self.requiresResolvedTarget(kind), target.resolved == nil {
+ if Self.requiresRuntimeTarget(kind), target.runtime == nil {
add(.invalidCondition, "target")
}
- if Self.requiresUnchangedResolvedPlan(kind), source.resolved != target.resolved {
- add(.invalidCondition, "target.resolved")
+ if Self.requiresUnchangedRuntimePlan(kind),
+ !Self.hasSameRuntimePlan(source.runtime, target.runtime) {
+ add(.invalidCondition, "target.runtime")
+ }
+ if kind == .starting {
+ if target.runtime?.authorizationState != .resolvedPlan
+ && target.runtime?.authorizationState != .legacyCompatibility {
+ add(.invalidCondition, "target.runtime.authorizationState")
+ }
+ if source.runtime?.policy != target.runtime?.policy {
+ add(.invalidCondition, "target.runtime.policy")
+ } else if source.runtime?.authorizationState != .requiresReplanning,
+ !Self.hasSameRuntimePlan(source.runtime, target.runtime) {
+ add(.invalidCondition, "target.runtime")
+ }
+ }
+ if sourceSchemaVersion == Self.schemaVersion,
+ kind == .restoring,
+ source.runtime?.policy == .requireResolvedPlan,
+ target.runtime?.authorizationState != .requiresReplanning {
+ add(.invalidCondition, "target.runtime.authorizationState")
+ }
+ if source.runtime?.policy != target.runtime?.policy,
+ kind != .importing, kind != .provisioning, kind != .resolving {
+ add(.invalidCondition, "target.runtime.policy")
+ }
+ if sourceSchemaVersion == Self.schemaVersion {
+ if source.state != .absent, source.configurationAuthority == nil {
+ add(.invalidCondition, "source.configurationAuthority")
+ }
+ if target.state != .absent, target.configurationAuthority == nil {
+ add(.invalidCondition, "target.configurationAuthority")
+ }
}
if !Self.allows(kind: kind, source: source.state, target: target.state) {
add(.invalidTransition, "target.state")
@@ -328,6 +569,15 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
} else if targetWorkspaceID != nil {
add(.invalidTargetWorkspace, "targetWorkspaceID")
}
+ let requiresResourceID = kind == .snapshotting || kind == .restoring || kind == .cloning
+ if requiresResourceID {
+ if sourceSchemaVersion == Self.schemaVersion,
+ targetResourceID.map(DoryOperationJournalStore.isToken) != true {
+ add(.invalidTargetWorkspace, "targetResourceID")
+ }
+ } else if targetResourceID != nil {
+ add(.invalidTargetWorkspace, "targetResourceID")
+ }
if createdAtUnixMilliseconds < 0
|| deadlineUnixMilliseconds <= createdAtUnixMilliseconds {
add(.invalidDeadline, "deadlineUnixMilliseconds")
@@ -365,6 +615,97 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
return issues
}
+ private enum CodingKeys: String, CodingKey {
+ case schemaVersion
+ case sourceSchemaVersion
+ case operationID
+ case kind
+ case source
+ case target
+ case targetWorkspaceID
+ case targetResourceID
+ case createdAtUnixMilliseconds
+ case deadlineUnixMilliseconds
+ case steps
+ case readinessGates
+ case retryBudgets
+ case cancellationPolicy
+ case recovery
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let persistedSchema = try container.decode(UInt16.self, forKey: .schemaVersion)
+ guard (Self.oldestSupportedSchemaVersion...Self.schemaVersion).contains(persistedSchema) else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .schemaVersion,
+ in: container,
+ debugDescription: "unsupported workspace lifecycle schema"
+ )
+ }
+ schemaVersion = persistedSchema
+ sourceSchemaVersion = try container.decodeIfPresent(UInt16.self, forKey: .sourceSchemaVersion)
+ ?? persistedSchema
+ guard sourceSchemaVersion == persistedSchema else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .sourceSchemaVersion,
+ in: container,
+ debugDescription: "workspace lifecycle source schema mismatch"
+ )
+ }
+ operationID = try container.decode(UUID.self, forKey: .operationID)
+ kind = try container.decode(DoryWorkspaceMutationKind.self, forKey: .kind)
+ source = try container.decode(DoryWorkspaceLifecycleCondition.self, forKey: .source)
+ target = try container.decode(DoryWorkspaceLifecycleCondition.self, forKey: .target)
+ guard source.encodedSchemaVersion == persistedSchema,
+ target.encodedSchemaVersion == persistedSchema else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .source,
+ in: container,
+ debugDescription: "workspace lifecycle condition schema mismatch"
+ )
+ }
+ targetWorkspaceID = try container.decodeIfPresent(String.self, forKey: .targetWorkspaceID)
+ targetResourceID = try container.decodeIfPresent(String.self, forKey: .targetResourceID)
+ createdAtUnixMilliseconds = try container.decode(Int64.self, forKey: .createdAtUnixMilliseconds)
+ deadlineUnixMilliseconds = try container.decode(Int64.self, forKey: .deadlineUnixMilliseconds)
+ steps = try container.decode([DoryWorkspaceOperationStep].self, forKey: .steps)
+ readinessGates = try container.decodeIfPresent(
+ [DoryWorkspaceReadinessGate].self,
+ forKey: .readinessGates
+ ) ?? []
+ retryBudgets = try container.decodeIfPresent(
+ [DoryWorkspaceRetryBudget].self,
+ forKey: .retryBudgets
+ ) ?? []
+ cancellationPolicy = try container.decode(
+ DoryWorkspaceCancellationPolicy.self,
+ forKey: .cancellationPolicy
+ )
+ recovery = try container.decode(DoryWorkspaceRecoveryRecipe.self, forKey: .recovery)
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(schemaVersion, forKey: .schemaVersion)
+ if schemaVersion == Self.schemaVersion {
+ try container.encode(sourceSchemaVersion, forKey: .sourceSchemaVersion)
+ }
+ try container.encode(operationID, forKey: .operationID)
+ try container.encode(kind, forKey: .kind)
+ try container.encode(source, forKey: .source)
+ try container.encode(target, forKey: .target)
+ try container.encodeIfPresent(targetWorkspaceID, forKey: .targetWorkspaceID)
+ try container.encodeIfPresent(targetResourceID, forKey: .targetResourceID)
+ try container.encode(createdAtUnixMilliseconds, forKey: .createdAtUnixMilliseconds)
+ try container.encode(deadlineUnixMilliseconds, forKey: .deadlineUnixMilliseconds)
+ try container.encode(steps, forKey: .steps)
+ try container.encode(readinessGates, forKey: .readinessGates)
+ try container.encode(retryBudgets, forKey: .retryBudgets)
+ try container.encode(cancellationPolicy, forKey: .cancellationPolicy)
+ try container.encode(recovery, forKey: .recovery)
+ }
+
public func journalSpecification() throws -> DoryOperationSpecification {
let issues = validate()
guard issues.isEmpty else {
@@ -423,7 +764,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
|| (kind == .restoring && targetState == .running)
}
- private static func requiresResolvedSource(_ kind: DoryWorkspaceMutationKind) -> Bool {
+ private static func requiresRuntimeSource(_ kind: DoryWorkspaceMutationKind) -> Bool {
switch kind {
case .starting, .stopping, .pausing, .resuming, .suspending, .restoring,
.snapshotting, .cloning, .updating:
@@ -433,7 +774,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
}
}
- private static func requiresResolvedTarget(_ kind: DoryWorkspaceMutationKind) -> Bool {
+ private static func requiresRuntimeTarget(_ kind: DoryWorkspaceMutationKind) -> Bool {
switch kind {
case .provisioning, .resolving, .starting, .stopping, .pausing, .resuming,
.suspending, .restoring, .snapshotting, .cloning, .updating, .repairing:
@@ -443,16 +784,29 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
}
}
- private static func requiresUnchangedResolvedPlan(_ kind: DoryWorkspaceMutationKind) -> Bool {
+ private static func requiresUnchangedRuntimePlan(_ kind: DoryWorkspaceMutationKind) -> Bool {
switch kind {
- case .starting, .stopping, .pausing, .resuming, .suspending, .snapshotting:
+ case .stopping, .pausing, .resuming, .suspending, .snapshotting:
true
- case .importing, .provisioning, .resolving, .restoring, .cloning, .updating,
+ case .importing, .provisioning, .resolving, .starting, .restoring, .cloning, .updating,
.repairing, .deleting:
false
}
}
+ private static func hasSameRuntimePlan(
+ _ source: DoryWorkspaceRuntimeBinding?,
+ _ target: DoryWorkspaceRuntimeBinding?
+ ) -> Bool {
+ guard let source, let target, source.policy == target.policy,
+ source.virtualHardwareABIVersion == target.virtualHardwareABIVersion else {
+ return false
+ }
+ return source.authorizationState == target.authorizationState
+ && source.runtimeIdentityDigest == target.runtimeIdentityDigest
+ && source.resolved == target.resolved
+ }
+
private func deadlineContains(offset: UInt64) -> Bool {
guard createdAtUnixMilliseconds >= 0,
deadlineUnixMilliseconds > createdAtUnixMilliseconds,
@@ -481,7 +835,8 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
case .suspending:
(source == .running || source == .paused) && target == .suspended
case .restoring:
- (source == .stopped || source == .suspended || source == .failed)
+ (source == .stopped || source == .suspended || source == .failed
+ || source == .running || source == .paused)
&& (target == .stopped || target == .running)
case .snapshotting:
(source == .stopped || source == .running || source == .paused)
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
index 26dd0117..638f71d4 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
@@ -60,6 +60,44 @@ struct DoryWorkspaceLifecycleOperationTests {
}
}
+ @Test("schema v2 importing persists an explicit absent-condition discriminator")
+ func importingAbsentConditionJournalRoundTrip() throws {
+ let operation = DoryWorkspaceLifecycleOperation(
+ operationID: UUID(uuidString: "c4cf98dc-c476-40c6-b5e9-f05f460b465f")!,
+ kind: .importing,
+ source: DoryWorkspaceLifecycleCondition(
+ workspaceID: "workspace-import",
+ state: .absent
+ ),
+ target: condition(
+ .defined,
+ workspaceID: "workspace-import",
+ resolved: nil
+ ),
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ deadlineUnixMilliseconds: 1_700_000_060_000,
+ steps: [step("import", 50_000)],
+ cancellationPolicy: .beforePublish,
+ recovery: .init(disposition: .rollback, stepIDs: ["import"])
+ )
+ #expect(operation.validate().isEmpty)
+ let binding = try operation.journalBinding(
+ dependencyClosureDigest: String(repeating: "b", count: 64)
+ )
+ #expect(binding.specification.data.range(of: Data("conditionSchemaVersion".utf8)) != nil)
+
+ let home = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-workspace-import-v2-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: home, withIntermediateDirectories: false)
+ defer { try? FileManager.default.removeItem(at: home) }
+ let store = try DoryOperationJournalStore(home: home.path)
+ var lease: DoryOperationLease? = try store.begin(binding)
+ #expect(try lease?.readWorkspaceLifecycleOperation() == operation)
+ lease = nil
+ let recovered = try store.acquire(operation.operationID)
+ #expect(try recovered.readWorkspaceLifecycleOperation() == operation)
+ }
+
@Test("all lifecycle mutation kinds have stable journal kinds")
func journalKinds() {
#expect(Set(DoryWorkspaceMutationKind.allCases.map(\.journalKind.rawValue)).count
@@ -71,7 +109,7 @@ struct DoryWorkspaceLifecycleOperationTests {
@Test("start requires exact resolved source target and backend readiness")
func startRequirements() {
var operation = makeOperation()
- operation.source.resolved = nil
+ operation.source.runtime = nil
#expect(has(.invalidCondition, "source", operation))
operation = makeOperation()
@@ -113,6 +151,7 @@ struct DoryWorkspaceLifecycleOperationTests {
kind: .cloning,
source: condition(.stopped, resolved: resolved),
target: condition(.stopped, workspaceID: "workspace-copy", resolved: resolved),
+ targetResourceID: "workspace-copy",
createdAtUnixMilliseconds: 1_700_000_000_000,
deadlineUnixMilliseconds: 1_700_000_060_000,
steps: [step("clone", 30_000)],
@@ -142,7 +181,8 @@ struct DoryWorkspaceLifecycleOperationTests {
let restoreStopped = DoryWorkspaceLifecycleOperation(
kind: .restoring,
source: condition(.stopped, resolved: resolved),
- target: condition(.stopped, resolved: resolved),
+ target: condition(.stopped, resolved: nil, requiresReplanning: true),
+ targetResourceID: "snapshot-one",
createdAtUnixMilliseconds: 1_700_000_000_000,
deadlineUnixMilliseconds: 1_700_000_060_000,
steps: [step("restore", 50_000)],
@@ -196,6 +236,141 @@ struct DoryWorkspaceLifecycleOperationTests {
#expect(has(.invalidCondition, "source", operation))
}
+ @Test("schema v1 resolved journals migrate into strict tagged runtime bindings")
+ func schemaV1GoldenMigration() throws {
+ let json = """
+ {
+ "schemaVersion": 1,
+ "operationID": "8F9F5B13-77EE-4F59-8A13-1704147C7F00",
+ "kind": "starting",
+ "source": {
+ "workspaceID": "workspace-one", "state": "stopped", "definitionRevision": 3,
+ "resolved": {
+ "planRevision": 4,
+ "planDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "backendID": "dory-hv-linux", "backendRuntimeBuildID": "dory-hv-1.0.0",
+ "virtualHardwareABIVersion": 1
+ }
+ },
+ "target": {
+ "workspaceID": "workspace-one", "state": "running", "definitionRevision": 3,
+ "resolved": {
+ "planRevision": 4,
+ "planDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "backendID": "dory-hv-linux", "backendRuntimeBuildID": "dory-hv-1.0.0",
+ "virtualHardwareABIVersion": 1
+ }
+ },
+ "createdAtUnixMilliseconds": 1700000000000,
+ "deadlineUnixMilliseconds": 1700000060000,
+ "steps": [
+ {"id":"launch","stage":"launch","deadlineOffsetMilliseconds":20000},
+ {"id":"ready","stage":"readiness","deadlineOffsetMilliseconds":55000}
+ ],
+ "readinessGates": [
+ {"kind":"backendRunning","required":true,"deadlineOffsetMilliseconds":20000}
+ ],
+ "retryBudgets": [],
+ "cancellationPolicy": "beforeGuestMutation",
+ "recovery": {"disposition":"rollback","stepIDs":["launch"]}
+ }
+ """
+ let migrated = try JSONDecoder().decode(
+ DoryWorkspaceLifecycleOperation.self,
+ from: Data(json.utf8)
+ )
+ #expect(migrated.schemaVersion == 1)
+ #expect(migrated.sourceSchemaVersion == 1)
+ #expect(migrated.source.runtime?.policy == .requireResolvedPlan)
+ #expect(migrated.source.runtime?.authorizationState == .resolvedPlan)
+ #expect(migrated.source.runtime?.resolved?.planRevision == 4)
+ #expect(migrated.source.runtime?.runtimeIdentityDigest == String(repeating: "a", count: 64))
+ #expect(migrated.validate().isEmpty)
+
+ let reencoded = try JSONEncoder().encode(migrated)
+ let current = try JSONDecoder().decode(
+ DoryWorkspaceLifecycleOperation.self,
+ from: reencoded
+ )
+ #expect(current == migrated)
+
+ let home = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-workspace-lifecycle-v1-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: home, withIntermediateDirectories: false)
+ defer { try? FileManager.default.removeItem(at: home) }
+ let binding = try migrated.journalBinding(
+ dependencyClosureDigest: String(repeating: "b", count: 64)
+ )
+ #expect(binding.specification.data.range(of: Data("sourceSchemaVersion".utf8)) == nil)
+ #expect(binding.specification.data.range(of: Data("runtimeIdentityDigest".utf8)) == nil)
+ #expect(binding.specification.data.range(of: Data("resolved".utf8)) != nil)
+ let store = try DoryOperationJournalStore(home: home.path)
+ var lease: DoryOperationLease? = try store.begin(binding)
+ #expect(try lease?.readWorkspaceLifecycleOperation() == migrated)
+ lease = nil
+ let recovered = try store.acquire(migrated.operationID)
+ #expect(try recovered.readWorkspaceLifecycleOperation() == migrated)
+
+ let legacyRestoreData = Data(
+ json.replacingOccurrences(of: "\"kind\": \"starting\"", with: "\"kind\": \"restoring\"")
+ .replacingOccurrences(of: "\"state\": \"running\"", with: "\"state\": \"stopped\"")
+ .utf8
+ )
+ let legacyRestore = try JSONDecoder().decode(
+ DoryWorkspaceLifecycleOperation.self,
+ from: legacyRestoreData
+ )
+ #expect(legacyRestore.validate().isEmpty)
+ #expect(legacyRestore.target.runtime?.authorizationState == .resolvedPlan)
+ }
+
+ @Test("schema v2 cannot claim v1 authority and exact runtime identity cannot drift")
+ func schemaAndRuntimeAuthorityAreExact() throws {
+ let dishonest = """
+ {
+ "schemaVersion": 2, "sourceSchemaVersion": 1,
+ "operationID": "8F9F5B13-77EE-4F59-8A13-1704147C7F00",
+ "kind": "starting", "source": {}, "target": {},
+ "createdAtUnixMilliseconds": 1700000000000,
+ "deadlineUnixMilliseconds": 1700000060000,
+ "steps": [], "readinessGates": [], "retryBudgets": [],
+ "cancellationPolicy": "prohibited",
+ "recovery": {"disposition":"rollback","stepIDs":[]}
+ }
+ """
+ #expect(throws: DecodingError.self) {
+ _ = try JSONDecoder().decode(
+ DoryWorkspaceLifecycleOperation.self,
+ from: Data(dishonest.utf8)
+ )
+ }
+
+ var operation = makeOperation()
+ operation.target.runtime?.runtimeIdentityDigest = String(repeating: "f", count: 64)
+ #expect(has(.invalidCondition, "target.runtime", operation))
+ }
+
+ @Test("legacy compatibility is an explicit ABI and identity binding, never a plan claim")
+ func legacyCompatibilityBindingIsExplicit() {
+ var operation = makeOperation()
+ let legacy = DoryWorkspaceRuntimeBinding.legacyCompatibility(
+ virtualHardwareABIVersion: 1,
+ runtimeIdentityDigest: String(repeating: "9", count: 64)
+ )
+ operation.source.runtime = legacy
+ operation.target.runtime = legacy
+ #expect(operation.validate().isEmpty)
+
+ operation.target.runtime = DoryWorkspaceRuntimeBinding(
+ policy: .legacyCompatibility,
+ authorizationState: .legacyCompatibility,
+ virtualHardwareABIVersion: 1,
+ runtimeIdentityDigest: String(repeating: "9", count: 64),
+ resolved: resolvedCondition()
+ )
+ #expect(has(.invalidCondition, "target", operation))
+ }
+
private func makeOperation() -> DoryWorkspaceLifecycleOperation {
let resolved = resolvedCondition()
return DoryWorkspaceLifecycleOperation(
@@ -223,13 +398,32 @@ struct DoryWorkspaceLifecycleOperationTests {
private func condition(
_ state: DoryWorkspaceLifecycleState,
workspaceID: String = "workspace-one",
- resolved: DoryWorkspaceResolvedCondition?
+ resolved: DoryWorkspaceResolvedCondition?,
+ requiresReplanning: Bool = false
) -> DoryWorkspaceLifecycleCondition {
- DoryWorkspaceLifecycleCondition(
+ let runtime: DoryWorkspaceRuntimeBinding?
+ if let resolved {
+ runtime = .resolvedPlan(
+ resolved,
+ runtimeIdentityDigest: String(repeating: "e", count: 64)
+ )
+ } else if requiresReplanning {
+ runtime = .requiresReplanning(
+ virtualHardwareABIVersion: 1,
+ runtimeIdentityDigest: String(repeating: "f", count: 64)
+ )
+ } else {
+ runtime = nil
+ }
+ return DoryWorkspaceLifecycleCondition(
workspaceID: workspaceID,
state: state,
definitionRevision: 3,
- resolved: resolved
+ runtime: runtime,
+ configurationAuthority: DoryWorkspaceConfigurationAuthority(
+ legacyConfigurationSHA256: String(repeating: "c", count: 64),
+ canonicalDefinitionSHA256: String(repeating: "d", count: 64)
+ )
)
}
From db9f596ff3334fc8ebfe1def7ded1e443718a729 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 14:08:04 +0000
Subject: [PATCH 022/338] fix(diagnostics): omit VM environments from CLI
output
---
.../DoryMachineDiagnosticsProjection.swift | 40 ++++++++++++++
dory-core-swift/Sources/dorydctl/main.swift | 5 +-
...oryMachineDiagnosticsProjectionTests.swift | 55 +++++++++++++++++++
3 files changed, 98 insertions(+), 2 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift
new file mode 100644
index 00000000..cf25c25f
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineDiagnosticsProjection.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+/// Produces the machine shape that is safe to print, log, or place in a support bundle.
+///
+/// Legacy machine configuration can contain arbitrary host/guest environment values. Those values
+/// are launch authority, not diagnostics, and an opaque credential cannot be identified reliably
+/// from its bytes. The public CLI therefore removes environment containers structurally instead of
+/// attempting value-pattern redaction.
+public enum DoryMachineDiagnosticsProjection {
+ private static let environmentKeys: Set = [
+ "env",
+ "environment",
+ "environment_variables",
+ "environmentvariables",
+ ]
+
+ public static func supportSafeMachineStatus(_ status: NSDictionary) -> NSDictionary {
+ sanitize(status) as? NSDictionary ?? [:]
+ }
+
+ public static func supportSafeMachineList(_ statuses: NSArray) -> NSArray {
+ sanitize(statuses) as? NSArray ?? []
+ }
+
+ private static func sanitize(_ value: Any) -> Any {
+ if let dictionary = value as? NSDictionary {
+ let result = NSMutableDictionary(capacity: dictionary.count)
+ for (rawKey, item) in dictionary {
+ guard let key = rawKey as? String else { continue }
+ if environmentKeys.contains(key.lowercased()) { continue }
+ result[key] = sanitize(item)
+ }
+ return result.copy() as? NSDictionary ?? [:]
+ }
+ if let array = value as? NSArray {
+ return array.map(sanitize) as NSArray
+ }
+ return value
+ }
+}
diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift
index 15443acb..04e15936 100644
--- a/dory-core-swift/Sources/dorydctl/main.swift
+++ b/dory-core-swift/Sources/dorydctl/main.swift
@@ -1048,10 +1048,11 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
message.isEmpty ? finish(.success(body)) : finish(.failure(DorydCtlError.daemon(message)))
}
}
- try emitJSON(rows)
+ try emitJSON(DoryMachineDiagnosticsProjection.supportSafeMachineList(rows))
case "status":
let name = try cursor.take("usage: dorydctl machine status NAME")
- try emitJSON(try machineDictionary(name: name, client: client))
+ let status = try machineDictionary(name: name, client: client)
+ try emitJSON(DoryMachineDiagnosticsProjection.supportSafeMachineStatus(status))
case "stats":
let name = try cursor.take("usage: dorydctl machine stats NAME")
guard cursor.values.isEmpty else {
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift
new file mode 100644
index 00000000..d1e4256e
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineDiagnosticsProjectionTests.swift
@@ -0,0 +1,55 @@
+import Foundation
+import Testing
+@testable import DorydKit
+
+@Suite("Machine diagnostics projection")
+struct DoryMachineDiagnosticsProjectionTests {
+ @Test("support projection structurally omits opaque environment secrets")
+ func omitsEnvironmentContainers() throws {
+ let opaqueSecret = "opaque-machine-credential-7f3b"
+ let statuses: NSArray = [
+ [
+ "id": "dev",
+ "state": "running",
+ "address": "dev.dory.local",
+ "environment": [
+ ["key": "ANTHROPIC_API_KEY", "value": opaqueSecret],
+ ],
+ "nested": [
+ "env": ["OPENAI_API_KEY": opaqueSecret],
+ "runtime": "qualified",
+ ],
+ ] as NSDictionary,
+ ]
+
+ let projected = DoryMachineDiagnosticsProjection.supportSafeMachineList(statuses)
+ let data = try JSONSerialization.data(withJSONObject: projected)
+ let text = String(decoding: data, as: UTF8.self)
+ let machine = try #require(projected.firstObject as? NSDictionary)
+ let nested = try #require(machine["nested"] as? NSDictionary)
+
+ #expect(machine["id"] as? String == "dev")
+ #expect(machine["address"] as? String == "dev.dory.local")
+ #expect(machine["environment"] == nil)
+ #expect(nested["env"] == nil)
+ #expect(nested["runtime"] as? String == "qualified")
+ #expect(!text.contains(opaqueSecret))
+ }
+
+ @Test("single status projection recognizes alternate environment spellings")
+ func omitsAlternateSpellings() {
+ let status: NSDictionary = [
+ "id": "dev",
+ "ENV": ["SECRET": "opaque"],
+ "environment_variables": ["SECRET": "opaque"],
+ "environmentVariables": ["SECRET": "opaque"],
+ ]
+
+ let projected = DoryMachineDiagnosticsProjection.supportSafeMachineStatus(status)
+
+ #expect(projected["id"] as? String == "dev")
+ #expect(projected["ENV"] == nil)
+ #expect(projected["environment_variables"] == nil)
+ #expect(projected["environmentVariables"] == nil)
+ }
+}
From c69e6679f6d9107e28459afe92cd16a8df569779 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 14:20:58 +0000
Subject: [PATCH 023/338] fix(vm): stop persisting host credentials
---
Dory/Features/Settings/SettingsView.swift | 110 +++----------------
Dory/Models/AppStore.swift | 62 ++++++-----
Dory/Runtime/Machines/MachineEnvImport.swift | 78 -------------
DoryTests/AppStoreEnvAllowListTests.swift | 41 +++----
DoryTests/DorydClientTests.swift | 27 +++--
DoryTests/MachineEnvImportTests.swift | 52 ---------
DoryTests/ManagedSettingsTests.swift | 3 +-
7 files changed, 84 insertions(+), 289 deletions(-)
delete mode 100644 Dory/Runtime/Machines/MachineEnvImport.swift
delete mode 100644 DoryTests/MachineEnvImportTests.swift
diff --git a/Dory/Features/Settings/SettingsView.swift b/Dory/Features/Settings/SettingsView.swift
index 59d2bf30..712fad1f 100644
--- a/Dory/Features/Settings/SettingsView.swift
+++ b/Dory/Features/Settings/SettingsView.swift
@@ -31,7 +31,6 @@ struct SettingsView: View {
@State private var customDomainDraft = ""
@State private var customDomainPortDraft = "80"
@State private var customSocketDraft = ""
- @State private var machineEnvAllowListDraft = ""
@State private var engineCPUCountDraft = 1
@State private var engineMemoryGiBDraft = 2
@State private var detectedEngineSources: [DockerSourceEngine] = []
@@ -1267,51 +1266,22 @@ struct SettingsView: View {
private var machines: some View {
VStack(alignment: .leading, spacing: 22) {
- groupLabel("NEW MACHINE DEFAULTS")
- VStack(alignment: .leading, spacing: 14) {
- HStack(alignment: .top, spacing: 12) {
- VStack(alignment: .leading, spacing: 3) {
- Text("Host environment allow-list")
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(p.text)
- Text("Only named variables are copied when a new machine is created. Empty values are skipped; values are read at creation time.")
- .font(.system(size: 11.5))
- .foregroundStyle(p.text3)
- .lineLimit(3)
- }
- Spacer(minLength: 0)
- Button(action: commitMachineEnvAllowListDraft) {
- Image(systemName: "checkmark")
- .font(.system(size: 12, weight: .bold))
- .foregroundStyle(.white)
- .frame(width: 28, height: 26)
- .background(p.accent, in: RoundedRectangle(cornerRadius: 7))
- }
- .buttonStyle(.plain)
- .help("Save environment allow-list")
- .accessibilityIdentifier("machine-env-save")
- }
- TextField("ANTHROPIC_API_KEY, GH_TOKEN", text: $machineEnvAllowListDraft, onCommit: commitMachineEnvAllowListDraft)
- .textFieldStyle(.plain)
- .font(.mono(12))
- .foregroundStyle(p.text)
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8))
- .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(p.border))
- .accessibilityIdentifier("machine-env-allow-list")
- VStack(spacing: 0) {
- machineEnvToggleRow("ANTHROPIC_API_KEY", divider: true)
- ForEach(MachineEnvImport.optionalExtras, id: \.self) { name in
- machineEnvToggleRow(name, divider: name != (MachineEnvImport.optionalExtras.last ?? ""))
- }
+ groupLabel("NEW MACHINE CREDENTIALS")
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: "lock.shield.fill")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(p.green)
+ .frame(width: 24, height: 24)
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Host credentials are never copied")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(p.text)
+ Text("New machines do not inherit API keys, tokens, or other host environment values. Legacy machine records remain compatible; scoped secret grants will be configured separately.")
+ .font(.system(size: 11.5))
+ .foregroundStyle(p.text3)
+ .lineLimit(4)
}
- .background(p.bgInput, in: RoundedRectangle(cornerRadius: 9))
- .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border))
- Text(machineEnvAllowListSummary)
- .font(.system(size: 11.5))
- .foregroundStyle(p.text3)
- .lineLimit(2)
+ Spacer(minLength: 0)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -1320,27 +1290,13 @@ struct SettingsView: View {
groupLabel("AGENT SANDBOXES")
VStack(spacing: 0) {
- machinePolicyRow("Persistent machines", "No host folders are shared unless the create request includes mounts. Settings above control which host env names may be copied.", divider: true)
+ machinePolicyRow("Persistent machines", "No host folders or host environment values are shared unless a typed, scoped grant explicitly authorizes them.", divider: true)
machinePolicyRow("Sandbox runs", "`dory sandbox run` starts a dedicated VM with no host file sharing by default. Add mounts explicitly for scoped workspace access.", divider: true)
- machinePolicyRow("Shell access", "`dory machine shell NAME` and `dory machine exec NAME -- ...` enter the VM boundary; the agent sees machine files, mounted folders, and copied env only.", divider: false)
+ machinePolicyRow("Shell access", "`dory machine shell NAME` and `dory machine exec NAME -- ...` enter the VM boundary; the agent sees machine files and explicitly mounted folders only.", divider: false)
}
.background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11))
.overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border))
}
- .onAppear(perform: syncMachineEnvAllowListDraft)
- .onChange(of: store.machineEnvAllowList) { _, _ in syncMachineEnvAllowListDraft() }
- }
-
- private func machineEnvToggleRow(_ name: String, divider: Bool) -> some View {
- toggleRow(
- name,
- machineEnvDraftNames.contains(name) ? "Copied when present" : "Not copied",
- isOn: Binding(
- get: { machineEnvDraftNames.contains(name) },
- set: { enabled in setMachineEnvDraft(name, enabled: enabled) }
- ),
- divider: divider
- )
}
private func machinePolicyRow(_ title: String, _ subtitle: String, divider: Bool) -> some View {
@@ -1365,38 +1321,6 @@ struct SettingsView: View {
.overlay(alignment: .bottom) { if divider { Rectangle().fill(p.border).frame(height: 1) } }
}
- private var machineEnvDraftNames: [String] {
- MachineEnvImport.parse(machineEnvAllowListDraft)
- }
-
- private var machineEnvAllowListSummary: String {
- let names = MachineEnvImport.normalize(store.machineEnvAllowList)
- guard !names.isEmpty else { return "No host environment variables are copied to new machines." }
- return "Saved: \(names.joined(separator: ", "))"
- }
-
- private func syncMachineEnvAllowListDraft() {
- machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList)
- }
-
- private func commitMachineEnvAllowListDraft() {
- let names = MachineEnvImport.parse(machineEnvAllowListDraft)
- store.setMachineEnvAllowList(names)
- machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList)
- }
-
- private func setMachineEnvDraft(_ name: String, enabled: Bool) {
- var names = machineEnvDraftNames
- if enabled {
- names.append(name)
- } else {
- names.removeAll { $0 == name }
- }
- machineEnvAllowListDraft = MachineEnvImport.serialize(names)
- store.setMachineEnvAllowList(names)
- machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList)
- }
-
private var engine: some View {
let kind = store.runtimeKind
let onShared = kind == .sharedVM
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index 68bbdba5..b6362224 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -80,7 +80,9 @@ final class AppStore {
var showMenuBarIcon = true
var routeDockerCLI = true
var keepDorydRunningAfterQuit = false
- var machineEnvAllowList: [String] = MachineEnvImport.defaultNames
+ /// Retained as an empty v1 managed-settings compatibility field. Host environment import is
+ /// disabled: credentials belong in scoped secret grants, never persisted machine environment.
+ var machineEnvAllowList: [String] = []
var openLoginsOnMac = true
var externalTerminalPreference = ExternalTerminalPreference(terminal: .terminal, customApplicationPath: nil)
var dockerHostConflict: DockerHostConflict.Conflict?
@@ -191,7 +193,6 @@ final class AppStore {
@ObservationIgnored private let authorizedNetworkingRemover: @Sendable () async throws -> Void
@ObservationIgnored private let localCATrustManager: any LocalCATrustManaging
@ObservationIgnored private let environment: [String: String]
- @ObservationIgnored private let machineEnvResolver: @Sendable ([String]) async -> [String: String]
@ObservationIgnored private let desktopMachineAssetPreparer: @Sendable (
_ home: String,
_ environment: [String: String],
@@ -219,9 +220,6 @@ final class AppStore {
environment: [String: String] = ProcessInfo.processInfo.environment,
composeCommandRunner: any ToolCommandRunning = BoundedToolProcessRunner(),
buildCommandRunner: any ToolCommandRunning = BoundedToolProcessRunner(),
- machineEnvResolver: @escaping @Sendable ([String]) async -> [String: String] = { names in
- await MachineEnvImport.resolve(names: names)
- },
desktopMachineAssetPreparer: @escaping @Sendable (
_ home: String,
_ environment: [String: String],
@@ -241,7 +239,6 @@ final class AppStore {
self.environment = env
self.composeCommandRunner = composeCommandRunner
self.buildCommandRunner = buildCommandRunner
- self.machineEnvResolver = machineEnvResolver
self.desktopMachineAssetPreparer = desktopMachineAssetPreparer
self.dorydClient = dorydClient
self.dorydEngineEnabled = dorydFlags.enabled
@@ -278,9 +275,10 @@ final class AppStore {
_ = DoryUpdater.shared
if let v = UserDefaults.standard.object(forKey: Self.routeDockerKey) as? Bool { routeDockerCLI = v }
keepDorydRunningAfterQuit = Self.resolvedKeepDorydRunningAfterQuit(defaults: .standard)
- if let raw = UserDefaults.standard.string(forKey: Self.machineEnvAllowListKey) {
- machineEnvAllowList = MachineEnvImport.parse(raw)
- }
+ // Earlier builds stored environment *names* here and copied their values into new VM
+ // definitions. Clear that opt-in permanently; legacy VM records remain launchable but
+ // no new machine inherits host credentials.
+ UserDefaults.standard.removeObject(forKey: Self.machineEnvAllowListKey)
if let v = UserDefaults.standard.object(forKey: Self.openLoginsOnMacKey) as? Bool { openLoginsOnMac = v }
externalTerminalPreference = ExternalTerminalPreferenceStore.load()
if let saved = UserDefaults.standard.string(forKey: Self.kubernetesVersionKey) {
@@ -697,13 +695,10 @@ final class AppStore {
showSettingsSuccess(showMenuBarIcon ? "Menu bar icon enabled." : "Menu bar icon hidden.")
}
- func setMachineEnvAllowList(_ names: [String]) {
- let normalized = MachineEnvImport.normalize(names)
- machineEnvAllowList = normalized
- UserDefaults.standard.set(MachineEnvImport.serialize(normalized), forKey: Self.machineEnvAllowListKey)
- showSettingsSuccess(normalized.isEmpty
- ? "New machines will not copy host environment variables."
- : "New machines will copy \(normalized.count) allowed environment variable\(normalized.count == 1 ? "" : "s") when present.")
+ func setMachineEnvAllowList(_: [String]) {
+ machineEnvAllowList = []
+ UserDefaults.standard.removeObject(forKey: Self.machineEnvAllowListKey)
+ showSettingsSuccess("Host environment import is disabled for new machines.")
}
func completeOnboarding() {
@@ -5410,15 +5405,6 @@ final class AppStore {
return Int(UInt16(bigEndian: result.sin_port))
}
- nonisolated static func mergingEnv(_ settings: MachineSettings, resolved: [String: String]) -> MachineSettings {
- guard !resolved.isEmpty else { return settings }
- var copy = settings
- for (key, value) in resolved where copy.env[key] == nil && !value.isEmpty {
- copy.env[key] = value
- }
- return copy
- }
-
nonisolated static func desktopAssetEnvironment(
processEnvironment: [String: String],
distro: DesktopMachineDistro
@@ -5428,6 +5414,26 @@ final class AppStore {
return result
}
+ /// Temporary compatibility projection until these fields move into native WorkspaceSpec
+ /// properties. New app-created machines never persist arbitrary environment values.
+ nonisolated static func sanitizedNewMachineEnvironment(
+ _ environment: [String: String]
+ ) -> [String: String] {
+ let allowed: Set = [
+ "DORY_CLIPBOARD_POLICY",
+ "DORY_CUSTOM_LINUX",
+ "DORY_DESKTOP_DISTRO",
+ "DORY_DESKTOP_ENVIRONMENT",
+ "DORY_DESKTOP_GRAPHICS",
+ "DORY_DESKTOP_NAME",
+ "DORY_DESKTOP_VERSION",
+ "DORY_DESKTOP_VMM",
+ "DORY_GUEST_UID",
+ "DORY_GUEST_USER",
+ ]
+ return environment.filter { allowed.contains($0.key) }
+ }
+
/// Brings persistent desktop machines forward after a signed desktop component activation.
/// The daemon preserves each guest's disk, applications, accounts, and settings; it owns the
/// last-good snapshot, reboot qualification, and automatic rollback transaction.
@@ -5602,7 +5608,7 @@ final class AppStore {
address: address,
displayMode: settings.displayMode,
shares: dorydShares(from: settings.mounts),
- environment: settings.env
+ environment: sanitizedNewMachineEnvironment(settings.env)
)
}
@@ -5641,9 +5647,7 @@ final class AppStore {
return message
}
guard requireDorydMachines() else { return actionError }
- let resolvedEnv = await machineEnvResolver(machineEnvAllowList)
- let effectiveSettings = Self.mergingEnv(settings, resolved: resolvedEnv)
- return await createDorydMachine(name: trimmedName, settings: effectiveSettings, recipe: recipe)
+ return await createDorydMachine(name: trimmedName, settings: settings, recipe: recipe)
}
nonisolated static func dorydRecipeID(for recipe: DevRecipe) -> String? {
diff --git a/Dory/Runtime/Machines/MachineEnvImport.swift b/Dory/Runtime/Machines/MachineEnvImport.swift
deleted file mode 100644
index b84369d7..00000000
--- a/Dory/Runtime/Machines/MachineEnvImport.swift
+++ /dev/null
@@ -1,78 +0,0 @@
-import Foundation
-
-nonisolated enum MachineEnvImport {
- static let defaultNames: [String] = ["ANTHROPIC_API_KEY"]
- static let optionalExtras: [String] = ["OPENAI_API_KEY", "GH_TOKEN", "HF_TOKEN"]
-
- static func normalize(_ names: [String]) -> [String] {
- var seen = Set()
- var ordered: [String] = []
- for name in names {
- let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
- guard !cleaned.isEmpty, cleaned.wholeMatch(of: /[A-Z_][A-Z0-9_]*/) != nil else { continue }
- guard seen.insert(cleaned).inserted else { continue }
- ordered.append(cleaned)
- }
- return ordered
- }
-
- static func parse(_ raw: String) -> [String] {
- let separators = CharacterSet(charactersIn: ", \t\n")
- return normalize(raw.components(separatedBy: separators))
- }
-
- static func serialize(_ names: [String]) -> String {
- normalize(names).joined(separator: ",")
- }
-
- static let sentinel = "@@DORYENV@@"
-
- static func probeCommand(for names: [String]) -> String {
- normalize(names).map { name in
- "printf '\(sentinel)\(name)=%s\(sentinel)' \"${\(name):-}\""
- }.joined(separator: "; ")
- }
-
- static func parseProbeOutput(_ output: String) -> [String: String] {
- var result: [String: String] = [:]
- let segments = output.components(separatedBy: sentinel)
- for segment in segments {
- guard let eq = segment.firstIndex(of: "="), segment.hasSuffix("=") == false else { continue }
- let key = String(segment[segment.startIndex.. [String: String] {
- let normalized = normalize(names)
- guard !normalized.isEmpty else { return [:] }
- let command = probeCommand(for: normalized)
- let result = await withTimeout(seconds: 6) {
- await Shell.runAsyncResult(loginShell(), ["-lic", command])
- }
- guard let output = result?.output else { return [:] }
- return parseProbeOutput(output)
- }
-
- private static func loginShell() -> String {
- if let shell = ProcessInfo.processInfo.environment["SHELL"],
- FileManager.default.isExecutableFile(atPath: shell) { return shell }
- return Shell.find("zsh", candidates: ["/bin/zsh", "/opt/homebrew/bin/zsh", "/usr/local/bin/zsh"]) ?? "/bin/zsh"
- }
-
- private static func withTimeout(seconds: Double, _ operation: @escaping @Sendable () async -> T) async -> T? {
- await withTaskGroup(of: T?.self) { group in
- group.addTask { await operation() }
- group.addTask {
- try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
- return nil
- }
- let first = await group.next() ?? nil
- group.cancelAll()
- return first
- }
- }
-}
diff --git a/DoryTests/AppStoreEnvAllowListTests.swift b/DoryTests/AppStoreEnvAllowListTests.swift
index 83904a93..f1e473b0 100644
--- a/DoryTests/AppStoreEnvAllowListTests.swift
+++ b/DoryTests/AppStoreEnvAllowListTests.swift
@@ -4,38 +4,33 @@ import Testing
@MainActor
struct AppStoreEnvAllowListTests {
- @Test func defaultAllowListIsAnthropicOnly() {
+ @Test func hostEnvironmentImportDefaultsToDisabled() {
let store = AppStore(runtime: MockRuntime())
- #expect(store.machineEnvAllowList == ["ANTHROPIC_API_KEY"])
- }
-
- @Test func setAllowListNormalizesDedupesAndPersistsUserChoice() {
- defer { UserDefaults.standard.removeObject(forKey: AppStore.machineEnvAllowListKey) }
- let store = AppStore(runtime: MockRuntime())
- store.setMachineEnvAllowList(["gh_token", " ", "gh_token"])
- #expect(store.machineEnvAllowList == ["GH_TOKEN"])
- #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == "GH_TOKEN")
+ #expect(store.machineEnvAllowList.isEmpty)
}
- @Test func setAllowListCanDisableAutomaticEnvTransfer() {
+ @Test func legacyAllowListPreferenceIsClearedAndCannotBeReenabled() {
defer { UserDefaults.standard.removeObject(forKey: AppStore.machineEnvAllowListKey) }
+ UserDefaults.standard.set("ANTHROPIC_API_KEY,GH_TOKEN", forKey: AppStore.machineEnvAllowListKey)
let store = AppStore(runtime: MockRuntime())
- store.setMachineEnvAllowList([])
+ store.setMachineEnvAllowList(["ANTHROPIC_API_KEY", "GH_TOKEN"])
#expect(store.machineEnvAllowList.isEmpty)
- #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == "")
+ #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == nil)
}
- @Test func mergingEnvAddsResolvedButUserKeysWin() {
- var settings = MachineSettings.default
- settings.env = ["ANTHROPIC_API_KEY": "user-set"]
- let merged = AppStore.mergingEnv(settings, resolved: ["ANTHROPIC_API_KEY": "probed", "GH_TOKEN": "gh-123"])
- #expect(merged.env["ANTHROPIC_API_KEY"] == "user-set")
- #expect(merged.env["GH_TOKEN"] == "gh-123")
- }
+ @Test func newMachineEnvironmentKeepsOnlyBoundedDoryMetadata() {
+ let result = AppStore.sanitizedNewMachineEnvironment([
+ "ANTHROPIC_API_KEY": "sk-ant-host",
+ "GH_TOKEN": "gh-host",
+ "APP_ENV": "development",
+ "DORY_DESKTOP_DISTRO": "ubuntu",
+ "DORY_GUEST_USER": "dory",
+ ])
- @Test func mergingEnvIgnoresEmptyResolved() {
- let merged = AppStore.mergingEnv(.default, resolved: [:])
- #expect(merged.env.isEmpty)
+ #expect(result == [
+ "DORY_DESKTOP_DISTRO": "ubuntu",
+ "DORY_GUEST_USER": "dory",
+ ])
}
@Test func createMachineRejectsPathTraversalName() async {
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 560e67c1..c947ca60 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -969,7 +969,7 @@ struct DorydClientTests {
}
@MainActor
- @Test func appStoreCopiesAllowedHostEnvWhenCreatingDorydMachine() async throws {
+ @Test func appStoreDoesNotCopyHostCredentialsWhenCreatingDorydMachine() async throws {
let base = "/tmp/damc-env-\(getpid())-\(UInt32.random(in: 0..
Date: Thu, 20 Aug 2026 14:22:08 +0000
Subject: [PATCH 024/338] feat(vm): add durable resource admission ledger
---
...irtualMachineResourceAdmissionLedger.swift | 1043 +++++++++++++++++
...lMachineResourceAdmissionLedgerTests.swift | 739 ++++++++++++
2 files changed, 1782 insertions(+)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
new file mode 100644
index 00000000..65555a5a
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
@@ -0,0 +1,1043 @@
+import CryptoKit
+import Darwin
+import DoryOperations
+import Foundation
+
+public enum DoryVirtualMachineResourceLeaseState: String, Codable, Sendable, CaseIterable {
+ case starting
+ case running
+ /// A bound start lease expired across a daemon outage. Capacity remains reserved until the
+ /// daemon proves whether the exact VM process is running or stopped.
+ case recoveryRequired = "recovery-required"
+ /// CPU and memory are released. The disk growth reservation remains durable until deletion.
+ case stopped
+}
+
+public enum DoryVirtualMachineObservedRuntimeState: String, Sendable, CaseIterable {
+ case running
+ case stopped
+}
+
+public struct DoryVirtualMachineResourceAdmissionPlanBinding:
+ Codable, Sendable, Equatable, Hashable
+{
+ public var machineID: String
+ public var definitionRevision: UInt64
+ public var definitionSHA256: String
+ public var plannedPlanRevision: UInt64
+
+ public init(
+ machineID: String,
+ definitionRevision: UInt64,
+ definitionSHA256: String,
+ plannedPlanRevision: UInt64
+ ) {
+ self.machineID = machineID
+ self.definitionRevision = definitionRevision
+ self.definitionSHA256 = definitionSHA256
+ self.plannedPlanRevision = plannedPlanRevision
+ }
+}
+
+public struct DoryVirtualMachineResourceAdmissionLease: Codable, Sendable, Equatable {
+ public static let schemaVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var leaseID: String
+ public var leaseRevision: UInt64
+ public var binding: DoryVirtualMachineResourceAdmissionPlanBinding
+ public var boundPlanSHA256: String?
+ public var hostFacts: DoryVMHostResources
+ public var hostFactsSHA256: String
+ public var workload: DoryVMWorkloadProfile
+ public var requirements: [DoryVMResourceRequirement]
+ public var resources: DoryVMResourceRequest
+ public var state: DoryVirtualMachineResourceLeaseState
+ public var startingExpiresAtUnixMilliseconds: Int64?
+ public var createdAtUnixMilliseconds: Int64
+ public var updatedAtUnixMilliseconds: Int64
+ public var evidence: DoryResolvedMachineResourceAdmissionEvidence
+
+ fileprivate init(
+ leaseID: String,
+ binding: DoryVirtualMachineResourceAdmissionPlanBinding,
+ hostFacts: DoryVMHostResources,
+ workload: DoryVMWorkloadProfile,
+ requirements: [DoryVMResourceRequirement],
+ resources: DoryVMResourceRequest,
+ startingExpiresAtUnixMilliseconds: Int64,
+ createdAtUnixMilliseconds: Int64,
+ evidence: DoryResolvedMachineResourceAdmissionEvidence
+ ) {
+ schemaVersion = Self.schemaVersion
+ self.leaseID = leaseID
+ leaseRevision = 1
+ self.binding = binding
+ boundPlanSHA256 = nil
+ self.hostFacts = hostFacts
+ hostFactsSHA256 = Self.digest(hostFacts)
+ self.workload = workload
+ self.requirements = requirements
+ self.resources = resources
+ state = .starting
+ self.startingExpiresAtUnixMilliseconds = startingExpiresAtUnixMilliseconds
+ self.createdAtUnixMilliseconds = createdAtUnixMilliseconds
+ updatedAtUnixMilliseconds = createdAtUnixMilliseconds
+ self.evidence = evidence
+ }
+
+ fileprivate static func digest(_ value: T) -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = (try? encoder.encode(value)) ?? Data()
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+public struct DoryVirtualMachineResourceAdmissionLedgerSnapshot: Sendable, Equatable {
+ public var ledgerRevision: UInt64
+ public var leases: [DoryVirtualMachineResourceAdmissionLease]
+
+ public init(
+ ledgerRevision: UInt64,
+ leases: [DoryVirtualMachineResourceAdmissionLease]
+ ) {
+ self.ledgerRevision = ledgerRevision
+ self.leases = leases
+ }
+}
+
+public enum DoryVirtualMachineResourceAdmissionLedgerError:
+ Error, Sendable, Equatable, CustomStringConvertible
+{
+ case invalidBinding
+ case invalidHostFacts
+ case invalidLeaseDuration
+ case capacityUnavailable([DoryVMResourceValidationIssue])
+ case machineAlreadyReserved(String)
+ case leaseNotFound(String)
+ case staleLedgerRevision(expected: UInt64, actual: UInt64)
+ case staleLeaseRevision(expected: UInt64, actual: UInt64)
+ case invalidLeaseState(DoryVirtualMachineResourceLeaseState)
+ case planAlreadyBound
+ case planMismatch
+ case hostFactsMismatch
+ case arithmeticOverflow
+ case invalidRecord
+ case filesystem(String)
+
+ public var description: String {
+ switch self {
+ case .invalidBinding: "resource admission plan binding is invalid"
+ case .invalidHostFacts: "host resource facts are invalid"
+ case .invalidLeaseDuration: "starting lease duration is invalid"
+ case .capacityUnavailable: "host capacity cannot admit the requested virtual machine"
+ case let .machineAlreadyReserved(machineID):
+ "machine already has a resource reservation: \(machineID)"
+ case let .leaseNotFound(leaseID): "resource admission lease not found: \(leaseID)"
+ case let .staleLedgerRevision(expected, actual):
+ "stale resource ledger revision: expected \(expected), found \(actual)"
+ case let .staleLeaseRevision(expected, actual):
+ "stale resource lease revision: expected \(expected), found \(actual)"
+ case let .invalidLeaseState(state):
+ "resource lease has invalid state: \(state.rawValue)"
+ case .planAlreadyBound: "resource admission lease is already bound to a plan"
+ case .planMismatch: "resolved plan does not match the admitted resource lease"
+ case .hostFactsMismatch: "current host facts do not match the admitted snapshot"
+ case .arithmeticOverflow: "resource commitment arithmetic overflow"
+ case .invalidRecord: "resource admission ledger record is invalid or tampered"
+ case let .filesystem(message): message
+ }
+ }
+}
+
+/// Durable admission authority for VM starts. Callers provide host facts excluding leases owned by
+/// this ledger; the ledger composes those facts with all starting/running CPU and RAM commitments
+/// and every durable disk reservation under one cross-process lock.
+public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendable {
+ public static let assessorIdentifier = "dory.resource-admission-ledger"
+ public static let assessorVersion: UInt16 = 1
+
+ private static let recordFilename = "resource-admissions.json"
+ private static let lockFilename = ".resource-admissions.lock"
+ private static let temporaryPrefix = ".resource-admissions."
+ private static let maximumRecordBytes = 4 * 1_024 * 1_024
+ private static let maximumStartingLeaseDurationMilliseconds: Int64 = 86_400_000
+
+ public let root: String
+ private let instanceLock = NSLock()
+ private let now: @Sendable () -> Int64
+
+ public init(root: String) {
+ self.root = URL(fileURLWithPath: root).standardizedFileURL.path
+ now = { Int64(Date().timeIntervalSince1970 * 1_000) }
+ }
+
+ init(root: String, now: @escaping @Sendable () -> Int64) {
+ self.root = URL(fileURLWithPath: root).standardizedFileURL.path
+ self.now = now
+ }
+
+ /// Atomically reserves a starting slot. The returned evidence is the only admission evidence
+ /// that should be embedded in the plan subsequently bound with `bind`.
+ public func reserveStarting(
+ binding: DoryVirtualMachineResourceAdmissionPlanBinding,
+ hostFacts: DoryVMHostResources,
+ workload: DoryVMWorkloadProfile,
+ requirements: [DoryVMResourceRequirement] = [],
+ resources: DoryVMResourceRequest,
+ startingLeaseDurationMilliseconds: Int64 = 120_000,
+ expectedLedgerRevision: UInt64? = nil
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ try withExclusiveAccess {
+ let timestamp = now()
+ var record = try readRecord()
+ let recovered = try recoverExpired(in: &record, at: timestamp)
+ try validateExpectedLedgerRevision(expectedLedgerRevision, actual: record.ledgerRevision)
+ try Self.validate(binding)
+ try Self.validate(hostFacts)
+ guard startingLeaseDurationMilliseconds > 0,
+ startingLeaseDurationMilliseconds
+ <= Self.maximumStartingLeaseDurationMilliseconds,
+ timestamp > 0,
+ timestamp <= Int64.max - startingLeaseDurationMilliseconds else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseDuration
+ }
+ let stoppedLeaseIndex = record.leases.firstIndex {
+ $0.binding.machineID == binding.machineID && $0.state == .stopped
+ }
+ guard !record.leases.contains(where: {
+ $0.binding.machineID == binding.machineID && $0.state != .stopped
+ }) else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.machineAlreadyReserved(
+ binding.machineID
+ )
+ }
+ // A restart atomically replaces the stopped reservation. Excluding that lease from
+ // assessment prevents double-counting its disk while the lock prevents any interval
+ // in which another admission could consume the released capacity.
+ let leasesBeingRetained = record.leases.enumerated().compactMap { index, lease in
+ index == stoppedLeaseIndex ? nil : lease
+ }
+ let commitments = try Self.commitments(in: leasesBeingRetained)
+ let assessedHost = try Self.composing(
+ hostFacts,
+ runtime: commitments.runtime,
+ storage: commitments.storage
+ )
+ let sortedRequirements = requirements.sorted(by: Self.requirementOrder)
+ let assessment = DoryVMResourcePolicy.assess(
+ host: assessedHost,
+ workload: workload,
+ requirements: sortedRequirements,
+ request: resources
+ )
+ let errors = assessment.issues.filter { $0.severity == .error }
+ guard errors.isEmpty else {
+ if recovered { try persistRecoveredRecord(&record) }
+ throw DoryVirtualMachineResourceAdmissionLedgerError.capacityUnavailable(errors)
+ }
+ let stoppedLease = stoppedLeaseIndex.map { record.leases[$0] }
+ let leaseID = stoppedLease?.leaseID
+ ?? "resource-lease-\(UUID().uuidString.lowercased())"
+ let evidence = Self.makeEvidence(
+ leaseID: leaseID,
+ binding: binding,
+ hostFacts: hostFacts,
+ assessedHost: assessedHost,
+ workload: workload,
+ requirements: sortedRequirements,
+ resources: resources,
+ reserve: assessment.recommendation.hostReserve
+ )
+ var lease = DoryVirtualMachineResourceAdmissionLease(
+ leaseID: leaseID,
+ binding: binding,
+ hostFacts: hostFacts,
+ workload: workload,
+ requirements: sortedRequirements,
+ resources: resources,
+ startingExpiresAtUnixMilliseconds: timestamp
+ + startingLeaseDurationMilliseconds,
+ createdAtUnixMilliseconds: timestamp,
+ evidence: evidence
+ )
+ if let stoppedLeaseIndex, let stoppedLease {
+ lease.leaseRevision = try Self.incrementing(stoppedLease.leaseRevision)
+ record.leases[stoppedLeaseIndex] = lease
+ } else {
+ record.leases.append(lease)
+ }
+ try advanceAndPublish(&record)
+ return lease
+ }
+ }
+
+ /// Completes the two-phase admission by pinning the exact canonical plan bytes. This closes
+ /// the circular dependency between admission evidence embedded in a plan and the final digest.
+ public func bind(
+ leaseID: String,
+ to plan: DoryResolvedMachinePlan,
+ expectedLeaseRevision: UInt64
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ try withExclusiveAccess {
+ let timestamp = now()
+ var record = try readRecord()
+ _ = try recoverExpired(in: &record, at: timestamp)
+ let index = try leaseIndex(leaseID, in: record)
+ var lease = record.leases[index]
+ try Self.validateLeaseRevision(expectedLeaseRevision, lease)
+ guard lease.state == .starting else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state)
+ }
+ guard lease.boundPlanSHA256 == nil else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.planAlreadyBound
+ }
+ try Self.validate(plan, against: lease, requireBoundDigest: false)
+ lease.boundPlanSHA256 = Self.planSHA256(plan)
+ lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
+ lease.updatedAtUnixMilliseconds = timestamp
+ record.leases[index] = lease
+ try advanceAndPublish(&record)
+ return lease
+ }
+ }
+
+ /// Revalidates an already bound starting lease against exact plan bytes and the same daemon
+ /// host snapshot. It never replans or silently refreshes evidence.
+ public func revalidateForStart(
+ leaseID: String,
+ plan: DoryResolvedMachinePlan,
+ hostFacts: DoryVMHostResources
+ ) throws -> DoryResolvedMachineResourceAdmissionEvidence {
+ try withExclusiveAccess {
+ let timestamp = now()
+ var record = try readRecord()
+ let recovered = try recoverExpired(in: &record, at: timestamp)
+ if recovered { try persistRecoveredRecord(&record) }
+ let index = try leaseIndex(leaseID, in: record)
+ let lease = record.leases[index]
+ guard lease.state == .starting else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state)
+ }
+ guard hostFacts == lease.hostFacts,
+ DoryVirtualMachineResourceAdmissionLease.digest(hostFacts)
+ == lease.hostFactsSHA256 else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.hostFactsMismatch
+ }
+ try Self.validate(plan, against: lease, requireBoundDigest: true)
+ try Self.validateCapacity(record.leases, hostFacts: hostFacts)
+ return lease.evidence
+ }
+ }
+
+ public func markRunning(
+ leaseID: String,
+ plan: DoryResolvedMachinePlan,
+ hostFacts: DoryVMHostResources,
+ expectedLeaseRevision: UInt64
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ try withExclusiveAccess {
+ let timestamp = now()
+ var record = try readRecord()
+ _ = try recoverExpired(in: &record, at: timestamp)
+ let index = try leaseIndex(leaseID, in: record)
+ var lease = record.leases[index]
+ try Self.validateLeaseRevision(expectedLeaseRevision, lease)
+ guard lease.state == .starting else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state)
+ }
+ guard hostFacts == lease.hostFacts,
+ DoryVirtualMachineResourceAdmissionLease.digest(hostFacts)
+ == lease.hostFactsSHA256 else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.hostFactsMismatch
+ }
+ try Self.validate(plan, against: lease, requireBoundDigest: true)
+ try Self.validateCapacity(record.leases, hostFacts: hostFacts)
+ lease.state = .running
+ lease.startingExpiresAtUnixMilliseconds = nil
+ lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
+ lease.updatedAtUnixMilliseconds = timestamp
+ record.leases[index] = lease
+ try advanceAndPublish(&record)
+ return lease
+ }
+ }
+
+ /// Releases CPU and RAM after stop while intentionally retaining the disk reservation.
+ public func markStopped(
+ leaseID: String,
+ expectedLeaseRevision: UInt64
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ try withExclusiveAccess {
+ let timestamp = now()
+ var record = try readRecord()
+ _ = try recoverExpired(in: &record, at: timestamp)
+ let index = try leaseIndex(leaseID, in: record)
+ var lease = record.leases[index]
+ try Self.validateLeaseRevision(expectedLeaseRevision, lease)
+ guard lease.state == .starting || lease.state == .running,
+ lease.boundPlanSHA256 != nil else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state)
+ }
+ lease.state = .stopped
+ lease.startingExpiresAtUnixMilliseconds = nil
+ lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
+ lease.updatedAtUnixMilliseconds = timestamp
+ record.leases[index] = lease
+ try advanceAndPublish(&record)
+ return lease
+ }
+ }
+
+ /// Resolves an expired bound start using daemon-observed runtime state. Until this explicit
+ /// reconciliation, recovery-required leases retain CPU, memory, and storage reservations.
+ public func reconcileExpiredStart(
+ leaseID: String,
+ observedRuntimeState: DoryVirtualMachineObservedRuntimeState,
+ expectedLeaseRevision: UInt64
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ try withExclusiveAccess {
+ let timestamp = now()
+ var record = try readRecord()
+ _ = try recoverExpired(in: &record, at: timestamp)
+ let index = try leaseIndex(leaseID, in: record)
+ var lease = record.leases[index]
+ try Self.validateLeaseRevision(expectedLeaseRevision, lease)
+ guard lease.state == .recoveryRequired else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state)
+ }
+ lease.state = observedRuntimeState == .running ? .running : .stopped
+ lease.startingExpiresAtUnixMilliseconds = nil
+ lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
+ lease.updatedAtUnixMilliseconds = timestamp
+ record.leases[index] = lease
+ try advanceAndPublish(&record)
+ return lease
+ }
+ }
+
+ /// Deletes the durable disk reservation. This belongs on machine/storage deletion, not stop.
+ public func releaseStorageReservation(
+ leaseID: String,
+ expectedLeaseRevision: UInt64
+ ) throws {
+ try withExclusiveAccess {
+ var record = try readRecord()
+ _ = try recoverExpired(in: &record, at: now())
+ let index = try leaseIndex(leaseID, in: record)
+ try Self.validateLeaseRevision(expectedLeaseRevision, record.leases[index])
+ guard record.leases[index].state == .stopped else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(
+ record.leases[index].state
+ )
+ }
+ record.leases.remove(at: index)
+ try advanceAndPublish(&record)
+ }
+ }
+
+ /// Reading also performs deterministic expiry recovery under the ledger lock.
+ public func snapshot() throws -> DoryVirtualMachineResourceAdmissionLedgerSnapshot {
+ try withExclusiveAccess {
+ var record = try readRecord()
+ if try recoverExpired(in: &record, at: now()) {
+ try persistRecoveredRecord(&record)
+ }
+ return DoryVirtualMachineResourceAdmissionLedgerSnapshot(
+ ledgerRevision: record.ledgerRevision,
+ leases: record.leases.sorted { $0.binding.machineID < $1.binding.machineID }
+ )
+ }
+ }
+
+ private struct LedgerRecord: Codable, Sendable, Equatable {
+ static let schemaVersion: UInt16 = 1
+ var schemaVersion: UInt16 = schemaVersion
+ var ledgerRevision: UInt64 = 0
+ var leases: [DoryVirtualMachineResourceAdmissionLease] = []
+ }
+
+ private struct LedgerEnvelope: Codable, Sendable, Equatable {
+ var recordSHA256: String
+ var record: LedgerRecord
+ }
+
+ private struct Commitments {
+ var runtime: DoryVMResourceRequest
+ var storage: UInt64
+ }
+
+ private struct AdmissionReport: Codable {
+ var leaseID: String
+ var binding: DoryVirtualMachineResourceAdmissionPlanBinding
+ var hostFactsSHA256: String
+ var workload: DoryVMWorkloadProfile
+ var requirements: [DoryVMResourceRequirement]
+ var resources: DoryVMResourceRequest
+ var hostReserve: DoryVMHostReserve
+ var existingVirtualCPUCommitment: UInt64
+ var existingMemoryCommitmentBytes: UInt64
+ var existingStorageReservationBytes: UInt64
+ var assessorIdentifier: String
+ var assessorVersion: UInt16
+ }
+
+ private func withExclusiveAccess(_ body: () throws -> T) throws -> T {
+ try instanceLock.withLock {
+ try Self.ensurePrivateDirectory(root)
+ let path = root + "/" + Self.lockFilename
+ let descriptor = path.withCString {
+ open($0, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600))
+ }
+ guard descriptor >= 0 else { throw filesystem("open resource admission lock") }
+ defer { close(descriptor) }
+ var status = stat()
+ guard fstat(descriptor, &status) == 0,
+ status.st_mode & S_IFMT == S_IFREG,
+ status.st_uid == geteuid(),
+ status.st_nlink == 1,
+ status.st_mode & 0o077 == 0 else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ while flock(descriptor, LOCK_EX) != 0 {
+ guard errno == EINTR else { throw filesystem("lock resource admission ledger") }
+ }
+ defer { _ = flock(descriptor, LOCK_UN) }
+ try cleanupTemporaryFiles()
+ return try body()
+ }
+ }
+
+ private func readRecord() throws -> LedgerRecord {
+ let path = root + "/" + Self.recordFilename
+ var initial = stat()
+ guard lstat(path, &initial) == 0 else {
+ if errno == ENOENT { return LedgerRecord() }
+ throw filesystem("inspect resource admission ledger")
+ }
+ let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) }
+ guard descriptor >= 0 else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ defer { close(descriptor) }
+ var status = stat()
+ guard fstat(descriptor, &status) == 0,
+ status.st_mode & S_IFMT == S_IFREG,
+ status.st_uid == geteuid(),
+ status.st_nlink == 1,
+ status.st_mode & 0o077 == 0,
+ status.st_size > 0,
+ status.st_size <= Self.maximumRecordBytes else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ var data = Data()
+ var buffer = [UInt8](repeating: 0, count: 16 * 1_024)
+ while true {
+ let count = buffer.withUnsafeMutableBytes {
+ Darwin.read(descriptor, $0.baseAddress, $0.count)
+ }
+ if count < 0, errno == EINTR { continue }
+ guard count >= 0, data.count + count <= Self.maximumRecordBytes else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ if count == 0 { break }
+ data.append(contentsOf: buffer.prefix(count))
+ }
+ guard let envelope = try? JSONDecoder().decode(LedgerEnvelope.self, from: data),
+ data == Self.canonicalData(envelope) + Data("\n".utf8),
+ envelope.recordSHA256 == Self.digest(Self.canonicalData(envelope.record)) else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ try Self.validate(envelope.record)
+ return envelope.record
+ }
+
+ private func advanceAndPublish(_ record: inout LedgerRecord) throws {
+ record.ledgerRevision = try Self.incrementing(record.ledgerRevision)
+ record.leases.sort { $0.binding.machineID < $1.binding.machineID }
+ try publish(record)
+ }
+
+ private func persistRecoveredRecord(_ record: inout LedgerRecord) throws {
+ try advanceAndPublish(&record)
+ }
+
+ private func publish(_ record: LedgerRecord) throws {
+ try Self.validate(record)
+ let envelope = LedgerEnvelope(
+ recordSHA256: Self.digest(Self.canonicalData(record)),
+ record: record
+ )
+ let data = Self.canonicalData(envelope) + Data("\n".utf8)
+ let temporary = root + "/\(Self.temporaryPrefix)\(UUID().uuidString)"
+ let descriptor = temporary.withCString {
+ open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600))
+ }
+ guard descriptor >= 0 else { throw filesystem("create resource admission record") }
+ var isOpen = true
+ do {
+ try data.withUnsafeBytes { bytes in
+ var offset = 0
+ while offset < bytes.count {
+ let count = Darwin.write(
+ descriptor,
+ bytes.baseAddress!.advanced(by: offset),
+ bytes.count - offset
+ )
+ if count < 0, errno == EINTR { continue }
+ guard count > 0 else { throw filesystem("write resource admission record") }
+ offset += count
+ }
+ }
+ guard fsync(descriptor) == 0 else { throw filesystem("sync resource admission record") }
+ close(descriptor)
+ isOpen = false
+ guard rename(temporary, root + "/" + Self.recordFilename) == 0 else {
+ throw filesystem("publish resource admission record")
+ }
+ try Self.syncDirectory(root)
+ } catch {
+ if isOpen { close(descriptor) }
+ unlink(temporary)
+ throw error
+ }
+ }
+
+ private func cleanupTemporaryFiles() throws {
+ let names: [String]
+ do {
+ names = try FileManager.default.contentsOfDirectory(atPath: root)
+ } catch {
+ throw filesystem("enumerate resource admission directory")
+ }
+ for name in names where name.hasPrefix(Self.temporaryPrefix) {
+ let path = root + "/" + name
+ var status = stat()
+ guard lstat(path, &status) == 0 else { continue }
+ guard status.st_uid == geteuid() else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ guard unlink(path) == 0 || errno == ENOENT else {
+ throw filesystem("remove stale resource admission temporary file")
+ }
+ }
+ }
+
+ private func recoverExpired(
+ in record: inout LedgerRecord,
+ at timestamp: Int64
+ ) throws -> Bool {
+ guard timestamp > 0 else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseDuration
+ }
+ var changed = false
+ var recovered: [DoryVirtualMachineResourceAdmissionLease] = []
+ for var lease in record.leases {
+ guard lease.state == .starting,
+ let expiry = lease.startingExpiresAtUnixMilliseconds,
+ expiry <= timestamp else {
+ recovered.append(lease)
+ continue
+ }
+ changed = true
+ // The composition contract does not permit artifact creation before plan binding, so
+ // an abandoned pre-publication reservation owns no durable disk yet.
+ guard lease.boundPlanSHA256 != nil else { continue }
+ // A process may have crossed the launch boundary before daemon failure. Never release
+ // runtime capacity until a daemon-owned process observation resolves that ambiguity.
+ lease.state = .recoveryRequired
+ lease.startingExpiresAtUnixMilliseconds = nil
+ lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
+ lease.updatedAtUnixMilliseconds = timestamp
+ recovered.append(lease)
+ }
+ if changed { record.leases = recovered }
+ return changed
+ }
+
+ private func leaseIndex(_ leaseID: String, in record: LedgerRecord) throws -> Int {
+ guard let index = record.leases.firstIndex(where: { $0.leaseID == leaseID }) else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.leaseNotFound(leaseID)
+ }
+ return index
+ }
+
+ private func validateExpectedLedgerRevision(_ expected: UInt64?, actual: UInt64) throws {
+ guard let expected else { return }
+ guard expected == actual else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.staleLedgerRevision(
+ expected: expected,
+ actual: actual
+ )
+ }
+ }
+
+ private static func validateLeaseRevision(
+ _ expected: UInt64,
+ _ lease: DoryVirtualMachineResourceAdmissionLease
+ ) throws {
+ guard expected == lease.leaseRevision else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.staleLeaseRevision(
+ expected: expected,
+ actual: lease.leaseRevision
+ )
+ }
+ }
+
+ private static func validate(
+ _ plan: DoryResolvedMachinePlan,
+ against lease: DoryVirtualMachineResourceAdmissionLease,
+ requireBoundDigest: Bool
+ ) throws {
+ guard plan.validate().isEmpty,
+ plan.machineID == lease.binding.machineID,
+ plan.definitionRevision == lease.binding.definitionRevision,
+ plan.definitionSHA256?.lowercased() == lease.binding.definitionSHA256.lowercased(),
+ plan.planRevision == lease.binding.plannedPlanRevision,
+ plan.resourceAdmission == lease.evidence else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.planMismatch
+ }
+ if requireBoundDigest {
+ guard let bound = lease.boundPlanSHA256,
+ bound == planSHA256(plan) else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.planMismatch
+ }
+ }
+ }
+
+ private static func validate(_ binding: DoryVirtualMachineResourceAdmissionPlanBinding) throws {
+ let machine = Array(binding.machineID.utf8)
+ guard (1...63).contains(machine.count),
+ let first = machine.first,
+ isAlphaNumeric(first),
+ machine.dropFirst().allSatisfy({ isAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95 }),
+ binding.definitionRevision > 0,
+ DoryResolvedMachinePlan.isSHA256(binding.definitionSHA256),
+ binding.plannedPlanRevision > 0 else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidBinding
+ }
+ }
+
+ private static func validate(_ host: DoryVMHostResources) throws {
+ guard host.logicalCPUCount > 0,
+ host.physicalMemoryBytes > 0,
+ host.freeStorageBytes > 0,
+ host.admittedVirtualCPUCount <= host.logicalCPUCount,
+ host.admittedMemoryBytes <= host.physicalMemoryBytes,
+ host.reservedStorageBytes <= host.freeStorageBytes else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidHostFacts
+ }
+ }
+
+ private static func validate(_ record: LedgerRecord) throws {
+ guard record.schemaVersion == LedgerRecord.schemaVersion,
+ record.ledgerRevision > 0,
+ Set(record.leases.map(\.leaseID)).count == record.leases.count,
+ Set(record.leases.map { $0.binding.machineID }).count == record.leases.count else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ for lease in record.leases {
+ do {
+ try validate(lease.binding)
+ try validate(lease.hostFacts)
+ } catch {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ guard lease.schemaVersion == DoryVirtualMachineResourceAdmissionLease.schemaVersion,
+ isSafeIdentifier(lease.leaseID),
+ lease.leaseRevision > 0,
+ lease.createdAtUnixMilliseconds > 0,
+ lease.updatedAtUnixMilliseconds >= lease.createdAtUnixMilliseconds,
+ lease.hostFactsSHA256
+ == DoryVirtualMachineResourceAdmissionLease.digest(lease.hostFacts),
+ lease.resources.virtualCPUCount > 0,
+ lease.resources.memoryBytes > 0,
+ lease.resources.diskBytes > 0,
+ lease.requirements == lease.requirements.sorted(by: requirementOrder),
+ lease.evidence.admittedVirtualCPUCount == lease.resources.virtualCPUCount,
+ lease.evidence.admittedMemoryBytes == lease.resources.memoryBytes,
+ lease.evidence.admittedStorageBytes == lease.resources.diskBytes,
+ lease.evidence.hostLogicalCPUCount == lease.hostFacts.logicalCPUCount,
+ lease.evidence.hostPhysicalMemoryBytes == lease.hostFacts.physicalMemoryBytes,
+ lease.evidence.hostFreeStorageBytes == lease.hostFacts.freeStorageBytes,
+ lease.evidence.existingVirtualCPUCommitment
+ >= lease.hostFacts.admittedVirtualCPUCount,
+ lease.evidence.existingMemoryCommitmentBytes
+ >= lease.hostFacts.admittedMemoryBytes,
+ lease.evidence.existingStorageReservationBytes
+ >= lease.hostFacts.reservedStorageBytes,
+ lease.evidence.assessorIdentifier == assessorIdentifier,
+ lease.evidence.assessorVersion == assessorVersion,
+ lease.evidence.admissionIdentity == lease.leaseID,
+ lease.evidence.admissionReportSHA256 == admissionReportSHA256(lease),
+ lease.boundPlanSHA256.map(DoryResolvedMachinePlan.isSHA256) ?? true else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ let reserve = DoryVMResourcePolicy.recommend(
+ host: lease.hostFacts,
+ workload: lease.workload,
+ requirements: lease.requirements
+ ).hostReserve
+ guard lease.evidence.hostReservedLogicalCPUCount == reserve.logicalCPUCount,
+ lease.evidence.hostReservedMemoryBytes == reserve.memoryBytes,
+ lease.evidence.hostReservedStorageBytes == reserve.storageBytes else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ switch lease.state {
+ case .starting:
+ guard let expiry = lease.startingExpiresAtUnixMilliseconds,
+ expiry > lease.createdAtUnixMilliseconds else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ case .running:
+ guard lease.boundPlanSHA256 != nil,
+ lease.startingExpiresAtUnixMilliseconds == nil else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ case .recoveryRequired:
+ guard lease.boundPlanSHA256 != nil,
+ lease.startingExpiresAtUnixMilliseconds == nil else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ case .stopped:
+ guard lease.boundPlanSHA256 != nil,
+ lease.startingExpiresAtUnixMilliseconds == nil else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ }
+ }
+ }
+
+ private static func admissionReportSHA256(
+ _ lease: DoryVirtualMachineResourceAdmissionLease
+ ) -> String {
+ digest(canonicalData(AdmissionReport(
+ leaseID: lease.leaseID,
+ binding: lease.binding,
+ hostFactsSHA256: lease.hostFactsSHA256,
+ workload: lease.workload,
+ requirements: lease.requirements,
+ resources: lease.resources,
+ hostReserve: DoryVMHostReserve(
+ logicalCPUCount: lease.evidence.hostReservedLogicalCPUCount,
+ memoryBytes: lease.evidence.hostReservedMemoryBytes,
+ storageBytes: lease.evidence.hostReservedStorageBytes
+ ),
+ existingVirtualCPUCommitment: lease.evidence.existingVirtualCPUCommitment,
+ existingMemoryCommitmentBytes: lease.evidence.existingMemoryCommitmentBytes,
+ existingStorageReservationBytes: lease.evidence.existingStorageReservationBytes,
+ assessorIdentifier: assessorIdentifier,
+ assessorVersion: assessorVersion
+ )))
+ }
+
+ private static func makeEvidence(
+ leaseID: String,
+ binding: DoryVirtualMachineResourceAdmissionPlanBinding,
+ hostFacts: DoryVMHostResources,
+ assessedHost: DoryVMHostResources,
+ workload: DoryVMWorkloadProfile,
+ requirements: [DoryVMResourceRequirement],
+ resources: DoryVMResourceRequest,
+ reserve: DoryVMHostReserve
+ ) -> DoryResolvedMachineResourceAdmissionEvidence {
+ let report = AdmissionReport(
+ leaseID: leaseID,
+ binding: binding,
+ hostFactsSHA256: DoryVirtualMachineResourceAdmissionLease.digest(hostFacts),
+ workload: workload,
+ requirements: requirements,
+ resources: resources,
+ hostReserve: reserve,
+ existingVirtualCPUCommitment: assessedHost.admittedVirtualCPUCount,
+ existingMemoryCommitmentBytes: assessedHost.admittedMemoryBytes,
+ existingStorageReservationBytes: assessedHost.reservedStorageBytes,
+ assessorIdentifier: assessorIdentifier,
+ assessorVersion: assessorVersion
+ )
+ return DoryResolvedMachineResourceAdmissionEvidence(
+ admittedVirtualCPUCount: resources.virtualCPUCount,
+ admittedMemoryBytes: resources.memoryBytes,
+ admittedStorageBytes: resources.diskBytes,
+ hostLogicalCPUCount: assessedHost.logicalCPUCount,
+ hostPhysicalMemoryBytes: assessedHost.physicalMemoryBytes,
+ hostFreeStorageBytes: assessedHost.freeStorageBytes,
+ existingVirtualCPUCommitment: assessedHost.admittedVirtualCPUCount,
+ existingMemoryCommitmentBytes: assessedHost.admittedMemoryBytes,
+ existingStorageReservationBytes: assessedHost.reservedStorageBytes,
+ hostReservedLogicalCPUCount: reserve.logicalCPUCount,
+ hostReservedMemoryBytes: reserve.memoryBytes,
+ hostReservedStorageBytes: reserve.storageBytes,
+ admissionIdentity: leaseID,
+ admissionReportSHA256: digest(canonicalData(report)),
+ assessorIdentifier: assessorIdentifier,
+ assessorVersion: assessorVersion
+ )
+ }
+
+ private static func commitments(
+ in leases: [DoryVirtualMachineResourceAdmissionLease]
+ ) throws -> Commitments {
+ var cpu: UInt64 = 0
+ var memory: UInt64 = 0
+ var storage: UInt64 = 0
+ for lease in leases {
+ storage = try adding(storage, lease.resources.diskBytes)
+ if lease.state == .starting || lease.state == .running
+ || lease.state == .recoveryRequired {
+ cpu = try adding(cpu, lease.resources.virtualCPUCount)
+ memory = try adding(memory, lease.resources.memoryBytes)
+ }
+ }
+ return Commitments(
+ runtime: DoryVMResourceRequest(
+ virtualCPUCount: cpu,
+ memoryBytes: memory,
+ diskBytes: 0
+ ),
+ storage: storage
+ )
+ }
+
+ private static func composing(
+ _ host: DoryVMHostResources,
+ runtime: DoryVMResourceRequest,
+ storage: UInt64
+ ) throws -> DoryVMHostResources {
+ DoryVMHostResources(
+ logicalCPUCount: host.logicalCPUCount,
+ physicalMemoryBytes: host.physicalMemoryBytes,
+ freeStorageBytes: host.freeStorageBytes,
+ admittedVirtualCPUCount: try adding(
+ host.admittedVirtualCPUCount,
+ runtime.virtualCPUCount
+ ),
+ admittedMemoryBytes: try adding(host.admittedMemoryBytes, runtime.memoryBytes),
+ reservedStorageBytes: try adding(host.reservedStorageBytes, storage)
+ )
+ }
+
+ private static func validateCapacity(
+ _ leases: [DoryVirtualMachineResourceAdmissionLease],
+ hostFacts: DoryVMHostResources
+ ) throws {
+ let total = try commitments(in: leases)
+ let composed = try composing(hostFacts, runtime: total.runtime, storage: total.storage)
+ let reserve = DoryVMResourcePolicy.recommend(
+ host: hostFacts,
+ workload: .server
+ ).hostReserve
+ guard sumFits(
+ total: composed.logicalCPUCount,
+ composed.admittedVirtualCPUCount,
+ reserve.logicalCPUCount
+ ),
+ sumFits(
+ total: composed.physicalMemoryBytes,
+ composed.admittedMemoryBytes,
+ reserve.memoryBytes
+ ),
+ sumFits(
+ total: composed.freeStorageBytes,
+ composed.reservedStorageBytes,
+ reserve.storageBytes
+ ) else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.capacityUnavailable([])
+ }
+ }
+
+ private static func sumFits(total: UInt64, _ values: UInt64...) -> Bool {
+ var sum: UInt64 = 0
+ for value in values {
+ let (next, overflow) = sum.addingReportingOverflow(value)
+ if overflow { return false }
+ sum = next
+ }
+ return sum <= total
+ }
+
+ private static func requirementOrder(
+ _ lhs: DoryVMResourceRequirement,
+ _ rhs: DoryVMResourceRequirement
+ ) -> Bool {
+ if lhs.kind.rawValue != rhs.kind.rawValue { return lhs.kind.rawValue < rhs.kind.rawValue }
+ return lhs.identifier < rhs.identifier
+ }
+
+ private static func adding(_ lhs: UInt64, _ rhs: UInt64) throws -> UInt64 {
+ let (value, overflow) = lhs.addingReportingOverflow(rhs)
+ guard !overflow else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.arithmeticOverflow
+ }
+ return value
+ }
+
+ private static func incrementing(_ value: UInt64) throws -> UInt64 {
+ try adding(value, 1)
+ }
+
+ private static func planSHA256(_ plan: DoryResolvedMachinePlan) -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = (try? encoder.encode(plan)) ?? Data()
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func canonicalData(_ value: T) -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return (try? encoder.encode(value)) ?? Data()
+ }
+
+ private static func digest(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func isSafeIdentifier(_ value: String) -> Bool {
+ let bytes = Array(value.utf8)
+ return (1...256).contains(bytes.count) && bytes.allSatisfy {
+ isAlphaNumeric($0) || $0 == 45 || $0 == 46 || $0 == 95
+ }
+ }
+
+ private static func isAlphaNumeric(_ byte: UInt8) -> Bool {
+ (byte >= 48 && byte <= 57)
+ || (byte >= 65 && byte <= 90)
+ || (byte >= 97 && byte <= 122)
+ }
+
+ private static func ensurePrivateDirectory(_ path: String) throws {
+ if mkdir(path, mode_t(0o700)) != 0, errno != EEXIST {
+ throw filesystem("create resource admission directory")
+ }
+ var status = stat()
+ guard lstat(path, &status) == 0,
+ status.st_mode & S_IFMT == S_IFDIR,
+ status.st_uid == geteuid(),
+ status.st_mode & 0o077 == 0 else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
+ }
+ }
+
+ private static func syncDirectory(_ path: String) throws {
+ let descriptor = path.withCString {
+ open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
+ }
+ guard descriptor >= 0 else { throw filesystem("open resource admission directory") }
+ defer { close(descriptor) }
+ guard fsync(descriptor) == 0 else { throw filesystem("sync resource admission directory") }
+ }
+
+ private static func filesystem(_ operation: String)
+ -> DoryVirtualMachineResourceAdmissionLedgerError
+ {
+ .filesystem("\(operation): errno \(errno)")
+ }
+
+ private func filesystem(_ operation: String)
+ -> DoryVirtualMachineResourceAdmissionLedgerError
+ {
+ Self.filesystem(operation)
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
new file mode 100644
index 00000000..c3b15b2b
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
@@ -0,0 +1,739 @@
+import Darwin
+import DoryOperations
+@testable import DorydKit
+import Foundation
+import XCTest
+
+final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase {
+ func testTwoLedgerInstancesAtomicallyAdmitOnlyOneContendingStart() async throws {
+ let fixture = try ResourceLedgerFixture("concurrent")
+ defer { fixture.cleanup() }
+ let first = fixture.ledger
+ let second = DoryVirtualMachineResourceAdmissionLedger(root: fixture.ledger.root)
+ let host = fixture.host
+ let resources = fixture.resources
+ let bindings = [fixture.binding("machine-a"), fixture.binding("machine-b")]
+
+ let results = await withTaskGroup(
+ of: Result.self,
+ returning: [Result].self
+ ) { group in
+ for (ledger, binding) in zip([first, second], bindings) {
+ group.addTask {
+ do {
+ return .success(try ledger.reserveStarting(
+ binding: binding,
+ hostFacts: host,
+ workload: .desktop,
+ resources: resources
+ ))
+ } catch let error as DoryVirtualMachineResourceAdmissionLedgerError {
+ return .failure(error)
+ } catch {
+ return .failure(.filesystem("unexpected test error"))
+ }
+ }
+ }
+ var collected: [Result] = []
+ for await result in group { collected.append(result) }
+ return collected
+ }
+
+ XCTAssertEqual(results.compactMap { try? $0.get() }.count, 1)
+ let failures = results.compactMap { result
+ -> DoryVirtualMachineResourceAdmissionLedgerError? in
+ guard case let .failure(error) = result else { return nil }
+ return error
+ }
+ XCTAssertEqual(failures.count, 1)
+ guard case let .capacityUnavailable(issues) = try XCTUnwrap(failures.first) else {
+ return XCTFail("expected capacity rejection")
+ }
+ XCTAssertTrue(issues.contains {
+ $0.code == .requestExceedsHostSafeMaximum && $0.resource == .cpu
+ })
+ let snapshot = try fixture.ledger.snapshot()
+ XCTAssertEqual(snapshot.leases.count, 1)
+ XCTAssertEqual(snapshot.leases.first?.state, .starting)
+ }
+
+ func testStoppedLeaseReleasesRuntimeCommitmentButRetainsStorageReservation() throws {
+ try withFixture("stopped-storage") { fixture in
+ let first = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let plan = fixture.plan(binding: first.binding, evidence: first.evidence)
+ let bound = try fixture.ledger.bind(
+ leaseID: first.leaseID,
+ to: plan,
+ expectedLeaseRevision: first.leaseRevision
+ )
+ let running = try fixture.ledger.markRunning(
+ leaseID: bound.leaseID,
+ plan: plan,
+ hostFacts: fixture.host,
+ expectedLeaseRevision: bound.leaseRevision
+ )
+ let stopped = try fixture.ledger.markStopped(
+ leaseID: running.leaseID,
+ expectedLeaseRevision: running.leaseRevision
+ )
+ XCTAssertEqual(stopped.state, .stopped)
+
+ let second = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-b"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ XCTAssertEqual(second.evidence.existingVirtualCPUCommitment, 0)
+ XCTAssertEqual(second.evidence.existingMemoryCommitmentBytes, 0)
+ XCTAssertEqual(
+ second.evidence.existingStorageReservationBytes,
+ fixture.resources.diskBytes
+ )
+ let states = Dictionary(uniqueKeysWithValues: try fixture.ledger.snapshot().leases.map {
+ ($0.binding.machineID, $0.state)
+ })
+ XCTAssertEqual(states["machine-a"], .stopped)
+ XCTAssertEqual(states["machine-b"], .starting)
+ }
+ }
+
+ func testStoppedMachineRestartAtomicallyReplacesItsStorageReservation() throws {
+ try withFixture("stopped-restart") { fixture in
+ let first = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let firstPlan = fixture.plan(binding: first.binding, evidence: first.evidence)
+ let bound = try fixture.ledger.bind(
+ leaseID: first.leaseID,
+ to: firstPlan,
+ expectedLeaseRevision: first.leaseRevision
+ )
+ let running = try fixture.ledger.markRunning(
+ leaseID: bound.leaseID,
+ plan: firstPlan,
+ hostFacts: fixture.host,
+ expectedLeaseRevision: bound.leaseRevision
+ )
+ let stopped = try fixture.ledger.markStopped(
+ leaseID: running.leaseID,
+ expectedLeaseRevision: running.leaseRevision
+ )
+
+ var nextBinding = fixture.binding("machine-a")
+ nextBinding.definitionRevision += 1
+ nextBinding.definitionSHA256 = digest("2")
+ nextBinding.plannedPlanRevision += 1
+ let largerDisk = DoryVMResourceRequest(
+ virtualCPUCount: fixture.resources.virtualCPUCount,
+ memoryBytes: fixture.resources.memoryBytes,
+ diskBytes: fixture.resources.diskBytes + 8 * ResourceLedgerFixture.gibibyte
+ )
+ let restarted = try fixture.ledger.reserveStarting(
+ binding: nextBinding,
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: largerDisk
+ )
+
+ XCTAssertEqual(restarted.leaseID, stopped.leaseID)
+ XCTAssertEqual(restarted.leaseRevision, stopped.leaseRevision + 1)
+ XCTAssertEqual(restarted.state, .starting)
+ XCTAssertEqual(restarted.binding, nextBinding)
+ XCTAssertNil(restarted.boundPlanSHA256)
+ XCTAssertEqual(restarted.evidence.existingVirtualCPUCommitment, 0)
+ XCTAssertEqual(restarted.evidence.existingMemoryCommitmentBytes, 0)
+ XCTAssertEqual(restarted.evidence.existingStorageReservationBytes, 0)
+ let snapshot = try fixture.ledger.snapshot()
+ XCTAssertEqual(snapshot.leases.count, 1)
+ XCTAssertEqual(snapshot.leases.first?.resources, largerDisk)
+ }
+ }
+
+ func testStoppedMachineRestartRollsBackOnCapacityFailureAndSerializesContention() async throws {
+ let fixture = try ResourceLedgerFixture("stopped-restart-contention")
+ defer { fixture.cleanup() }
+ let first = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let plan = fixture.plan(binding: first.binding, evidence: first.evidence)
+ let bound = try fixture.ledger.bind(
+ leaseID: first.leaseID,
+ to: plan,
+ expectedLeaseRevision: first.leaseRevision
+ )
+ let running = try fixture.ledger.markRunning(
+ leaseID: bound.leaseID,
+ plan: plan,
+ hostFacts: fixture.host,
+ expectedLeaseRevision: bound.leaseRevision
+ )
+ _ = try fixture.ledger.markStopped(
+ leaseID: running.leaseID,
+ expectedLeaseRevision: running.leaseRevision
+ )
+ let beforeFailure = try fixture.ledger.snapshot()
+ let oversized = DoryVMResourceRequest(
+ virtualCPUCount: fixture.host.logicalCPUCount,
+ memoryBytes: fixture.resources.memoryBytes,
+ diskBytes: fixture.resources.diskBytes
+ )
+ XCTAssertThrowsError(try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: oversized
+ )) { error in
+ guard case .capacityUnavailable =
+ error as? DoryVirtualMachineResourceAdmissionLedgerError else {
+ return XCTFail("expected capacity rejection")
+ }
+ }
+ XCTAssertEqual(try fixture.ledger.snapshot(), beforeFailure)
+
+ let ledgers = [
+ fixture.ledger,
+ DoryVirtualMachineResourceAdmissionLedger(root: fixture.ledger.root),
+ ]
+ let restartBinding = fixture.binding("machine-a")
+ let host = fixture.host
+ let resources = fixture.resources
+ let results = await withTaskGroup(
+ of: Result.self,
+ returning: [Result].self
+ ) { group in
+ for ledger in ledgers {
+ group.addTask {
+ do {
+ return .success(try ledger.reserveStarting(
+ binding: restartBinding,
+ hostFacts: host,
+ workload: .desktop,
+ resources: resources
+ ))
+ } catch let error as DoryVirtualMachineResourceAdmissionLedgerError {
+ return .failure(error)
+ } catch {
+ return .failure(.filesystem("unexpected test error"))
+ }
+ }
+ }
+ var values: [Result] = []
+ for await value in group { values.append(value) }
+ return values
+ }
+ XCTAssertEqual(results.compactMap { try? $0.get() }.count, 1)
+ XCTAssertEqual(results.compactMap { result -> String? in
+ guard case let .failure(.machineAlreadyReserved(machineID)) = result else { return nil }
+ return machineID
+ }, ["machine-a"])
+ let snapshot = try fixture.ledger.snapshot()
+ XCTAssertEqual(snapshot.leases.count, 1)
+ XCTAssertEqual(snapshot.leases.first?.state, .starting)
+ XCTAssertEqual(snapshot.leases.first?.leaseID, first.leaseID)
+ }
+
+ func testRestartRecoveryExpiresAbandonedStartAndPreservesBoundDisk() throws {
+ let clock = ResourceLedgerClock(now: 10_000)
+ let fixture = try ResourceLedgerFixture("recovery", clock: clock)
+ defer { fixture.cleanup() }
+ _ = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("abandoned"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources,
+ startingLeaseDurationMilliseconds: 100
+ )
+ clock.advance(by: 100)
+ let restarted = DoryVirtualMachineResourceAdmissionLedger(
+ root: fixture.ledger.root,
+ now: clock.read
+ )
+ XCTAssertTrue(try restarted.snapshot().leases.isEmpty)
+
+ let durable = try restarted.reserveStarting(
+ binding: fixture.binding("durable"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources,
+ startingLeaseDurationMilliseconds: 100
+ )
+ let plan = fixture.plan(binding: durable.binding, evidence: durable.evidence)
+ let bound = try restarted.bind(
+ leaseID: durable.leaseID,
+ to: plan,
+ expectedLeaseRevision: durable.leaseRevision
+ )
+ XCTAssertNotNil(bound.boundPlanSHA256)
+ clock.advance(by: 100)
+
+ let recoveredAgain = DoryVirtualMachineResourceAdmissionLedger(
+ root: fixture.ledger.root,
+ now: clock.read
+ )
+ let recovered = try XCTUnwrap(recoveredAgain.snapshot().leases.first)
+ XCTAssertEqual(recovered.state, .recoveryRequired)
+ XCTAssertNotNil(recovered.boundPlanSHA256)
+ XCTAssertNil(recovered.startingExpiresAtUnixMilliseconds)
+ XCTAssertThrowsError(try recoveredAgain.reserveStarting(
+ binding: fixture.binding("blocked-until-reconciled"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )) { error in
+ guard case .capacityUnavailable =
+ error as? DoryVirtualMachineResourceAdmissionLedgerError else {
+ return XCTFail("expected retained runtime commitment")
+ }
+ }
+ let stopped = try recoveredAgain.reconcileExpiredStart(
+ leaseID: recovered.leaseID,
+ observedRuntimeState: .stopped,
+ expectedLeaseRevision: recovered.leaseRevision
+ )
+ XCTAssertEqual(stopped.state, .stopped)
+ XCTAssertNoThrow(try recoveredAgain.reserveStarting(
+ binding: fixture.binding("allowed-after-reconciliation"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ ))
+ }
+
+ func testBoundPlanAndHostFactsAreExactStartGates() throws {
+ try withFixture("exact-binding") { fixture in
+ let reserved = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let plan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence)
+ XCTAssertThrowsError(try fixture.ledger.bind(
+ leaseID: reserved.leaseID,
+ to: plan,
+ expectedLeaseRevision: 0
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .staleLeaseRevision(expected: 0, actual: 1)
+ )
+ }
+ let bound = try fixture.ledger.bind(
+ leaseID: reserved.leaseID,
+ to: plan,
+ expectedLeaseRevision: reserved.leaseRevision
+ )
+ var alternatePlan = plan
+ alternatePlan.updatedAtUnixMilliseconds += 1
+ XCTAssertThrowsError(try fixture.ledger.bind(
+ leaseID: reserved.leaseID,
+ to: alternatePlan,
+ expectedLeaseRevision: bound.leaseRevision
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .planAlreadyBound
+ )
+ }
+ XCTAssertEqual(
+ try fixture.ledger.revalidateForStart(
+ leaseID: bound.leaseID,
+ plan: plan,
+ hostFacts: fixture.host
+ ),
+ reserved.evidence
+ )
+
+ var changedPlan = plan
+ changedPlan.updatedAtUnixMilliseconds += 1
+ XCTAssertThrowsError(try fixture.ledger.revalidateForStart(
+ leaseID: bound.leaseID,
+ plan: changedPlan,
+ hostFacts: fixture.host
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .planMismatch
+ )
+ }
+ let changedHost = DoryVMHostResources(
+ logicalCPUCount: fixture.host.logicalCPUCount,
+ physicalMemoryBytes: fixture.host.physicalMemoryBytes,
+ freeStorageBytes: fixture.host.freeStorageBytes + 1
+ )
+ XCTAssertThrowsError(try fixture.ledger.revalidateForStart(
+ leaseID: bound.leaseID,
+ plan: plan,
+ hostFacts: changedHost
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .hostFactsMismatch
+ )
+ }
+ }
+ }
+
+ func testConcurrentPlanBindingIsOneShot() async throws {
+ let fixture = try ResourceLedgerFixture("bind-race")
+ defer { fixture.cleanup() }
+ let reserved = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let firstPlan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence)
+ var secondPlan = firstPlan
+ secondPlan.updatedAtUnixMilliseconds += 1
+ let ledgers = [
+ fixture.ledger,
+ DoryVirtualMachineResourceAdmissionLedger(root: fixture.ledger.root),
+ ]
+ let plans = [firstPlan, secondPlan]
+ let results = await withTaskGroup(
+ of: Result.self,
+ returning: [Result].self
+ ) { group in
+ for (ledger, plan) in zip(ledgers, plans) {
+ group.addTask {
+ do {
+ return .success(try ledger.bind(
+ leaseID: reserved.leaseID,
+ to: plan,
+ expectedLeaseRevision: reserved.leaseRevision
+ ))
+ } catch let error as DoryVirtualMachineResourceAdmissionLedgerError {
+ return .failure(error)
+ } catch {
+ return .failure(.filesystem("unexpected test error"))
+ }
+ }
+ }
+ var values: [Result] = []
+ for await value in group { values.append(value) }
+ return values
+ }
+ XCTAssertEqual(results.compactMap { try? $0.get() }.count, 1)
+ XCTAssertEqual(results.compactMap { result
+ -> DoryVirtualMachineResourceAdmissionLedgerError? in
+ guard case let .failure(error) = result else { return nil }
+ return error
+ }, [.staleLeaseRevision(expected: 1, actual: 2)])
+
+ let acceptedPlans = plans.filter { candidate in
+ (try? fixture.ledger.revalidateForStart(
+ leaseID: reserved.leaseID,
+ plan: candidate,
+ hostFacts: fixture.host
+ )) != nil
+ }
+ XCTAssertEqual(acceptedPlans.count, 1)
+ }
+
+ func testRunningLeaseSurvivesExpiryAndStorageReleaseIsExplicit() throws {
+ let clock = ResourceLedgerClock(now: 20_000)
+ let fixture = try ResourceLedgerFixture("running", clock: clock)
+ defer { fixture.cleanup() }
+ let reserved = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources,
+ startingLeaseDurationMilliseconds: 100
+ )
+ let plan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence)
+ let bound = try fixture.ledger.bind(
+ leaseID: reserved.leaseID,
+ to: plan,
+ expectedLeaseRevision: reserved.leaseRevision
+ )
+ let running = try fixture.ledger.markRunning(
+ leaseID: bound.leaseID,
+ plan: plan,
+ hostFacts: fixture.host,
+ expectedLeaseRevision: bound.leaseRevision
+ )
+ clock.advance(by: 1_000)
+ let restarted = DoryVirtualMachineResourceAdmissionLedger(
+ root: fixture.ledger.root,
+ now: clock.read
+ )
+ XCTAssertEqual(try restarted.snapshot().leases.first?.state, .running)
+ let stopped = try restarted.markStopped(
+ leaseID: running.leaseID,
+ expectedLeaseRevision: running.leaseRevision
+ )
+ XCTAssertEqual(try restarted.snapshot().leases.count, 1)
+ try restarted.releaseStorageReservation(
+ leaseID: stopped.leaseID,
+ expectedLeaseRevision: stopped.leaseRevision
+ )
+ XCTAssertTrue(try restarted.snapshot().leases.isEmpty)
+ }
+
+ func testTamperedRecordAndUnsafeRecordLinkFailClosed() throws {
+ try withFixture("record-safety") { fixture in
+ _ = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let path = fixture.ledger.root + "/resource-admissions.json"
+ var data = try Data(contentsOf: URL(fileURLWithPath: path))
+ data.append(0x20)
+ try data.write(to: URL(fileURLWithPath: path))
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ XCTAssertThrowsError(try fixture.ledger.snapshot()) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .invalidRecord
+ )
+ }
+ }
+
+ try withFixture("record-hardlink") { fixture in
+ _ = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let path = fixture.ledger.root + "/resource-admissions.json"
+ XCTAssertEqual(link(path, fixture.root + "/record-copy"), 0)
+ XCTAssertThrowsError(try fixture.ledger.snapshot()) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .invalidRecord
+ )
+ }
+ }
+ }
+
+ func testOptimisticLedgerRevisionAndActiveStorageReleaseFailClosed() throws {
+ try withFixture("optimistic") { fixture in
+ let reserved = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources,
+ expectedLedgerRevision: 0
+ )
+ XCTAssertThrowsError(try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-b"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources,
+ expectedLedgerRevision: 0
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .staleLedgerRevision(expected: 0, actual: 1)
+ )
+ }
+ XCTAssertThrowsError(try fixture.ledger.releaseStorageReservation(
+ leaseID: reserved.leaseID,
+ expectedLeaseRevision: reserved.leaseRevision
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .invalidLeaseState(.starting)
+ )
+ }
+ XCTAssertEqual(try fixture.ledger.snapshot().leases.count, 1)
+ }
+ }
+
+ private func withFixture(
+ _ name: String,
+ body: (ResourceLedgerFixture) throws -> Void
+ ) throws {
+ let fixture = try ResourceLedgerFixture(name)
+ defer { fixture.cleanup() }
+ try body(fixture)
+ }
+}
+
+private final class ResourceLedgerFixture {
+ static let gibibyte: UInt64 = 1_073_741_824
+
+ let root: String
+ let ledger: DoryVirtualMachineResourceAdmissionLedger
+ let host = DoryVMHostResources(
+ logicalCPUCount: 8,
+ physicalMemoryBytes: 16 * gibibyte,
+ freeStorageBytes: 256 * gibibyte
+ )
+ let resources = DoryVMResourceRequest(
+ virtualCPUCount: 4,
+ memoryBytes: 4 * gibibyte,
+ diskBytes: 32 * gibibyte
+ )
+
+ init(_ name: String, clock: ResourceLedgerClock? = nil) throws {
+ root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-resource-ledger-\(name)-\(UUID().uuidString)",
+ isDirectory: true
+ ).path
+ try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
+ try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root)
+ if let clock {
+ ledger = DoryVirtualMachineResourceAdmissionLedger(
+ root: root + "/ledger",
+ now: clock.read
+ )
+ } else {
+ ledger = DoryVirtualMachineResourceAdmissionLedger(root: root + "/ledger")
+ }
+ }
+
+ func binding(_ machineID: String) -> DoryVirtualMachineResourceAdmissionPlanBinding {
+ DoryVirtualMachineResourceAdmissionPlanBinding(
+ machineID: machineID,
+ definitionRevision: 3,
+ definitionSHA256: digest("1"),
+ plannedPlanRevision: 1
+ )
+ }
+
+ func plan(
+ binding: DoryVirtualMachineResourceAdmissionPlanBinding,
+ evidence: DoryResolvedMachineResourceAdmissionEvidence
+ ) -> DoryResolvedMachinePlan {
+ let provenance = DoryMutableBootMediaProvenanceReference(
+ repositoryIdentity: "machine-store",
+ mediaIdentity: "\(binding.machineID)-disk",
+ revision: 1
+ )
+ let media = DoryBootMedia(
+ kind: .virtualDisk,
+ source: .userProvided,
+ mutableProvenance: provenance
+ )
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let guest = DoryGuestPlatform(family: .linux, architecture: .arm64)
+ return DoryResolvedMachinePlan(
+ machineID: binding.machineID,
+ definitionRevision: binding.definitionRevision,
+ definitionSHA256: binding.definitionSHA256,
+ planRevision: binding.plannedPlanRevision,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000,
+ guest: guest,
+ backend: .appleVirtualizationFramework,
+ backendImplementationIdentifier: "dory.vz-linux.compatibility.v1",
+ backendRuntimeBuildIdentifier: "vz-runtime-1",
+ virtualHardwareABIVersion: 1,
+ bootMedia: DoryResolvedMachineBootMedia(
+ resolverReference: DoryVMResolverReference(
+ namespace: "machine",
+ identifier: "\(binding.machineID)-disk"
+ ),
+ media: media,
+ mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence(
+ receiptIdentity: "disk-receipt-1",
+ provenance: provenance,
+ receiptSHA256: digest("7"),
+ resolverID: "machine-store",
+ resolverVersion: 1
+ )
+ ),
+ components: [DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-vmm",
+ buildIdentifier: "vz-runtime-1",
+ artifactSHA256: digest("d")
+ )],
+ devices: devices,
+ graphics: .software,
+ supportTier: .supported,
+ selectionEvidence: DoryResolvedMachineBackendSelectionEvidence(
+ disposition: .primary,
+ plannerRequest: DoryVirtualMachineBackendPlanRequest(
+ guest: guest,
+ bootMedia: media,
+ acceptableGraphics: [.software],
+ devices: devices,
+ backendPreferences: [.appleVirtualizationFramework],
+ backendPreferencePolicy: .required
+ ),
+ selectedEvaluationIndex: 0,
+ rejectedCandidates: []
+ ),
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence(
+ runtime: DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "runtime-qualification-1",
+ qualificationReportSHA256: digest("c"),
+ signingKeyID: "dory-runtime-1",
+ qualificationFormatVersion: 1,
+ guest: guest,
+ bootMediaKind: media.kind,
+ immutableArtifactSHA256: nil,
+ mutableProvenance: provenance,
+ backend: .appleVirtualizationFramework,
+ backendRuntimeBuildID: "vz-runtime-1",
+ virtualHardwareABIVersion: 1,
+ graphics: .software,
+ devices: devices
+ )
+ ),
+ resourceAdmission: evidence,
+ hostQualification: DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: "host-qualification-1",
+ qualificationReportSHA256: digest("e"),
+ hostHardwareModelIdentifier: "Mac16.1",
+ hostOperatingSystemBuild: "26A5406c",
+ backend: .appleVirtualizationFramework,
+ backendRuntimeBuildIdentifier: "vz-runtime-1",
+ virtualHardwareABIVersion: 1,
+ qualifierIdentifier: "dory-host-qualifier",
+ qualifierVersion: 1
+ )
+ )
+ }
+
+ func cleanup() { try? FileManager.default.removeItem(atPath: root) }
+}
+
+private final class ResourceLedgerClock: @unchecked Sendable {
+ private let lock = NSLock()
+ private var value: Int64
+
+ init(now: Int64) { value = now }
+
+ var read: @Sendable () -> Int64 {
+ { [self] in lock.withLock { value } }
+ }
+
+ func advance(by milliseconds: Int64) {
+ lock.withLock { value += milliseconds }
+ }
+}
+
+private func digest(_ character: Character) -> String {
+ String(repeating: String(character), count: 64)
+}
From d537e9ee86b02f31a99267e4ebc935c5e18b9753 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 14:26:14 +0000
Subject: [PATCH 025/338] fix(vm): retain storage across admission recovery
---
...irtualMachineResourceAdmissionLedger.swift | 28 ++++++++++++------
...lMachineResourceAdmissionLedgerTests.swift | 29 ++++++++++++++++++-
2 files changed, 47 insertions(+), 10 deletions(-)
diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
index 65555a5a..c75643d1 100644
--- a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
@@ -119,6 +119,7 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError:
case staleLedgerRevision(expected: UInt64, actual: UInt64)
case staleLeaseRevision(expected: UInt64, actual: UInt64)
case invalidLeaseState(DoryVirtualMachineResourceLeaseState)
+ case storageReservationCannotShrink(existing: UInt64, requested: UInt64)
case planAlreadyBound
case planMismatch
case hostFactsMismatch
@@ -141,6 +142,8 @@ public enum DoryVirtualMachineResourceAdmissionLedgerError:
"stale resource lease revision: expected \(expected), found \(actual)"
case let .invalidLeaseState(state):
"resource lease has invalid state: \(state.rawValue)"
+ case let .storageReservationCannotShrink(existing, requested):
+ "storage reservation cannot shrink from \(existing) to \(requested) bytes"
case .planAlreadyBound: "resource admission lease is already bound to a plan"
case .planMismatch: "resolved plan does not match the admitted resource lease"
case .hostFactsMismatch: "current host facts do not match the admitted snapshot"
@@ -206,6 +209,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl
let stoppedLeaseIndex = record.leases.firstIndex {
$0.binding.machineID == binding.machineID && $0.state == .stopped
}
+ let stoppedLease = stoppedLeaseIndex.map { record.leases[$0] }
guard !record.leases.contains(where: {
$0.binding.machineID == binding.machineID && $0.state != .stopped
}) else {
@@ -213,6 +217,15 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl
binding.machineID
)
}
+ if let stoppedLease,
+ resources.diskBytes < stoppedLease.resources.diskBytes {
+ if recovered { try persistRecoveredRecord(&record) }
+ throw DoryVirtualMachineResourceAdmissionLedgerError
+ .storageReservationCannotShrink(
+ existing: stoppedLease.resources.diskBytes,
+ requested: resources.diskBytes
+ )
+ }
// A restart atomically replaces the stopped reservation. Excluding that lease from
// assessment prevents double-counting its disk while the lock prevents any interval
// in which another admission could consume the released capacity.
@@ -237,7 +250,6 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl
if recovered { try persistRecoveredRecord(&record) }
throw DoryVirtualMachineResourceAdmissionLedgerError.capacityUnavailable(errors)
}
- let stoppedLease = stoppedLeaseIndex.map { record.leases[$0] }
let leaseID = stoppedLease?.leaseID
?? "resource-lease-\(UUID().uuidString.lowercased())"
let evidence = Self.makeEvidence(
@@ -641,12 +653,11 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl
continue
}
changed = true
- // The composition contract does not permit artifact creation before plan binding, so
- // an abandoned pre-publication reservation owns no durable disk yet.
- guard lease.boundPlanSHA256 != nil else { continue }
- // A process may have crossed the launch boundary before daemon failure. Never release
- // runtime capacity until a daemon-owned process observation resolves that ambiguity.
- lease.state = .recoveryRequired
+ // Binding is the launch boundary. An unbound start cannot have launched, so CPU and
+ // memory are released, but storage is always durable and requires explicit release.
+ // A bound start may have launched before daemon failure and retains every commitment
+ // until a daemon-owned process observation resolves that ambiguity.
+ lease.state = lease.boundPlanSHA256 == nil ? .stopped : .recoveryRequired
lease.startingExpiresAtUnixMilliseconds = nil
lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
lease.updatedAtUnixMilliseconds = timestamp
@@ -801,8 +812,7 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl
throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
}
case .stopped:
- guard lease.boundPlanSHA256 != nil,
- lease.startingExpiresAtUnixMilliseconds == nil else {
+ guard lease.startingExpiresAtUnixMilliseconds == nil else {
throw DoryVirtualMachineResourceAdmissionLedgerError.invalidRecord
}
}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
index c3b15b2b..df7429bf 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
@@ -187,6 +187,27 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase {
expectedLeaseRevision: running.leaseRevision
)
let beforeFailure = try fixture.ledger.snapshot()
+ let smallerDisk = DoryVMResourceRequest(
+ virtualCPUCount: fixture.resources.virtualCPUCount,
+ memoryBytes: fixture.resources.memoryBytes,
+ diskBytes: fixture.resources.diskBytes - ResourceLedgerFixture.gibibyte
+ )
+ XCTAssertThrowsError(try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: smallerDisk
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .storageReservationCannotShrink(
+ existing: fixture.resources.diskBytes,
+ requested: smallerDisk.diskBytes
+ )
+ )
+ }
+ XCTAssertEqual(try fixture.ledger.snapshot(), beforeFailure)
+
let oversized = DoryVMResourceRequest(
virtualCPUCount: fixture.host.logicalCPUCount,
memoryBytes: fixture.resources.memoryBytes,
@@ -266,7 +287,13 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase {
root: fixture.ledger.root,
now: clock.read
)
- XCTAssertTrue(try restarted.snapshot().leases.isEmpty)
+ let abandoned = try XCTUnwrap(restarted.snapshot().leases.first)
+ XCTAssertEqual(abandoned.state, .stopped)
+ XCTAssertNil(abandoned.boundPlanSHA256)
+ try restarted.releaseStorageReservation(
+ leaseID: abandoned.leaseID,
+ expectedLeaseRevision: abandoned.leaseRevision
+ )
let durable = try restarted.reserveStarting(
binding: fixture.binding("durable"),
From e3ff3451c104ce568cf235a0b8e7d06ebdf56deb Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 15:05:14 +0000
Subject: [PATCH 026/338] feat(vm): journal machine lifecycle recovery
---
.../DoryOperations/DoryOperationJournal.swift | 79 +-
.../DoryOperationJournalPublication.swift | 15 +-
.../DoryWorkspaceLifecycleOperation.swift | 43 +-
.../Sources/DorydKit/MachineManager.swift | 1567 +++++++++++++++--
...DoryWorkspaceLifecycleOperationTests.swift | 13 +
...agerLifecycleJournalIntegrationTests.swift | 611 +++++++
6 files changed, 2170 insertions(+), 158 deletions(-)
create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift
diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournal.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournal.swift
index db7bc936..7f5eb3c1 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournal.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournal.swift
@@ -25,6 +25,13 @@ public struct DoryOperationJournalStore: Sendable, Equatable {
root + "/" + id.uuidString.lowercased() + ".doryop"
}
+ /// Creates and validates the private journal root before a later mutation needs to publish.
+ /// Daemons use this during startup so a transiently read-only managed-data directory cannot
+ /// strand an otherwise recoverable operation before its journal is written.
+ public func prepare(fileManager: FileManager = .default) throws {
+ try prepareRoot(fileManager: fileManager)
+ }
+
public func begin(
_ plan: DoryOperationPlan,
at date: Date = Date(),
@@ -42,14 +49,46 @@ public struct DoryOperationJournalStore: Sendable, Equatable {
public func acquire(
_ id: UUID,
fileManager: FileManager = .default
+ ) throws -> DoryOperationLease {
+ try acquireVerified(id, requestedMutationScope: nil, fileManager: fileManager)
+ }
+
+ /// Reacquires a scoped operation only when the caller's expectation matches the scope
+ /// authenticated by the immutable journal plan. This overload is useful for callers that
+ /// already know which workspace they are recovering; it cannot select a different lock.
+ public func acquire(
+ _ id: UUID,
+ mutationScope: String,
+ fileManager: FileManager = .default
+ ) throws -> DoryOperationLease {
+ try acquireVerified(
+ id,
+ requestedMutationScope: mutationScope,
+ fileManager: fileManager
+ )
+ }
+
+ private func acquireVerified(
+ _ id: UUID,
+ requestedMutationScope: String?,
+ fileManager: FileManager
) throws -> DoryOperationLease {
guard Self.pathEntryExists(root) else {
throw DoryOperationJournalError.operationNotFound(id)
}
try validateRoot()
- let lock = try acquireMutationLock()
+ let before = try readRecord(id)
+ let authenticatedScope = authenticatedMutationScope(for: before.plan)
+ if let requestedMutationScope, requestedMutationScope != authenticatedScope {
+ throw DoryOperationJournalError.invalidPlan("mutation scope mismatch")
+ }
+ let lock = try acquireMutationLock(scope: authenticatedScope)
let lease = DoryOperationLease(store: self, operationID: id, lock: lock)
- _ = try lease.read()
+ let after = try lease.read()
+ guard after.plan == before.plan,
+ authenticatedMutationScope(for: after.plan) == authenticatedScope else {
+ throw DoryOperationJournalError.invalidRecord(operationDirectory(for: id))
+ }
try lease.reconcileAuditLog()
return lease
}
@@ -75,7 +114,9 @@ public struct DoryOperationJournalStore: Sendable, Equatable {
}
var records: [DoryOperationRecord] = []
for entry in entries.sorted() {
- if entry == ".mutation.lock" || Self.isUnpublishedPartial(entry) { continue }
+ if entry == ".mutation.lock"
+ || (entry.hasPrefix(".mutation.") && entry.hasSuffix(".lock"))
+ || Self.isUnpublishedPartial(entry) { continue }
guard entry.hasSuffix(".doryop"),
let id = UUID(uuidString: String(entry.dropLast(".doryop".count))) else {
throw DoryOperationJournalError.invalidRecord(root + "/" + entry)
@@ -137,11 +178,14 @@ public struct DoryOperationJournalStore: Sendable, Equatable {
}
}
- func acquireMutationLock() throws -> EngineStateDirectoryLock {
+ func acquireMutationLock(scope: String? = nil) throws -> EngineStateDirectoryLock {
+ if let scope, !Self.isToken(scope) {
+ throw DoryOperationJournalError.invalidPlan("mutation scope")
+ }
do {
return try EngineStateDirectoryLock(
stateDirectory: root,
- lockFileName: ".mutation.lock"
+ lockFileName: scope.map { ".mutation.\($0).lock" } ?? ".mutation.lock"
)
} catch let error as EngineStateDirectoryLockError {
switch error {
@@ -153,6 +197,31 @@ public struct DoryOperationJournalStore: Sendable, Equatable {
}
}
+ /// A same-workspace lifecycle journal has one deterministic lock scope. Multi-workspace
+ /// operations (currently clone) retain the established global mutation fence.
+ func authenticatedMutationScope(for plan: DoryOperationPlan) -> String? {
+ guard Self.isWorkspaceLifecycleKind(plan.kind),
+ plan.source.kind == .workspace,
+ plan.target.kind == .workspace,
+ plan.source.id == plan.target.id,
+ Self.isToken(plan.source.id) else {
+ return nil
+ }
+ return plan.source.id
+ }
+
+ private static func isWorkspaceLifecycleKind(_ kind: DoryOperationKind) -> Bool {
+ switch kind {
+ case .workspaceImport, .workspaceProvision, .workspaceResolve, .workspaceStart,
+ .workspaceStop, .workspacePause, .workspaceResume, .workspaceSuspend,
+ .workspaceRestore, .workspaceSnapshot, .workspaceClone, .workspaceUpdate,
+ .workspaceRepair, .workspaceDelete:
+ true
+ case .competitorImport, .driveBackup, .driveRestore, .driveRelocation, .driveUpgrade:
+ false
+ }
+ }
+
private static func isUnpublishedPartial(_ name: String) -> Bool {
let components = name.split(separator: ".", omittingEmptySubsequences: false)
guard components.count == 4,
diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
index a419151d..9d8fbdd9 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
@@ -7,18 +7,19 @@ extension DoryOperationJournalStore {
completenessPlanData: Data?,
specifications: [DoryOperationSpecification],
at date: Date,
- fileManager: FileManager
+ fileManager: FileManager,
+ mutationScope: String? = nil
) throws -> DoryOperationLease {
guard plan.isValid else {
throw DoryOperationJournalError.invalidPlan(plan.id.uuidString.lowercased())
}
try prepareRoot(fileManager: fileManager)
- let lock = try acquireMutationLock()
+ let lock = try acquireMutationLock(scope: mutationScope)
let destination = operationDirectory(for: plan.id)
guard !Self.pathEntryExists(destination) else {
throw DoryOperationJournalError.operationExists(plan.id)
}
- try requireNoUnfinishedOperation()
+ try requireNoUnfinishedOperation(mutationScope: mutationScope)
let partial = root + "/." + plan.id.uuidString.lowercased() + "."
+ UUID().uuidString.lowercased() + ".partial"
@@ -60,9 +61,13 @@ extension DoryOperationJournalStore {
}
}
- private func requireNoUnfinishedOperation() throws {
+ private func requireNoUnfinishedOperation(mutationScope: String?) throws {
if let unfinished = try list().first(where: {
- $0.state.status != .completed && $0.state.status != .failed
+ guard $0.state.status != .completed && $0.state.status != .failed else {
+ return false
+ }
+ guard let mutationScope else { return true }
+ return $0.plan.source.id == mutationScope || $0.plan.target.id == mutationScope
}) {
throw DoryOperationJournalError.operationInUse(
"Dory operation \(unfinished.plan.id.uuidString.lowercased()) requires recovery "
diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
index 224abda1..d59d4f76 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
@@ -188,6 +188,24 @@ public struct DoryWorkspaceConfigurationAuthority: Codable, Sendable, Equatable
}
}
+/// Content-only identity for a snapshot selected by a lifecycle operation. The descriptor digest
+/// binds the complete persisted snapshot record while the evidence digest independently binds its
+/// rootfs/kernel/firmware evidence. Daemon-local paths remain outside the journal contract.
+public struct DoryWorkspaceSnapshotAuthority: Codable, Sendable, Equatable {
+ public var descriptorSHA256: String
+ public var artifactEvidenceSHA256: String
+
+ public init(descriptorSHA256: String, artifactEvidenceSHA256: String) {
+ self.descriptorSHA256 = descriptorSHA256
+ self.artifactEvidenceSHA256 = artifactEvidenceSHA256
+ }
+
+ fileprivate var isValid: Bool {
+ DoryOperationJournalStore.isDigest(descriptorSHA256)
+ && DoryOperationJournalStore.isDigest(artifactEvidenceSHA256)
+ }
+}
+
public struct DoryWorkspaceLifecycleCondition: Codable, Sendable, Equatable {
private var persistenceSchemaVersion: UInt16
public var workspaceID: String
@@ -456,6 +474,8 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
public var targetWorkspaceID: String?
/// Stable snapshot or clone identity needed for deterministic recovery; never a host path.
public var targetResourceID: String?
+ /// Exact content authority for snapshot/restore resources when supplied by the executor.
+ public var targetSnapshotAuthority: DoryWorkspaceSnapshotAuthority?
public var createdAtUnixMilliseconds: Int64
public var deadlineUnixMilliseconds: Int64
public var steps: [DoryWorkspaceOperationStep]
@@ -471,6 +491,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
target: DoryWorkspaceLifecycleCondition,
targetWorkspaceID: String? = nil,
targetResourceID: String? = nil,
+ targetSnapshotAuthority: DoryWorkspaceSnapshotAuthority? = nil,
createdAtUnixMilliseconds: Int64,
deadlineUnixMilliseconds: Int64,
steps: [DoryWorkspaceOperationStep],
@@ -487,6 +508,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
self.target = target
self.targetWorkspaceID = targetWorkspaceID
self.targetResourceID = targetResourceID
+ self.targetSnapshotAuthority = targetSnapshotAuthority
self.createdAtUnixMilliseconds = createdAtUnixMilliseconds
self.deadlineUnixMilliseconds = deadlineUnixMilliseconds
self.steps = steps
@@ -578,6 +600,17 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
} else if targetResourceID != nil {
add(.invalidTargetWorkspace, "targetResourceID")
}
+ let requiresSnapshotAuthority = kind == .snapshotting || kind == .restoring
+ if sourceSchemaVersion == Self.schemaVersion, requiresSnapshotAuthority {
+ if targetSnapshotAuthority?.isValid != true || targetResourceID == nil {
+ add(.invalidCondition, "targetSnapshotAuthority")
+ }
+ } else if let targetSnapshotAuthority,
+ (!targetSnapshotAuthority.isValid
+ || !requiresSnapshotAuthority
+ || targetResourceID == nil) {
+ add(.invalidCondition, "targetSnapshotAuthority")
+ }
if createdAtUnixMilliseconds < 0
|| deadlineUnixMilliseconds <= createdAtUnixMilliseconds {
add(.invalidDeadline, "deadlineUnixMilliseconds")
@@ -624,6 +657,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
case target
case targetWorkspaceID
case targetResourceID
+ case targetSnapshotAuthority
case createdAtUnixMilliseconds
case deadlineUnixMilliseconds
case steps
@@ -667,6 +701,10 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
}
targetWorkspaceID = try container.decodeIfPresent(String.self, forKey: .targetWorkspaceID)
targetResourceID = try container.decodeIfPresent(String.self, forKey: .targetResourceID)
+ targetSnapshotAuthority = try container.decodeIfPresent(
+ DoryWorkspaceSnapshotAuthority.self,
+ forKey: .targetSnapshotAuthority
+ )
createdAtUnixMilliseconds = try container.decode(Int64.self, forKey: .createdAtUnixMilliseconds)
deadlineUnixMilliseconds = try container.decode(Int64.self, forKey: .deadlineUnixMilliseconds)
steps = try container.decode([DoryWorkspaceOperationStep].self, forKey: .steps)
@@ -697,6 +735,7 @@ public struct DoryWorkspaceLifecycleOperation: Codable, Sendable, Equatable {
try container.encode(target, forKey: .target)
try container.encodeIfPresent(targetWorkspaceID, forKey: .targetWorkspaceID)
try container.encodeIfPresent(targetResourceID, forKey: .targetResourceID)
+ try container.encodeIfPresent(targetSnapshotAuthority, forKey: .targetSnapshotAuthority)
try container.encode(createdAtUnixMilliseconds, forKey: .createdAtUnixMilliseconds)
try container.encode(deadlineUnixMilliseconds, forKey: .deadlineUnixMilliseconds)
try container.encode(steps, forKey: .steps)
@@ -877,6 +916,7 @@ extension DoryOperationJournalStore {
"workspace lifecycle journal binding mismatch"
)
}
+ let mutationScope = authenticatedMutationScope(for: binding.plan)
return try begin(
binding.plan,
completenessPlanData: nil,
@@ -884,7 +924,8 @@ extension DoryOperationJournalStore {
at: Date(
timeIntervalSince1970: Double(operation.createdAtUnixMilliseconds) / 1_000
),
- fileManager: fileManager
+ fileManager: fileManager,
+ mutationScope: mutationScope
)
}
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 7533357f..5ed13403 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -10,6 +10,9 @@ public struct MachineManagerConfiguration: Sendable, Equatable {
public var acceleratedDesktopExecutablePath: String?
public var stateDirectory: String
public var runtimeDirectory: String
+ /// Durable mutation authority kept outside individual machine directories so interrupted
+ /// deletion can recover without deleting its own journal.
+ public var lifecycleJournalHome: String
public var baseArguments: [String]
public var acceleratedDesktopBaseArguments: [String]
public var passMachineArguments: Bool
@@ -35,6 +38,7 @@ public struct MachineManagerConfiguration: Sendable, Equatable {
acceleratedDesktopExecutablePath: String? = nil,
stateDirectory: String,
runtimeDirectory: String? = nil,
+ lifecycleJournalHome: String? = nil,
baseArguments: [String] = [],
acceleratedDesktopBaseArguments: [String] = [],
passMachineArguments: Bool = true,
@@ -55,6 +59,8 @@ public struct MachineManagerConfiguration: Sendable, Equatable {
self.acceleratedDesktopExecutablePath = acceleratedDesktopExecutablePath
self.stateDirectory = stateDirectory
self.runtimeDirectory = runtimeDirectory ?? stateDirectory
+ self.lifecycleJournalHome = lifecycleJournalHome
+ ?? "\(self.runtimeDirectory)/.lifecycle-journal"
self.baseArguments = baseArguments
self.acceleratedDesktopBaseArguments = acceleratedDesktopBaseArguments
self.passMachineArguments = passMachineArguments
@@ -679,6 +685,8 @@ public final class MachineManager: @unchecked Sendable {
private let processStarter: ProcessStarter
private let workspaceRepository: DoryWorkspaceRepository
private let launchPolicy: DoryMachineLaunchPolicy
+ private let lifecycleJournalStore: DoryOperationJournalStore?
+ private let lifecycleJournalInitializationError: String?
private let operationLock = NSRecursiveLock()
private let lock = NSLock()
private var machines: [String: MachineEntry] = [:]
@@ -690,6 +698,10 @@ public final class MachineManager: @unchecked Sendable {
private var resolvedPlanRevisionProvider: ResolvedPlanRevisionProvider?
private var pendingResolvedStart: PendingResolvedMachineStart?
private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:]
+ private var activeLifecycleOperations: [String: MachineLifecycleJournalContext] = [:]
+#if DEBUG
+ private var lifecycleFaultInjector: (@Sendable (MachineLifecycleFaultPoint) throws -> Void)?
+#endif
public init(
configuration: MachineManagerConfiguration,
@@ -706,6 +718,17 @@ public final class MachineManager: @unchecked Sendable {
self.agentConnector = agentConnector
self.processStarter = processStarter
self.workspaceRepository = DoryWorkspaceRepository(root: configuration.stateDirectory)
+ do {
+ let store = try DoryOperationJournalStore(
+ home: configuration.lifecycleJournalHome
+ )
+ try store.prepare()
+ lifecycleJournalStore = store
+ lifecycleJournalInitializationError = nil
+ } catch {
+ lifecycleJournalStore = nil
+ lifecycleJournalInitializationError = String(describing: error)
+ }
_ = HelperProcessJanitor.terminateStaleHelpers(
executablePath: configuration.vmmExecutablePath,
stateDirectory: configuration.stateDirectory,
@@ -719,6 +742,10 @@ public final class MachineManager: @unchecked Sendable {
includeDescendants: true
)
}
+ let lifecycleRecoveryDiagnostics = Self.recoverInterruptedLifecycleOperations(
+ store: lifecycleJournalStore,
+ configuration: configuration
+ )
Self.removeStaleDeletionQuarantines(stateDirectory: configuration.stateDirectory)
Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory)
Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory)
@@ -727,6 +754,9 @@ public final class MachineManager: @unchecked Sendable {
configuration: configuration,
launchPolicy: launchPolicy
)
+ for (machineID, diagnostic) in lifecycleRecoveryDiagnostics {
+ machines[machineID]?.lastError = diagnostic
+ }
reconcileLoadedWorkspaceProjections()
recoverInterruptedDesktopUpdates()
}
@@ -897,13 +927,38 @@ public final class MachineManager: @unchecked Sendable {
public func start(id: String) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
+ return try startImplementation(id: id, journalLifecycle: true)
+ }
+
+ private func startImplementation(
+ id: String,
+ journalLifecycle: Bool
+ ) throws -> DoryMachineStatus {
switch launchPolicy {
case .legacyCompatibility:
- let prepared = try prepareMachineStart(
+ let prepared = try prepareMachineStartWithLifecycle(
id: id,
- requiresAuthoritativeDefinition: false
+ requiresAuthoritativeDefinition: false,
+ journalLifecycle: journalLifecycle
)
- return try spawnPreparedMachine(prepared.machine, launchBinding: nil)
+ let identity = try currentRuntimeIdentity(id: id)
+ let lifecycle = try journalLifecycle
+ ? beginLifecycleStart(machine: prepared.machine, targetIdentity: identity)
+ : nil
+ do {
+ if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
+ let status = try spawnPreparedMachine(prepared.machine, launchBinding: nil)
+ if let lifecycle, status.state != .starting {
+ _ = completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "running machine has an unfinished start journal"
+ )
+ }
+ return status
+ } catch {
+ if let lifecycle { failLifecycle(lifecycle, stepID: "start.failed") }
+ throw error
+ }
case .requireResolvedPlan:
guard let registry = resolvedLaunchRegistry,
let resolver = resolvedLaunchPlanResolver,
@@ -918,7 +973,8 @@ public final class MachineManager: @unchecked Sendable {
registry: registry,
resolver: resolver,
planStore: planStore,
- revisionProvider: revisionProvider
+ revisionProvider: revisionProvider,
+ journalLifecycle: journalLifecycle
)
}
}
@@ -928,9 +984,14 @@ public final class MachineManager: @unchecked Sendable {
registry: BackendRegistry,
resolver: any DoryDaemonVirtualMachineLaunchPlanResolving,
planStore: any DoryResolvedMachinePlanStoring,
- revisionProvider: ResolvedPlanRevisionProvider
+ revisionProvider: ResolvedPlanRevisionProvider,
+ journalLifecycle: Bool
) throws -> DoryMachineStatus {
- let prepared = try prepareMachineStart(id: id, requiresAuthoritativeDefinition: true)
+ let prepared = try prepareMachineStartWithLifecycle(
+ id: id,
+ requiresAuthoritativeDefinition: true,
+ journalLifecycle: journalLifecycle
+ )
guard let definition = prepared.definition,
let definitionData = prepared.canonicalDefinitionData else {
throw MachineManagerError.persistence(
@@ -972,39 +1033,54 @@ public final class MachineManager: @unchecked Sendable {
resolvedPlan: resolved.resolvedPlan,
planSHA256: resolved.resolvedPlanSHA256
)
+ let lifecycle = try journalLifecycle
+ ? beginLifecycleStart(machine: prepared.machine, targetIdentity: runtimeIdentity)
+ : nil
- pendingResolvedStart = PendingResolvedMachineStart(
- machine: prepared.machine,
- backend: resolved.backendPlan.backend,
- runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier,
- runtimeComponents: resolved.resolvedPlan.components,
- planRevision: resolved.resolvedPlan.planRevision,
- planSHA256: resolved.resolvedPlanSHA256
- )
- defer { pendingResolvedStart = nil }
- let operation = registry.start(resolved.backendPlan)
- guard operation.isSuccess,
- operation.backend == resolved.resolvedPlan.backend,
- operation.observation?.machineID == id else {
- let failure = operation.failure?.message ?? "backend adapter returned no observation"
- throw MachineManagerError.persistence("resolved backend start failed: \(failure)")
- }
- lock.lock()
- if var entry = machines[id] {
- entry.runtimeIdentity = runtimeIdentity
- machines[id] = entry
- }
- lock.unlock()
- guard let status = status(id: id), [.starting, .running].contains(status.state) else {
- throw MachineManagerError.persistence(
- "resolved backend did not enter MachineManager's prepared spawn path"
+ do {
+ if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
+ pendingResolvedStart = PendingResolvedMachineStart(
+ machine: prepared.machine,
+ backend: resolved.backendPlan.backend,
+ runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier,
+ runtimeComponents: resolved.resolvedPlan.components,
+ planRevision: resolved.resolvedPlan.planRevision,
+ planSHA256: resolved.resolvedPlanSHA256
)
+ defer { pendingResolvedStart = nil }
+ let operation = registry.start(resolved.backendPlan)
+ guard operation.isSuccess,
+ operation.backend == resolved.resolvedPlan.backend,
+ operation.observation?.machineID == id else {
+ let failure = operation.failure?.message ?? "backend adapter returned no observation"
+ throw MachineManagerError.persistence("resolved backend start failed: \(failure)")
+ }
+ lock.lock()
+ if var entry = machines[id] {
+ entry.runtimeIdentity = runtimeIdentity
+ machines[id] = entry
+ }
+ lock.unlock()
+ guard let status = status(id: id), [.starting, .running].contains(status.state) else {
+ throw MachineManagerError.persistence(
+ "resolved backend did not enter MachineManager's prepared spawn path"
+ )
+ }
+ resolvedLaunchIdentities[id] = DoryMachineResolvedLaunchIdentity(
+ plan: resolved.resolvedPlan,
+ planSHA256: resolved.resolvedPlanSHA256
+ )
+ if let lifecycle, status.state != .starting {
+ _ = completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "running machine has an unfinished resolved-start journal"
+ )
+ }
+ return status
+ } catch {
+ if let lifecycle { failLifecycle(lifecycle, stepID: "start.failed") }
+ throw error
}
- resolvedLaunchIdentities[id] = DoryMachineResolvedLaunchIdentity(
- plan: resolved.resolvedPlan,
- planSHA256: resolved.resolvedPlanSHA256
- )
- return status
}
private func revalidateResolvedPlanAuthorityImmediatelyBeforeSpawn(
@@ -1228,6 +1304,48 @@ public final class MachineManager: @unchecked Sendable {
)
}
+ private func prepareMachineStartWithLifecycle(
+ id: String,
+ requiresAuthoritativeDefinition: Bool,
+ journalLifecycle: Bool
+ ) throws -> PreparedMachineStart {
+ guard journalLifecycle else {
+ return try prepareMachineStart(
+ id: id,
+ requiresAuthoritativeDefinition: requiresAuthoritativeDefinition
+ )
+ }
+ let lifecycle = try beginLifecycleStartPreparation(id: id)
+ do {
+ try advanceLifecycleToPublishing(lifecycle)
+ let prepared = try prepareMachineStart(
+ id: id,
+ requiresAuthoritativeDefinition: requiresAuthoritativeDefinition
+ )
+#if DEBUG
+ try injectLifecycleFault(.startAfterPreparation)
+#endif
+ guard completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "start preparation committed; journal completion requires recovery"
+ ) else {
+ throw MachineLifecycleJournalCompletionPending()
+ }
+ return prepared
+ } catch {
+#if DEBUG
+ if error is MachineLifecycleInjectedCrash { throw error }
+#endif
+ if error is MachineLifecycleJournalCompletionPending {
+ throw MachineManagerError.persistence(
+ "start preparation committed but journal completion requires recovery"
+ )
+ }
+ failLifecycle(lifecycle, stepID: "start-preparation.failed")
+ throw error
+ }
+ }
+
private func spawnPreparedMachine(
_ preparedMachine: DoryMachineConfiguration,
launchBinding: MachineBackendLaunchBinding?
@@ -1275,7 +1393,16 @@ public final class MachineManager: @unchecked Sendable {
lock.unlock()
throw error
}
- let process = HvProcess(configuration: processConfiguration)
+ let process = HvProcess(
+ configuration: processConfiguration,
+ unexpectedTerminationHandler: { [weak self] termination in
+ self?.handleUnexpectedMachineProcessTermination(
+ machineID: id,
+ launchID: launchID,
+ termination: termination
+ )
+ }
+ )
let handoffReadyTimeout = handoffReadyTimeout(for: entry.configuration)
entry.process = process
entry.handoffServer = handoffServer
@@ -1308,7 +1435,14 @@ public final class MachineManager: @unchecked Sendable {
}
private func startAndWaitUntilReady(id: String) throws -> DoryMachineStatus {
- let started = try start(id: id)
+ try startAndWaitUntilReady(id: id, journalLifecycle: true)
+ }
+
+ private func startAndWaitUntilReady(
+ id: String,
+ journalLifecycle: Bool
+ ) throws -> DoryMachineStatus {
+ let started = try startImplementation(id: id, journalLifecycle: journalLifecycle)
guard started.state == .starting else { return started }
let handoffReadyTimeout = handoffReadyTimeout(
@@ -1375,33 +1509,106 @@ public final class MachineManager: @unchecked Sendable {
entry.lastError = "vmm ready handoff timed out after \(Int(timeout))s"
self.machines[id] = entry
self.lock.unlock()
+ self.operationLock.lock()
+ self.failActiveStartLifecycle(id: id, stepID: "start.readiness-timeout")
+ self.operationLock.unlock()
process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
}
}
- public func stop(id: String) throws -> DoryMachineStatus {
- operationLock.lock()
- defer { operationLock.unlock() }
+ private func handleUnexpectedMachineProcessTermination(
+ machineID: String,
+ launchID: UUID,
+ termination: HvProcessTermination
+ ) {
+ var handoffServer: VmmHandoffServer?
lock.lock()
- guard var entry = machines[id] else {
+ guard var entry = machines[machineID],
+ entry.launchID == launchID,
+ [.starting, .running].contains(entry.state),
+ entry.process?.isRunningOrRestarting != true else {
lock.unlock()
- throw MachineManagerError.unknownMachine(id)
+ return
}
- let process = entry.process
- let handoffServer = entry.handoffServer
- entry.process = nil
+ handoffServer = entry.handoffServer
entry.handoffServer = nil
entry.handoff = nil
entry.launchID = nil
entry.runtimeAddress = nil
entry.currentBalloonTargetMB = nil
- entry.state = .stopped
- machines[id] = entry
+ entry.state = .failed
+ entry.lastError = "dory-vmm \(termination.description)"
+ machines[machineID] = entry
lock.unlock()
+ operationLock.lock()
+ failActiveStartLifecycle(id: machineID, stepID: "start.helper-exited")
+ operationLock.unlock()
handoffServer?.stop()
- process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
- return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
+ }
+
+ public func stop(id: String) throws -> DoryMachineStatus {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-stop")
+ return try stopImplementation(id: id, journalLifecycle: true)
+ }
+
+ private func stopImplementation(
+ id: String,
+ journalLifecycle: Bool
+ ) throws -> DoryMachineStatus {
+ lock.lock()
+ guard var entry = machines[id] else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ let wasActive = [.starting, .running].contains(entry.state) && entry.process != nil
+ let machine = entry.configuration
+ let runtimeIdentity = entry.runtimeIdentity
+ lock.unlock()
+ let lifecycle = try journalLifecycle && wasActive
+ ? beginLifecycleStop(machine: machine, runtimeIdentity: runtimeIdentity)
+ : nil
+ do {
+ if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
+ lock.lock()
+ guard let current = machines[id] else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ entry = current
+ let process = entry.process
+ let handoffServer = entry.handoffServer
+ entry.process = nil
+ entry.handoffServer = nil
+ entry.handoff = nil
+ entry.launchID = nil
+ entry.runtimeAddress = nil
+ entry.currentBalloonTargetMB = nil
+ entry.state = .stopped
+ machines[id] = entry
+ lock.unlock()
+
+ handoffServer?.stop()
+ process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
+#if DEBUG
+ try injectLifecycleFault(.stopAfterProcessStop)
+#endif
+ if let lifecycle {
+ _ = completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "stopped machine has an unfinished stop journal"
+ )
+ }
+ return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
+ } catch {
+#if DEBUG
+ if error is MachineLifecycleInjectedCrash { throw error }
+#endif
+ if let lifecycle { failLifecycle(lifecycle, stepID: "stop.failed") }
+ throw error
+ }
}
public func stopAll() {
@@ -1436,52 +1643,97 @@ public final class MachineManager: @unchecked Sendable {
guard Self.isValidID(id) else {
throw MachineManagerError.invalidID(id)
}
+ try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-delete")
lock.lock()
- guard let entry = machines.removeValue(forKey: id) else {
+ guard let sourceEntry = machines[id] else {
lock.unlock()
throw MachineManagerError.unknownMachine(id)
}
- deletingMachineIDs.insert(id)
lock.unlock()
+ let lifecycle = try beginLifecycleDelete(
+ machine: sourceEntry.configuration,
+ runtimeIdentity: sourceEntry.runtimeIdentity,
+ sourceState: lifecycleState(for: sourceEntry.state)
+ )
+ var deletionCommitted = false
- entry.handoffServer?.stop()
- entry.process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
+ do {
+ try advanceLifecycleToPublishing(lifecycle)
+ _ = try stopImplementation(id: id, journalLifecycle: false)
+ lock.lock()
+ guard machines.removeValue(forKey: id) != nil else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ deletingMachineIDs.insert(id)
+ lock.unlock()
- let fileManager = FileManager.default
- let statePath = machineStateDirectory(id: id)
- var quarantinePath: String?
- if fileManager.fileExists(atPath: statePath) {
- let quarantine = "\(configuration.stateDirectory)/\(Self.deletionQuarantinePrefix)\(id)-\(UUID().uuidString)"
- do {
+ let fileManager = FileManager.default
+ let statePath = machineStateDirectory(id: id)
+ var quarantinePath: String?
+ if fileManager.fileExists(atPath: statePath) {
+ let quarantine = lifecycleDeletionQuarantinePath(
+ machineID: id,
+ operationID: lifecycle.operation.operationID
+ )
try fileManager.moveItem(atPath: statePath, toPath: quarantine)
quarantinePath = quarantine
- } catch {
- var restored = entry
- restored.process = nil
- restored.handoffServer = nil
- restored.handoff = nil
- restored.currentBalloonTargetMB = nil
- restored.state = .stopped
- restored.lastError = "delete failed: \(error)"
- lock.lock()
- deletingMachineIDs.remove(id)
- if machines[id] == nil {
- machines[id] = restored
- }
- lock.unlock()
- throw MachineManagerError.persistence("could not delete \(id): \(error)")
+#if DEBUG
+ try injectLifecycleFault(.deleteAfterQuarantine)
+#endif
}
- }
- lock.lock()
- deletingMachineIDs.remove(id)
- workspaceProjectionDiagnostics.removeValue(forKey: id)
- lock.unlock()
- resolvedLaunchIdentities.removeValue(forKey: id)
+ lock.lock()
+ deletingMachineIDs.remove(id)
+ workspaceProjectionDiagnostics.removeValue(forKey: id)
+ lock.unlock()
+ resolvedLaunchIdentities.removeValue(forKey: id)
- try? FileManager.default.removeItem(atPath: machineRuntimeDirectory(id: id))
- if let quarantinePath {
- try? fileManager.removeItem(atPath: quarantinePath)
+ try? FileManager.default.removeItem(atPath: machineRuntimeDirectory(id: id))
+ if let quarantinePath {
+ try fileManager.removeItem(atPath: quarantinePath)
+ }
+ // Once both the authoritative state and its quarantine are gone, deletion is
+ // committed. A later journal fsync/transition failure must not resurrect an
+ // in-memory entry whose storage no longer exists. Recovery can deterministically
+ // finish the still-durable deleting operation on the next daemon start.
+ deletionCommitted = true
+ do {
+ try completeLifecycle(lifecycle)
+ } catch {
+ activeLifecycleOperations.removeValue(forKey: id)
+ lifecycle.releaseLease()
+ }
+ } catch {
+#if DEBUG
+ if error is MachineLifecycleInjectedCrash { throw error }
+#endif
+ if deletionCommitted {
+ activeLifecycleOperations.removeValue(forKey: id)
+ return
+ }
+ let quarantine = lifecycleDeletionQuarantinePath(
+ machineID: id,
+ operationID: lifecycle.operation.operationID
+ )
+ let statePath = machineStateDirectory(id: id)
+ if Self.pathEntryExists(quarantine), !Self.pathEntryExists(statePath) {
+ try? FileManager.default.moveItem(atPath: quarantine, toPath: statePath)
+ }
+ var restored = sourceEntry
+ restored.process = nil
+ restored.handoffServer = nil
+ restored.handoff = nil
+ restored.currentBalloonTargetMB = nil
+ restored.state = .stopped
+ restored.lastError = "delete failed: \(error)"
+ lock.lock()
+ deletingMachineIDs.remove(id)
+ if machines[id] == nil { machines[id] = restored }
+ lock.unlock()
+ failLifecycle(lifecycle, stepID: "delete.failed", rolledBack: true)
+ if let error = error as? MachineManagerError { throw error }
+ throw MachineManagerError.persistence("could not delete \(id): \(error)")
}
}
@@ -1631,28 +1883,79 @@ public final class MachineManager: @unchecked Sendable {
!Self.pathEntryExists(nvramPath) else {
throw MachineManagerError.duplicateSnapshot(snapshotID)
}
-
+ // Quiesce under its own durable stop journal. Once stopped, the exact bytes selected for
+ // the snapshot can be hashed and bound before any snapshot artifact is published.
if wasRunning {
- _ = try stop(id: id)
+ _ = try stopImplementation(id: id, journalLifecycle: true)
+ }
+ let liveMachineIdentifierPath = machine.bootMode == .efi
+ ? machineFirmwareIdentifierPath(id: id) : nil
+ let liveNVRAMPath = machine.bootMode == .efi
+ ? machineFirmwareNVRAMPath(id: id) : nil
+ if machine.bootMode == .efi {
+ guard let liveMachineIdentifierPath, let liveNVRAMPath,
+ Self.isPrivateRegularFile(path: liveMachineIdentifierPath),
+ Self.isPrivateRegularFile(path: liveNVRAMPath) else {
+ throw MachineManagerError.persistence(
+ "EFI firmware state is unavailable; start the machine once before taking a snapshot"
+ )
+ }
+ }
+ let artifactEvidence = try Self.snapshotArtifactEvidence(
+ rootfsPath: machine.rootfsPath,
+ kernelPath: machine.kernelPath,
+ machineIdentifierPath: liveMachineIdentifierPath,
+ nvramPath: liveNVRAMPath
+ )
+ guard let snapshotSize = Int64(exactly: artifactEvidence.rootfs.byteCount) else {
+ throw MachineManagerError.persistence("machine snapshot disk is too large")
}
- let snapshot: DoryMachineSnapshot
+ let snapshot = DoryMachineSnapshot(
+ id: snapshotID,
+ machineID: id,
+ note: note,
+ createdISO: createdISO,
+ rootfsPath: rootfsPath,
+ sizeBytes: snapshotSize,
+ kernelPath: kernelPath,
+ architecture: configuration.guestArchitecture,
+ memoryMB: machine.memoryMB,
+ cpuCount: machine.cpuCount,
+ displayMode: machine.displayMode,
+ address: machine.address,
+ shares: machine.shares,
+ environment: machine.environment,
+ bootMode: machine.bootMode,
+ machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil,
+ nvramPath: machine.bootMode == .efi ? nvramPath : nil,
+ runtimeIdentity: snapshotRuntimeIdentity,
+ artifactEvidence: artifactEvidence
+ )
+ let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot)
+ let lifecycle = try beginLifecycleSnapshot(
+ machine: machine,
+ runtimeIdentity: snapshotRuntimeIdentity,
+ sourceState: .stopped,
+ snapshotID: snapshotID,
+ snapshotAuthority: snapshotAuthority
+ )
+
var publishedRootfs = false
var publishedKernel = false
var publishedMachineIdentifier = false
var publishedNVRAM = false
do {
+ try advanceLifecycleToPublishing(lifecycle)
try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsPath)
publishedRootfs = true
+#if DEBUG
+ try injectLifecycleFault(.snapshotAfterRootfs)
+#endif
try Self.cloneOrCopyFile(source: machine.kernelPath, destination: kernelPath)
publishedKernel = true
if machine.bootMode == .efi {
- let liveMachineIdentifierPath = machineFirmwareIdentifierPath(id: id)
- let liveNVRAMPath = machineFirmwareNVRAMPath(id: id)
- guard Self.isPrivateRegularFile(path: liveMachineIdentifierPath),
- Self.isPrivateRegularFile(path: liveNVRAMPath) else {
- throw MachineManagerError.persistence(
- "EFI firmware state is unavailable; start the machine once before taking a snapshot"
- )
+ guard let liveMachineIdentifierPath, let liveNVRAMPath else {
+ throw MachineManagerError.persistence("EFI firmware state is unavailable")
}
try Self.cloneOrCopyFile(
source: liveMachineIdentifierPath,
@@ -1662,35 +1965,13 @@ public final class MachineManager: @unchecked Sendable {
try Self.cloneOrCopyFile(source: liveNVRAMPath, destination: nvramPath)
publishedNVRAM = true
}
- let artifactEvidence = try Self.snapshotArtifactEvidence(
- rootfsPath: rootfsPath,
- kernelPath: kernelPath,
- machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil,
- nvramPath: machine.bootMode == .efi ? nvramPath : nil
- )
- snapshot = DoryMachineSnapshot(
- id: snapshotID,
- machineID: id,
- note: note,
- createdISO: createdISO,
- rootfsPath: rootfsPath,
- sizeBytes: Self.fileSize(path: rootfsPath),
- kernelPath: kernelPath,
- architecture: configuration.guestArchitecture,
- memoryMB: machine.memoryMB,
- cpuCount: machine.cpuCount,
- displayMode: machine.displayMode,
- address: machine.address,
- shares: machine.shares,
- environment: machine.environment,
- bootMode: machine.bootMode,
- machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil,
- nvramPath: machine.bootMode == .efi ? nvramPath : nil,
- runtimeIdentity: snapshotRuntimeIdentity,
- artifactEvidence: artifactEvidence
- )
+ // Validate the copies against the pre-publish authority before publishing metadata.
+ try Self.validateSnapshotArtifactEvidence(snapshot)
try persistSnapshot(snapshot)
} catch {
+#if DEBUG
+ if error is MachineLifecycleInjectedCrash { throw error }
+#endif
if publishedRootfs {
try? FileManager.default.removeItem(atPath: rootfsPath)
}
@@ -1703,8 +1984,9 @@ public final class MachineManager: @unchecked Sendable {
if publishedNVRAM {
try? FileManager.default.removeItem(atPath: nvramPath)
}
+ failLifecycle(lifecycle, stepID: "snapshot.failed", rolledBack: true)
if wasRunning {
- _ = try? start(id: id)
+ _ = try? startImplementation(id: id, journalLifecycle: true)
}
if let error = error as? MachineManagerError {
throw error
@@ -1712,12 +1994,16 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.persistence("could not snapshot \(id): \(error)")
}
- if wasRunning {
+ let journalCompleted = completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "published snapshot has an unfinished snapshot journal"
+ )
+ if wasRunning, journalCompleted {
do {
- _ = try start(id: id)
+ _ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch let firstError {
do {
- _ = try start(id: id)
+ _ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch {
throw MachineManagerError.persistence(
"snapshot \(snapshotID) was created, but \(id) could not restart: \(firstError); retry: \(error)"
@@ -2018,40 +2304,65 @@ public final class MachineManager: @unchecked Sendable {
restoredMachine.address = address
restoredMachine.shares = snapshot.shares
restoredMachine.environment = snapshot.environment
- if wasRunning {
- _ = try stop(id: machineID)
- }
+ let sourceIdentity = try currentRuntimeIdentity(id: machineID)
+ let targetIdentity = runtimeIdentityAfterSnapshotRestore(snapshot.runtimeIdentity)
+ let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot)
+ let lifecycle = try beginLifecycleRestore(
+ sourceMachine: machine,
+ targetMachine: restoredMachine,
+ sourceIdentity: sourceIdentity,
+ targetIdentity: targetIdentity,
+ sourceState: wasRunning ? .running : .stopped,
+ targetState: wasRunning && launchPolicy == .legacyCompatibility ? .running : .stopped,
+ snapshotID: snapshotID,
+ snapshotAuthority: snapshotAuthority
+ )
do {
- try restoreManagedArtifacts(machine: machine, snapshot: snapshot) {
+ try advanceLifecycleToPublishing(lifecycle)
+ if wasRunning {
+ _ = try stopImplementation(id: machineID, journalLifecycle: false)
+ }
+ try restoreManagedArtifacts(
+ machine: machine,
+ snapshot: snapshot,
+ operationID: lifecycle.operation.operationID
+ ) {
try persist(restoredMachine)
lock.lock()
if var entry = machines[machineID] {
entry.configuration = restoredMachine
entry.currentBalloonTargetMB = nil
- entry.runtimeIdentity = runtimeIdentityAfterSnapshotRestore(
- snapshot.runtimeIdentity
- )
+ entry.runtimeIdentity = targetIdentity
machines[machineID] = entry
}
lock.unlock()
}
let status: DoryMachineStatus
if wasRunning, launchPolicy == .legacyCompatibility {
- status = try start(id: machineID)
+ status = try startAndWaitUntilReady(id: machineID, journalLifecycle: false)
} else {
status = self.status(id: machineID)
?? DoryMachineStatus(id: machineID, state: .stopped)
}
+ _ = completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "restored machine has an unfinished restore journal"
+ )
return status
} catch let error as MachineManagerError {
if wasRunning {
- _ = try? start(id: machineID)
+ _ = try? startImplementation(id: machineID, journalLifecycle: false)
}
+ failLifecycle(lifecycle, stepID: "restore.failed", rolledBack: true)
throw error
} catch {
+#if DEBUG
+ if error is MachineLifecycleInjectedCrash { throw error }
+#endif
if wasRunning {
- _ = try? start(id: machineID)
+ _ = try? startImplementation(id: machineID, journalLifecycle: false)
}
+ failLifecycle(lifecycle, stepID: "restore.failed", rolledBack: true)
throw MachineManagerError.persistence("could not restore snapshot \(snapshotID): \(error)")
}
}
@@ -3105,19 +3416,25 @@ public final class MachineManager: @unchecked Sendable {
private func restoreManagedArtifacts(
machine: DoryMachineConfiguration,
snapshot: DoryMachineSnapshot,
+ operationID: UUID,
commit: () throws -> Void
) throws {
let directory = machineStateDirectory(id: machine.id)
- let token = UUID().uuidString
- let rootfsBackup = "\(directory)/.restore-rootfs-\(token)"
- let kernelBackup = "\(directory)/.restore-kernel-\(token)"
- let machineIdentifierBackup = "\(directory)/.restore-machine-identifier-\(token)"
- let nvramBackup = "\(directory)/.restore-nvram-\(token)"
+ let token = operationID.uuidString.lowercased()
+ let rootfsBackup = "\(directory)/.restore-\(token)-rootfs"
+ let kernelBackup = "\(directory)/.restore-\(token)-kernel"
+ let machineIdentifierBackup = "\(directory)/.restore-\(token)-machine-identifier"
+ let nvramBackup = "\(directory)/.restore-\(token)-nvram"
+ let configurationBackup = "\(directory)/.restore-\(token)-machine-json"
+ var preserveBackupsForRecovery = false
defer {
- try? FileManager.default.removeItem(atPath: rootfsBackup)
- try? FileManager.default.removeItem(atPath: kernelBackup)
- try? FileManager.default.removeItem(atPath: machineIdentifierBackup)
- try? FileManager.default.removeItem(atPath: nvramBackup)
+ if !preserveBackupsForRecovery {
+ try? FileManager.default.removeItem(atPath: rootfsBackup)
+ try? FileManager.default.removeItem(atPath: kernelBackup)
+ try? FileManager.default.removeItem(atPath: machineIdentifierBackup)
+ try? FileManager.default.removeItem(atPath: nvramBackup)
+ try? FileManager.default.removeItem(atPath: configurationBackup)
+ }
}
try Self.cloneOrCopyFile(source: machine.rootfsPath, destination: rootfsBackup)
do {
@@ -3125,6 +3442,16 @@ public final class MachineManager: @unchecked Sendable {
} catch {
throw MachineManagerError.persistence("could not preserve live kernel before restore: \(error)")
}
+ do {
+ try Self.cloneOrCopyFile(
+ source: machineConfigPath(id: machine.id),
+ destination: configurationBackup
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "could not preserve machine metadata before restore: \(error)"
+ )
+ }
let firmwareRestore: (identifier: String, nvram: String, snapshotIdentifier: String, snapshotNVRAM: String)?
if snapshot.bootMode == .efi {
let identifier = machineFirmwareIdentifierPath(id: machine.id)
@@ -3145,6 +3472,14 @@ public final class MachineManager: @unchecked Sendable {
} else {
firmwareRestore = nil
}
+#if DEBUG
+ do {
+ try injectLifecycleFault(.restoreAfterBackups)
+ } catch {
+ preserveBackupsForRecovery = true
+ throw error
+ }
+#endif
do {
try Self.cloneOrCopyFile(
source: snapshot.rootfsPath,
@@ -3168,6 +3503,15 @@ public final class MachineManager: @unchecked Sendable {
replaceExisting: true
)
}
+ guard Self.recoveredLiveArtifactsMatch(
+ snapshot: snapshot,
+ machineID: machine.id,
+ configuration: configuration
+ ) else {
+ throw MachineManagerError.persistence(
+ "restored machine artifacts do not match bound snapshot evidence"
+ )
+ }
try commit()
} catch {
var rollbackFailures: [String] = []
@@ -3189,6 +3533,15 @@ public final class MachineManager: @unchecked Sendable {
} catch {
rollbackFailures.append("kernel rollback failed: \(error)")
}
+ do {
+ try Self.cloneOrCopyFile(
+ source: configurationBackup,
+ destination: machineConfigPath(id: machine.id),
+ replaceExisting: true
+ )
+ } catch {
+ rollbackFailures.append("machine metadata rollback failed: \(error)")
+ }
if let firmwareRestore {
do {
try Self.cloneOrCopyFile(
@@ -3479,9 +3832,7 @@ public final class MachineManager: @unchecked Sendable {
guard Self.isPrivateDirectory(path: directory) else {
throw MachineManagerError.persistence("machine snapshot path is not a private directory")
}
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- let data = try encoder.encode(snapshot)
+ let data = try Self.snapshotDescriptorData(snapshot)
let path = snapshotMetadataPath(machineID: snapshot.machineID, snapshotID: snapshot.id)
try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic)
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath)
@@ -3545,6 +3896,34 @@ public final class MachineManager: @unchecked Sendable {
return validated
}
+ private static func snapshotDescriptorData(_ snapshot: DoryMachineSnapshot) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ return try encoder.encode(snapshot)
+ }
+
+ private static func snapshotEvidenceData(
+ _ evidence: DoryMachineSnapshotArtifactEvidence
+ ) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ return try encoder.encode(evidence)
+ }
+
+ private static func lifecycleSnapshotAuthority(
+ _ snapshot: DoryMachineSnapshot
+ ) throws -> DoryWorkspaceSnapshotAuthority {
+ guard let evidence = snapshot.artifactEvidence, evidence.isValid else {
+ throw MachineManagerError.persistence(
+ "machine snapshot lacks immutable lifecycle artifact evidence"
+ )
+ }
+ return DoryWorkspaceSnapshotAuthority(
+ descriptorSHA256: sha256(data: try snapshotDescriptorData(snapshot)),
+ artifactEvidenceSHA256: sha256(data: try snapshotEvidenceData(evidence))
+ )
+ }
+
fileprivate static func validateSnapshotRuntimeIdentity(
_ snapshot: DoryMachineSnapshot
) throws {
@@ -3710,6 +4089,7 @@ public final class MachineManager: @unchecked Sendable {
var handoffServer: VmmHandoffServer?
var processToStop: HvProcess?
var agentSocketPath: String?
+ var lifecycleReadinessSucceeded = false
lock.lock()
guard var entry = machines[machineID], entry.launchID == launchID else {
lock.unlock()
@@ -3732,6 +4112,7 @@ public final class MachineManager: @unchecked Sendable {
entry.lastError = nil
entry.process?.disableRestarts()
agentSocketPath = handoff.ready.agentSocketPath
+ lifecycleReadinessSucceeded = true
case let .failure(error):
entry.state = .failed
entry.lastError = "\(error)"
@@ -3742,6 +4123,14 @@ public final class MachineManager: @unchecked Sendable {
machines[machineID] = entry
lock.unlock()
+ operationLock.lock()
+ if lifecycleReadinessSucceeded {
+ completeActiveStartLifecycle(id: machineID)
+ } else {
+ failActiveStartLifecycle(id: machineID, stepID: "start.readiness-failed")
+ }
+ operationLock.unlock()
+
handoffServer?.stop()
processToStop?.stop()
if let agentSocketPath {
@@ -4263,6 +4652,831 @@ public final class MachineManager: @unchecked Sendable {
return loaded
}
+ private func currentRuntimeIdentity(id: String) throws -> DoryMachineRuntimeIdentity {
+ lock.lock()
+ defer { lock.unlock() }
+ guard let identity = machines[id]?.runtimeIdentity else {
+ throw MachineManagerError.unknownMachine(id)
+ }
+ guard identity.validate().isEmpty else {
+ throw MachineManagerError.persistence("machine runtime identity is invalid")
+ }
+ return identity
+ }
+
+ private func lifecycleState(for state: DoryMachineState) -> DoryWorkspaceLifecycleState {
+ switch state {
+ case .starting, .running: .running
+ case .created, .stopped: .stopped
+ case .failed: .failed
+ }
+ }
+
+ private func lifecycleCondition(
+ machine: DoryMachineConfiguration,
+ state: DoryWorkspaceLifecycleState,
+ runtimeIdentity: DoryMachineRuntimeIdentity
+ ) throws -> DoryWorkspaceLifecycleCondition {
+ let legacyData: Data
+ if let current = Self.readPrivateMetadata(path: machineConfigPath(id: machine.id)),
+ let decoded = try? JSONDecoder().decode(DoryMachineConfiguration.self, from: current),
+ decoded == machine {
+ legacyData = current
+ } else {
+ legacyData = try DoryMachineConfigurationMigrationBridge.encodeLegacy(machine)
+ }
+ let facts = try workspaceMigrationFacts(for: machine)
+ let definition = try DoryMachineConfigurationMigrationBridge.migrate(
+ machine,
+ facts: facts
+ ).definition
+ let definitionData = try Self.canonicalDefinitionData(definition)
+ return DoryWorkspaceLifecycleCondition(
+ workspaceID: machine.id,
+ state: state,
+ definitionRevision: definition.lifecycle.revision,
+ runtime: try lifecycleRuntimeBinding(runtimeIdentity),
+ configurationAuthority: DoryWorkspaceConfigurationAuthority(
+ legacyConfigurationSHA256: Self.sha256(data: legacyData),
+ canonicalDefinitionSHA256: Self.sha256(data: definitionData)
+ )
+ )
+ }
+
+ private func lifecycleRuntimeBinding(
+ _ identity: DoryMachineRuntimeIdentity
+ ) throws -> DoryWorkspaceRuntimeBinding {
+ guard identity.validate().isEmpty else {
+ throw MachineManagerError.persistence("machine runtime identity is invalid")
+ }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let digest = Self.sha256(data: try encoder.encode(identity))
+ switch identity.mode {
+ case .legacyCompatibility:
+ return .legacyCompatibility(
+ virtualHardwareABIVersion: identity.virtualHardwareABIVersion,
+ runtimeIdentityDigest: digest
+ )
+ case .requiresReplanning:
+ return .requiresReplanning(
+ virtualHardwareABIVersion: identity.virtualHardwareABIVersion,
+ runtimeIdentityDigest: digest
+ )
+ case .resolvedPlan:
+ guard let plan = identity.resolvedPlan,
+ let planDigest = identity.resolvedPlanSHA256 else {
+ throw MachineManagerError.persistence("resolved runtime identity is incomplete")
+ }
+ return .resolvedPlan(
+ DoryWorkspaceResolvedCondition(
+ planRevision: plan.planRevision,
+ planDigest: planDigest,
+ backendID: plan.backend.rawValue,
+ backendRuntimeBuildID: plan.backendRuntimeBuildIdentifier,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ ),
+ runtimeIdentityDigest: digest
+ )
+ }
+ }
+
+ private func beginLifecycleStart(
+ machine: DoryMachineConfiguration,
+ targetIdentity: DoryMachineRuntimeIdentity
+ ) throws -> MachineLifecycleJournalContext {
+ let sourceIdentity = try currentRuntimeIdentity(id: machine.id)
+ return try beginLifecycleOperation(
+ kind: .starting,
+ source: lifecycleCondition(
+ machine: machine,
+ state: .stopped,
+ runtimeIdentity: sourceIdentity
+ ),
+ target: lifecycleCondition(
+ machine: machine,
+ state: .running,
+ runtimeIdentity: targetIdentity
+ ),
+ targetResourceID: nil,
+ readiness: true
+ )
+ }
+
+ private func beginLifecycleStartPreparation(
+ id: String
+ ) throws -> MachineLifecycleJournalContext {
+ lock.lock()
+ guard let entry = machines[id] else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ let machine = entry.configuration
+ let runtimeIdentity = entry.runtimeIdentity
+ lock.unlock()
+
+ var condition = try lifecycleCondition(
+ machine: machine,
+ state: .stopped,
+ runtimeIdentity: runtimeIdentity
+ )
+ // Boot-bundle and direct-boot materialization are derived from the unchanged authoritative
+ // legacy configuration. Their projection facts may legitimately become more specific, so
+ // this preparation boundary binds the exact raw authority and runtime identity while the
+ // subsequent start journal binds the reconciled canonical definition.
+ condition.configurationAuthority?.canonicalDefinitionSHA256 = nil
+ return try beginLifecycleOperation(
+ kind: .resolving,
+ source: condition,
+ target: condition,
+ targetResourceID: nil,
+ readiness: false
+ )
+ }
+
+ private func beginLifecycleStop(
+ machine: DoryMachineConfiguration,
+ runtimeIdentity: DoryMachineRuntimeIdentity
+ ) throws -> MachineLifecycleJournalContext {
+ try beginLifecycleOperation(
+ kind: .stopping,
+ source: lifecycleCondition(
+ machine: machine,
+ state: .running,
+ runtimeIdentity: runtimeIdentity
+ ),
+ target: lifecycleCondition(
+ machine: machine,
+ state: .stopped,
+ runtimeIdentity: runtimeIdentity
+ ),
+ targetResourceID: nil,
+ readiness: false
+ )
+ }
+
+ private func beginLifecycleSnapshot(
+ machine: DoryMachineConfiguration,
+ runtimeIdentity: DoryMachineRuntimeIdentity,
+ sourceState: DoryWorkspaceLifecycleState,
+ snapshotID: String,
+ snapshotAuthority: DoryWorkspaceSnapshotAuthority
+ ) throws -> MachineLifecycleJournalContext {
+ let condition = try lifecycleCondition(
+ machine: machine,
+ state: sourceState,
+ runtimeIdentity: runtimeIdentity
+ )
+ return try beginLifecycleOperation(
+ kind: .snapshotting,
+ source: condition,
+ target: condition,
+ targetResourceID: snapshotID,
+ targetSnapshotAuthority: snapshotAuthority,
+ readiness: false
+ )
+ }
+
+ private func beginLifecycleRestore(
+ sourceMachine: DoryMachineConfiguration,
+ targetMachine: DoryMachineConfiguration,
+ sourceIdentity: DoryMachineRuntimeIdentity,
+ targetIdentity: DoryMachineRuntimeIdentity,
+ sourceState: DoryWorkspaceLifecycleState,
+ targetState: DoryWorkspaceLifecycleState,
+ snapshotID: String,
+ snapshotAuthority: DoryWorkspaceSnapshotAuthority
+ ) throws -> MachineLifecycleJournalContext {
+ try beginLifecycleOperation(
+ kind: .restoring,
+ source: lifecycleCondition(
+ machine: sourceMachine,
+ state: sourceState,
+ runtimeIdentity: sourceIdentity
+ ),
+ target: lifecycleCondition(
+ machine: targetMachine,
+ state: targetState,
+ runtimeIdentity: targetIdentity
+ ),
+ targetResourceID: snapshotID,
+ targetSnapshotAuthority: snapshotAuthority,
+ readiness: targetState == .running
+ )
+ }
+
+ private func beginLifecycleDelete(
+ machine: DoryMachineConfiguration,
+ runtimeIdentity: DoryMachineRuntimeIdentity,
+ sourceState: DoryWorkspaceLifecycleState
+ ) throws -> MachineLifecycleJournalContext {
+ try beginLifecycleOperation(
+ kind: .deleting,
+ source: lifecycleCondition(
+ machine: machine,
+ state: sourceState,
+ runtimeIdentity: runtimeIdentity
+ ),
+ target: lifecycleCondition(
+ machine: machine,
+ state: .deleting,
+ runtimeIdentity: runtimeIdentity
+ ),
+ targetResourceID: nil,
+ readiness: false
+ )
+ }
+
+ private func beginLifecycleOperation(
+ kind: DoryWorkspaceMutationKind,
+ source: DoryWorkspaceLifecycleCondition,
+ target: DoryWorkspaceLifecycleCondition,
+ targetResourceID: String?,
+ targetSnapshotAuthority: DoryWorkspaceSnapshotAuthority? = nil,
+ readiness: Bool
+ ) throws -> MachineLifecycleJournalContext {
+ let machineID = source.workspaceID
+ if kind == .snapshotting || kind == .restoring {
+ guard targetSnapshotAuthority != nil else {
+ throw MachineManagerError.persistence(
+ "snapshot lifecycle operation is missing immutable artifact authority"
+ )
+ }
+ }
+ guard activeLifecycleOperations[machineID] == nil else {
+ throw MachineManagerError.persistence(
+ "machine \(machineID) already has an active lifecycle mutation"
+ )
+ }
+ guard let store = lifecycleJournalStore else {
+ throw MachineManagerError.persistence(
+ "lifecycle journal is unavailable: \(lifecycleJournalInitializationError ?? "unknown error")"
+ )
+ }
+ let now = Date()
+ let created = Int64(max(0, (now.timeIntervalSince1970 * 1_000).rounded()))
+ let deadlineDelta: Int64 = 15 * 60 * 1_000
+ let deadline = created > Int64.max - deadlineDelta ? Int64.max : created + deadlineDelta
+ let operation = DoryWorkspaceLifecycleOperation(
+ kind: kind,
+ source: source,
+ target: target,
+ targetResourceID: targetResourceID,
+ targetSnapshotAuthority: targetSnapshotAuthority,
+ createdAtUnixMilliseconds: created,
+ deadlineUnixMilliseconds: deadline,
+ steps: [
+ .init(id: "quiesce", stage: .quiesce, deadlineOffsetMilliseconds: 60_000),
+ .init(id: "stage", stage: .prepare, deadlineOffsetMilliseconds: 120_000),
+ .init(id: "verify", stage: .mutate, deadlineOffsetMilliseconds: 300_000),
+ .init(id: "publish", stage: .publish, deadlineOffsetMilliseconds: 600_000),
+ .init(id: "validate", stage: readiness ? .readiness : .cleanup,
+ deadlineOffsetMilliseconds: 840_000),
+ ],
+ readinessGates: readiness
+ ? [.init(kind: .backendRunning, deadlineOffsetMilliseconds: 840_000)] : [],
+ retryBudgets: [],
+ cancellationPolicy: kind == .deleting ? .prohibited : .rollbackRequired,
+ recovery: .init(disposition: .rollback, stepIDs: ["stage", "publish"])
+ )
+ let dependency = MachineLifecycleDependencyAuthority(
+ mutationKind: kind.rawValue,
+ workspaceID: machineID,
+ sourceLegacyConfigurationSHA256:
+ source.configurationAuthority?.legacyConfigurationSHA256,
+ sourceDefinitionSHA256: source.configurationAuthority?.canonicalDefinitionSHA256,
+ sourceRuntimeIdentitySHA256: source.runtime?.runtimeIdentityDigest,
+ targetLegacyConfigurationSHA256:
+ target.configurationAuthority?.legacyConfigurationSHA256,
+ targetDefinitionSHA256: target.configurationAuthority?.canonicalDefinitionSHA256,
+ targetRuntimeIdentitySHA256: target.runtime?.runtimeIdentityDigest,
+ targetResourceID: targetResourceID,
+ targetSnapshotDescriptorSHA256:
+ targetSnapshotAuthority?.descriptorSHA256,
+ targetSnapshotArtifactEvidenceSHA256:
+ targetSnapshotAuthority?.artifactEvidenceSHA256
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let binding = try operation.journalBinding(
+ dependencyClosureDigest: Self.sha256(data: try encoder.encode(dependency))
+ )
+ let context = MachineLifecycleJournalContext(
+ operation: operation,
+ lease: try store.begin(binding)
+ )
+ activeLifecycleOperations[machineID] = context
+ return context
+ }
+
+ private func advanceLifecycleToPublishing(
+ _ context: MachineLifecycleJournalContext
+ ) throws {
+ for phase in [
+ DoryOperationPhase.quiescing,
+ .staging,
+ .verifying,
+ .readyToPublish,
+ .publishing,
+ ] {
+ let current = try context.lease.read().state
+ if current.phase.indexForMachineLifecycle >= phase.indexForMachineLifecycle { continue }
+ _ = try context.lease.transition(
+ to: phase,
+ status: .running,
+ expectedRevision: current.revision,
+ stepID: "lifecycle.\(phase.rawValue)"
+ )
+ }
+ }
+
+ private func completeLifecycle(_ context: MachineLifecycleJournalContext) throws {
+ var current = try context.lease.read().state
+ if current.phase.indexForMachineLifecycle < DoryOperationPhase.validating.indexForMachineLifecycle {
+ current = try context.lease.transition(
+ to: .validating,
+ status: .running,
+ expectedRevision: current.revision,
+ stepID: "lifecycle.validated"
+ )
+ }
+#if DEBUG
+ // Persist the post-commit boundary before exercising the terminal journal write. Recovery
+ // can then distinguish a committed target from a pre-commit interruption even when the
+ // source and target machine.json bytes happen to be identical.
+ try injectLifecycleFault(.completionBeforeJournalWrite(context.operation.kind))
+#endif
+ if current.phase.indexForMachineLifecycle < DoryOperationPhase.completed.indexForMachineLifecycle {
+ _ = try context.lease.transition(
+ to: .completed,
+ status: .completed,
+ expectedRevision: current.revision,
+ stepID: "lifecycle.completed"
+ )
+ }
+ activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID)
+ context.releaseLease()
+ }
+
+ @discardableResult
+ private func completeCommittedLifecycle(
+ _ context: MachineLifecycleJournalContext,
+ diagnostic: String
+ ) -> Bool {
+ do {
+ try completeLifecycle(context)
+ return true
+ } catch {
+ // The target side effect is already durable. Preserve the journal's last valid,
+ // nonterminal state so restart recovery can verify and finish it; claiming rollback
+ // or terminal failure here would contradict the machine/snapshot authority on disk.
+ activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID)
+ context.releaseLease()
+ lock.lock()
+ if machines[context.operation.source.workspaceID] != nil {
+ machines[context.operation.source.workspaceID]?.lastError =
+ "\(diagnostic): \(error)"
+ }
+ lock.unlock()
+ return false
+ }
+ }
+
+ private func failLifecycle(
+ _ context: MachineLifecycleJournalContext,
+ stepID: String,
+ rolledBack: Bool = false
+ ) {
+ defer {
+ activeLifecycleOperations.removeValue(forKey: context.operation.source.workspaceID)
+ context.releaseLease()
+ }
+ guard var current = try? context.lease.read().state,
+ current.status != .failed, current.status != .completed else { return }
+ if rolledBack,
+ let rollingBack = try? context.lease.transition(
+ to: current.phase,
+ status: .rollingBack,
+ expectedRevision: current.revision,
+ stepID: "lifecycle.rolling-back",
+ recoveryAction: "rollback"
+ ) {
+ current = rollingBack
+ }
+ _ = try? context.lease.transition(
+ to: current.phase,
+ status: .failed,
+ expectedRevision: current.revision,
+ stepID: stepID,
+ recoveryAction: rolledBack ? "rollback" : nil
+ )
+ }
+
+ private func completeActiveStartLifecycle(id: String) {
+ guard let context = activeLifecycleOperations[id], context.operation.kind == .starting else {
+ return
+ }
+ _ = completeCommittedLifecycle(
+ context,
+ diagnostic: "running machine has an unfinished readiness journal"
+ )
+ }
+
+ private func failActiveStartLifecycle(id: String, stepID: String) {
+ guard let context = activeLifecycleOperations[id], context.operation.kind == .starting else {
+ return
+ }
+ failLifecycle(context, stepID: stepID)
+ }
+
+ private func cancelActiveStartLifecycleIfNeeded(id: String, reason: String) throws {
+ guard let context = activeLifecycleOperations[id] else { return }
+ guard context.operation.kind == .starting else {
+ throw MachineManagerError.persistence(
+ "machine \(id) already has an active lifecycle mutation"
+ )
+ }
+ failLifecycle(context, stepID: reason)
+ }
+
+ private func lifecycleDeletionQuarantinePath(
+ machineID: String,
+ operationID: UUID
+ ) -> String {
+ Self.lifecycleDeletionQuarantinePath(
+ configuration: configuration,
+ machineID: machineID,
+ operationID: operationID
+ )
+ }
+
+ private static func lifecycleDeletionQuarantinePath(
+ configuration: MachineManagerConfiguration,
+ machineID: String,
+ operationID: UUID
+ ) -> String {
+ "\(configuration.stateDirectory)/\(deletionQuarantinePrefix)\(machineID)-"
+ + operationID.uuidString.lowercased()
+ }
+
+#if DEBUG
+ func installLifecycleFaultInjectorForTesting(
+ _ injector: @escaping @Sendable (MachineLifecycleFaultPoint) throws -> Void
+ ) {
+ operationLock.lock()
+ lifecycleFaultInjector = injector
+ operationLock.unlock()
+ }
+
+ private func injectLifecycleFault(_ point: MachineLifecycleFaultPoint) throws {
+ try lifecycleFaultInjector?(point)
+ }
+#endif
+
+ private static func recoverInterruptedLifecycleOperations(
+ store: DoryOperationJournalStore?,
+ configuration: MachineManagerConfiguration
+ ) -> [String: String] {
+ guard let store, let records = try? store.list() else { return [:] }
+ var diagnostics: [String: String] = [:]
+ for record in records where record.state.status != .completed
+ && record.state.status != .failed {
+ var lease: DoryOperationLease?
+ do {
+ guard Self.isValidID(record.plan.source.id) else { continue }
+ lease = try store.acquire(record.plan.id)
+ guard let lease else { continue }
+ let operation = try lease.readWorkspaceLifecycleOperation()
+ guard operation.source.workspaceID == record.plan.source.id,
+ operation.target.workspaceID == record.plan.source.id else {
+ throw MachineManagerError.persistence(
+ "lifecycle journal mutation scope changed during recovery"
+ )
+ }
+ let id = operation.source.workspaceID
+ let recoveryState = try lease.read().state
+ switch operation.kind {
+ case .resolving:
+ if lifecycleConfigurationMatches(
+ operation.target.configurationAuthority,
+ machineID: id,
+ configuration: configuration
+ ) {
+ try completeRecoveredLifecycle(lease)
+ diagnostics[id] =
+ "interrupted start preparation was recovered; machine remains stopped"
+ } else {
+ try failRecoveredLifecycle(lease, rolledBack: false)
+ diagnostics[id] =
+ "interrupted start preparation authority changed; recovery failed closed"
+ }
+ case .starting:
+ try failRecoveredLifecycle(lease, rolledBack: true)
+ diagnostics[id] = "interrupted start was recovered as stopped"
+ case .stopping:
+ if lifecycleConfigurationMatches(
+ operation.target.configurationAuthority,
+ machineID: id,
+ configuration: configuration
+ ) {
+ try completeRecoveredLifecycle(lease)
+ diagnostics[id] = "interrupted stop completed during daemon recovery"
+ } else {
+ try failRecoveredLifecycle(lease, rolledBack: false)
+ diagnostics[id] = "interrupted stop authority changed; recovery failed closed"
+ }
+ case .snapshotting:
+ if recoveryState.phase.indexForMachineLifecycle
+ >= DoryOperationPhase.validating.indexForMachineLifecycle,
+ recoveredSnapshot(
+ machineID: id,
+ operation: operation,
+ configuration: configuration
+ ) != nil,
+ lifecycleConfigurationMatches(
+ operation.target.configurationAuthority,
+ machineID: id,
+ configuration: configuration
+ ) {
+ try completeRecoveredLifecycle(lease)
+ diagnostics[id] =
+ "published snapshot journal completed during daemon recovery"
+ } else {
+ if let snapshotID = operation.targetResourceID {
+ removeRecoveredSnapshot(
+ machineID: id,
+ snapshotID: snapshotID,
+ configuration: configuration
+ )
+ }
+ try failRecoveredLifecycle(lease, rolledBack: true)
+ diagnostics[id] = "interrupted snapshot was rolled back"
+ }
+ case .restoring:
+ if recoveryState.phase.indexForMachineLifecycle
+ >= DoryOperationPhase.validating.indexForMachineLifecycle {
+ if let snapshot = recoveredSnapshot(
+ machineID: id,
+ operation: operation,
+ configuration: configuration
+ ), lifecycleConfigurationMatches(
+ operation.target.configurationAuthority,
+ machineID: id,
+ configuration: configuration
+ ), recoveredLiveArtifactsMatch(
+ snapshot: snapshot,
+ machineID: id,
+ configuration: configuration
+ ) {
+ try completeRecoveredLifecycle(lease)
+ diagnostics[id] =
+ "interrupted snapshot restore completed before daemon restart"
+ } else {
+ try failRecoveredLifecycle(lease, rolledBack: false)
+ diagnostics[id] = "committed snapshot restore authority requires repair"
+ }
+ } else {
+ let restored = restoreRecoveredMachineBackups(
+ machineID: id,
+ operationID: operation.operationID,
+ configuration: configuration
+ )
+ if restored || lifecycleConfigurationMatches(
+ operation.source.configurationAuthority,
+ machineID: id,
+ configuration: configuration
+ ) {
+ try failRecoveredLifecycle(lease, rolledBack: true)
+ diagnostics[id] = "interrupted snapshot restore was rolled back"
+ } else if lifecycleConfigurationMatches(
+ operation.target.configurationAuthority,
+ machineID: id,
+ configuration: configuration
+ ) {
+ try completeRecoveredLifecycle(lease)
+ diagnostics[id] =
+ "interrupted snapshot restore completed before daemon restart"
+ } else {
+ try failRecoveredLifecycle(lease, rolledBack: false)
+ diagnostics[id] = "interrupted snapshot restore requires repair"
+ }
+ }
+ case .deleting:
+ let state = "\(configuration.stateDirectory)/\(id)"
+ let quarantine = lifecycleDeletionQuarantinePath(
+ configuration: configuration,
+ machineID: id,
+ operationID: operation.operationID
+ )
+ if pathEntryExists(quarantine), !pathEntryExists(state) {
+ guard rename(quarantine, state) == 0 else {
+ throw MachineManagerError.persistence(
+ "could not roll back interrupted machine deletion"
+ )
+ }
+ try failRecoveredLifecycle(lease, rolledBack: true)
+ diagnostics[id] = "interrupted deletion was rolled back"
+ } else if !pathEntryExists(quarantine), !pathEntryExists(state) {
+ try completeRecoveredLifecycle(lease)
+ } else {
+ try failRecoveredLifecycle(lease, rolledBack: false)
+ diagnostics[id] = "interrupted deletion preserved existing machine data"
+ }
+ case .importing, .provisioning, .pausing, .resuming,
+ .suspending, .cloning, .updating, .repairing:
+ try failRecoveredLifecycle(lease, rolledBack: false)
+ diagnostics[id] = "unsupported interrupted lifecycle mutation requires repair"
+ }
+ } catch {
+ if let id = try? lease?.readWorkspaceLifecycleOperation().source.workspaceID {
+ diagnostics[id] = "lifecycle recovery failed closed: \(error)"
+ }
+ }
+ lease = nil
+ }
+ return diagnostics
+ }
+
+ private static func lifecycleConfigurationMatches(
+ _ authority: DoryWorkspaceConfigurationAuthority?,
+ machineID: String,
+ configuration: MachineManagerConfiguration
+ ) -> Bool {
+ guard let expected = authority?.legacyConfigurationSHA256,
+ let data = readPrivateMetadata(
+ path: "\(configuration.stateDirectory)/\(machineID)/machine.json"
+ ) else { return false }
+ return sha256(data: data) == expected
+ }
+
+ private static func completeRecoveredLifecycle(_ lease: DoryOperationLease) throws {
+ var current = try lease.read().state
+ if current.status != .running {
+ current = try lease.transition(
+ to: current.phase,
+ status: .running,
+ expectedRevision: current.revision,
+ stepID: "recovery.resumed"
+ )
+ }
+ for phase in DoryOperationPhase.allCases
+ where phase.indexForMachineLifecycle > current.phase.indexForMachineLifecycle {
+ current = try lease.transition(
+ to: phase,
+ status: phase == .completed ? .completed : .running,
+ expectedRevision: current.revision,
+ stepID: phase == .completed ? "recovery.completed" : "recovery.\(phase.rawValue)"
+ )
+ }
+ }
+
+ private static func failRecoveredLifecycle(
+ _ lease: DoryOperationLease,
+ rolledBack: Bool
+ ) throws {
+ var current = try lease.read().state
+ if rolledBack, current.status != .rollingBack {
+ current = try lease.transition(
+ to: current.phase,
+ status: .rollingBack,
+ expectedRevision: current.revision,
+ stepID: "recovery.rolling-back",
+ recoveryAction: "rollback"
+ )
+ }
+ _ = try lease.transition(
+ to: current.phase,
+ status: .failed,
+ expectedRevision: current.revision,
+ stepID: "recovery.failed",
+ recoveryAction: rolledBack ? "rollback" : nil
+ )
+ }
+
+ private static func removeRecoveredSnapshot(
+ machineID: String,
+ snapshotID: String,
+ configuration: MachineManagerConfiguration
+ ) {
+ let directory = "\(configuration.stateDirectory)/\(machineID)/snapshots"
+ for suffix in ["json", "ext4", "kernel", "machine-identifier", "nvram"] {
+ try? FileManager.default.removeItem(
+ atPath: "\(directory)/\(snapshotID).\(suffix)"
+ )
+ }
+ }
+
+ private static func recoveredSnapshot(
+ machineID: String,
+ operation: DoryWorkspaceLifecycleOperation,
+ configuration: MachineManagerConfiguration
+ ) -> DoryMachineSnapshot? {
+ guard let snapshotID = operation.targetResourceID,
+ let authority = operation.targetSnapshotAuthority,
+ isValidID(machineID), isValidID(snapshotID) else { return nil }
+ let directory = "\(configuration.stateDirectory)/\(machineID)/snapshots"
+ let metadataPath = "\(directory)/\(snapshotID).json"
+ let rootfsPath = "\(directory)/\(snapshotID).ext4"
+ let kernelPath = "\(directory)/\(snapshotID).kernel"
+ let machineIdentifierPath = "\(directory)/\(snapshotID).machine-identifier"
+ let nvramPath = "\(directory)/\(snapshotID).nvram"
+ guard isPrivateDirectory(path: directory),
+ let metadata = readPrivateMetadata(path: metadataPath),
+ sha256(data: metadata) == authority.descriptorSHA256,
+ let snapshot = try? JSONDecoder().decode(DoryMachineSnapshot.self, from: metadata),
+ snapshot.id == snapshotID,
+ snapshot.machineID == machineID,
+ snapshot.rootfsPath == rootfsPath,
+ snapshot.kernelPath == kernelPath,
+ snapshot.architecture == configuration.guestArchitecture,
+ snapshot.sizeBytes > 0,
+ snapshot.runtimeIdentity.validate().isEmpty,
+ (try? validateResources(
+ memoryMB: snapshot.memoryMB,
+ cpuCount: snapshot.cpuCount
+ )) != nil,
+ let evidence = snapshot.artifactEvidence,
+ evidence.isValid,
+ (try? snapshotEvidenceData(evidence)).map(sha256(data:))
+ == authority.artifactEvidenceSHA256,
+ isPrivateRegularFile(path: rootfsPath),
+ isPrivateRegularFile(path: kernelPath) else { return nil }
+ switch snapshot.bootMode {
+ case .linuxKernel:
+ guard snapshot.machineIdentifierPath == nil,
+ snapshot.nvramPath == nil else { return nil }
+ case .efi:
+ guard snapshot.machineIdentifierPath == machineIdentifierPath,
+ snapshot.nvramPath == nvramPath,
+ isPrivateRegularFile(path: machineIdentifierPath),
+ isPrivateRegularFile(path: nvramPath) else { return nil }
+ }
+ guard (try? validateSnapshotRuntimeIdentity(snapshot)) != nil,
+ (try? validateSnapshotArtifactEvidence(snapshot)) != nil else { return nil }
+ return snapshot
+ }
+
+ private static func recoveredLiveArtifactsMatch(
+ snapshot: DoryMachineSnapshot,
+ machineID: String,
+ configuration: MachineManagerConfiguration
+ ) -> Bool {
+ guard let expected = snapshot.artifactEvidence else { return false }
+ let directory = "\(configuration.stateDirectory)/\(machineID)"
+ let rootfsPath = "\(directory)/rootfs.ext4"
+ let kernelPath = "\(directory)/kernel"
+ let machineIdentifierPath = snapshot.bootMode == .efi
+ ? "\(directory)/MachineIdentifier" : nil
+ let nvramPath = snapshot.bootMode == .efi ? "\(directory)/NVRAM" : nil
+ guard isPrivateDirectory(path: directory),
+ isPrivateRegularFile(path: rootfsPath),
+ isPrivateRegularFile(path: kernelPath),
+ machineIdentifierPath.map(isPrivateRegularFile(path:)) ?? true,
+ nvramPath.map(isPrivateRegularFile(path:)) ?? true,
+ let actual = try? snapshotArtifactEvidence(
+ rootfsPath: rootfsPath,
+ kernelPath: kernelPath,
+ machineIdentifierPath: machineIdentifierPath,
+ nvramPath: nvramPath
+ ) else { return false }
+ return actual == expected
+ }
+
+ private static func restoreRecoveredMachineBackups(
+ machineID: String,
+ operationID: UUID,
+ configuration: MachineManagerConfiguration
+ ) -> Bool {
+ let directory = "\(configuration.stateDirectory)/\(machineID)"
+ let token = operationID.uuidString.lowercased()
+ let pairs = [
+ (".restore-\(token)-rootfs", "rootfs.ext4"),
+ (".restore-\(token)-kernel", "kernel"),
+ (".restore-\(token)-machine-json", "machine.json"),
+ (".restore-\(token)-machine-identifier", "MachineIdentifier"),
+ (".restore-\(token)-nvram", "NVRAM"),
+ ]
+ let required = Array(pairs.prefix(3))
+ guard required.allSatisfy({ pathEntryExists("\(directory)/\($0.0)") }) else {
+ return false
+ }
+ do {
+ for (backup, destination) in pairs where pathEntryExists("\(directory)/\(backup)") {
+ try cloneOrCopyFile(
+ source: "\(directory)/\(backup)",
+ destination: "\(directory)/\(destination)",
+ replaceExisting: true
+ )
+ }
+ for (backup, _) in pairs {
+ try? FileManager.default.removeItem(atPath: "\(directory)/\(backup)")
+ }
+ return true
+ } catch {
+ return false
+ }
+ }
+
private static func removeStaleDeletionQuarantines(stateDirectory: String) {
let fileManager = FileManager.default
guard let entries = try? fileManager.contentsOfDirectory(atPath: stateDirectory) else {
@@ -4883,6 +6097,65 @@ private struct WorkspaceMigrationAuthorityFacts: Codable {
var lifecycle: DoryVMLifecycleMetadata
}
+private final class MachineLifecycleJournalContext: @unchecked Sendable {
+ let operation: DoryWorkspaceLifecycleOperation
+ private(set) var lease: DoryOperationLease!
+
+ init(operation: DoryWorkspaceLifecycleOperation, lease: DoryOperationLease) {
+ self.operation = operation
+ self.lease = lease
+ }
+
+ func releaseLease() {
+ lease = nil
+ }
+}
+
+private struct MachineLifecycleDependencyAuthority: Codable {
+ var schemaVersion: UInt16 = 2
+ var mutationKind: String
+ var workspaceID: String
+ var sourceLegacyConfigurationSHA256: String?
+ var sourceDefinitionSHA256: String?
+ var sourceRuntimeIdentitySHA256: String?
+ var targetLegacyConfigurationSHA256: String?
+ var targetDefinitionSHA256: String?
+ var targetRuntimeIdentitySHA256: String?
+ var targetResourceID: String?
+ var targetSnapshotDescriptorSHA256: String?
+ var targetSnapshotArtifactEvidenceSHA256: String?
+}
+
+private extension DoryOperationPhase {
+ var indexForMachineLifecycle: Int {
+ switch self {
+ case .planned: 0
+ case .quiescing: 1
+ case .staging: 2
+ case .verifying: 3
+ case .readyToPublish: 4
+ case .publishing: 5
+ case .validating: 6
+ case .completed: 7
+ }
+ }
+}
+
+#if DEBUG
+enum MachineLifecycleFaultPoint: Sendable, Equatable {
+ case startAfterPreparation
+ case completionBeforeJournalWrite(DoryWorkspaceMutationKind)
+ case stopAfterProcessStop
+ case snapshotAfterRootfs
+ case restoreAfterBackups
+ case deleteAfterQuarantine
+}
+
+struct MachineLifecycleInjectedCrash: Error, Sendable {}
+#endif
+
+private struct MachineLifecycleJournalCompletionPending: Error, Sendable {}
+
private struct PreparedMachineStart {
var machine: DoryMachineConfiguration
var definition: DoryVirtualMachineDefinition?
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
index 638f71d4..c8ecd5e3 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryWorkspaceLifecycleOperationTests.swift
@@ -183,6 +183,7 @@ struct DoryWorkspaceLifecycleOperationTests {
source: condition(.stopped, resolved: resolved),
target: condition(.stopped, resolved: nil, requiresReplanning: true),
targetResourceID: "snapshot-one",
+ targetSnapshotAuthority: snapshotAuthority(),
createdAtUnixMilliseconds: 1_700_000_000_000,
deadlineUnixMilliseconds: 1_700_000_060_000,
steps: [step("restore", 50_000)],
@@ -191,6 +192,10 @@ struct DoryWorkspaceLifecycleOperationTests {
)
#expect(restoreStopped.validate().isEmpty)
+ var missingSnapshotAuthority = restoreStopped
+ missingSnapshotAuthority.targetSnapshotAuthority = nil
+ #expect(has(.invalidCondition, "targetSnapshotAuthority", missingSnapshotAuthority))
+
var restoreRunning = restoreStopped
restoreRunning.target.state = .running
#expect(has(.invalidReadinessGates, "readinessGates", restoreRunning))
@@ -320,6 +325,7 @@ struct DoryWorkspaceLifecycleOperationTests {
DoryWorkspaceLifecycleOperation.self,
from: legacyRestoreData
)
+ #expect(legacyRestore.targetSnapshotAuthority == nil)
#expect(legacyRestore.validate().isEmpty)
#expect(legacyRestore.target.runtime?.authorizationState == .resolvedPlan)
}
@@ -437,6 +443,13 @@ struct DoryWorkspaceLifecycleOperationTests {
)
}
+ private func snapshotAuthority() -> DoryWorkspaceSnapshotAuthority {
+ DoryWorkspaceSnapshotAuthority(
+ descriptorSHA256: String(repeating: "6", count: 64),
+ artifactEvidenceSHA256: String(repeating: "7", count: 64)
+ )
+ }
+
private func step(_ id: String, _ deadline: UInt64) -> DoryWorkspaceOperationStep {
DoryWorkspaceOperationStep(
id: id,
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift
new file mode 100644
index 00000000..5fd3cdb2
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift
@@ -0,0 +1,611 @@
+import Darwin
+import DoryCore
+import DoryOperations
+@testable import DorydKit
+import Foundation
+import XCTest
+
+final class MachineManagerLifecycleJournalIntegrationTests: XCTestCase {
+ func testStartJournalRemainsActiveUntilReadyAndFencesConcurrentMutation() throws {
+ let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true)
+ defer { fixture.cleanup() }
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+
+ let starting = try manager.start(id: fixture.machineID)
+ XCTAssertEqual(starting.state, .starting)
+ let pending = try XCTUnwrap(try fixture.records().last)
+ XCTAssertEqual(pending.plan.kind, .workspaceStart)
+ XCTAssertEqual(pending.state.status, .running)
+
+ let competingStore = try DoryOperationJournalStore(home: fixture.journal)
+ XCTAssertThrowsError(
+ try competingStore.acquire(pending.plan.id, mutationScope: "different-vm")
+ ) { error in
+ guard case DoryOperationJournalError.invalidPlan = error else {
+ return XCTFail("wrong scope was not rejected: \(error)")
+ }
+ }
+ XCTAssertThrowsError(try competingStore.acquire(pending.plan.id)) { error in
+ guard case DoryOperationJournalError.operationInUse = error else {
+ return XCTFail("derived scope did not preserve the active fence: \(error)")
+ }
+ }
+
+ XCTAssertThrowsError(
+ try manager.snapshot(id: fixture.machineID, snapshotID: "while-starting")
+ ) { error in
+ XCTAssertTrue("\(error)".contains("active lifecycle mutation"))
+ }
+
+ try sendVmmHandoff(
+ path: try XCTUnwrap(starting.handoffSocketPath),
+ ready: VmmReadyMessage(machineID: fixture.machineID),
+ fileDescriptors: []
+ )
+ XCTAssertEqual(try waitForState(manager, id: fixture.machineID, state: .running).state, .running)
+ let completed = try waitForJournal(
+ fixture,
+ kind: .workspaceStart,
+ status: .completed
+ )
+ let lease = try fixture.store.acquire(completed.plan.id)
+ let operation = try lease.readWorkspaceLifecycleOperation()
+ XCTAssertEqual(operation.kind, .starting)
+ XCTAssertEqual(operation.source.runtime?.policy, .legacyCompatibility)
+ XCTAssertEqual(operation.source.runtime?.authorizationState, .legacyCompatibility)
+ XCTAssertEqual(operation.target.runtime?.policy, .legacyCompatibility)
+ XCTAssertEqual(operation.target.runtime?.authorizationState, .legacyCompatibility)
+ XCTAssertEqual(
+ operation.target.runtime?.virtualHardwareABIVersion,
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ )
+ }
+
+ func testPerMachineFenceAllowsDifferentMachinesToReachReadinessConcurrently() throws {
+ let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true)
+ defer { fixture.cleanup() }
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+ _ = try fixture.createMachine(manager, id: "lifecycle-vm-two")
+
+ let first = try manager.start(id: fixture.machineID)
+ let second = try manager.start(id: "lifecycle-vm-two")
+ XCTAssertEqual(first.state, .starting)
+ XCTAssertEqual(second.state, .starting)
+ XCTAssertEqual(
+ try fixture.records().filter {
+ $0.plan.kind == .workspaceStart && $0.state.status == .running
+ }.count,
+ 2
+ )
+
+ try sendVmmHandoff(
+ path: try XCTUnwrap(first.handoffSocketPath),
+ ready: VmmReadyMessage(machineID: fixture.machineID),
+ fileDescriptors: []
+ )
+ try sendVmmHandoff(
+ path: try XCTUnwrap(second.handoffSocketPath),
+ ready: VmmReadyMessage(machineID: "lifecycle-vm-two"),
+ fileDescriptors: []
+ )
+ _ = try waitForState(manager, id: fixture.machineID, state: .running)
+ _ = try waitForState(manager, id: "lifecycle-vm-two", state: .running)
+ let deadline = Date().addingTimeInterval(2)
+ while Date() < deadline {
+ if try fixture.records().filter({
+ $0.plan.kind == .workspaceStart && $0.state.status == .completed
+ }).count == 2 {
+ break
+ }
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ XCTAssertEqual(
+ try fixture.records().filter {
+ $0.plan.kind == .workspaceStart && $0.state.status == .completed
+ }.count,
+ 2
+ )
+ }
+
+ func testReadinessTimeoutFailsStartJournalAndNeverPublishesRunning() throws {
+ let fixture = try LifecycleFixture(
+ name: #function,
+ requiresReadyHandoff: true,
+ readyTimeout: 0.05
+ )
+ defer { fixture.cleanup() }
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+
+ XCTAssertEqual(try manager.start(id: fixture.machineID).state, .starting)
+ let failed = try waitForState(manager, id: fixture.machineID, state: .failed)
+ XCTAssertTrue(failed.lastError?.contains("ready handoff timed out") == true)
+ _ = try waitForJournal(fixture, kind: .workspaceStart, status: .failed)
+ XCTAssertFalse(try fixture.records().contains { record in
+ record.plan.kind == .workspaceStart && record.state.status == .completed
+ })
+ }
+
+ func testTerminalHelperExitFailsStartJournalWithoutWaitingForReadinessTimeout() throws {
+ let fixture = try LifecycleFixture(
+ name: #function,
+ executable: "/usr/bin/false",
+ requiresReadyHandoff: true,
+ readyTimeout: 5,
+ restartPolicy: .none
+ )
+ defer { fixture.cleanup() }
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+
+ _ = try manager.start(id: fixture.machineID)
+ let failed = try waitForState(manager, id: fixture.machineID, state: .failed, timeout: 1)
+ XCTAssertTrue(failed.lastError?.contains("exited with status") == true)
+ _ = try waitForJournal(
+ fixture,
+ kind: .workspaceStart,
+ status: .failed,
+ timeout: 1
+ )
+ }
+
+ func testInterruptedStartRecoversStoppedWithoutFalseRunningState() throws {
+ let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ XCTAssertEqual(
+ try XCTUnwrap(manager).start(id: fixture.machineID).state,
+ .starting
+ )
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ let status = try XCTUnwrap(recovered.status(id: fixture.machineID))
+ XCTAssertEqual(status.state, .stopped)
+ XCTAssertNil(status.pid)
+ XCTAssertTrue(status.lastError?.contains("interrupted start") == true)
+ _ = try waitForJournal(fixture, kind: .workspaceStart, status: .failed)
+ }
+
+ func testCrashAfterDurableStartPreparationHasJournalButNeverStartsHelper() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .startAfterPreparation { throw MachineLifecycleInjectedCrash() }
+ }
+
+ XCTAssertThrowsError(try XCTUnwrap(manager).start(id: fixture.machineID)) { error in
+ XCTAssertTrue(error is MachineLifecycleInjectedCrash)
+ }
+ XCTAssertEqual(try XCTUnwrap(manager).status(id: fixture.machineID)?.state, .created)
+ XCTAssertEqual(
+ try fixture.records().filter {
+ $0.plan.kind == .workspaceResolve && $0.state.status == .running
+ }.count,
+ 1
+ )
+ XCTAssertFalse(try fixture.records().contains { $0.plan.kind == .workspaceStart })
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped)
+ _ = try waitForJournal(fixture, kind: .workspaceResolve, status: .completed)
+ XCTAssertFalse(try fixture.records().contains { $0.plan.kind == .workspaceStart })
+ }
+
+ func testReadinessCompletionWriteFailureKeepsRunningTargetAndNonterminalJournal() throws {
+ let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .completionBeforeJournalWrite(.starting) {
+ throw MachineLifecycleInjectedCrash()
+ }
+ }
+
+ let starting = try XCTUnwrap(manager).start(id: fixture.machineID)
+ try sendVmmHandoff(
+ path: try XCTUnwrap(starting.handoffSocketPath),
+ ready: VmmReadyMessage(machineID: fixture.machineID),
+ fileDescriptors: []
+ )
+ let running = try waitForState(
+ try XCTUnwrap(manager),
+ id: fixture.machineID,
+ state: .running
+ )
+ XCTAssertTrue(running.lastError?.contains("unfinished readiness journal") == true)
+ let unfinished = try XCTUnwrap(try fixture.records().last(where: {
+ $0.plan.kind == .workspaceStart
+ }))
+ XCTAssertEqual(unfinished.state.status, .running)
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped)
+ _ = try waitForJournal(fixture, kind: .workspaceStart, status: .failed)
+ }
+
+ func testInterruptedStopCompletesFromPersistedTargetAuthorityOnRestart() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ XCTAssertEqual(try XCTUnwrap(manager).start(id: fixture.machineID).state, .running)
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .stopAfterProcessStop { throw MachineLifecycleInjectedCrash() }
+ }
+
+ XCTAssertThrowsError(try XCTUnwrap(manager).stop(id: fixture.machineID)) { error in
+ XCTAssertTrue(error is MachineLifecycleInjectedCrash)
+ }
+ XCTAssertEqual(try XCTUnwrap(manager).status(id: fixture.machineID)?.state, .stopped)
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ let status = try XCTUnwrap(recovered.status(id: fixture.machineID))
+ XCTAssertEqual(status.state, .stopped)
+ XCTAssertTrue(status.lastError?.contains("interrupted stop completed") == true)
+ _ = try waitForJournal(fixture, kind: .workspaceStop, status: .completed)
+ }
+
+ func testInterruptedSnapshotRemovesOnlyOperationOwnedPartialArtifacts() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .snapshotAfterRootfs { throw MachineLifecycleInjectedCrash() }
+ }
+
+ XCTAssertThrowsError(
+ try XCTUnwrap(manager).snapshot(id: fixture.machineID, snapshotID: "partial")
+ ) { error in
+ XCTAssertTrue(error is MachineLifecycleInjectedCrash)
+ }
+ XCTAssertTrue(FileManager.default.fileExists(
+ atPath: fixture.state + "/\(fixture.machineID)/snapshots/partial.ext4"
+ ))
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped)
+ XCTAssertTrue(try recovered.listSnapshots(machineID: fixture.machineID).isEmpty)
+ XCTAssertFalse(FileManager.default.fileExists(
+ atPath: fixture.state + "/\(fixture.machineID)/snapshots/partial.ext4"
+ ))
+ _ = try waitForJournal(fixture, kind: .workspaceSnapshot, status: .failed)
+ }
+
+ func testInterruptedRestoreRollsBackBackupsAndPreservesMachine() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ _ = try XCTUnwrap(manager).snapshot(id: fixture.machineID, snapshotID: "known-good")
+ let managedDisk = fixture.state + "/\(fixture.machineID)/rootfs.ext4"
+ try Data("current-before-restore".utf8).write(to: URL(fileURLWithPath: managedDisk))
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .restoreAfterBackups { throw MachineLifecycleInjectedCrash() }
+ }
+
+ XCTAssertThrowsError(
+ try XCTUnwrap(manager).restoreSnapshot(
+ machineID: fixture.machineID,
+ snapshotID: "known-good"
+ )
+ ) { error in
+ XCTAssertTrue(error is MachineLifecycleInjectedCrash)
+ }
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped)
+ XCTAssertEqual(
+ try String(contentsOfFile: managedDisk, encoding: .utf8),
+ "current-before-restore"
+ )
+ XCTAssertEqual(
+ try recovered.listSnapshots(machineID: fixture.machineID).map(\.id),
+ ["known-good"]
+ )
+ _ = try waitForJournal(fixture, kind: .workspaceRestore, status: .failed)
+ }
+
+ func testRestoreRejectsSnapshotMutationAfterAuthorityBindingAndRollsBack() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+ let managedDisk = fixture.state + "/\(fixture.machineID)/rootfs.ext4"
+ try Data("snapshot-bound-state".utf8).write(to: URL(fileURLWithPath: managedDisk))
+ let snapshot = try manager.snapshot(id: fixture.machineID, snapshotID: "bound")
+ try Data("original-live-state".utf8).write(to: URL(fileURLWithPath: managedDisk))
+ manager.installLifecycleFaultInjectorForTesting { point in
+ if point == .restoreAfterBackups {
+ try Data("mutated-after-authority-binding".utf8).write(
+ to: URL(fileURLWithPath: snapshot.rootfsPath)
+ )
+ }
+ }
+
+ XCTAssertThrowsError(
+ try manager.restoreSnapshot(machineID: fixture.machineID, snapshotID: "bound")
+ ) { error in
+ XCTAssertTrue("\(error)".contains("bound snapshot evidence"))
+ }
+ XCTAssertEqual(
+ try String(contentsOfFile: managedDisk, encoding: .utf8),
+ "original-live-state"
+ )
+ let restoreRecords = try fixture.records().filter {
+ $0.plan.kind == .workspaceRestore
+ }
+ XCTAssertEqual(restoreRecords.last?.state.status, .failed)
+ XCTAssertFalse(restoreRecords.contains { $0.state.status == .completed })
+ }
+
+ func testRestoreCompletionWriteFailurePreservesRestoredTargetForRecovery() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ let managedDisk = fixture.state + "/\(fixture.machineID)/rootfs.ext4"
+ try Data("snapshot-target".utf8).write(to: URL(fileURLWithPath: managedDisk))
+ _ = try XCTUnwrap(manager).snapshot(id: fixture.machineID, snapshotID: "target")
+ try Data("later-state".utf8).write(to: URL(fileURLWithPath: managedDisk))
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .completionBeforeJournalWrite(.restoring) {
+ throw MachineLifecycleInjectedCrash()
+ }
+ }
+
+ let restored = try XCTUnwrap(manager).restoreSnapshot(
+ machineID: fixture.machineID,
+ snapshotID: "target"
+ )
+ XCTAssertEqual(restored.state, .created)
+ XCTAssertEqual(
+ try String(contentsOfFile: managedDisk, encoding: .utf8),
+ "snapshot-target"
+ )
+ XCTAssertTrue(
+ try XCTUnwrap(manager).status(id: fixture.machineID)?.lastError?
+ .contains("unfinished restore journal") == true
+ )
+ let unfinished = try XCTUnwrap(try fixture.records().last(where: {
+ $0.plan.kind == .workspaceRestore
+ }))
+ XCTAssertEqual(unfinished.state.status, .running)
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped)
+ XCTAssertEqual(
+ try String(contentsOfFile: managedDisk, encoding: .utf8),
+ "snapshot-target"
+ )
+ let recoveredJournal = try XCTUnwrap(try fixture.records().last(where: {
+ $0.plan.kind == .workspaceRestore
+ }))
+ XCTAssertEqual(
+ recoveredJournal.state.status,
+ .completed,
+ "recovery diagnostic: \(recovered.status(id: fixture.machineID)?.lastError ?? "none")"
+ )
+ }
+
+ func testSnapshotCompletionRecoveryRejectsMissingBoundKernel() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .completionBeforeJournalWrite(.snapshotting) {
+ throw MachineLifecycleInjectedCrash()
+ }
+ }
+
+ let snapshot = try XCTUnwrap(manager).snapshot(
+ id: fixture.machineID,
+ snapshotID: "bound-snapshot"
+ )
+ XCTAssertEqual(
+ try XCTUnwrap(try fixture.records().last(where: {
+ $0.plan.kind == .workspaceSnapshot
+ })).state.status,
+ .running
+ )
+ try FileManager.default.removeItem(atPath: snapshot.kernelPath)
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped)
+ let recoveredJournal = try XCTUnwrap(try fixture.records().last(where: {
+ $0.plan.kind == .workspaceSnapshot
+ }))
+ XCTAssertEqual(recoveredJournal.state.status, .failed)
+ XCTAssertTrue(
+ recovered.status(id: fixture.machineID)?.lastError?
+ .contains("interrupted snapshot") == true
+ )
+ XCTAssertFalse(try recovered.listSnapshots(machineID: fixture.machineID).contains {
+ $0.id == "bound-snapshot"
+ })
+ }
+
+ func testRestoreCompletionRecoveryRejectsTamperedLiveRootfs() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ let managedDisk = fixture.state + "/\(fixture.machineID)/rootfs.ext4"
+ try Data("snapshot-authority".utf8).write(to: URL(fileURLWithPath: managedDisk))
+ _ = try XCTUnwrap(manager).snapshot(id: fixture.machineID, snapshotID: "authority")
+ try Data("later-state".utf8).write(to: URL(fileURLWithPath: managedDisk))
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .completionBeforeJournalWrite(.restoring) {
+ throw MachineLifecycleInjectedCrash()
+ }
+ }
+
+ _ = try XCTUnwrap(manager).restoreSnapshot(
+ machineID: fixture.machineID,
+ snapshotID: "authority"
+ )
+ try Data("tampered-after-commit".utf8).write(to: URL(fileURLWithPath: managedDisk))
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ XCTAssertEqual(recovered.status(id: fixture.machineID)?.state, .stopped)
+ XCTAssertEqual(
+ try String(contentsOfFile: managedDisk, encoding: .utf8),
+ "tampered-after-commit"
+ )
+ let recoveredJournal = try XCTUnwrap(try fixture.records().last(where: {
+ $0.plan.kind == .workspaceRestore
+ }))
+ XCTAssertEqual(recoveredJournal.state.status, .failed)
+ XCTAssertTrue(
+ recovered.status(id: fixture.machineID)?.lastError?
+ .contains("requires repair") == true
+ )
+ }
+
+ func testInterruptedDeleteRestoresQuarantinedMachineOnRestart() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ var manager: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try XCTUnwrap(manager))
+ try XCTUnwrap(manager).installLifecycleFaultInjectorForTesting { point in
+ if point == .deleteAfterQuarantine { throw MachineLifecycleInjectedCrash() }
+ }
+
+ XCTAssertThrowsError(try XCTUnwrap(manager).delete(id: fixture.machineID)) { error in
+ XCTAssertTrue(error is MachineLifecycleInjectedCrash)
+ }
+ XCTAssertNil(try XCTUnwrap(manager).status(id: fixture.machineID))
+ manager = nil
+
+ let recovered = fixture.makeManager()
+ let status = try XCTUnwrap(recovered.status(id: fixture.machineID))
+ XCTAssertEqual(status.state, .stopped)
+ XCTAssertTrue(status.lastError?.contains("interrupted deletion") == true)
+ XCTAssertTrue(FileManager.default.fileExists(
+ atPath: fixture.state + "/\(fixture.machineID)/machine.json"
+ ))
+ _ = try waitForJournal(fixture, kind: .workspaceDelete, status: .failed)
+ }
+}
+
+private final class LifecycleFixture {
+ let machineID = "lifecycle-vm"
+ let base: String
+ let state: String
+ let journal: String
+ let store: DoryOperationJournalStore
+ private let configuration: MachineManagerConfiguration
+
+ init(
+ name: String,
+ executable: String = "/bin/sleep",
+ requiresReadyHandoff: Bool = false,
+ readyTimeout: TimeInterval = 2,
+ restartPolicy: HvRestartPolicy = HvRestartPolicy(
+ maxRestarts: 4,
+ delaySeconds: 0.01,
+ maximumDelaySeconds: 0.02,
+ stableRunSeconds: 0
+ )
+ ) throws {
+ _ = name
+ base = "/tmp/dorylc-\(getpid())-\(UInt64.random(in: 0.. MachineManager {
+ MachineManager(configuration: configuration)
+ }
+
+ @discardableResult
+ func createMachine(
+ _ manager: MachineManager,
+ id: String? = nil
+ ) throws -> DoryMachineStatus {
+ try manager.create(DoryMachineConfiguration(
+ id: id ?? machineID,
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath
+ ))
+ }
+
+ func records() throws -> [DoryOperationRecord] {
+ try store.list().sorted { $0.plan.createdAt < $1.plan.createdAt }
+ }
+
+ func cleanup() {
+ try? FileManager.default.removeItem(atPath: base)
+ }
+}
+
+private func waitForState(
+ _ manager: MachineManager,
+ id: String,
+ state: DoryMachineState,
+ timeout: TimeInterval = 3
+) throws -> DoryMachineStatus {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if let status = manager.status(id: id), status.state == state { return status }
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ throw NSError(
+ domain: "MachineManagerLifecycleJournalIntegrationTests",
+ code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "timed out waiting for machine state \(state)"]
+ )
+}
+
+private func waitForJournal(
+ _ fixture: LifecycleFixture,
+ kind: DoryOperationKind,
+ status: DoryOperationStatus,
+ timeout: TimeInterval = 3
+) throws -> DoryOperationRecord {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if let record = try fixture.records().last(where: {
+ $0.plan.kind == kind && $0.state.status == status
+ }) {
+ return record
+ }
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ throw NSError(
+ domain: "MachineManagerLifecycleJournalIntegrationTests",
+ code: 2,
+ userInfo: [
+ NSLocalizedDescriptionKey:
+ "timed out waiting for lifecycle journal \(kind.rawValue) \(status.rawValue)",
+ ]
+ )
+}
From f5216638fde5d940cc235e72fa8a554c639690db Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 15:17:30 +0000
Subject: [PATCH 027/338] feat(diagnostics): report exact VM runtime evidence
---
.../Sources/DorydKit/HealthReporter.swift | 163 +++++++++++++---
.../DorydKitTests/HealthReporterTests.swift | 181 +++++++++++++++++-
2 files changed, 311 insertions(+), 33 deletions(-)
diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift
index 932ef168..94a90ea4 100644
--- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift
+++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift
@@ -419,7 +419,8 @@ public final class HealthReporter: @unchecked Sendable {
}
public func doctorReport(now: Date = Date()) -> DoctorReport {
- let checks = compatibilityChecks(now: now)
+ var checks = compatibilityChecks(now: now)
+ checks.append(contentsOf: machineChecks())
return DoctorReport(
generatedAt: now,
results: checks,
@@ -1920,43 +1921,145 @@ public final class HealthReporter: @unchecked Sendable {
"failed": String(failed.count),
]
+ let summary: HealthCheck
if !failed.isEmpty {
- return [
- HealthCheck(
- id: "machine.local",
- status: .fail,
- code: "machine.failed",
- title: "Local machine failed",
- detail: failed.map { "\($0.id): \($0.lastError ?? "unknown failure")" }.joined(separator: "; "),
- action: "Inspect the dory-vmm log for the failed machine.",
- data: data
- ),
- ]
- }
-
- if !starting.isEmpty {
- return [
- HealthCheck(
- id: "machine.local",
- status: .warn,
- code: "machine.starting",
- title: "Local machine starting",
- detail: starting.map(\.id).joined(separator: ", "),
- data: data
- ),
- ]
- }
-
- return [
- HealthCheck(
+ summary = HealthCheck(
+ id: "machine.local",
+ status: .fail,
+ code: "machine.failed",
+ title: "Local machine failed",
+ detail: failed.map(\.id).joined(separator: ", "),
+ action: "Inspect the machine evidence and operation journal for the failed workspace.",
+ data: data
+ )
+ } else if !starting.isEmpty {
+ summary = HealthCheck(
+ id: "machine.local",
+ status: .warn,
+ code: "machine.starting",
+ title: "Local machine starting",
+ detail: starting.map(\.id).joined(separator: ", "),
+ data: data
+ )
+ } else {
+ summary = HealthCheck(
id: "machine.local",
status: .pass,
code: running.isEmpty ? "machine.configured" : "machine.running",
title: running.isEmpty ? "Local machines configured" : "Local machine running",
detail: statuses.map { "\($0.id)=\($0.state.rawValue)" }.joined(separator: ", "),
data: data
- ),
+ )
+ }
+ return [summary] + statuses.map(Self.machineEvidenceCheck)
+ }
+
+ /// Emits exact, non-secret launch evidence for one workspace. Environment values, host paths,
+ /// guest file contents, sockets, and clipboard data are deliberately excluded.
+ static func machineEvidenceCheck(_ status: DoryMachineStatus) -> HealthCheck {
+ let identity = status.runtimeIdentity
+ let issues = identity.validate()
+ var data: [String: String] = [
+ "state": status.state.rawValue,
+ "runtime_identity_mode": identity.mode.rawValue,
+ "runtime_identity_schema": String(identity.schemaVersion),
+ "virtual_hardware_abi": String(identity.virtualHardwareABIVersion),
+ "runtime_identity_valid": issues.isEmpty ? "true" : "false",
]
+ if !issues.isEmpty {
+ data["runtime_identity_issues"] = issues
+ .map { "\($0.code.rawValue):\($0.field)" }
+ .sorted()
+ .joined(separator: ",")
+ }
+ if let reason = identity.invalidationReason {
+ data["replanning_reason"] = reason.rawValue
+ }
+ if issues.isEmpty, let plan = identity.resolvedPlan {
+ data["plan_sha256"] = identity.resolvedPlanSHA256
+ data["plan_revision"] = String(plan.planRevision)
+ data["spec_revision"] = String(plan.definitionRevision)
+ data["spec_sha256"] = plan.definitionSHA256
+ data["backend"] = plan.backend.rawValue
+ data["backend_implementation"] = plan.backendImplementationIdentifier
+ data["backend_runtime_build"] = plan.backendRuntimeBuildIdentifier
+ data["support_tier"] = plan.supportTier.rawValue
+ data["graphics"] = plan.graphics.rawValue
+ data["selection"] = plan.selectionEvidence?.disposition.rawValue
+ data["component_count"] = String(plan.components.count)
+ data["components"] = plan.components
+ .sorted { lhs, rhs in
+ if lhs.componentIdentifier == rhs.componentIdentifier {
+ return lhs.buildIdentifier < rhs.buildIdentifier
+ }
+ return lhs.componentIdentifier < rhs.componentIdentifier
+ }
+ .map { "\($0.componentIdentifier)@\($0.buildIdentifier):\($0.artifactSHA256)" }
+ .joined(separator: ",")
+ data["media_kind"] = plan.bootMedia.media.kind.rawValue
+ data["media_source"] = plan.bootMedia.media.source.rawValue
+ data["media_artifact_sha256"] = plan.bootMedia.media.artifactSHA256
+ if let resolver = plan.bootMedia.resolverReference {
+ data["media_resolver"] = "\(resolver.namespace):\(resolver.identifier)"
+ }
+ if let provenance = plan.bootMedia.media.mutableProvenance {
+ data["media_repository_identity"] = provenance.repositoryIdentity
+ data["media_identity"] = provenance.mediaIdentity
+ data["media_revision"] = String(provenance.revision)
+ }
+ if let runtime = plan.qualificationEvidence.runtime {
+ data["runtime_qualification"] = runtime.qualificationIdentity
+ data["runtime_qualification_sha256"] = runtime.qualificationReportSHA256
+ }
+ if let graphics = plan.qualificationEvidence.graphics {
+ data["graphics_qualification"] = graphics.manifestIdentity
+ data["graphics_manifest_sha256"] = graphics.manifestSHA256
+ }
+ if let host = plan.hostQualification {
+ data["host_qualification"] = host.qualificationIdentity
+ data["host_qualification_sha256"] = host.qualificationReportSHA256
+ data["host_hardware_model"] = host.hostHardwareModelIdentifier
+ data["host_os_build"] = host.hostOperatingSystemBuild
+ }
+ if let admission = plan.resourceAdmission {
+ data["resource_admission"] = admission.admissionIdentity
+ data["resource_admission_sha256"] = admission.admissionReportSHA256
+ }
+ }
+
+ let healthStatus: HealthCheckStatus
+ let code: String
+ let action: String?
+ if !issues.isEmpty {
+ healthStatus = .fail
+ code = "machine.runtime_identity_invalid"
+ action = "Repair or re-resolve this workspace before its next launch."
+ } else if identity.mode == .requiresReplanning {
+ healthStatus = .warn
+ code = "machine.requires_replanning"
+ action = "Resolve and approve a current launch plan before starting this workspace."
+ } else if status.state == .failed {
+ healthStatus = .fail
+ code = "machine.runtime_failed"
+ action = "Inspect the operation journal and backend evidence for this workspace."
+ } else {
+ healthStatus = .pass
+ code = identity.mode == .resolvedPlan
+ ? "machine.runtime_resolved"
+ : "machine.runtime_legacy_compatibility"
+ action = identity.mode == .legacyCompatibility
+ ? "Re-plan this workspace to move it onto evidence-bound launch policy."
+ : nil
+ }
+ return HealthCheck(
+ id: "machine.local.\(status.id)",
+ status: healthStatus,
+ code: code,
+ title: "Workspace runtime evidence",
+ detail: "\(status.id) is \(status.state.rawValue) using \(identity.mode.rawValue) ABI \(identity.virtualHardwareABIVersion)",
+ action: action,
+ data: data
+ )
}
private func engineData(_ status: DockerTierStatus) -> [String: String] {
diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
index ecb32693..695f21aa 100644
--- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
@@ -1,5 +1,6 @@
import Darwin
import DoryCore
+import DoryOperations
@testable import DorydKit
import Foundation
import XCTest
@@ -342,7 +343,7 @@ final class HealthReporterTests: XCTestCase {
XCTAssertNil(tier.status().hvPID)
}
- func testReportIncludesLocalMachineHealthOutsideDoctorContract() throws {
+ func testReportAndDoctorIncludeNonSecretLocalMachineRuntimeEvidence() throws {
let base = "/tmp/dory-health-machine-\(getpid())-\(UInt32.random(in: 0.. DoryResolvedMachinePlan {
+ let artifact = healthDigest("a")
+ let guest = DoryGuestPlatform(family: .linux, architecture: .arm64)
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let media = DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifactSHA256: artifact
+ )
+ return DoryResolvedMachinePlan(
+ machineID: "qualified",
+ definitionRevision: 7,
+ definitionSHA256: healthDigest("1"),
+ planRevision: 2,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000,
+ guest: guest,
+ backend: .doryHypervisor,
+ backendImplementationIdentifier: "dory.raw-hv-linux.v1",
+ backendRuntimeBuildIdentifier: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ bootMedia: DoryResolvedMachineBootMedia(
+ resolverReference: DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "qualified-linux"
+ ),
+ media: media
+ ),
+ components: [DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: "raw-runtime-1",
+ artifactSHA256: healthDigest("d")
+ )],
+ devices: devices,
+ graphics: .hostAcceleratedDisplay,
+ supportTier: .supported,
+ selectionEvidence: DoryResolvedMachineBackendSelectionEvidence(
+ disposition: .primary,
+ plannerRequest: DoryVirtualMachineBackendPlanRequest(
+ guest: guest,
+ bootMedia: media,
+ acceptableGraphics: [.hostAcceleratedDisplay],
+ devices: devices,
+ backendPreferences: [.doryHypervisor],
+ backendPreferencePolicy: .required
+ ),
+ selectedEvaluationIndex: 0,
+ rejectedCandidates: []
+ ),
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence(
+ graphics: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: "graphics-qualification-1",
+ artifactSHA256: artifact,
+ manifestSHA256: healthDigest("b"),
+ signingKeyID: "dory-release-1",
+ manifestFormatVersion: 1
+ ),
+ runtime: DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "runtime-qualification-1",
+ qualificationReportSHA256: healthDigest("c"),
+ signingKeyID: "dory-runtime-1",
+ qualificationFormatVersion: 1,
+ guest: guest,
+ bootMediaKind: media.kind,
+ immutableArtifactSHA256: artifact,
+ backend: .doryHypervisor,
+ backendRuntimeBuildID: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ graphics: .hostAcceleratedDisplay,
+ devices: devices
+ )
+ ),
+ resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence(
+ admittedVirtualCPUCount: 4,
+ admittedMemoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ admittedStorageBytes: 64 * 1_024 * 1_024 * 1_024,
+ hostLogicalCPUCount: 12,
+ hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024,
+ hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024,
+ existingVirtualCPUCommitment: 0,
+ existingMemoryCommitmentBytes: 0,
+ existingStorageReservationBytes: 0,
+ hostReservedLogicalCPUCount: 2,
+ hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024,
+ admissionIdentity: "resource-admission-1",
+ admissionReportSHA256: healthDigest("e"),
+ assessorIdentifier: "dory-resource-policy",
+ assessorVersion: 1
+ ),
+ hostQualification: DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: "host-qualification-1",
+ qualificationReportSHA256: healthDigest("f"),
+ hostHardwareModelIdentifier: "Mac16.1",
+ hostOperatingSystemBuild: "26A5406c",
+ backend: .doryHypervisor,
+ backendRuntimeBuildIdentifier: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ qualifierIdentifier: "dory-host-qualifier",
+ qualifierVersion: 1
+ )
+ )
+}
+
+private func healthDigest(_ character: Character) -> String {
+ String(repeating: String(character), count: 64)
+}
+
private final class HealthFakeSSHKeyStore: SSHKeyStore, @unchecked Sendable {
func privateKey(for identifier: String) throws -> String {
throw SSHKeyStoreError.notFound(identifier)
From d78a45ac276fd80c7005656810b44146a68edb76 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 15:20:06 +0000
Subject: [PATCH 028/338] feat(vm): type guest and clipboard intent
---
.../DoryVirtualMachineDefinition.swift | 97 ++++++++++
.../DoryVirtualMachineGuestIntent.swift | 179 ++++++++++++++++++
.../DoryMachineConfigurationMigration.swift | 127 +++++++++++++
.../DoryVirtualMachineDefinitionTests.swift | 118 ++++++++++++
...ryMachineConfigurationMigrationTests.swift | 134 ++++++++++++-
5 files changed, 654 insertions(+), 1 deletion(-)
create mode 100644 dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
index 48d70d60..505bf86d 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
@@ -298,6 +298,9 @@ public enum DoryVMDefinitionValidationCode: String, Codable, Sendable, CaseItera
case unsafeGuestMountPath = "unsafe-guest-mount-path"
case duplicateIntegration = "duplicate-integration"
case integrationRequiresDisplay = "integration-requires-display"
+ case invalidGuestIdentityIntent = "invalid-guest-identity-intent"
+ case guestIdentityIncompatibleWithGuest = "guest-identity-incompatible-with-guest"
+ case clipboardPolicyRequiresIntegration = "clipboard-policy-requires-integration"
case legacyInstallerWorkload = "legacy-installer-workload"
case invalidLifecycleMetadata = "invalid-lifecycle-metadata"
}
@@ -339,6 +342,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
public var input: DoryVMInputConfiguration
public var shares: [DoryVMShare]
public var integrations: [DoryVMGuestIntegration]
+ public var guestIdentityIntent: DoryVMGuestIdentityIntent
+ public var clipboardPolicy: DoryVMClipboardPolicy
public var lifecycle: DoryVMLifecycleMetadata
public init(
@@ -358,6 +363,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
input: DoryVMInputConfiguration = DoryVMInputConfiguration(),
shares: [DoryVMShare] = [],
integrations: [DoryVMGuestIntegration] = [],
+ guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified,
+ clipboardPolicy: DoryVMClipboardPolicy? = nil,
lifecycle: DoryVMLifecycleMetadata
) {
self.schemaVersion = schemaVersion
@@ -376,6 +383,10 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
self.input = input
self.shares = shares
self.integrations = integrations
+ self.guestIdentityIntent = guestIdentityIntent
+ self.clipboardPolicy = clipboardPolicy
+ ?? (integrations.contains(.clipboard)
+ ? .legacyDesktop(.bidirectional) : .disabled)
self.lifecycle = lifecycle
}
@@ -397,6 +408,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
case input
case shares
case integrations
+ case guestIdentityIntent
+ case clipboardPolicy
case lifecycle
}
@@ -511,6 +524,9 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
[DoryVMGuestIntegration].self,
forKey: .integrations
)
+ guestIdentityIntent = .unspecified
+ clipboardPolicy = integrations.contains(.clipboard)
+ ? .legacyDesktop(.bidirectional) : .disabled
lifecycle = DoryVMLifecycleMetadata(
revision: legacyLifecycle.revision,
createdAt: legacyLifecycle.createdAt,
@@ -538,6 +554,15 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
input = try container.decode(DoryVMInputConfiguration.self, forKey: .input)
shares = try container.decode([DoryVMShare].self, forKey: .shares)
integrations = try container.decode([DoryVMGuestIntegration].self, forKey: .integrations)
+ guestIdentityIntent = try container.decodeIfPresent(
+ DoryVMGuestIdentityIntent.self,
+ forKey: .guestIdentityIntent
+ ) ?? .unspecified
+ clipboardPolicy = try container.decodeIfPresent(
+ DoryVMClipboardPolicy.self,
+ forKey: .clipboardPolicy
+ ) ?? (integrations.contains(.clipboard)
+ ? .legacyDesktop(.bidirectional) : .disabled)
lifecycle = try container.decode(DoryVMLifecycleMetadata.self, forKey: .lifecycle)
}
@@ -559,6 +584,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
try container.encode(input, forKey: .input)
try container.encode(shares, forKey: .shares)
try container.encode(integrations, forKey: .integrations)
+ try container.encode(guestIdentityIntent, forKey: .guestIdentityIntent)
+ try container.encode(clipboardPolicy, forKey: .clipboardPolicy)
try container.encode(lifecycle, forKey: .lifecycle)
}
@@ -610,6 +637,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
validateDisplay(into: &issues)
validateShares(into: &issues)
validateIntegrations(into: &issues)
+ validateGuestIdentityIntent(into: &issues)
+ validateClipboardPolicy(into: &issues)
validateLifecycle(into: &issues)
return issues
}
@@ -819,6 +848,74 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
}
}
+ private func validateGuestIdentityIntent(
+ into issues: inout [DoryVMDefinitionValidationIssue]
+ ) {
+ if !guestIdentityIntent.isEmpty, guest.family != .linux {
+ issues.append(issue(
+ .guestIdentityIncompatibleWithGuest,
+ "guestIdentityIntent"
+ ))
+ }
+ if let account = guestIdentityIntent.account, !account.isValidForPersistence {
+ issues.append(issue(.invalidGuestIdentityIntent, "guestIdentityIntent.account"))
+ }
+ if let username = guestIdentityIntent.account?.username,
+ !DoryVMGuestAccountIntent.isValidUsername(username) {
+ issues.append(issue(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.account.username"
+ ))
+ }
+ if let numericUserID = guestIdentityIntent.account?.numericUserID,
+ !DoryVMGuestAccountIntent.isValidNumericUserID(numericUserID) {
+ issues.append(issue(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.account.numericUserID"
+ ))
+ }
+ if let desktop = guestIdentityIntent.desktop {
+ if !desktop.isValidForPersistence {
+ issues.append(issue(.invalidGuestIdentityIntent, "guestIdentityIntent.desktop"))
+ }
+ if let identifier = desktop.distributionIdentifier,
+ !DoryVMDesktopIdentityIntent.isValidDistributionIdentifier(identifier) {
+ issues.append(issue(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.desktop.distributionIdentifier"
+ ))
+ }
+ for (field, value) in [
+ ("displayName", desktop.displayName),
+ ("version", desktop.version),
+ ("desktopEnvironment", desktop.desktopEnvironment),
+ ] where value.map(DoryVMDesktopIdentityIntent.isValidLabel) == false {
+ issues.append(issue(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.desktop.\(field)"
+ ))
+ }
+ if !desktop.isEmpty,
+ (workload != .desktop || !display.enabled) {
+ issues.append(issue(
+ .guestIdentityIncompatibleWithGuest,
+ "guestIdentityIntent.desktop"
+ ))
+ }
+ }
+ }
+
+ private func validateClipboardPolicy(
+ into issues: inout [DoryVMDefinitionValidationIssue]
+ ) {
+ if clipboardPolicy.isEnabled, !integrations.contains(.clipboard) {
+ issues.append(issue(
+ .clipboardPolicyRequiresIntegration,
+ "clipboardPolicy"
+ ))
+ }
+ }
+
private func validateLifecycle(into issues: inout [DoryVMDefinitionValidationIssue]) {
if lifecycle.revision == 0
|| lifecycle.createdAtUnixMilliseconds <= 0
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift
new file mode 100644
index 00000000..c1a3fdd1
--- /dev/null
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineGuestIntent.swift
@@ -0,0 +1,179 @@
+import Foundation
+
+/// Direction in which one clipboard content class may cross the host/guest boundary.
+public enum DoryVMClipboardDirection: String, Codable, Sendable, Equatable, CaseIterable {
+ case off
+ case hostToGuest = "host-to-guest"
+ case guestToHost = "guest-to-host"
+ case bidirectional
+
+ public var allowsHostToGuest: Bool {
+ self == .hostToGuest || self == .bidirectional
+ }
+
+ public var allowsGuestToHost: Bool {
+ self == .guestToHost || self == .bidirectional
+ }
+}
+
+/// Clipboard intent is persisted per content class. Runtime negotiation may select only a
+/// capability that preserves these directions; it must not silently widen them.
+public struct DoryVMClipboardPolicy: Codable, Sendable, Equatable {
+ public var text: DoryVMClipboardDirection
+ public var image: DoryVMClipboardDirection
+ public var files: DoryVMClipboardDirection
+
+ public init(
+ text: DoryVMClipboardDirection,
+ image: DoryVMClipboardDirection,
+ files: DoryVMClipboardDirection
+ ) {
+ self.text = text
+ self.image = image
+ self.files = files
+ }
+
+ public static let disabled = DoryVMClipboardPolicy(text: .off, image: .off, files: .off)
+
+ /// Current Linux compatibility runtimes apply one direction to text and images and do not
+ /// provide clipboard-backed file transfer.
+ public static func legacyDesktop(_ direction: DoryVMClipboardDirection) -> Self {
+ Self(text: direction, image: direction, files: .off)
+ }
+
+ public var isEnabled: Bool {
+ text != .off || image != .off || files != .off
+ }
+}
+
+/// Non-secret account provisioning intent. This is not a credential and never contains a
+/// password, token, home-directory path, or host identity.
+public struct DoryVMGuestAccountIntent: Codable, Sendable, Equatable {
+ public static let legacyUsernameEnvironmentKey = "DORY_GUEST_USER"
+ public static let legacyNumericUserIDEnvironmentKey = "DORY_GUEST_UID"
+
+ public var username: String?
+ public var numericUserID: UInt32?
+
+ public init(username: String? = nil, numericUserID: UInt32? = nil) {
+ self.username = username
+ self.numericUserID = numericUserID
+ }
+
+ public var isEmpty: Bool { username == nil && numericUserID == nil }
+
+ public var isValidForPersistence: Bool {
+ !isEmpty
+ && (username.map(Self.isValidUsername) ?? true)
+ && (numericUserID.map(Self.isValidNumericUserID) ?? true)
+ }
+
+ public static func isValidUsername(_ value: String) -> Bool {
+ let bytes = Array(value.utf8)
+ guard (1...32).contains(bytes.count),
+ (bytes[0] >= 97 && bytes[0] <= 122) || bytes[0] == 95 else {
+ return false
+ }
+ return bytes.dropFirst().allSatisfy { byte in
+ (byte >= 97 && byte <= 122)
+ || (byte >= 48 && byte <= 57)
+ || byte == 95
+ || byte == 45
+ }
+ }
+
+ public static func isValidNumericUserID(_ value: UInt32) -> Bool {
+ (100...60_000).contains(value)
+ }
+}
+
+/// Display metadata for a provisioned guest desktop. Exact boot media and component provenance
+/// remain separately bound by their artifact references and resolved launch evidence.
+public struct DoryVMDesktopIdentityIntent: Codable, Sendable, Equatable {
+ public static let legacyDistributionEnvironmentKey = "DORY_DESKTOP_DISTRO"
+ public static let legacyDisplayNameEnvironmentKey = "DORY_DESKTOP_NAME"
+ public static let legacyVersionEnvironmentKey = "DORY_DESKTOP_VERSION"
+ public static let legacyDesktopEnvironmentKey = "DORY_DESKTOP_ENVIRONMENT"
+
+ public var distributionIdentifier: String?
+ public var displayName: String?
+ public var version: String?
+ public var desktopEnvironment: String?
+
+ public init(
+ distributionIdentifier: String? = nil,
+ displayName: String? = nil,
+ version: String? = nil,
+ desktopEnvironment: String? = nil
+ ) {
+ self.distributionIdentifier = distributionIdentifier
+ self.displayName = displayName
+ self.version = version
+ self.desktopEnvironment = desktopEnvironment
+ }
+
+ public var isEmpty: Bool {
+ distributionIdentifier == nil
+ && displayName == nil
+ && version == nil
+ && desktopEnvironment == nil
+ }
+
+ public var isValidForPersistence: Bool {
+ !isEmpty
+ && (distributionIdentifier.map(Self.isValidDistributionIdentifier) ?? true)
+ && (displayName.map(Self.isValidLabel) ?? true)
+ && (version.map(Self.isValidLabel) ?? true)
+ && (desktopEnvironment.map(Self.isValidLabel) ?? true)
+ }
+
+ public static func isValidDistributionIdentifier(_ value: String) -> Bool {
+ let bytes = Array(value.utf8)
+ guard (1...64).contains(bytes.count),
+ (bytes[0] >= 97 && bytes[0] <= 122) || (bytes[0] >= 48 && bytes[0] <= 57) else {
+ return false
+ }
+ return bytes.dropFirst().allSatisfy { byte in
+ (byte >= 97 && byte <= 122)
+ || (byte >= 48 && byte <= 57)
+ || byte == 95
+ || byte == 46
+ || byte == 45
+ }
+ }
+
+ public static func isValidLabel(_ value: String) -> Bool {
+ !value.isEmpty
+ && value.utf8.count <= 128
+ && value == value.trimmingCharacters(in: .whitespacesAndNewlines)
+ && !value.hasPrefix("/")
+ && !value.hasPrefix("~")
+ && !value.contains("://")
+ && !value.contains("/")
+ && !value.contains("\\")
+ && !value.unicodeScalars.contains(where: {
+ CharacterSet.controlCharacters.contains($0)
+ })
+ }
+}
+
+/// Guest-visible identity intent owned by the workspace definition rather than an opaque
+/// environment dictionary.
+public struct DoryVMGuestIdentityIntent: Codable, Sendable, Equatable {
+ public var account: DoryVMGuestAccountIntent?
+ public var desktop: DoryVMDesktopIdentityIntent?
+
+ public init(
+ account: DoryVMGuestAccountIntent? = nil,
+ desktop: DoryVMDesktopIdentityIntent? = nil
+ ) {
+ self.account = account
+ self.desktop = desktop
+ }
+
+ public static let unspecified = DoryVMGuestIdentityIntent()
+
+ public var isEmpty: Bool {
+ (account?.isEmpty ?? true) && (desktop?.isEmpty ?? true)
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
index 3df0347a..34edd429 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
@@ -235,6 +235,8 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable {
var environment = authoritativeLegacyConfiguration.environment
try applyBackendPreference(to: &environment)
try applyGraphicsPolicy(to: &environment)
+ try applyGuestIdentityIntent(to: &environment)
+ try applyClipboardPolicy(to: &environment)
let shares = try definition.shares.map { share -> DoryMachineShareConfiguration in
guard let hostPath = hostPath(for: share.hostLocation) else {
@@ -318,6 +320,79 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable {
environment[DoryDesktopGraphicsPreference.environmentKey] = preference.rawValue
environment.removeValue(forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey)
}
+
+ private func applyGuestIdentityIntent(to environment: inout [String: String]) throws {
+ guard definition.guestIdentityIntent != baselineDefinition.guestIdentityIntent else {
+ return
+ }
+ guard definition.guest.family == .linux else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange(
+ "guestIdentityIntent"
+ )
+ }
+ Self.assignIfChanged(
+ definition.guestIdentityIntent.account?.username,
+ baseline: baselineDefinition.guestIdentityIntent.account?.username,
+ key: DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey,
+ to: &environment
+ )
+ Self.assignIfChanged(
+ definition.guestIdentityIntent.account?.numericUserID.map(String.init),
+ baseline: baselineDefinition.guestIdentityIntent.account?.numericUserID.map(String.init),
+ key: DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey,
+ to: &environment
+ )
+ Self.assignIfChanged(
+ definition.guestIdentityIntent.desktop?.distributionIdentifier,
+ baseline: baselineDefinition.guestIdentityIntent.desktop?.distributionIdentifier,
+ key: DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey,
+ to: &environment
+ )
+ Self.assignIfChanged(
+ definition.guestIdentityIntent.desktop?.displayName,
+ baseline: baselineDefinition.guestIdentityIntent.desktop?.displayName,
+ key: DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey,
+ to: &environment
+ )
+ Self.assignIfChanged(
+ definition.guestIdentityIntent.desktop?.version,
+ baseline: baselineDefinition.guestIdentityIntent.desktop?.version,
+ key: DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey,
+ to: &environment
+ )
+ Self.assignIfChanged(
+ definition.guestIdentityIntent.desktop?.desktopEnvironment,
+ baseline: baselineDefinition.guestIdentityIntent.desktop?.desktopEnvironment,
+ key: DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey,
+ to: &environment
+ )
+ }
+
+ private func applyClipboardPolicy(to environment: inout [String: String]) throws {
+ guard definition.clipboardPolicy != baselineDefinition.clipboardPolicy else { return }
+ guard definition.clipboardPolicy.text == definition.clipboardPolicy.image,
+ definition.clipboardPolicy.files == .off else {
+ throw DoryMachineConfigurationMigrationError.unsupportedDefinitionChange(
+ "clipboardPolicy"
+ )
+ }
+ environment[DoryDesktopClipboardPolicy.environmentKey]
+ = definition.clipboardPolicy.text.rawValue
+ }
+
+ private static func assignIfChanged(
+ _ value: String?,
+ baseline: String?,
+ key: String,
+ to environment: inout [String: String]
+ ) {
+ guard value != baseline else { return }
+ if let value {
+ environment[key] = value
+ } else {
+ environment.removeValue(forKey: key)
+ }
+ }
}
/// Pure migration entry points. MachineManager remains the legacy persistence owner; this bridge
@@ -503,6 +578,20 @@ public enum DoryMachineConfigurationMigrationBridge {
}
let isDesktop = configuration.displayMode == .desktop
+ let guestIdentityIntent = typedGuestIdentityIntent(
+ configuration.environment,
+ includeDesktop: isDesktop
+ )
+ let clipboardPolicy: DoryVMClipboardPolicy
+ if isDesktop {
+ let legacyDirection = DoryVMClipboardDirection(
+ rawValue: configuration.environment[DoryDesktopClipboardPolicy.environmentKey]
+ ?? DoryVMClipboardDirection.bidirectional.rawValue
+ ) ?? .off
+ clipboardPolicy = .legacyDesktop(legacyDirection)
+ } else {
+ clipboardPolicy = .disabled
+ }
let acceleratedBoot = bootContract == .managedDirectKernel
|| bootContract == .efiInstalledDirectBoot
let backendPreference: DoryVMBackendPreference
@@ -555,6 +644,8 @@ public enum DoryMachineConfigurationMigrationBridge {
integrations: isDesktop
? [.clipboard, .clockSynchronization, .dynamicDisplay, .gracefulShutdown]
: [.clockSynchronization, .gracefulShutdown],
+ guestIdentityIntent: guestIdentityIntent,
+ clipboardPolicy: clipboardPolicy,
lifecycle: facts.lifecycle
)
let issues = definition.validate()
@@ -570,6 +661,42 @@ public enum DoryMachineConfigurationMigrationBridge {
)
}
+ private static func typedGuestIdentityIntent(
+ _ environment: [String: String],
+ includeDesktop: Bool
+ ) -> DoryVMGuestIdentityIntent {
+ let username = environment[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey]
+ .flatMap { DoryVMGuestAccountIntent.isValidUsername($0) ? $0 : nil }
+ let numericUserID = environment[DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey]
+ .flatMap(UInt32.init)
+ .flatMap { DoryVMGuestAccountIntent.isValidNumericUserID($0) ? $0 : nil }
+ let accountCandidate = DoryVMGuestAccountIntent(
+ username: username,
+ numericUserID: numericUserID
+ )
+ let account = accountCandidate.isValidForPersistence ? accountCandidate : nil
+
+ let distributionIdentifier = environment[
+ DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey
+ ].flatMap {
+ DoryVMDesktopIdentityIntent.isValidDistributionIdentifier($0) ? $0 : nil
+ }
+ func safeLabel(_ key: String) -> String? {
+ environment[key].flatMap { DoryVMDesktopIdentityIntent.isValidLabel($0) ? $0 : nil }
+ }
+ let desktopCandidate = DoryVMDesktopIdentityIntent(
+ distributionIdentifier: distributionIdentifier,
+ displayName: safeLabel(DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey),
+ version: safeLabel(DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey),
+ desktopEnvironment: safeLabel(
+ DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey
+ )
+ )
+ let desktop = includeDesktop && desktopCandidate.isValidForPersistence
+ ? desktopCandidate : nil
+ return DoryVMGuestIdentityIntent(account: account, desktop: desktop)
+ }
+
private static func legacyBootContract(
_ configuration: DoryMachineConfiguration,
facts: DoryMachineConfigurationMigrationFacts
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
index 37625101..5fcfea08 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
@@ -23,11 +23,129 @@ struct DoryVirtualMachineDefinitionTests {
#expect(json.contains("\"virtualHardwareABIVersion\":1"))
#expect(json.contains("\"createdAtUnixMilliseconds\":1787200000000"))
#expect(json.contains("\"namespace\":\"boot\""))
+ #expect(json.contains("\"clipboardPolicy\""))
+ #expect(json.contains("\"guestIdentityIntent\""))
#expect(!json.contains("\"bootMedia\""))
#expect(!json.contains("artifactID"))
#expect(!json.contains("hostLocationID"))
}
+ @Test("schema 2 records written before typed guest intent retain compatibility defaults")
+ func additiveSchemaTwoMigration() throws {
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(linuxDefinition())
+ var object = try #require(
+ JSONSerialization.jsonObject(with: data) as? [String: Any]
+ )
+ object.removeValue(forKey: "guestIdentityIntent")
+ object.removeValue(forKey: "clipboardPolicy")
+ let oldSchemaTwo = try JSONSerialization.data(withJSONObject: object)
+
+ let decoded = try JSONDecoder().decode(
+ DoryVirtualMachineDefinition.self,
+ from: oldSchemaTwo
+ )
+ #expect(decoded.guestIdentityIntent == .unspecified)
+ #expect(decoded.clipboardPolicy == .legacyDesktop(.bidirectional))
+ #expect(decoded.isValid)
+ }
+
+ @Test("guest identity and clipboard policies are bounded typed intent")
+ func guestIdentityAndClipboardValidation() {
+ var definition = linuxDefinition()
+ definition.guestIdentityIntent = DoryVMGuestIdentityIntent(
+ account: DoryVMGuestAccountIntent(username: "developer", numericUserID: 1_000),
+ desktop: DoryVMDesktopIdentityIntent(
+ distributionIdentifier: "ubuntu",
+ displayName: "Ubuntu",
+ version: "24.04 LTS",
+ desktopEnvironment: "GNOME"
+ )
+ )
+ definition.clipboardPolicy = DoryVMClipboardPolicy(
+ text: .hostToGuest,
+ image: .guestToHost,
+ files: .off
+ )
+ #expect(definition.isValid)
+ #expect(definition.clipboardPolicy.text.allowsHostToGuest)
+ #expect(!definition.clipboardPolicy.text.allowsGuestToHost)
+
+ definition.guestIdentityIntent.account?.username = "../host"
+ #expect(has(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.account.username",
+ in: definition.validate()
+ ))
+ definition.guestIdentityIntent.account?.username = "developer"
+ definition.guestIdentityIntent.account?.numericUserID = 99
+ #expect(has(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.account.numericUserID",
+ in: definition.validate()
+ ))
+ definition.guestIdentityIntent.account?.numericUserID = 100
+ #expect(!has(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.account.numericUserID",
+ in: definition.validate()
+ ))
+ definition.guestIdentityIntent.account?.numericUserID = 60_000
+ #expect(!has(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.account.numericUserID",
+ in: definition.validate()
+ ))
+ definition.guestIdentityIntent.account?.numericUserID = 60_001
+ #expect(has(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.account.numericUserID",
+ in: definition.validate()
+ ))
+ definition.guestIdentityIntent.account?.numericUserID = 1_000
+ definition.guestIdentityIntent.desktop?.distributionIdentifier = "Ubuntu/../../host"
+ #expect(has(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.desktop.distributionIdentifier",
+ in: definition.validate()
+ ))
+ definition.guestIdentityIntent.desktop?.distributionIdentifier = "ubuntu"
+ definition.guestIdentityIntent.desktop?.version = "https://token.invalid/secret"
+ #expect(has(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.desktop.version",
+ in: definition.validate()
+ ))
+
+ definition.guestIdentityIntent.desktop?.version = "a"
+ + String(repeating: "\u{0301}", count: 128)
+ #expect(has(
+ .invalidGuestIdentityIntent,
+ "guestIdentityIntent.desktop.version",
+ in: definition.validate()
+ ))
+
+ definition.guestIdentityIntent.desktop?.version = "24.04"
+ definition.integrations.removeAll { $0 == .clipboard }
+ #expect(has(
+ .clipboardPolicyRequiresIntegration,
+ "clipboardPolicy",
+ in: definition.validate()
+ ))
+
+ for family in [DoryGuestFamily.windows, .macOS] {
+ var nonLinux = installerDefinition(family: family)
+ nonLinux.guestIdentityIntent = DoryVMGuestIdentityIntent(
+ account: DoryVMGuestAccountIntent(username: "developer", numericUserID: 1_000)
+ )
+ #expect(has(
+ .guestIdentityIncompatibleWithGuest,
+ "guestIdentityIntent",
+ in: nonLinux.validate()
+ ))
+ }
+ }
+
@Test("oldest schema 1 golden JSON migrates deterministically")
func oldestSchemaMigration() throws {
let golden = Data(#"""
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
index 177db8e6..569b4c6a 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
@@ -27,6 +27,12 @@ struct DoryMachineConfigurationMigrationTests {
DoryDesktopVMMPreference.environmentKey: "accelerated",
DoryDesktopGraphicsPreference.environmentKey: "virgl",
"DORY_DESKTOP_DISTRO": "ubuntu",
+ "DORY_DESKTOP_NAME": "Ubuntu",
+ "DORY_DESKTOP_VERSION": "24.04",
+ "DORY_DESKTOP_ENVIRONMENT": "GNOME",
+ "DORY_GUEST_USER": "developer",
+ "DORY_GUEST_UID": "1000",
+ "DORY_CLIPBOARD_POLICY": "host-to-guest",
"PRIVATE_RUNTIME_VALUE": "do-not-copy-into-workspace",
]
)
@@ -46,6 +52,17 @@ struct DoryMachineConfigurationMigrationTests {
#expect(migrated.definition.resources.memoryBytes == 8 * gibibyte)
#expect(migrated.definition.resources.virtualCPUCount == 6)
#expect(migrated.definition.storage[0].capacityBytes == 96 * gibibyte)
+ #expect(migrated.definition.guestIdentityIntent.account == DoryVMGuestAccountIntent(
+ username: "developer",
+ numericUserID: 1_000
+ ))
+ #expect(migrated.definition.guestIdentityIntent.desktop == DoryVMDesktopIdentityIntent(
+ distributionIdentifier: "ubuntu",
+ displayName: "Ubuntu",
+ version: "24.04",
+ desktopEnvironment: "GNOME"
+ ))
+ #expect(migrated.definition.clipboardPolicy == .legacyDesktop(.hostToGuest))
#expect(migrated.hostPath(for: migrated.definition.shares[0].hostLocation)
== "/Users/developer/Source")
#expect(migrated.artifactPath(for: migrated.definition.storage[0].artifact)
@@ -56,6 +73,8 @@ struct DoryMachineConfigurationMigrationTests {
#expect(!definitionJSON.contains("/Users/developer"))
#expect(!definitionJSON.contains("PRIVATE_RUNTIME_VALUE"))
#expect(!definitionJSON.contains("do-not-copy-into-workspace"))
+ #expect(!definitionJSON.contains("DORY_GUEST_USER"))
+ #expect(!definitionJSON.contains("DORY_CLIPBOARD_POLICY"))
#expect(try migrated.legacyConfiguration() == legacy)
#expect(try migrated.authoritativeLegacyData()
@@ -69,6 +88,115 @@ struct DoryMachineConfigurationMigrationTests {
== migrated.definition.shares[0].hostLocation)
}
+ @Test("typed guest and clipboard edits back-project through legacy compatibility keys")
+ func typedGuestIntentBackProjection() throws {
+ let legacy = DoryMachineConfiguration(
+ id: "desktop-intent",
+ kernelPath: "/managed/desktop-intent/kernel",
+ rootfsPath: "/managed/desktop-intent/rootfs.ext4",
+ displayMode: .desktop,
+ environment: ["PRESERVE": "yes"]
+ )
+ var migrated = try migrate(legacy, capacity: 64 * gibibyte)
+ migrated.definition.guestIdentityIntent = DoryVMGuestIdentityIntent(
+ account: DoryVMGuestAccountIntent(username: "builder", numericUserID: 1_001),
+ desktop: DoryVMDesktopIdentityIntent(
+ distributionIdentifier: "ubuntu",
+ displayName: "Ubuntu",
+ version: "24.04",
+ desktopEnvironment: "GNOME"
+ )
+ )
+ migrated.definition.clipboardPolicy = .legacyDesktop(.guestToHost)
+
+ let projected = try migrated.legacyConfiguration()
+ #expect(projected.environment["DORY_GUEST_USER"] == "builder")
+ #expect(projected.environment["DORY_GUEST_UID"] == "1001")
+ #expect(projected.environment["DORY_DESKTOP_DISTRO"] == "ubuntu")
+ #expect(projected.environment["DORY_DESKTOP_NAME"] == "Ubuntu")
+ #expect(projected.environment["DORY_DESKTOP_VERSION"] == "24.04")
+ #expect(projected.environment["DORY_DESKTOP_ENVIRONMENT"] == "GNOME")
+ #expect(projected.environment["DORY_CLIPBOARD_POLICY"] == "guest-to-host")
+ #expect(projected.environment["PRESERVE"] == "yes")
+
+ migrated.definition.clipboardPolicy = DoryVMClipboardPolicy(
+ text: .hostToGuest,
+ image: .guestToHost,
+ files: .off
+ )
+ #expect(throws: DoryMachineConfigurationMigrationError.unsupportedDefinitionChange(
+ "clipboardPolicy"
+ )) {
+ try migrated.legacyConfiguration()
+ }
+ }
+
+ @Test("unsafe legacy reserved values remain only in authoritative legacy bytes")
+ func unsafeReservedValuesDoNotLeak() throws {
+ let legacy = DoryMachineConfiguration(
+ id: "legacy-hostile",
+ kernelPath: "/managed/legacy-hostile/kernel",
+ rootfsPath: "/managed/legacy-hostile/rootfs.ext4",
+ displayMode: .desktop,
+ environment: [
+ "DORY_GUEST_USER": "../../host",
+ "DORY_GUEST_UID": "not-a-uid",
+ "DORY_DESKTOP_DISTRO": "https://token.invalid/secret",
+ "DORY_DESKTOP_VERSION": "secret\nvalue",
+ "DORY_CLIPBOARD_POLICY": "invalid-widening-value",
+ ]
+ )
+ let migrated = try migrate(legacy, capacity: 64 * gibibyte)
+ #expect(migrated.definition.guestIdentityIntent == .unspecified)
+ #expect(migrated.definition.clipboardPolicy == .legacyDesktop(.off))
+ #expect(migrated.definition.isValid)
+
+ let data = try JSONEncoder().encode(migrated.definition)
+ let json = try #require(String(data: data, encoding: .utf8))
+ #expect(!json.contains("../../host"))
+ #expect(!json.contains("token.invalid"))
+ #expect(!json.contains("secret\\nvalue"))
+ #expect(try migrated.legacyConfiguration() == legacy)
+
+ var edited = migrated
+ edited.definition.guestIdentityIntent.desktop = DoryVMDesktopIdentityIntent(
+ distributionIdentifier: "ubuntu",
+ displayName: "Ubuntu",
+ version: "24.04",
+ desktopEnvironment: "GNOME"
+ )
+ let projected = try edited.legacyConfiguration()
+ #expect(projected.environment["DORY_GUEST_USER"] == "../../host")
+ #expect(projected.environment["DORY_GUEST_UID"] == "not-a-uid")
+ #expect(projected.environment["DORY_DESKTOP_DISTRO"] == "ubuntu")
+ #expect(projected.environment["DORY_DESKTOP_VERSION"] == "24.04")
+ }
+
+ @Test("legacy UID migration accepts exactly the guest provisioning range")
+ func guestUIDMigrationBoundaries() throws {
+ for (raw, expected) in [
+ ("99", nil),
+ ("100", UInt32(100)),
+ ("60000", UInt32(60_000)),
+ ("60001", nil),
+ (String(UInt32.max), nil),
+ ] {
+ let legacy = DoryMachineConfiguration(
+ id: "uid-\(raw)",
+ kernelPath: "/managed/uid-\(raw)/kernel",
+ rootfsPath: "/managed/uid-\(raw)/rootfs.ext4",
+ displayMode: .desktop,
+ environment: [
+ "DORY_GUEST_USER": "developer",
+ "DORY_GUEST_UID": raw,
+ ]
+ )
+ let migrated = try migrate(legacy, capacity: 64 * gibibyte)
+ #expect(migrated.definition.guestIdentityIntent.account?.numericUserID == expected)
+ #expect(try migrated.legacyConfiguration() == legacy)
+ }
+ }
+
@Test("headless Linux maps to an explicit non-graphical Virtualization.framework contract")
func headlessRoundTrip() throws {
let legacy = DoryMachineConfiguration(
@@ -78,7 +206,10 @@ struct DoryMachineConfigurationMigrationTests {
memoryMB: 4_096,
cpuCount: 4,
displayMode: .headless,
- environment: ["SERVICE_TOKEN": "legacy-only-value"]
+ environment: [
+ "SERVICE_TOKEN": "legacy-only-value",
+ "DORY_DESKTOP_DISTRO": "stale-desktop-metadata",
+ ]
)
let migrated = try migrate(legacy, capacity: 48 * gibibyte)
@@ -90,6 +221,7 @@ struct DoryMachineConfigurationMigrationTests {
#expect(!migrated.definition.audio.outputEnabled)
#expect(!migrated.definition.input.keyboardEnabled)
#expect(!migrated.definition.input.pointerEnabled)
+ #expect(migrated.definition.guestIdentityIntent.desktop == nil)
#expect(try migrated.legacyConfiguration() == legacy)
}
From b7e9310a3c175b4a0bcbe41ba39e69aff0553f80 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 15:23:25 +0000
Subject: [PATCH 029/338] test(vm): gate real scheduled backup recovery
---
.../MachineBackupSchedulerTests.swift | 120 ++++++++++++++++++
1 file changed, 120 insertions(+)
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackupSchedulerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackupSchedulerTests.swift
index 3036812a..7b96c839 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineBackupSchedulerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineBackupSchedulerTests.swift
@@ -1,7 +1,127 @@
+import DoryOperations
@testable import DorydKit
import XCTest
final class MachineBackupSchedulerTests: XCTestCase {
+ func testRealMachineManagerBackupRoundTripsBundleABIAndBootVerifies() throws {
+ let base = NSTemporaryDirectory()
+ + "dory-real-machine-backup-\(getpid())-\(UUID().uuidString)"
+ try FileManager.default.createDirectory(atPath: base, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: base) }
+ let kernel = base + "/kernel"
+ let rootfs = base + "/rootfs.ext4"
+ try Data("kernel-v1".utf8).write(to: URL(fileURLWithPath: kernel))
+ try Data("rootfs-v1".utf8).write(to: URL(fileURLWithPath: rootfs))
+ let manager = MachineManager(configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ stateDirectory: base + "/machines",
+ baseArguments: ["30"],
+ passMachineArguments: false,
+ requiresReadyHandoff: false
+ ))
+ defer { try? manager.delete(id: "dev") }
+ let created = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: kernel,
+ rootfsPath: rootfs,
+ memoryMB: 2_048,
+ cpuCount: 2
+ ))
+ XCTAssertEqual(created.runtimeIdentity.mode, .legacyCompatibility)
+ XCTAssertEqual(
+ created.runtimeIdentity.virtualHardwareABIVersion,
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ )
+
+ let scheduler = try MachineBackupScheduler(
+ machines: manager,
+ rootDirectory: base + "/backups",
+ now: { Date(timeIntervalSince1970: 1_783_392_000) }
+ )
+ _ = try scheduler.upsert(DoryMachineBackupSchedule(
+ machineID: "dev",
+ frequency: .daily,
+ keepLocal: 1,
+ verifyEveryRuns: 1
+ ))
+ let completed = try scheduler.runNow(machineID: "dev")
+
+ XCTAssertEqual(completed.successfulRuns, 1)
+ XCTAssertEqual(completed.retainedSnapshots, 1)
+ XCTAssertEqual(completed.retainedArchives, 1)
+ XCTAssertNotNil(completed.lastBootVerificationISO)
+ let archive = try XCTUnwrap(completed.lastArchivePath)
+ XCTAssertEqual(
+ try Data(contentsOf: URL(fileURLWithPath: archive))
+ .prefix(Data("DORYMACHINE3\n".utf8).count),
+ Data("DORYMACHINE3\n".utf8)
+ )
+ XCTAssertEqual(manager.list().map(\.id), ["dev"])
+ let retained = try XCTUnwrap(manager.listSnapshots(machineID: "dev").first)
+ XCTAssertEqual(retained.runtimeIdentity, created.runtimeIdentity)
+
+ let imported = try manager.importSnapshot(fromPath: archive)
+ defer { try? manager.deleteSnapshot(machineID: imported.machineID, snapshotID: imported.id) }
+ XCTAssertEqual(imported.runtimeIdentity, created.runtimeIdentity)
+ XCTAssertEqual(
+ imported.runtimeIdentity.virtualHardwareABIVersion,
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ )
+ XCTAssertEqual(
+ try String(contentsOfFile: imported.rootfsPath, encoding: .utf8),
+ "rootfs-v1"
+ )
+ XCTAssertEqual(
+ try String(contentsOfFile: imported.kernelPath, encoding: .utf8),
+ "kernel-v1"
+ )
+ }
+
+ func testRealMachineManagerBackupRollsBackWhenDisposableBootFails() throws {
+ let base = NSTemporaryDirectory()
+ + "dory-real-machine-backup-failure-\(getpid())-\(UUID().uuidString)"
+ try FileManager.default.createDirectory(atPath: base, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: base) }
+ let kernel = base + "/kernel"
+ let rootfs = base + "/rootfs.ext4"
+ try Data("kernel-v1".utf8).write(to: URL(fileURLWithPath: kernel))
+ try Data("rootfs-v1".utf8).write(to: URL(fileURLWithPath: rootfs))
+ let manager = MachineManager(configuration: MachineManagerConfiguration(
+ vmmExecutablePath: base + "/missing-vmm",
+ stateDirectory: base + "/machines",
+ passMachineArguments: false,
+ requiresReadyHandoff: false
+ ))
+ defer { try? manager.delete(id: "dev") }
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "dev",
+ kernelPath: kernel,
+ rootfsPath: rootfs
+ ))
+ let scheduler = try MachineBackupScheduler(
+ machines: manager,
+ rootDirectory: base + "/backups",
+ now: { Date(timeIntervalSince1970: 1_783_392_000) }
+ )
+ _ = try scheduler.upsert(DoryMachineBackupSchedule(
+ machineID: "dev",
+ keepLocal: 1,
+ verifyEveryRuns: 1
+ ))
+
+ XCTAssertThrowsError(try scheduler.runNow(machineID: "dev"))
+ let failed = try XCTUnwrap(scheduler.list().first)
+ XCTAssertEqual(failed.successfulRuns, 0)
+ XCTAssertEqual(failed.consecutiveFailures, 1)
+ XCTAssertNotNil(failed.lastError)
+ XCTAssertTrue(try manager.listSnapshots(machineID: "dev").isEmpty)
+ XCTAssertEqual(manager.list().map(\.id), ["dev"])
+ let archiveDirectory = base + "/backups/archives/dev"
+ let archives = (try? FileManager.default.contentsOfDirectory(atPath: archiveDirectory)) ?? []
+ XCTAssertTrue(archives.filter { $0.hasSuffix(".dorymachine") }.isEmpty)
+ XCTAssertFalse(archives.contains { $0.hasSuffix(".partial") })
+ }
+
func testSchedulePersistsAndReloadsWithOwnerOnlyState() throws {
let fixture = try Fixture()
defer { fixture.cleanup() }
From 4ab5f18f2de79d5bd0bf85e64ac7e7479bdc987b Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 15:57:31 +0000
Subject: [PATCH 030/338] feat(vm): enforce production launch trust
---
.../DoryInstalledLinuxBootBundle.swift | 41 +
...yVirtualMachineQualificationManifest.swift | 4 +
...emonVirtualMachineLaunchPlanResolver.swift | 101 +-
...yDaemonVirtualMachineProductionTrust.swift | 1159 +++++++++++++++++
...yDaemonVirtualMachineRuntimePlanning.swift | 8 +-
.../DorydKit/DoryResolvedMachinePlan.swift | 2 +-
.../Sources/DorydKit/DorydXPCSecurity.swift | 30 +
.../Sources/DorydKit/MachineManager.swift | 26 +-
dory-core-swift/Sources/doryd/main.swift | 48 +-
...onVirtualMachineProductionTrustTests.swift | 861 ++++++++++++
...eManagerResolvedPlanIntegrationTests.swift | 84 +-
11 files changed, 2334 insertions(+), 30 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
diff --git a/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift b/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift
index 8cbdfdda..60d5bc89 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryInstalledLinuxBootBundle.swift
@@ -94,6 +94,32 @@ public enum DoryInstalledLinuxBootBundle {
return try readHeader(from: input).descriptor
}
+ /// Verifies the complete immutable bundle without materializing guest boot files. Reading the
+ /// header alone is insufficient at a daemon trust boundary because the embedded kernel or
+ /// initrd may have changed independently of their declared lengths.
+ @discardableResult
+ public static func verifyContents(
+ atPath path: String
+ ) throws -> DoryInstalledLinuxBootDescriptor {
+ let input = try openForReading(path)
+ defer { try? input.close() }
+ let header = try readHeader(from: input)
+ try input.seek(toOffset: header.kernelOffset)
+ let kernelDigest = try hashExactly(
+ from: input,
+ byteCount: header.descriptor.kernelLength
+ )
+ let initrdDigest = try hashExactly(
+ from: input,
+ byteCount: header.descriptor.initrdLength
+ )
+ guard kernelDigest == header.kernelDigest,
+ initrdDigest == header.initrdDigest else {
+ throw DoryInstalledLinuxBootBundleError.digestMismatch
+ }
+ return header.descriptor
+ }
+
@discardableResult
public static func materialize(
fromPath path: String,
@@ -245,6 +271,21 @@ public enum DoryInstalledLinuxBootBundle {
return Data(hasher.finalize())
}
+ private static func hashExactly(
+ from input: FileHandle,
+ byteCount: UInt64
+ ) throws -> Data {
+ var remaining = byteCount
+ var hasher = SHA256()
+ while remaining > 0 {
+ let count = Int(min(UInt64(copyChunkBytes), remaining))
+ let data = try readExactly(from: input, count: count)
+ hasher.update(data: data)
+ remaining -= UInt64(data.count)
+ }
+ return Data(hasher.finalize())
+ }
+
private static func readExactly(from input: FileHandle, count: Int) throws -> Data {
var result = Data()
while result.count < count {
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
index 9cd55f76..8267611f 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
@@ -140,6 +140,7 @@ public enum DoryVirtualMachineQualificationAuthorityError:
public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable {
public let catalogDigest: String
public let catalogReleaseVersion: String
+ public let catalogGeneratedAt: String
public let componentIdentifier: String
public let componentVersion: String
public let manifestSHA256: String
@@ -151,6 +152,7 @@ public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable {
fileprivate init(
catalogDigest: String,
catalogReleaseVersion: String,
+ catalogGeneratedAt: String,
componentIdentifier: String,
componentVersion: String,
manifestSHA256: String,
@@ -160,6 +162,7 @@ public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable {
) {
self.catalogDigest = catalogDigest
self.catalogReleaseVersion = catalogReleaseVersion
+ self.catalogGeneratedAt = catalogGeneratedAt
self.componentIdentifier = componentIdentifier
self.componentVersion = componentVersion
self.manifestSHA256 = manifestSHA256
@@ -344,6 +347,7 @@ public enum DoryVirtualMachineQualificationAuthorityResolver {
return DoryVerifiedVirtualMachineQualificationAuthority(
catalogDigest: catalogDigest,
catalogReleaseVersion: cached.catalog.releaseVersion,
+ catalogGeneratedAt: cached.catalog.generatedAt,
componentIdentifier: installed.id.rawValue,
componentVersion: installed.version,
manifestSHA256: declaredAsset.installedSHA256,
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
index e24ba4f2..a174d606 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
@@ -1,6 +1,46 @@
import DoryOperations
import Foundation
+public enum DoryDaemonVirtualMachinePreSpawnAuthorizationError:
+ Error, Sendable, Equatable
+{
+ case alreadyConsumed
+ case revalidationFailed
+}
+
+/// Single-use daemon authorization for the last possible trust check before MachineManager reads
+/// path-derived boot arguments or starts the helper. It is non-Codable and cannot be supplied as
+/// API intent.
+public final class DoryDaemonVirtualMachinePreSpawnAuthorization: @unchecked Sendable {
+ private let lock = NSLock()
+ private var consumed = false
+ private let revalidate: @Sendable () throws -> Void
+
+ init(revalidate: @escaping @Sendable () throws -> Void) {
+ self.revalidate = revalidate
+ }
+
+ public func authorize() throws {
+ lock.lock()
+ guard !consumed else {
+ lock.unlock()
+ throw DoryDaemonVirtualMachinePreSpawnAuthorizationError.alreadyConsumed
+ }
+ consumed = true
+ lock.unlock()
+ do { try revalidate() }
+ catch {
+ throw DoryDaemonVirtualMachinePreSpawnAuthorizationError.revalidationFailed
+ }
+ }
+}
+
+protocol DoryDaemonVirtualMachinePreSpawnAuthorizationProviding: Sendable {
+ func preSpawnAuthorization(
+ for request: DoryDaemonVirtualMachineStartInventoryRequest
+ ) throws -> DoryDaemonVirtualMachinePreSpawnAuthorization
+}
+
public protocol DoryDaemonVirtualMachineExactCapabilityEvaluating: Sendable {
func evaluate(
_ request: DoryVirtualMachineCapabilityRequest,
@@ -33,13 +73,26 @@ public struct DoryAppleSiliconDaemonVirtualMachineExactCapabilityEvaluator:
public struct DoryDaemonVirtualMachineStartEvidenceCollection: Sendable, Equatable {
public var capability: DoryVirtualMachineCapabilityDescriptor
public var runtimeEvidence: DoryResolvedMachineRuntimeEvidence
+ public var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization?
public init(
capability: DoryVirtualMachineCapabilityDescriptor,
- runtimeEvidence: DoryResolvedMachineRuntimeEvidence
+ runtimeEvidence: DoryResolvedMachineRuntimeEvidence,
+ preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil
) {
self.capability = capability
self.runtimeEvidence = runtimeEvidence
+ self.preSpawnAuthorization = preSpawnAuthorization
+ }
+
+ public static func == (
+ lhs: DoryDaemonVirtualMachineStartEvidenceCollection,
+ rhs: DoryDaemonVirtualMachineStartEvidenceCollection
+ ) -> Bool {
+ lhs.capability == rhs.capability
+ && lhs.runtimeEvidence == rhs.runtimeEvidence
+ && ((lhs.preSpawnAuthorization == nil)
+ == (rhs.preSpawnAuthorization == nil))
}
}
@@ -56,6 +109,7 @@ public enum DoryDaemonVirtualMachineStartEvidenceFailureCode: String, Sendable,
case backendUnavailable = "backend-unavailable"
case backendInventoryUnavailable = "backend-inventory-unavailable"
case exactCapabilityUnavailable = "exact-capability-unavailable"
+ case preSpawnAuthorizationUnavailable = "pre-spawn-authorization-unavailable"
}
public struct DoryDaemonVirtualMachineStartEvidenceFailure: Error, Sendable, Equatable {
@@ -103,15 +157,17 @@ public final class DoryDaemonVirtualMachineStartEvidenceCollector:
devices: plan.devices,
virtualHardwareABIVersion: plan.virtualHardwareABIVersion
)
+ let inventoryRequest = DoryDaemonVirtualMachineStartInventoryRequest(
+ machineID: plan.machineID,
+ definitionRevision: plan.definitionRevision,
+ planRevision: plan.planRevision,
+ bootMediaReference: reference,
+ exactCapabilityRequest: persistedRequest,
+ resolvedPlan: plan
+ )
let snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot
do {
- snapshot = try inventory.startInventory(for: DoryDaemonVirtualMachineStartInventoryRequest(
- machineID: plan.machineID,
- definitionRevision: plan.definitionRevision,
- planRevision: plan.planRevision,
- bootMediaReference: reference,
- exactCapabilityRequest: persistedRequest
- ))
+ snapshot = try inventory.startInventory(for: inventoryRequest)
} catch {
throw failure(.inventoryUnavailable, "Fresh trusted start inventory could not be resolved.")
}
@@ -170,9 +226,28 @@ public final class DoryDaemonVirtualMachineStartEvidenceCollector:
hostQualification: runtime.hostQualification,
experimentalAuthorization: plan.experimentalAuthorization
)
+ guard let authorizationProvider = inventory
+ as? any DoryDaemonVirtualMachinePreSpawnAuthorizationProviding else {
+ throw failure(
+ .preSpawnAuthorizationUnavailable,
+ "The trusted inventory cannot authorize final pre-spawn revalidation."
+ )
+ }
+ let preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization
+ do {
+ preSpawnAuthorization = try authorizationProvider.preSpawnAuthorization(
+ for: inventoryRequest
+ )
+ } catch {
+ throw failure(
+ .preSpawnAuthorizationUnavailable,
+ "Final pre-spawn revalidation could not be prepared."
+ )
+ }
return DoryDaemonVirtualMachineStartEvidenceCollection(
capability: capability,
- runtimeEvidence: runtimeEvidence
+ runtimeEvidence: runtimeEvidence,
+ preSpawnAuthorization: preSpawnAuthorization
)
}
@@ -210,17 +285,20 @@ public struct DoryDaemonVirtualMachineLaunchPlanResolution: Sendable {
public var resolvedPlanSHA256: String
public var revalidation: DoryResolvedMachinePlanRevalidationResult
public var backendPlan: MachineBackendPlan
+ public var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization?
public init(
resolvedPlan: DoryResolvedMachinePlan,
resolvedPlanSHA256: String,
revalidation: DoryResolvedMachinePlanRevalidationResult,
- backendPlan: MachineBackendPlan
+ backendPlan: MachineBackendPlan,
+ preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil
) {
self.resolvedPlan = resolvedPlan
self.resolvedPlanSHA256 = resolvedPlanSHA256
self.revalidation = revalidation
self.backendPlan = backendPlan
+ self.preSpawnAuthorization = preSpawnAuthorization
}
}
@@ -358,7 +436,8 @@ public final class DoryDaemonVirtualMachineLaunchPlanResolver:
resolvedPlan: plan,
resolvedPlanSHA256: DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(plan),
revalidation: revalidation,
- backendPlan: backendPlan
+ backendPlan: backendPlan,
+ preSpawnAuthorization: fresh.preSpawnAuthorization
)
}
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
new file mode 100644
index 00000000..03862832
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
@@ -0,0 +1,1159 @@
+import CryptoKit
+import Darwin
+import DoryOperations
+import Foundation
+import Security
+
+public enum DoryDaemonVirtualMachineProductionTrustReadinessCode:
+ String, Sendable, Equatable
+{
+ case catalogUnavailable = "catalog-unavailable"
+ case catalogSchemaV1Migration = "catalog-schema-v1-migration"
+ case catalogSchemaUnsupported = "catalog-schema-unsupported"
+ case trustFloorViolated = "trust-floor-violated"
+ case planningTransactionUnavailable = "planning-transaction-unavailable"
+ case qualificationAuthorityUnavailable = "qualification-authority-unavailable"
+ case daemonSignatureUnavailable = "daemon-signature-unavailable"
+ case hostFactsUnavailable = "host-facts-unavailable"
+ case backendRuntimeUnavailable = "backend-runtime-unavailable"
+ case resourceAuthorityUnavailable = "resource-authority-unavailable"
+ case compositionFailed = "composition-failed"
+}
+
+public struct DoryDaemonVirtualMachineProductionTrustUnavailable:
+ Error, Sendable, Equatable
+{
+ public var code: DoryDaemonVirtualMachineProductionTrustReadinessCode
+ public var message: String
+ private var legacyCompatibilityMigrationPermitted: Bool
+
+ public init(
+ code: DoryDaemonVirtualMachineProductionTrustReadinessCode,
+ message: String
+ ) {
+ self.code = code
+ self.message = message
+ legacyCompatibilityMigrationPermitted = false
+ }
+
+ init(
+ code: DoryDaemonVirtualMachineProductionTrustReadinessCode,
+ message: String,
+ permitsLegacyCompatibilityMigration: Bool
+ ) {
+ self.code = code
+ self.message = message
+ legacyCompatibilityMigrationPermitted = permitsLegacyCompatibilityMigration
+ }
+
+ /// Only absence and the explicitly supported schema-v1 migration state may retain the
+ /// labeled compatibility path. Integrity, signer, runtime, host, ledger, and composition
+ /// failures must never turn into authorization to launch the rejected helper through legacy.
+ public var permitsLegacyCompatibilityMigration: Bool {
+ legacyCompatibilityMigrationPermitted
+ }
+}
+
+/// A ready result owns the exact inventory and launch composition installed into MachineManager.
+/// It is intentionally non-Codable: verified authorities, helper paths, and live ledger handles
+/// are daemon state and must never become API intent or a persisted trust assertion.
+public struct DoryDaemonVirtualMachineProductionTrustContext: Sendable {
+ public let machineManager: MachineManager
+ public let inventory: any DoryDaemonVirtualMachineTrustInventory
+ public let backendRuntimeBuildIdentifiers: [
+ DoryVirtualizationBackendIdentity: String
+ ]
+
+ init(
+ machineManager: MachineManager,
+ inventory: any DoryDaemonVirtualMachineTrustInventory,
+ backendRuntimeBuildIdentifiers: [
+ DoryVirtualizationBackendIdentity: String
+ ]
+ ) {
+ self.machineManager = machineManager
+ self.inventory = inventory
+ self.backendRuntimeBuildIdentifiers = backendRuntimeBuildIdentifiers
+ }
+}
+
+public enum DoryDaemonVirtualMachineProductionTrustReadiness: Sendable {
+ case ready(DoryDaemonVirtualMachineProductionTrustContext)
+ case unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable)
+}
+
+private enum DoryDaemonVirtualMachineProductionTrustFloor {
+ private static let fileName = ".vm-production-trust-floor-v1"
+ private static let lockFileName = ".vm-production-trust-floor.lock"
+
+ struct Record: Codable, Sendable, Equatable {
+ static let kind = "dev.dory.vm-production-trust-floor"
+ static let schemaVersion = 1
+
+ let kind: String
+ let schemaVersion: Int
+ let catalogReleaseVersion: String
+ let catalogGeneratedAt: String
+ let catalogDigest: String
+
+ init(
+ catalogReleaseVersion: String,
+ catalogGeneratedAt: String,
+ catalogDigest: String
+ ) {
+ kind = Self.kind
+ schemaVersion = Self.schemaVersion
+ self.catalogReleaseVersion = catalogReleaseVersion
+ self.catalogGeneratedAt = catalogGeneratedAt
+ self.catalogDigest = catalogDigest.lowercased()
+ }
+ }
+
+ static func read(stateDirectory: String) throws -> Record? {
+ let path = stateDirectory + "/" + fileName
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ if descriptor < 0 {
+ if errno == ENOENT { return nil }
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust-floor record cannot be opened."
+ )
+ }
+ defer { close(descriptor) }
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ info.st_mode & S_IFMT == S_IFREG,
+ info.st_size > 0, info.st_size <= 1_024,
+ info.st_nlink == 1,
+ info.st_uid == geteuid(),
+ info.st_mode & (S_IRWXG | S_IRWXO) == 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust-floor record is insecure or corrupt."
+ )
+ }
+ let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false)
+ let data = try handle.readToEnd() ?? Data()
+ guard data.count == info.st_size,
+ let record = try? JSONDecoder().decode(Record.self, from: data),
+ isValid(record) else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust-floor record is invalid."
+ )
+ }
+ return record
+ }
+
+ static func validate(
+ authority: DoryVerifiedVirtualMachineQualificationAuthority,
+ against record: Record?
+ ) throws {
+ let candidate = Record(
+ catalogReleaseVersion: authority.catalogReleaseVersion,
+ catalogGeneratedAt: authority.catalogGeneratedAt,
+ catalogDigest: authority.catalogDigest
+ )
+ guard isValid(candidate) else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "Verified catalog identity cannot establish the trust floor."
+ )
+ }
+ guard let record else { return }
+ guard let candidateTimestamp = timestamp(candidate.catalogGeneratedAt),
+ let recordTimestamp = timestamp(record.catalogGeneratedAt) else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "The accepted VM trust-floor timestamp is invalid."
+ )
+ }
+ let versionOrder = candidate.catalogReleaseVersion.compare(
+ record.catalogReleaseVersion,
+ options: .numeric
+ )
+ guard versionOrder != .orderedAscending,
+ candidateTimestamp >= recordTimestamp else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "The signed component catalog is older than the accepted VM trust floor."
+ )
+ }
+ if versionOrder == .orderedSame {
+ guard candidate == record else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "The signed component catalog conflicts with the accepted VM trust floor."
+ )
+ }
+ }
+ }
+
+ static func activate(
+ stateDirectory: String,
+ authority: DoryVerifiedVirtualMachineQualificationAuthority,
+ synchronizeDirectory: @Sendable (Int32) -> Bool
+ ) throws {
+ let candidate = Record(
+ catalogReleaseVersion: authority.catalogReleaseVersion,
+ catalogGeneratedAt: authority.catalogGeneratedAt,
+ catalogDigest: authority.catalogDigest
+ )
+ guard isValid(candidate) else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "Verified catalog identity cannot establish the trust floor."
+ )
+ }
+ let lock = try EngineStateDirectoryLock(
+ stateDirectory: stateDirectory,
+ lockFileName: lockFileName
+ )
+ defer { withExtendedLifetime(lock) {} }
+ let current = try read(stateDirectory: stateDirectory)
+ try validate(authority: authority, against: current)
+ if current == candidate {
+ return
+ }
+ let path = stateDirectory + "/" + fileName
+ let temporary = stateDirectory
+ + "/.\(fileName).\(UUID().uuidString.lowercased()).tmp"
+ let descriptor = open(
+ temporary,
+ O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
+ mode_t(0o600)
+ )
+ guard descriptor >= 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust floor cannot be persisted."
+ )
+ }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ let data = try encoder.encode(candidate) + Data("\n".utf8)
+ var descriptorOpen = true
+ do {
+ try data.withUnsafeBytes { bytes in
+ var offset = 0
+ while offset < bytes.count {
+ let count = Darwin.write(
+ descriptor,
+ bytes.baseAddress!.advanced(by: offset),
+ bytes.count - offset
+ )
+ guard count > 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust floor cannot be written."
+ )
+ }
+ offset += count
+ }
+ }
+ guard fsync(descriptor) == 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust floor cannot be synchronized."
+ )
+ }
+ close(descriptor)
+ descriptorOpen = false
+ guard rename(temporary, path) == 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust floor cannot be published."
+ )
+ }
+ let directory = open(stateDirectory, O_RDONLY | O_DIRECTORY | O_CLOEXEC)
+ guard directory >= 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust-floor directory cannot be opened."
+ )
+ }
+ guard synchronizeDirectory(directory) else {
+ close(directory)
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust-floor directory cannot be synchronized."
+ )
+ }
+ close(directory)
+ } catch {
+ if descriptorOpen { close(descriptor) }
+ unlink(temporary)
+ throw error
+ }
+ }
+
+ static func hasResolvedPlanState(stateDirectory: String) throws -> Bool {
+ let names: [String]
+ do { names = try FileManager.default.contentsOfDirectory(atPath: stateDirectory) }
+ catch {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM state cannot be inspected for existing resolved plans."
+ )
+ }
+ for name in names {
+ guard name.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil,
+ !name.hasPrefix(".") else {
+ continue
+ }
+ var directoryInfo = stat()
+ let directory = stateDirectory + "/" + name
+ guard lstat(directory, &directoryInfo) == 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM state entry cannot be inspected."
+ )
+ }
+ guard directoryInfo.st_mode & S_IFMT == S_IFDIR else { continue }
+ var planInfo = stat()
+ let plan = directory + "/" + DoryResolvedMachinePlanRepository.recordFileName
+ if lstat(plan, &planInfo) == 0 { return true }
+ guard errno == ENOENT else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "Existing resolved-plan state cannot be inspected."
+ )
+ }
+ }
+ return false
+ }
+
+ private static func isValid(_ record: Record) -> Bool {
+ record.kind == Record.kind
+ && record.schemaVersion == Record.schemaVersion
+ && validVersion(record.catalogReleaseVersion)
+ && timestamp(record.catalogGeneratedAt) != nil
+ && isSHA256(record.catalogDigest)
+ }
+
+ private static func validVersion(_ value: String) -> Bool {
+ !value.isEmpty && value.utf8.count <= 64 && value.utf8.allSatisfy { byte in
+ (48...57).contains(byte) || (65...90).contains(byte)
+ || (97...122).contains(byte) || byte == 43 || byte == 45
+ || byte == 46 || byte == 95
+ }
+ }
+
+ private static func timestamp(_ value: String) -> Date? {
+ let fractional = ISO8601DateFormatter()
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value)
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy { byte in
+ (48...57).contains(byte) || (65...70).contains(byte)
+ || (97...102).contains(byte)
+ }
+ }
+}
+
+struct DoryDaemonVerifiedBackendRuntime: Sendable, Equatable {
+ var descriptor: MachineBackendDescriptor
+ var executablePath: String
+ var runtimeBuildIdentifier: String
+ var components: [DoryVirtualMachineQualifiedComponent]
+
+ init(
+ descriptor: MachineBackendDescriptor,
+ executablePath: String,
+ runtimeBuildIdentifier: String,
+ components: [DoryVirtualMachineQualifiedComponent]
+ ) {
+ self.descriptor = descriptor
+ self.executablePath = executablePath
+ self.runtimeBuildIdentifier = runtimeBuildIdentifier
+ self.components = components.sorted { $0.componentIdentifier < $1.componentIdentifier }
+ }
+
+ var componentEvidence: [DoryResolvedBackendComponentEvidence] {
+ components.map {
+ DoryResolvedBackendComponentEvidence(
+ componentIdentifier: $0.componentIdentifier,
+ buildIdentifier: $0.buildIdentifier,
+ artifactSHA256: $0.artifactSHA256
+ )
+ }
+ }
+}
+
+struct DoryDaemonBackendRuntimeSpecification: Sendable, Equatable {
+ var descriptor: MachineBackendDescriptor
+ var executablePath: String
+ var componentIdentifier: String
+}
+
+struct DoryDaemonProductionHostObservation: Sendable, Equatable {
+ var hardwareModelIdentifier: String
+ var operatingSystemBuild: String
+ var macOSMajorVersion: Int
+ var virtualizationFrameworkAvailable: Bool
+ var hypervisorFrameworkAvailable: Bool
+ var metalAvailable: Bool
+ var resources: DoryVMHostResources
+}
+
+enum DoryDaemonProductionTrustInventoryError:
+ Error, Sendable, Equatable
+{
+ case planningRequiresAdmissionTransaction
+ case invalidRequest
+ case mediaUnavailable
+ case mediaInvalid
+ case backendUnavailable
+ case qualificationUnavailable
+ case resourceAdmissionUnavailable
+}
+
+/// Production inventory backed exclusively by opaque verified authorities. It deliberately does
+/// not implement admission reservation yet: the current coordinator has no atomic reserve -> plan
+/// bind -> publish transaction. Start-time verification is complete for previously published and
+/// bound plans; planning fails explicitly instead of returning invented admission evidence.
+final class DoryProductionDaemonVirtualMachineTrustInventory:
+ DoryDaemonVirtualMachineTrustInventory,
+ DoryDaemonVirtualMachinePreSpawnAuthorizationProviding,
+ @unchecked Sendable
+{
+ private let qualificationAuthority: DoryVerifiedVirtualMachineQualificationAuthority
+ private let artifactAuthority: DoryVirtualMachineArtifactAuthority
+ private let resourceLedger: DoryVirtualMachineResourceAdmissionLedger
+ private let stateDirectory: String
+ private let runtimeSpecifications: [
+ DoryVirtualizationBackendIdentity: DoryDaemonBackendRuntimeSpecification
+ ]
+ private let runtimeVerifier: DoryDaemonVirtualMachineProductionTrustFactory.RuntimeVerifier
+ private let hostProbe: DoryDaemonVirtualMachineProductionTrustFactory.HostProbe
+
+ init(
+ qualificationAuthority: DoryVerifiedVirtualMachineQualificationAuthority,
+ artifactAuthority: DoryVirtualMachineArtifactAuthority,
+ resourceLedger: DoryVirtualMachineResourceAdmissionLedger,
+ stateDirectory: String,
+ runtimeSpecifications: [DoryDaemonBackendRuntimeSpecification],
+ runtimeVerifier: @escaping DoryDaemonVirtualMachineProductionTrustFactory.RuntimeVerifier,
+ hostProbe: @escaping DoryDaemonVirtualMachineProductionTrustFactory.HostProbe
+ ) {
+ self.qualificationAuthority = qualificationAuthority
+ self.artifactAuthority = artifactAuthority
+ self.resourceLedger = resourceLedger
+ self.stateDirectory = stateDirectory
+ self.runtimeSpecifications = Dictionary(uniqueKeysWithValues: runtimeSpecifications.map {
+ ($0.descriptor.identity, $0)
+ })
+ self.runtimeVerifier = runtimeVerifier
+ self.hostProbe = hostProbe
+ }
+
+ func planningInventory(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot {
+ _ = request
+ throw DoryDaemonProductionTrustInventoryError
+ .planningRequiresAdmissionTransaction
+ }
+
+ func startInventory(
+ for request: DoryDaemonVirtualMachineStartInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot {
+ let plan = request.resolvedPlan
+ guard request.machineID == plan.machineID,
+ request.definitionRevision == plan.definitionRevision,
+ request.planRevision == plan.planRevision,
+ request.bootMediaReference == plan.bootMedia.resolverReference,
+ request.exactCapabilityRequest == DoryVirtualMachineCapabilityRequest(
+ guest: plan.guest,
+ bootMedia: plan.bootMedia.media,
+ backend: plan.backend,
+ graphics: plan.graphics,
+ devices: plan.devices,
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ ) else {
+ throw DoryDaemonProductionTrustInventoryError.invalidRequest
+ }
+ let host: DoryDaemonProductionHostObservation
+ do { host = try hostProbe(stateDirectory) }
+ catch { throw DoryDaemonProductionTrustInventoryError.invalidRequest }
+ guard let runtimeSpecification = runtimeSpecifications[plan.backend] else {
+ throw DoryDaemonProductionTrustInventoryError.backendUnavailable
+ }
+ let runtime: DoryDaemonVerifiedBackendRuntime
+ do {
+ runtime = try runtimeVerifier(
+ runtimeSpecification.executablePath,
+ runtimeSpecification.descriptor,
+ runtimeSpecification.componentIdentifier
+ )
+ } catch {
+ throw DoryDaemonProductionTrustInventoryError.backendUnavailable
+ }
+ guard runtime.descriptor.implementationIdentifier
+ == runtimeSpecification.descriptor.implementationIdentifier,
+ runtime.descriptor.implementationIdentifier
+ == plan.backendImplementationIdentifier,
+ runtime.runtimeBuildIdentifier
+ == plan.backendRuntimeBuildIdentifier else {
+ throw DoryDaemonProductionTrustInventoryError.backendUnavailable
+ }
+
+ let artifact: DoryVerifiedVirtualMachineArtifact
+ do {
+ artifact = try artifactAuthority.resolve(
+ reference: request.bootMediaReference,
+ kind: plan.bootMedia.media.kind,
+ source: plan.bootMedia.media.source
+ )
+ } catch {
+ throw DoryDaemonProductionTrustInventoryError.mediaUnavailable
+ }
+ guard artifact.media == request.exactCapabilityRequest.bootMedia else {
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+
+ let qualification: DoryResolvedTrustedVirtualMachineQualification
+ do {
+ qualification = try qualificationAuthority.resolve(
+ request: request.exactCapabilityRequest,
+ backendImplementationIdentifier:
+ runtime.descriptor.implementationIdentifier,
+ backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier,
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ installedComponents: runtime.components
+ )
+ } catch {
+ throw DoryDaemonProductionTrustInventoryError.qualificationUnavailable
+ }
+
+ let inspection: DoryTrustedBootMediaInspection?
+ switch artifact.media.kind {
+ case .installerISO:
+ do {
+ inspection = try DoryQualifiedBootMediaInspector.inspectInstallerISO(
+ atPath: artifact.path,
+ qualification: qualification
+ ).inspection
+ } catch {
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+ case .installedLinuxBootBundle:
+ do {
+ _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: artifact.path)
+ inspection = nil
+ } catch {
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+ case .virtualDisk:
+ guard artifact.mutableProvenance != nil else {
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+ inspection = nil
+ case .macOSRestoreImage:
+ // The current product has no daemon-owned VZMac restore-image inspector.
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+
+ let admission: DoryResolvedMachineResourceAdmissionEvidence
+ do {
+ let snapshot = try resourceLedger.snapshot()
+ let leases = snapshot.leases.filter {
+ $0.binding.machineID == plan.machineID
+ && $0.binding.definitionRevision == plan.definitionRevision
+ && $0.binding.definitionSHA256 == plan.definitionSHA256
+ && $0.binding.plannedPlanRevision == plan.planRevision
+ && $0.state == .starting
+ }
+ guard leases.count == 1, let lease = leases.first else {
+ throw DoryDaemonProductionTrustInventoryError
+ .resourceAdmissionUnavailable
+ }
+ admission = try resourceLedger.revalidateForStart(
+ leaseID: lease.leaseID,
+ plan: plan,
+ hostFacts: host.resources
+ )
+ } catch {
+ throw DoryDaemonProductionTrustInventoryError.resourceAdmissionUnavailable
+ }
+
+ let record = qualification.record
+ let hostQualification = DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: record.qualificationIdentity,
+ qualificationReportSHA256: Self.digest(Self.canonicalData(record)),
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ backend: record.backend,
+ backendRuntimeBuildIdentifier: record.backendRuntimeBuildIdentifier,
+ virtualHardwareABIVersion: record.virtualHardwareABIVersion,
+ qualifierIdentifier: "dory.catalog-v2.virtual-machine-qualification",
+ qualifierVersion: DoryVirtualMachineQualificationManifest.schemaVersion
+ )
+ let runtimeInventory = DoryDaemonVirtualMachineBackendRuntimeInventory(
+ backend: plan.backend,
+ runtimeBuildIdentifier: runtime.runtimeBuildIdentifier,
+ components: runtime.componentEvidence,
+ hostQualification: hostQualification
+ )
+ return DoryDaemonVirtualMachineTrustedInventorySnapshot(
+ hostFacts: hostFacts(host: host, runtime: runtime),
+ media: DoryDaemonVirtualMachineResolvedMedia(
+ reference: artifact.reference,
+ media: artifact.media,
+ guestGraphicsQualification: qualification.graphics,
+ bootInspection: inspection,
+ mutableProvenance: artifact.mutableProvenance
+ ),
+ backendRuntimes: [runtimeInventory],
+ resourceAdmission: admission,
+ exactStartRuntimeQualification: qualification.runtime
+ )
+ }
+
+ func preSpawnAuthorization(
+ for request: DoryDaemonVirtualMachineStartInventoryRequest
+ ) throws -> DoryDaemonVirtualMachinePreSpawnAuthorization {
+ DoryDaemonVirtualMachinePreSpawnAuthorization { [weak self] in
+ guard let self else {
+ throw DoryDaemonProductionTrustInventoryError.invalidRequest
+ }
+ // Repeats artifact hashing, structural media verification, helper signature/digest,
+ // host/resource probing, signed exact qualification, and bound-ledger validation.
+ _ = try self.startInventory(for: request)
+ }
+ }
+
+ private func hostFacts(
+ host: DoryDaemonProductionHostObservation,
+ runtime: DoryDaemonVerifiedBackendRuntime
+ ) -> DoryAppleSiliconHostFacts {
+ let rawBuild = runtime.descriptor.identity == .doryHypervisor
+ ? runtime.runtimeBuildIdentifier : ""
+ let vzBuild = runtime.descriptor.identity == .appleVirtualizationFramework
+ ? runtime.runtimeBuildIdentifier : ""
+ return DoryAppleSiliconHostFacts(
+ macOSMajorVersion: host.macOSMajorVersion,
+ virtualizationFrameworkAvailable:
+ host.virtualizationFrameworkAvailable && !vzBuild.isEmpty,
+ hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable,
+ doryHypervisorAvailable: !rawBuild.isEmpty,
+ qemuHypervisorFrameworkAvailable: false,
+ windowsUEFIFirmwareAvailable: false,
+ windowsSecureBootAvailable: false,
+ windowsSBSADeviceModelAvailable: false,
+ virtualTPM20Available: false,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: false,
+ networkAvailable: false,
+ displayAvailable: false,
+ inputAvailable: false
+ ),
+ macOSGuestVirtualizationSupported: false,
+ macOSRestoreImageInstallationSupported: false,
+ doryMacOSBackendAvailable: false,
+ doryMacOSBackendQualified: false,
+ metalAvailable: host.metalAvailable,
+ doryAcceleratedRendererAvailable:
+ host.metalAvailable && !rawBuild.isEmpty,
+ runtimeQualificationContext:
+ DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: rawBuild,
+ virtualizationFrameworkAdapterBuildID: vzBuild,
+ qemuRuntimeBuildID: ""
+ )
+ )
+ }
+
+ private static func canonicalData(_ value: T) -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return (try? encoder.encode(value)) ?? Data()
+ }
+
+ private static func digest(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+/// Resolves the production trust root before constructing a resolved-plan MachineManager. Every
+/// default dependency probes daemon-owned state. Internal injected dependencies exist solely for
+/// deterministic tests; the public entry point never accepts caller trust booleans.
+public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
+ typealias AuthorityResolver = @Sendable (
+ DoryComponentStore, String, String, String
+ ) throws -> DoryVerifiedVirtualMachineQualificationAuthority
+ typealias RuntimeVerifier = @Sendable (
+ String, MachineBackendDescriptor, String
+ ) throws -> DoryDaemonVerifiedBackendRuntime
+ typealias HostProbe = @Sendable (String) throws -> DoryDaemonProductionHostObservation
+ typealias DirectorySynchronizer = @Sendable (Int32) -> Bool
+
+ private let authorityResolver: AuthorityResolver
+ private let runtimeVerifier: RuntimeVerifier
+ private let hostProbe: HostProbe
+ private let daemonIdentityVerifier: @Sendable () -> Bool
+ private let planningTransactionAvailable: @Sendable () -> Bool
+ private let synchronizeTrustFloorDirectory: DirectorySynchronizer
+
+ /// Compiled into and covered by the daemon's code signature. Production catalog compatibility
+ /// must not trust an environment override or a mutable outer Info.plist.
+ public static let compiledDaemonVersion = "0.4.5"
+
+ public init() {
+ authorityResolver = { store, publicKey, architecture, appVersion in
+ try DoryVirtualMachineQualificationAuthorityResolver.resolve(
+ store: store,
+ publicKey: publicKey,
+ expectedArchitecture: architecture,
+ appVersion: appVersion
+ )
+ }
+ runtimeVerifier = Self.verifyProductionRuntime
+ hostProbe = Self.probeProductionHost
+ daemonIdentityVerifier = DorydXPCSecurity
+ .currentProcessSatisfiesProductionDaemonRequirement
+ planningTransactionAvailable = { false }
+ synchronizeTrustFloorDirectory = { fsync($0) == 0 }
+ }
+
+ init(
+ authorityResolver: @escaping AuthorityResolver,
+ runtimeVerifier: @escaping RuntimeVerifier,
+ hostProbe: @escaping HostProbe,
+ daemonIdentityVerifier: @escaping @Sendable () -> Bool,
+ planningTransactionAvailable: @escaping @Sendable () -> Bool = { false },
+ synchronizeTrustFloorDirectory: @escaping DirectorySynchronizer = { fsync($0) == 0 }
+ ) {
+ self.authorityResolver = authorityResolver
+ self.runtimeVerifier = runtimeVerifier
+ self.hostProbe = hostProbe
+ self.daemonIdentityVerifier = daemonIdentityVerifier
+ self.planningTransactionAvailable = planningTransactionAvailable
+ self.synchronizeTrustFloorDirectory = synchronizeTrustFloorDirectory
+ }
+
+ public func resolve(
+ store: DoryComponentStore,
+ machineConfiguration: MachineManagerConfiguration
+ ) -> DoryDaemonVirtualMachineProductionTrustReadiness {
+ resolve(
+ store: store,
+ machineConfiguration: machineConfiguration,
+ appVersion: Self.compiledDaemonVersion,
+ publicKey: DoryComponentDefaults.publicKey,
+ expectedArchitecture: DoryComponentDefaults.architecture
+ )
+ }
+
+ /// Test-only/internal trust seam. Production callers cannot replace the pinned catalog key or
+ /// current architecture through the public API.
+ func resolve(
+ store: DoryComponentStore,
+ machineConfiguration: MachineManagerConfiguration,
+ appVersion: String,
+ publicKey: String,
+ expectedArchitecture: String
+ ) -> DoryDaemonVirtualMachineProductionTrustReadiness {
+ let trustFloor: DoryDaemonVirtualMachineProductionTrustFloor.Record?
+ let hasResolvedPlanState: Bool
+ do {
+ trustFloor = try DoryDaemonVirtualMachineProductionTrustFloor
+ .read(stateDirectory: machineConfiguration.stateDirectory)
+ hasResolvedPlanState = try DoryDaemonVirtualMachineProductionTrustFloor
+ .hasResolvedPlanState(stateDirectory: machineConfiguration.stateDirectory)
+ } catch {
+ return unavailable(
+ .trustFloorViolated,
+ "The durable VM production trust floor is invalid or unavailable."
+ )
+ }
+ let mayUseLegacyMigration = trustFloor == nil && !hasResolvedPlanState
+ let authority: DoryVerifiedVirtualMachineQualificationAuthority
+ do {
+ authority = try authorityResolver(
+ store, publicKey, expectedArchitecture, appVersion
+ )
+ } catch let error as DoryVirtualMachineQualificationAuthorityError {
+ let code: DoryDaemonVirtualMachineProductionTrustReadinessCode
+ switch error {
+ case .catalogUnavailable:
+ code = .catalogUnavailable
+ case let .catalogSchemaUnsupported(version):
+ code = version == DoryComponentCatalog.oldestSupportedSchemaVersion
+ ? .catalogSchemaV1Migration : .catalogSchemaUnsupported
+ default:
+ code = .qualificationAuthorityUnavailable
+ }
+ if code == .catalogUnavailable || code == .catalogSchemaV1Migration {
+ guard mayUseLegacyMigration else {
+ return unavailable(
+ .trustFloorViolated,
+ "VM production trust was previously activated; catalog authority cannot be removed or downgraded."
+ )
+ }
+ return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: code,
+ message: error.description,
+ permitsLegacyCompatibilityMigration: true
+ ))
+ }
+ return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: code,
+ message: error.description
+ ))
+ } catch {
+ return unavailable(
+ .qualificationAuthorityUnavailable,
+ "VM qualification authority could not be verified."
+ )
+ }
+
+ do {
+ try DoryDaemonVirtualMachineProductionTrustFloor.validate(
+ authority: authority,
+ against: trustFloor
+ )
+ } catch {
+ return unavailable(
+ .trustFloorViolated,
+ "The signed component catalog violates the accepted VM trust floor."
+ )
+ }
+
+ guard daemonIdentityVerifier() else {
+ return unavailable(
+ .daemonSignatureUnavailable,
+ "Resolved-plan launch requires a production-signed Dory daemon."
+ )
+ }
+ do { _ = try hostProbe(machineConfiguration.stateDirectory) }
+ catch {
+ return unavailable(
+ .hostFactsUnavailable,
+ "Exact host model, OS build, framework, or resource facts are unavailable."
+ )
+ }
+
+ var runtimes: [DoryDaemonVerifiedBackendRuntime] = []
+ do {
+ runtimes.append(try runtimeVerifier(
+ machineConfiguration.vmmExecutablePath,
+ VirtualizationFrameworkLinuxMachineBackend.backendDescriptor,
+ "dory-vmm"
+ ))
+ } catch {
+ return unavailable(
+ .backendRuntimeUnavailable,
+ "The signed Virtualization.framework helper failed exact verification."
+ )
+ }
+ if let rawPath = machineConfiguration.acceleratedDesktopExecutablePath {
+ if let runtime = try? runtimeVerifier(
+ rawPath,
+ RawHVLinuxMachineBackend.backendDescriptor,
+ "dory-hv"
+ ) {
+ runtimes.append(runtime)
+ }
+ }
+
+ let artifactAuthority = DoryVirtualMachineArtifactAuthority(
+ root: machineConfiguration.stateDirectory + "/.artifact-authority"
+ )
+ let resourceLedger = DoryVirtualMachineResourceAdmissionLedger(
+ root: machineConfiguration.stateDirectory + "/.resource-admissions"
+ )
+ do { _ = try resourceLedger.snapshot() }
+ catch {
+ return unavailable(
+ .resourceAuthorityUnavailable,
+ "The durable VM resource-admission ledger is unavailable."
+ )
+ }
+ guard planningTransactionAvailable() else {
+ return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .planningTransactionUnavailable,
+ message: "Resolved-plan reserve, bind, and publication are not yet installed in the production workspace workflow.",
+ permitsLegacyCompatibilityMigration: mayUseLegacyMigration
+ ))
+ }
+ let inventory = DoryProductionDaemonVirtualMachineTrustInventory(
+ qualificationAuthority: authority,
+ artifactAuthority: artifactAuthority,
+ resourceLedger: resourceLedger,
+ stateDirectory: machineConfiguration.stateDirectory,
+ runtimeSpecifications: runtimes.map {
+ DoryDaemonBackendRuntimeSpecification(
+ descriptor: $0.descriptor,
+ executablePath: $0.executablePath,
+ componentIdentifier: $0.components[0].componentIdentifier
+ )
+ },
+ runtimeVerifier: runtimeVerifier,
+ hostProbe: hostProbe
+ )
+
+ do {
+ try DoryDaemonVirtualMachineProductionTrustFloor.activate(
+ stateDirectory: machineConfiguration.stateDirectory,
+ authority: authority,
+ synchronizeDirectory: synchronizeTrustFloorDirectory
+ )
+ let manager = MachineManager(
+ configuration: machineConfiguration,
+ launchPolicy: .requireResolvedPlan
+ )
+ let backends: [any MachineBackend] = runtimes.map { runtime in
+ switch runtime.descriptor.identity {
+ case .appleVirtualizationFramework:
+ VirtualizationFrameworkLinuxMachineBackend(
+ executablePath: runtime.executablePath,
+ operations: manager.resolvedLaunchCompatibilityOperations(
+ for: runtime.descriptor.identity
+ )
+ )
+ case .doryHypervisor:
+ RawHVLinuxMachineBackend(
+ executablePath: runtime.executablePath,
+ operations: manager.resolvedLaunchCompatibilityOperations(
+ for: runtime.descriptor.identity
+ )
+ )
+ case .qemuHypervisorFramework:
+ // No production QEMU/Windows adapter exists in this milestone.
+ preconditionFailure("unreachable unverified backend runtime")
+ }
+ }
+ let registry = try BackendRegistry(backends: backends)
+ let plans = DoryResolvedMachinePlanRepository(
+ root: machineConfiguration.stateDirectory
+ )
+ let collector = DoryDaemonVirtualMachineStartEvidenceCollector(
+ registry: registry,
+ inventory: inventory
+ )
+ let resolver = DoryDaemonVirtualMachineLaunchPlanResolver(
+ registry: registry,
+ plans: plans,
+ evidenceCollector: collector
+ )
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { machineID in
+ try? plans.read(id: machineID).planRevision
+ }
+ )
+ return .ready(DoryDaemonVirtualMachineProductionTrustContext(
+ machineManager: manager,
+ inventory: inventory,
+ backendRuntimeBuildIdentifiers: Dictionary(
+ uniqueKeysWithValues: runtimes.map {
+ ($0.descriptor.identity, $0.runtimeBuildIdentifier)
+ }
+ )
+ ))
+ } catch {
+ return unavailable(
+ .compositionFailed,
+ "Resolved-plan VM launch infrastructure could not be composed."
+ )
+ }
+ }
+
+ private func unavailable(
+ _ code: DoryDaemonVirtualMachineProductionTrustReadinessCode,
+ _ message: String
+ ) -> DoryDaemonVirtualMachineProductionTrustReadiness {
+ .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: code,
+ message: message
+ ))
+ }
+
+ private static func verifyProductionRuntime(
+ path: String,
+ descriptor: MachineBackendDescriptor,
+ componentIdentifier: String
+ ) throws -> DoryDaemonVerifiedBackendRuntime {
+ guard DorydXPCSecurity.currentTeamIdentifier()
+ == DorydXPCSecurity.productionTeamID else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .daemonSignatureUnavailable,
+ message: "The daemon is not production signed."
+ )
+ }
+ let canonical = URL(fileURLWithPath: path).standardizedFileURL.path
+ guard canonical == path else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .backendRuntimeUnavailable,
+ message: "Backend helper path is not canonical."
+ )
+ }
+ var staticCode: SecStaticCode?
+ guard SecStaticCodeCreateWithPath(
+ URL(fileURLWithPath: canonical) as CFURL,
+ SecCSFlags(),
+ &staticCode
+ ) == errSecSuccess,
+ let staticCode,
+ SecStaticCodeCheckValidity(staticCode, SecCSFlags(rawValue: kSecCSCheckAllArchitectures), nil)
+ == errSecSuccess else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .backendRuntimeUnavailable,
+ message: "Backend helper signature is invalid."
+ )
+ }
+ var signingInformation: CFDictionary?
+ guard SecCodeCopySigningInformation(
+ staticCode,
+ SecCSFlags(rawValue: kSecCSSigningInformation),
+ &signingInformation
+ ) == errSecSuccess,
+ let values = signingInformation as? [CFString: Any],
+ values[kSecCodeInfoTeamIdentifier] as? String
+ == DorydXPCSecurity.productionTeamID,
+ values[kSecCodeInfoIdentifier] as? String == componentIdentifier else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .backendRuntimeUnavailable,
+ message: "Backend helper signer does not match Dory."
+ )
+ }
+
+ let digest = try stableExecutableDigest(canonical)
+ let build = "sha256:\(digest)"
+ return DoryDaemonVerifiedBackendRuntime(
+ descriptor: descriptor,
+ executablePath: canonical,
+ runtimeBuildIdentifier: build,
+ components: [DoryVirtualMachineQualifiedComponent(
+ componentIdentifier: componentIdentifier,
+ buildIdentifier: build,
+ artifactSHA256: digest
+ )]
+ )
+ }
+
+ private static func stableExecutableDigest(_ path: String) throws -> String {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
+ guard descriptor >= 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .backendRuntimeUnavailable,
+ message: "Backend helper cannot be opened."
+ )
+ }
+ defer { close(descriptor) }
+ var before = stat()
+ guard fstat(descriptor, &before) == 0,
+ before.st_mode & S_IFMT == S_IFREG,
+ before.st_size > 0,
+ before.st_nlink == 1,
+ before.st_uid == 0 || before.st_uid == geteuid(),
+ before.st_mode & (S_IWGRP | S_IWOTH) == 0,
+ before.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH) != 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .backendRuntimeUnavailable,
+ message: "Backend helper file identity is insecure."
+ )
+ }
+ let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false)
+ var hasher = SHA256()
+ while true {
+ let data = try handle.read(upToCount: 1_048_576) ?? Data()
+ if data.isEmpty { break }
+ hasher.update(data: data)
+ }
+ var after = stat()
+ guard fstat(descriptor, &after) == 0,
+ sameSnapshot(before, after) else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .backendRuntimeUnavailable,
+ message: "Backend helper changed during verification."
+ )
+ }
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func sameSnapshot(_ lhs: stat, _ rhs: stat) -> Bool {
+ lhs.st_dev == rhs.st_dev
+ && lhs.st_ino == rhs.st_ino
+ && lhs.st_size == rhs.st_size
+ && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec
+ && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec
+ && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec
+ && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec
+ }
+
+ private static func probeProductionHost(
+ stateDirectory: String
+ ) throws -> DoryDaemonProductionHostObservation {
+ guard let model = sysctlString("hw.model"),
+ let build = sysctlString("kern.osversion"),
+ !model.isEmpty, !build.isEmpty else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .hostFactsUnavailable,
+ message: "Host model or OS build is unavailable."
+ )
+ }
+ let process = ProcessInfo.processInfo
+ guard process.operatingSystemVersion.majorVersion > 0,
+ process.activeProcessorCount > 0,
+ process.physicalMemory > 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .hostFactsUnavailable,
+ message: "Host CPU, memory, or OS facts are invalid."
+ )
+ }
+ let values = try URL(fileURLWithPath: stateDirectory).resourceValues(
+ forKeys: [.volumeAvailableCapacityForImportantUsageKey]
+ )
+ guard let free = values.volumeAvailableCapacityForImportantUsage,
+ free > 0 else {
+ throw DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .hostFactsUnavailable,
+ message: "Host storage capacity is unavailable."
+ )
+ }
+ return DoryDaemonProductionHostObservation(
+ hardwareModelIdentifier: model,
+ operatingSystemBuild: build,
+ macOSMajorVersion: process.operatingSystemVersion.majorVersion,
+ virtualizationFrameworkAvailable: frameworkAvailable(
+ "/System/Library/Frameworks/Virtualization.framework/Virtualization"
+ ),
+ hypervisorFrameworkAvailable: frameworkAvailable(
+ "/System/Library/Frameworks/Hypervisor.framework/Hypervisor"
+ ),
+ metalAvailable: frameworkAvailable(
+ "/System/Library/Frameworks/Metal.framework/Metal"
+ ),
+ resources: DoryVMHostResources(
+ logicalCPUCount: UInt64(process.activeProcessorCount),
+ physicalMemoryBytes: process.physicalMemory,
+ freeStorageBytes: UInt64(free)
+ )
+ )
+ }
+
+ private static func sysctlString(_ name: String) -> String? {
+ var length = 0
+ guard sysctlbyname(name, nil, &length, nil, 0) == 0, length > 1 else {
+ return nil
+ }
+ var bytes = [CChar](repeating: 0, count: length)
+ guard sysctlbyname(name, &bytes, &length, nil, 0) == 0 else { return nil }
+ let payload = bytes.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
+ return String(decoding: payload, as: UTF8.self)
+ }
+
+ private static func frameworkAvailable(_ path: String) -> Bool {
+ guard let handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL) else { return false }
+ dlclose(handle)
+ return true
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
index 5791f382..a4ca9581 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
@@ -116,19 +116,25 @@ public struct DoryDaemonVirtualMachineStartInventoryRequest: Sendable, Equatable
public var planRevision: UInt64
public var bootMediaReference: DoryVMResolverReference
public var exactCapabilityRequest: DoryVirtualMachineCapabilityRequest
+ /// Exact already-persisted plan. This request is daemon-local and non-Codable; carrying the
+ /// plan lets the resource authority verify its one-shot plan binding rather than trusting a
+ /// caller-supplied digest or merely matching resource numbers.
+ public var resolvedPlan: DoryResolvedMachinePlan
public init(
machineID: String,
definitionRevision: UInt64,
planRevision: UInt64,
bootMediaReference: DoryVMResolverReference,
- exactCapabilityRequest: DoryVirtualMachineCapabilityRequest
+ exactCapabilityRequest: DoryVirtualMachineCapabilityRequest,
+ resolvedPlan: DoryResolvedMachinePlan
) {
self.machineID = machineID
self.definitionRevision = definitionRevision
self.planRevision = planRevision
self.bootMediaReference = bootMediaReference
self.exactCapabilityRequest = exactCapabilityRequest
+ self.resolvedPlan = resolvedPlan
}
}
diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
index 365c8039..0cc83325 100644
--- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
@@ -1247,7 +1247,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
guard (1...256).contains(bytes.count) else { return false }
return bytes.allSatisfy { byte in
isAlphaNumeric(byte) || byte == 45 || byte == 46 || byte == 47
- || byte == 58 || byte == 64 || byte == 95
+ || byte == 44 || byte == 58 || byte == 64 || byte == 95
}
}
diff --git a/dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift b/dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift
index 3cd34039..b60af561 100644
--- a/dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydXPCSecurity.swift
@@ -67,4 +67,34 @@ public enum DorydXPCSecurity {
}
return team
}
+
+ public static func isProductionDaemonIdentity(
+ teamIdentifier: String?,
+ signingIdentifier: String?
+ ) -> Bool {
+ teamIdentifier == productionTeamID && signingIdentifier == "doryd"
+ }
+
+ /// Checks the complete signed daemon requirement, not only team membership. This prevents a
+ /// different binary signed by Dory's team from constructing production VM trust authority.
+ public static func currentProcessSatisfiesProductionDaemonRequirement() -> Bool {
+ var code: SecCode?
+ guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, let code else {
+ return false
+ }
+ var requirement: SecRequirement?
+ guard SecRequirementCreateWithString(
+ productionDaemonRequirement as CFString,
+ SecCSFlags(),
+ &requirement
+ ) == errSecSuccess,
+ let requirement else {
+ return false
+ }
+ return SecCodeCheckValidity(
+ code,
+ SecCSFlags(rawValue: kSecCSCheckAllArchitectures),
+ requirement
+ ) == errSecSuccess
+ }
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 5ed13403..7b60e8bc 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -1029,6 +1029,11 @@ public final class MachineManager: @unchecked Sendable {
resolved,
planStore: planStore
)
+ guard let preSpawnAuthorization = resolved.preSpawnAuthorization else {
+ throw MachineManagerError.persistence(
+ "resolved launch is missing final pre-spawn authorization"
+ )
+ }
let runtimeIdentity = try DoryMachineRuntimeIdentity(
resolvedPlan: resolved.resolvedPlan,
planSHA256: resolved.resolvedPlanSHA256
@@ -1045,7 +1050,8 @@ public final class MachineManager: @unchecked Sendable {
runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier,
runtimeComponents: resolved.resolvedPlan.components,
planRevision: resolved.resolvedPlan.planRevision,
- planSHA256: resolved.resolvedPlanSHA256
+ planSHA256: resolved.resolvedPlanSHA256,
+ preSpawnAuthorization: preSpawnAuthorization
)
defer { pendingResolvedStart = nil }
let operation = registry.start(resolved.backendPlan)
@@ -1173,7 +1179,8 @@ public final class MachineManager: @unchecked Sendable {
pendingResolvedStart = nil
return MachineBackendRuntimeObservation(try spawnPreparedMachine(
authorization.machine,
- launchBinding: binding
+ launchBinding: binding,
+ preSpawnAuthorization: authorization.preSpawnAuthorization
))
}
@@ -1348,7 +1355,8 @@ public final class MachineManager: @unchecked Sendable {
private func spawnPreparedMachine(
_ preparedMachine: DoryMachineConfiguration,
- launchBinding: MachineBackendLaunchBinding?
+ launchBinding: MachineBackendLaunchBinding?,
+ preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil
) throws -> DoryMachineStatus {
lock.lock()
guard var entry = machines[preparedMachine.id] else {
@@ -1383,6 +1391,17 @@ public final class MachineManager: @unchecked Sendable {
}
let processConfiguration: HvProcessConfiguration
do {
+ // This single-use daemon-owned check deliberately sits after all persisted
+ // machine/plan validation and immediately before any path-derived launch arguments
+ // are read. Failure consumes the token and cannot reach processStarter.
+ if launchBinding != nil {
+ guard let preSpawnAuthorization else {
+ throw MachineManagerError.persistence(
+ "resolved launch is missing final pre-spawn authorization"
+ )
+ }
+ try preSpawnAuthorization.authorize()
+ }
processConfiguration = try self.processConfiguration(
for: entry.configuration,
handoffPath: handoffPath,
@@ -6170,6 +6189,7 @@ private struct PendingResolvedMachineStart {
var runtimeComponents: [DoryResolvedBackendComponentEvidence]
var planRevision: UInt64
var planSHA256: String
+ var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization
}
private struct MachineEntry {
diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift
index cb9ad3dc..ea7e74c7 100644
--- a/dory-core-swift/Sources/doryd/main.swift
+++ b/dory-core-swift/Sources/doryd/main.swift
@@ -14,17 +14,18 @@ _ = signal(SIGPIPE, SIG_IGN)
let env = ProcessInfo.processInfo.environment
let dorydEnvironment = DorydEnvironment(values: env)
let dataDriveSelectionAuthority: DoryDataDriveSelectionAuthority
+let dataDrive: DoryDataDrive
do {
let requestedDrive = try dorydEnvironment.dataDriveConfiguration()
let selectionStore = try DoryDataDriveSelectionStore(home: dorydEnvironment.home)
dataDriveSelectionAuthority = try selectionStore.acquireAuthority()
- let drive = try selectionStore.prepareSelection(
+ dataDrive = try selectionStore.prepareSelection(
requestedRoot: requestedDrive.root,
authority: dataDriveSelectionAuthority
)
- let driveID = try drive.readManifest().id.uuidString.lowercased()
+ let driveID = try dataDrive.readManifest().id.uuidString.lowercased()
FileHandle.standardError.write(
- Data("doryd: data drive \(driveID) ready at \(drive.root)\n".utf8)
+ Data("doryd: data drive \(driveID) ready at \(dataDrive.root)\n".utf8)
)
} catch {
FileHandle.standardError.write(Data("doryd: data drive unavailable: \(error)\n".utf8))
@@ -63,17 +64,38 @@ let idleController = IdleController()
let dockerTier = dorydEnvironment.dockerTierConfiguration().map {
DockerTier(configuration: $0, idleController: idleController)
}
-let machineManager = dorydEnvironment.machineManagerConfiguration().map { configuration in
- // Transitional and explicit: existing machines remain launchable until the production trusted
- // inventory collector is installed. MachineManager's resolved-plan policy fails closed and is
- // not enabled here without that evidence source.
- FileHandle.standardError.write(Data(
- "doryd: VM launch policy legacyCompatibility (trusted resolved-plan inventory unavailable)\n".utf8
- ))
- return MachineManager(
- configuration: configuration,
- launchPolicy: .legacyCompatibility
+let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { configuration -> MachineManager? in
+ let readiness = DoryDaemonVirtualMachineProductionTrustFactory().resolve(
+ store: DoryComponentStore(drive: dataDrive),
+ machineConfiguration: configuration
)
+ switch readiness {
+ case let .ready(context):
+ FileHandle.standardError.write(Data(
+ "doryd: VM launch policy requireResolvedPlan (production trust inventory ready)\n".utf8
+ ))
+ return context.machineManager
+ case let .unavailable(reason):
+ guard reason.permitsLegacyCompatibilityMigration else {
+ // Integrity and runtime verification failures disable VM launch. Falling through to
+ // legacy here would execute the same helper that production trust just rejected.
+ FileHandle.standardError.write(Data(
+ "doryd: VM launch unavailable "
+ .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8
+ ))
+ return nil
+ }
+ // Explicit migration window: no catalog or a signature-verified schema-v1 catalog has no
+ // qualification authority yet, so the existing compatibility path stays labeled.
+ FileHandle.standardError.write(Data(
+ "doryd: VM launch policy legacyCompatibility "
+ .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8
+ ))
+ return MachineManager(
+ configuration: configuration,
+ launchPolicy: .legacyCompatibility
+ )
+ }
}
let sandboxTTLReconciler = machineManager.map { manager in
SandboxTTLReconciler(
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
new file mode 100644
index 00000000..0d2efe50
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
@@ -0,0 +1,861 @@
+import CryptoKit
+@testable import DorydKit
+import DoryOperations
+import Foundation
+import Testing
+
+@Suite("Production VM trust composition")
+struct DoryDaemonVirtualMachineProductionTrustTests {
+ @Test("missing catalog remains explicitly unavailable")
+ func missingCatalog() throws {
+ let fixture = try ProductionTrustFixture(installCatalog: false)
+ defer { fixture.cleanup() }
+
+ let result = fixture.factory.resolve(
+ store: fixture.store,
+ machineConfiguration: fixture.machineConfiguration,
+ appVersion: fixture.appVersion,
+ publicKey: fixture.publicKey,
+ expectedArchitecture: "arm64"
+ )
+ guard case let .unavailable(reason) = result else {
+ Issue.record("Expected unavailable readiness")
+ return
+ }
+ #expect(reason.code == .catalogUnavailable)
+ #expect(reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("schema-v1 catalog is not promoted to resolved-plan trust")
+ func schemaV1FailsClosed() throws {
+ let fixture = try ProductionTrustFixture(catalogSchemaVersion: 1)
+ defer { fixture.cleanup() }
+
+ let result = fixture.resolve()
+ guard case let .unavailable(reason) = result else {
+ Issue.record("Expected unavailable readiness")
+ return
+ }
+ #expect(reason.code == .catalogSchemaV1Migration)
+ #expect(reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("tampered cached signature is rejected before helper probing")
+ func tamperedSignature() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ try Data("invalid-signature\n".utf8).write(
+ to: URL(fileURLWithPath: fixture.store.root + "/catalog.sig")
+ )
+
+ let result = fixture.resolve()
+ guard case let .unavailable(reason) = result else {
+ Issue.record("Expected unavailable readiness")
+ return
+ }
+ #expect(reason.code == .qualificationAuthorityUnavailable)
+ }
+
+ @Test("tampered installed qualification manifest is rejected")
+ func tamperedManifest() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ let path = try #require(fixture.store.assetPath(
+ component: .linuxMachines,
+ path: fixture.manifestPath
+ ))
+ try Data("{}\n".utf8).write(to: URL(fileURLWithPath: path))
+
+ let result = fixture.resolve()
+ guard case let .unavailable(reason) = result else {
+ Issue.record("Expected unavailable readiness")
+ return
+ }
+ #expect(reason.code == .qualificationAuthorityUnavailable)
+ }
+
+ @Test("developer-signed daemon cannot enable resolved-plan production mode")
+ func developerDaemonRejected() throws {
+ let fixture = try ProductionTrustFixture(daemonTeamIdentifier: nil)
+ defer { fixture.cleanup() }
+
+ let result = fixture.resolve()
+ guard case let .unavailable(reason) = result else {
+ Issue.record("Expected unavailable readiness")
+ return
+ }
+ #expect(reason.code == .daemonSignatureUnavailable)
+ }
+
+ @Test("stale or unverified helper build blocks readiness")
+ func helperBuildRejected() throws {
+ let fixture = try ProductionTrustFixture(runtimeVerificationFails: true)
+ defer { fixture.cleanup() }
+
+ let result = fixture.resolve()
+ guard case let .unavailable(reason) = result else {
+ Issue.record("Expected unavailable readiness")
+ return
+ }
+ #expect(reason.code == .backendRuntimeUnavailable)
+ #expect(!reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("only explicit catalog migration states permit legacy compatibility")
+ func legacyMigrationClassification() {
+ for code in [
+ DoryDaemonVirtualMachineProductionTrustReadinessCode.catalogUnavailable,
+ .catalogSchemaV1Migration,
+ .catalogSchemaUnsupported,
+ .trustFloorViolated,
+ .planningTransactionUnavailable,
+ .qualificationAuthorityUnavailable,
+ .daemonSignatureUnavailable,
+ .hostFactsUnavailable,
+ .backendRuntimeUnavailable,
+ .resourceAuthorityUnavailable,
+ .compositionFailed,
+ ] {
+ let unavailable = DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: code,
+ message: "fixture"
+ )
+ #expect(!unavailable.permitsLegacyCompatibilityMigration)
+ }
+ }
+
+ @Test("verified v2 cannot activate resolved mode without production plan publication")
+ func v2WithoutPlanningTransactionStaysMigrationOnly() throws {
+ let fixture = try ProductionTrustFixture(planningTransactionAvailable: false)
+ defer { fixture.cleanup() }
+ guard case let .unavailable(reason) = fixture.resolve() else {
+ Issue.record("Expected unavailable production planning")
+ return
+ }
+ #expect(reason.code == .planningTransactionUnavailable)
+ #expect(reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("accepted production trust cannot downgrade after catalog removal")
+ func trustFloorRejectsCatalogRemoval() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ guard case .ready = fixture.resolve() else {
+ Issue.record("Expected initial production activation")
+ return
+ }
+ try FileManager.default.removeItem(atPath: fixture.store.root + "/catalog.json")
+ try FileManager.default.removeItem(atPath: fixture.store.root + "/catalog.sig")
+ guard case let .unavailable(reason) = fixture.resolve() else {
+ Issue.record("Expected trust-floor rejection")
+ return
+ }
+ #expect(reason.code == .trustFloorViolated)
+ #expect(!reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("accepted production trust rejects an older signed v2 catalog")
+ func trustFloorRejectsSignedCatalogRollback() throws {
+ let fixture = try ProductionTrustFixture(
+ catalogReleaseVersion: "2.0.0",
+ catalogGeneratedAt: "2026-08-20T12:00:00.000Z"
+ )
+ defer { fixture.cleanup() }
+ guard case .ready = fixture.resolve() else {
+ Issue.record("Expected initial production activation")
+ return
+ }
+ try fixture.installCatalogFixture(
+ schemaVersion: 2,
+ releaseVersion: "1.0.0",
+ generatedAt: "2026-08-19T12:00:00.000Z"
+ )
+ guard case let .unavailable(reason) = fixture.resolve() else {
+ Issue.record("Expected signed catalog rollback rejection")
+ return
+ }
+ #expect(reason.code == .trustFloorViolated)
+ #expect(!reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("trust-floor directory sync failure cannot return ready")
+ func trustFloorDirectorySyncFailureFailsClosed() throws {
+ let fixture = try ProductionTrustFixture(trustFloorDirectorySyncFails: true)
+ defer { fixture.cleanup() }
+ guard case let .unavailable(reason) = fixture.resolve() else {
+ Issue.record("Expected trust-floor persistence failure")
+ return
+ }
+ #expect(reason.code == .compositionFailed)
+ #expect(!reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("uninspectable VM state never permits legacy migration")
+ func invalidStateDirectoryFailsClosed() throws {
+ let fixture = try ProductionTrustFixture(installCatalog: false)
+ defer { fixture.cleanup() }
+ try FileManager.default.removeItem(
+ atPath: fixture.machineConfiguration.stateDirectory
+ )
+ try Data("not-a-directory".utf8).write(to: URL(
+ fileURLWithPath: fixture.machineConfiguration.stateDirectory
+ ))
+ guard case let .unavailable(reason) = fixture.resolve() else {
+ Issue.record("Expected VM state inspection failure")
+ return
+ }
+ #expect(reason.code == .trustFloorViolated)
+ #expect(!reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("missing catalog cannot downgrade existing resolved-plan state")
+ func resolvedPlanStateRejectsMissingCatalogDowngrade() throws {
+ let fixture = try ProductionTrustFixture(installCatalog: false)
+ defer { fixture.cleanup() }
+ let machine = URL(fileURLWithPath: fixture.machineConfiguration.stateDirectory)
+ .appendingPathComponent("resolved-machine", isDirectory: true)
+ try FileManager.default.createDirectory(at: machine, withIntermediateDirectories: true)
+ try Data("{}\n".utf8).write(to: machine.appendingPathComponent(
+ DoryResolvedMachinePlanRepository.recordFileName
+ ))
+
+ guard case let .unavailable(reason) = fixture.resolve() else {
+ Issue.record("Expected trust-floor rejection")
+ return
+ }
+ #expect(reason.code == .trustFloorViolated)
+ #expect(!reason.permitsLegacyCompatibilityMigration)
+ }
+
+ @Test("same-team binary with wrong signing identifier is not doryd")
+ func exactDaemonSigningIdentity() {
+ #expect(DorydXPCSecurity.isProductionDaemonIdentity(
+ teamIdentifier: DorydXPCSecurity.productionTeamID,
+ signingIdentifier: "doryd"
+ ))
+ #expect(!DorydXPCSecurity.isProductionDaemonIdentity(
+ teamIdentifier: DorydXPCSecurity.productionTeamID,
+ signingIdentifier: "Dory"
+ ))
+ }
+
+ @Test("pre-spawn authorization is single use and consumes failures")
+ func preSpawnAuthorizationIsSingleUse() throws {
+ let counter = ProductionCallCounter()
+ let authorization = DoryDaemonVirtualMachinePreSpawnAuthorization {
+ counter.increment()
+ }
+ try authorization.authorize()
+ #expect(throws: DoryDaemonVirtualMachinePreSpawnAuthorizationError.self) {
+ try authorization.authorize()
+ }
+ #expect(counter.value == 1)
+
+ let failing = DoryDaemonVirtualMachinePreSpawnAuthorization {
+ throw ProductionTrustFixtureError.runtimeRejected
+ }
+ #expect(throws: DoryDaemonVirtualMachinePreSpawnAuthorizationError.self) {
+ try failing.authorize()
+ }
+ #expect(throws: DoryDaemonVirtualMachinePreSpawnAuthorizationError.self) {
+ try failing.authorize()
+ }
+ }
+
+ @Test("start inventory refreshes volatile host resources")
+ func startInventoryRefreshesHostResources() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ guard case let .ready(context) = fixture.resolve() else {
+ Issue.record("Expected ready test composition")
+ return
+ }
+ let request = try fixture.makeBoundStartRequest()
+ _ = try context.inventory.startInventory(for: request)
+
+ var changed = fixture.host
+ changed = DoryDaemonProductionHostObservation(
+ hardwareModelIdentifier: changed.hardwareModelIdentifier,
+ operatingSystemBuild: changed.operatingSystemBuild,
+ macOSMajorVersion: changed.macOSMajorVersion,
+ virtualizationFrameworkAvailable: changed.virtualizationFrameworkAvailable,
+ hypervisorFrameworkAvailable: changed.hypervisorFrameworkAvailable,
+ metalAvailable: changed.metalAvailable,
+ resources: DoryVMHostResources(
+ logicalCPUCount: changed.resources.logicalCPUCount,
+ physicalMemoryBytes: changed.resources.physicalMemoryBytes,
+ freeStorageBytes: changed.resources.freeStorageBytes - 1
+ )
+ )
+ fixture.hostState.set(changed)
+ #expect(throws: DoryDaemonProductionTrustInventoryError.self) {
+ _ = try context.inventory.startInventory(for: request)
+ }
+ }
+
+ @Test("signed v2 authority composes exact resolved-plan infrastructure")
+ func signedV2Ready() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+
+ let result = fixture.resolve()
+ guard case let .ready(context) = result else {
+ Issue.record("Expected ready production trust")
+ return
+ }
+ #expect(context.backendRuntimeBuildIdentifiers[.doryHypervisor]
+ == fixture.runtimeBuildIdentifier)
+ #expect(context.backendRuntimeBuildIdentifiers[.appleVirtualizationFramework]
+ == fixture.runtimeBuildIdentifier)
+ }
+
+ @Test("production inventory never fabricates an admission during planning")
+ func planningFailsUntilAtomicAdmissionBindingExists() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ guard case let .ready(context) = fixture.resolve() else {
+ Issue.record("Expected ready production trust")
+ return
+ }
+ let reference = DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "qualified-linux-boot"
+ )
+ #expect(throws: DoryDaemonProductionTrustInventoryError.self) {
+ _ = try context.inventory.planningInventory(for:
+ DoryDaemonVirtualMachineInventoryRequest(
+ machineID: "qualified-linux",
+ definitionRevision: 1,
+ guest: fixture.guest,
+ bootMedia: DoryVMBootMediaReference(
+ id: "system",
+ role: .system,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifact: reference,
+ removable: false
+ ),
+ resources: DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 2 * 1_024 * 1_024 * 1_024,
+ diskBytes: 32 * 1_024 * 1_024 * 1_024
+ ),
+ devices: .minimumBootable,
+ acceptableGraphics: [.none],
+ virtualHardwareABIVersion: 1
+ )
+ )
+ }
+ }
+
+ @Test("installed Linux boot verification hashes the embedded kernel and initrd")
+ func installedBootBundleContentVerification() throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-boot-content-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: root) }
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let path = root.appendingPathComponent("boot.bundle").path
+ try DoryInstalledLinuxBootBundle.write(
+ assets: DoryLinuxInstallerBootAssets(
+ kernel: Data("kernel-payload".utf8),
+ initrd: Data("initrd-payload".utf8),
+ kernelISOPath: "/boot/kernel",
+ initrdISOPath: "/boot/initrd"
+ ),
+ rootDevice: "/dev/vda2",
+ toPath: path
+ )
+ #expect(try DoryInstalledLinuxBootBundle.verifyContents(atPath: path).rootDevice
+ == "/dev/vda2")
+
+ var bytes = try Data(contentsOf: URL(fileURLWithPath: path))
+ bytes[bytes.index(before: bytes.endIndex)] ^= 0xff
+ try bytes.write(to: URL(fileURLWithPath: path))
+ #expect(throws: DoryInstalledLinuxBootBundleError.self) {
+ _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: path)
+ }
+ }
+}
+
+private enum ProductionTrustFixtureError: Error {
+ case runtimeRejected
+}
+
+private final class ProductionCallCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var count = 0
+
+ var value: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return count
+ }
+
+ func increment() {
+ lock.lock()
+ count += 1
+ lock.unlock()
+ }
+}
+
+private final class ProductionHostState: @unchecked Sendable {
+ private let lock = NSLock()
+ private var observation: DoryDaemonProductionHostObservation
+
+ init(_ observation: DoryDaemonProductionHostObservation) {
+ self.observation = observation
+ }
+
+ func get() -> DoryDaemonProductionHostObservation {
+ lock.lock()
+ defer { lock.unlock() }
+ return observation
+ }
+
+ func set(_ observation: DoryDaemonProductionHostObservation) {
+ lock.lock()
+ self.observation = observation
+ lock.unlock()
+ }
+}
+
+private final class ProductionTrustFixture: @unchecked Sendable {
+ let root: URL
+ let drive: DoryDataDrive
+ let store: DoryComponentStore
+ let machineConfiguration: MachineManagerConfiguration
+ let appVersion = "1.0.0"
+ let manifestPath = "vm-qualifications.json"
+ let privateKey = Curve25519.Signing.PrivateKey()
+ let helperDigest: String
+ let runtimeBuildIdentifier: String
+ let mediaPath: String
+ let mediaDigest: String
+ let mediaReference = DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "qualified-linux-boot"
+ )
+ let guest = DoryGuestPlatform(family: .linux, architecture: .arm64)
+ let host = DoryDaemonProductionHostObservation(
+ hardwareModelIdentifier: "Mac16,1",
+ operatingSystemBuild: "26A5406c",
+ macOSMajorVersion: 26,
+ virtualizationFrameworkAvailable: true,
+ hypervisorFrameworkAvailable: true,
+ metalAvailable: true,
+ resources: DoryVMHostResources(
+ logicalCPUCount: 12,
+ physicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024,
+ freeStorageBytes: 512 * 1_024 * 1_024 * 1_024
+ )
+ )
+ let hostState: ProductionHostState
+ var factory: DoryDaemonVirtualMachineProductionTrustFactory!
+ var publicKey: String { privateKey.publicKey.rawRepresentation.base64EncodedString() }
+
+ init(
+ catalogSchemaVersion: Int = 2,
+ catalogReleaseVersion: String = "1.0.0",
+ catalogGeneratedAt: String = "2026-08-20T12:00:00.000Z",
+ installCatalog: Bool = true,
+ daemonTeamIdentifier: String? = DorydXPCSecurity.productionTeamID,
+ runtimeVerificationFails: Bool = false,
+ planningTransactionAvailable: Bool = true,
+ trustFloorDirectorySyncFails: Bool = false
+ ) throws {
+ helperDigest = Self.digest(Data("verified-helper".utf8))
+ hostState = ProductionHostState(host)
+ root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-production-trust-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ drive = try DoryDataDrive(home: root.path)
+ try drive.prepare()
+ store = DoryComponentStore(drive: drive)
+ try store.prepare()
+ let state = root.appendingPathComponent("machine-state", isDirectory: true)
+ try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true)
+ try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: state.path)
+ let vz = root.appendingPathComponent("dory-vmm").path
+ let raw = root.appendingPathComponent("dory-hv").path
+ for path in [vz, raw] {
+ try Data("#!/bin/sh\nexit 0\n".utf8).write(to: URL(fileURLWithPath: path))
+ try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: path)
+ }
+ machineConfiguration = MachineManagerConfiguration(
+ vmmExecutablePath: vz,
+ acceleratedDesktopExecutablePath: raw,
+ stateDirectory: state.path,
+ runtimeDirectory: root.appendingPathComponent("runtime").path,
+ requiresReadyHandoff: false
+ )
+ runtimeBuildIdentifier = "sha256:\(helperDigest)"
+ mediaPath = root.appendingPathComponent("qualified-linux.boot").path
+ try DoryInstalledLinuxBootBundle.write(
+ assets: DoryLinuxInstallerBootAssets(
+ kernel: Data("qualified-kernel".utf8),
+ initrd: Data("qualified-initrd".utf8),
+ kernelISOPath: "/boot/kernel",
+ initrdISOPath: "/boot/initrd"
+ ),
+ rootDevice: "/dev/vda2",
+ toPath: mediaPath
+ )
+ mediaDigest = try DoryComponentCatalogVerifier.fileDigest(mediaPath)
+ _ = try DoryVirtualMachineArtifactAuthority(
+ root: state.path + "/.artifact-authority"
+ ).publishImmutable(
+ reference: mediaReference,
+ path: mediaPath,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory
+ )
+
+ if installCatalog {
+ try installCatalogFixture(
+ schemaVersion: catalogSchemaVersion,
+ releaseVersion: catalogReleaseVersion,
+ generatedAt: catalogGeneratedAt
+ )
+ }
+ let digest = helperDigest
+ let build = runtimeBuildIdentifier
+ let observedHostState = hostState
+ factory = DoryDaemonVirtualMachineProductionTrustFactory(
+ authorityResolver: { store, key, architecture, appVersion in
+ try DoryVirtualMachineQualificationAuthorityResolver.resolve(
+ store: store,
+ publicKey: key,
+ expectedArchitecture: architecture,
+ appVersion: appVersion
+ )
+ },
+ runtimeVerifier: { path, descriptor, component in
+ if runtimeVerificationFails { throw ProductionTrustFixtureError.runtimeRejected }
+ return DoryDaemonVerifiedBackendRuntime(
+ descriptor: descriptor,
+ executablePath: path,
+ runtimeBuildIdentifier: build,
+ components: [DoryVirtualMachineQualifiedComponent(
+ componentIdentifier: component,
+ buildIdentifier: build,
+ artifactSHA256: digest
+ )]
+ )
+ },
+ hostProbe: { _ in observedHostState.get() },
+ daemonIdentityVerifier: {
+ daemonTeamIdentifier == DorydXPCSecurity.productionTeamID
+ },
+ planningTransactionAvailable: { planningTransactionAvailable },
+ synchronizeTrustFloorDirectory: { descriptor in
+ !trustFloorDirectorySyncFails && fsync(descriptor) == 0
+ }
+ )
+ }
+
+ func resolve() -> DoryDaemonVirtualMachineProductionTrustReadiness {
+ factory.resolve(
+ store: store,
+ machineConfiguration: machineConfiguration,
+ appVersion: appVersion,
+ publicKey: publicKey,
+ expectedArchitecture: "arm64"
+ )
+ }
+
+ func cleanup() { try? FileManager.default.removeItem(at: root) }
+
+ func makeBoundStartRequest() throws -> DoryDaemonVirtualMachineStartInventoryRequest {
+ let resources = DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 4 * 1_024 * 1_024 * 1_024,
+ diskBytes: 32 * 1_024 * 1_024 * 1_024
+ )
+ let definitionSHA = Self.digest(Data("definition".utf8))
+ let binding = DoryVirtualMachineResourceAdmissionPlanBinding(
+ machineID: "qualified-linux",
+ definitionRevision: 1,
+ definitionSHA256: definitionSHA,
+ plannedPlanRevision: 1
+ )
+ let ledger = DoryVirtualMachineResourceAdmissionLedger(
+ root: machineConfiguration.stateDirectory + "/.resource-admissions"
+ )
+ let lease = try ledger.reserveStarting(
+ binding: binding,
+ hostFacts: host.resources,
+ workload: .desktop,
+ resources: resources
+ )
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let media = DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifactSHA256: mediaDigest
+ )
+ let capabilityRequest = DoryVirtualMachineCapabilityRequest(
+ guest: guest,
+ bootMedia: media,
+ backend: .doryHypervisor,
+ graphics: .none,
+ devices: devices
+ )
+ let authority = try DoryVirtualMachineQualificationAuthorityResolver.resolve(
+ store: store,
+ publicKey: publicKey,
+ expectedArchitecture: "arm64",
+ appVersion: appVersion
+ )
+ let runtimeComponent = DoryVirtualMachineQualifiedComponent(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: runtimeBuildIdentifier,
+ artifactSHA256: helperDigest
+ )
+ let qualification = try authority.resolve(
+ request: capabilityRequest,
+ backendImplementationIdentifier:
+ RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier,
+ backendRuntimeBuildIdentifier: runtimeBuildIdentifier,
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ installedComponents: [runtimeComponent]
+ )
+ let hostFacts = DoryAppleSiliconHostFacts(
+ macOSMajorVersion: host.macOSMajorVersion,
+ virtualizationFrameworkAvailable: true,
+ hypervisorFrameworkAvailable: true,
+ doryHypervisorAvailable: true,
+ qemuHypervisorFrameworkAvailable: false,
+ windowsUEFIFirmwareAvailable: false,
+ windowsSecureBootAvailable: false,
+ windowsSBSADeviceModelAvailable: false,
+ virtualTPM20Available: false,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: false,
+ networkAvailable: false,
+ displayAvailable: false,
+ inputAvailable: false
+ ),
+ macOSGuestVirtualizationSupported: false,
+ macOSRestoreImageInstallationSupported: false,
+ doryMacOSBackendAvailable: false,
+ doryMacOSBackendQualified: false,
+ metalAvailable: true,
+ doryAcceleratedRendererAvailable: true,
+ runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: runtimeBuildIdentifier,
+ virtualizationFrameworkAdapterBuildID: "",
+ qemuRuntimeBuildID: ""
+ )
+ )
+ let descriptor = DoryAppleSiliconCapabilityEvaluator.evaluate(
+ capabilityRequest,
+ host: hostFacts,
+ trustedGuestImageGraphicsQualification: qualification.graphics,
+ trustedRuntimeQualification: qualification.runtime
+ )
+ #expect(descriptor.availability.isUsable)
+ let plan = DoryResolvedMachinePlan(
+ machineID: binding.machineID,
+ definitionRevision: binding.definitionRevision,
+ definitionSHA256: definitionSHA,
+ planRevision: binding.plannedPlanRevision,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000,
+ guest: guest,
+ backend: .doryHypervisor,
+ backendImplementationIdentifier:
+ RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier,
+ backendRuntimeBuildIdentifier: runtimeBuildIdentifier,
+ virtualHardwareABIVersion: 1,
+ bootMedia: DoryResolvedMachineBootMedia(
+ resolverReference: mediaReference,
+ media: media
+ ),
+ components: [DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: runtimeBuildIdentifier,
+ artifactSHA256: helperDigest
+ )],
+ devices: devices,
+ graphics: .none,
+ supportTier: .supported,
+ selectionEvidence: DoryResolvedMachineBackendSelectionEvidence(
+ disposition: .primary,
+ plannerRequest: DoryVirtualMachineBackendPlanRequest(
+ guest: guest,
+ bootMedia: media,
+ acceptableGraphics: [.none],
+ devices: devices,
+ backendPreferences: [.doryHypervisor],
+ backendPreferencePolicy: .required
+ ),
+ selectedEvaluationIndex: 0,
+ rejectedCandidates: []
+ ),
+ qualificationEvidence: DoryResolvedMachineQualificationEvidence(
+ graphics: descriptor.graphicsQualificationEvidence,
+ runtime: descriptor.runtimeQualificationEvidence
+ ),
+ resourceAdmission: lease.evidence,
+ hostQualification: DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: qualification.record.qualificationIdentity,
+ qualificationReportSHA256: Self.digest(
+ try Self.canonicalData(qualification.record)
+ ),
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ backend: .doryHypervisor,
+ backendRuntimeBuildIdentifier: runtimeBuildIdentifier,
+ virtualHardwareABIVersion: 1,
+ qualifierIdentifier: "dory.catalog-v2.virtual-machine-qualification",
+ qualifierVersion: 1
+ )
+ )
+ #expect(plan.validate().isEmpty)
+ _ = try ledger.bind(
+ leaseID: lease.leaseID,
+ to: plan,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ return DoryDaemonVirtualMachineStartInventoryRequest(
+ machineID: plan.machineID,
+ definitionRevision: plan.definitionRevision,
+ planRevision: plan.planRevision,
+ bootMediaReference: mediaReference,
+ exactCapabilityRequest: capabilityRequest,
+ resolvedPlan: plan
+ )
+ }
+
+ func installCatalogFixture(
+ schemaVersion: Int,
+ releaseVersion: String,
+ generatedAt: String
+ ) throws {
+ let signingKeyID = Self.digest(privateKey.publicKey.rawRepresentation)
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let components = ["dory-hv", "dory-vmm"].map {
+ DoryVirtualMachineQualifiedComponent(
+ componentIdentifier: $0,
+ buildIdentifier: runtimeBuildIdentifier,
+ artifactSHA256: helperDigest
+ )
+ }
+ let records = [
+ (DoryVirtualizationBackendIdentity.doryHypervisor,
+ RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier,
+ "dory-hv"),
+ (DoryVirtualizationBackendIdentity.appleVirtualizationFramework,
+ VirtualizationFrameworkLinuxMachineBackend.backendDescriptor.implementationIdentifier,
+ "dory-vmm"),
+ ].map { backend, implementation, component in
+ DoryVirtualMachineQualificationRecord(
+ qualificationIdentity: "\(component)-qualification",
+ guest: guest,
+ bootMediaKind: .installedLinuxBootBundle,
+ bootMediaSource: .bundledByDory,
+ immutableArtifactSHA256: mediaDigest,
+ backend: backend,
+ backendImplementationIdentifier: implementation,
+ backendRuntimeBuildIdentifier: runtimeBuildIdentifier,
+ virtualHardwareABIVersion: 1,
+ graphics: .none,
+ devices: devices,
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ components: [components.first { $0.componentIdentifier == component }!]
+ )
+ }
+ let manifest = DoryVirtualMachineQualificationManifest(
+ manifestIdentity: "production-vm-qualification-1",
+ catalogReleaseVersion: releaseVersion,
+ architecture: "arm64",
+ signingKeyID: signingKeyID,
+ records: records
+ )
+ let manifestData = try Self.encoded(manifest)
+ let asset = DoryComponentAsset(
+ path: manifestPath,
+ url: "https://example.invalid/qualification.json",
+ downloadBytes: UInt64(manifestData.count),
+ installedBytes: UInt64(manifestData.count),
+ sha256: Self.digest(manifestData),
+ installedSHA256: Self.digest(manifestData)
+ )
+ let release = DoryComponentRelease(
+ id: .linuxMachines,
+ version: releaseVersion,
+ displayName: "Linux Machines",
+ summary: "Qualified VM runtime",
+ dependencies: [.dockerCore],
+ downloadBytes: UInt64(manifestData.count),
+ installedBytes: UInt64(manifestData.count),
+ assets: [asset]
+ )
+ let core = DoryComponentRelease(
+ id: .dockerCore,
+ version: releaseVersion,
+ displayName: "Docker Core",
+ summary: "Bundled core",
+ dependencies: [],
+ downloadBytes: 1,
+ installedBytes: 1,
+ assets: []
+ )
+ let catalog = DoryComponentCatalog(
+ schemaVersion: schemaVersion,
+ releaseVersion: releaseVersion,
+ generatedAt: generatedAt,
+ minimumAppVersion: appVersion,
+ architecture: "arm64",
+ components: [core, release],
+ virtualMachineQualification: schemaVersion == 2
+ ? DoryComponentVirtualMachineQualificationAsset(
+ component: .linuxMachines,
+ path: manifestPath,
+ manifestIdentity: manifest.manifestIdentity,
+ manifestFormatVersion: manifest.schemaVersion,
+ signingKeyID: signingKeyID
+ ) : nil
+ )
+ let catalogData = try Self.encoded(catalog)
+ let signature = try privateKey.signature(for: catalogData).base64EncodedString()
+ _ = try store.cacheCatalog(
+ data: catalogData,
+ signature: signature,
+ publicKey: publicKey,
+ expectedArchitecture: "arm64",
+ appVersion: appVersion
+ )
+ let source = root.appendingPathComponent("qualification.json")
+ try manifestData.write(to: source)
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: source.path)
+ _ = try store.install(
+ release,
+ catalogDigest: DoryComponentCatalogVerifier.digest(catalogData),
+ downloadedAssets: [manifestPath: source.path]
+ )
+ }
+
+ private static func encoded(_ value: T) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return try encoder.encode(value) + Data("\n".utf8)
+ }
+
+ private static func canonicalData(_ value: T) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return try encoder.encode(value)
+ }
+
+ private static func digest(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
index e1ae95d2..564dd2eb 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -380,6 +380,84 @@ struct MachineManagerResolvedPlanIntegrationTests {
}
}
+ @Test("final media mutation is rejected by single-use authorization before process starter")
+ func preSpawnArtifactMutationIsRejected() throws {
+ try withHarness("pre-spawn-media-mutation") { manager, starter, state in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ let mediaPath = state + "/resolved-media"
+ try Data("qualified-media".utf8).write(
+ to: URL(fileURLWithPath: mediaPath),
+ options: .atomic
+ )
+ let expectedSHA256 = SHA256.hash(
+ data: try Data(contentsOf: URL(fileURLWithPath: mediaPath))
+ ).map { String(format: "%02x", $0) }.joined()
+ let resolution = try exactResolution(
+ request: request,
+ preSpawnRevalidation: {
+ let current = try Data(contentsOf: URL(fileURLWithPath: mediaPath))
+ let currentSHA256 = SHA256.hash(data: current)
+ .map { String(format: "%02x", $0) }.joined()
+ guard currentSHA256 == expectedSHA256 else {
+ throw DoryDaemonVirtualMachinePreSpawnAuthorizationError
+ .revalidationFailed
+ }
+ }
+ )
+ plans.set(resolution.resolvedPlan)
+ try Data("mutated-after-evidence".utf8).write(
+ to: URL(fileURLWithPath: mediaPath),
+ options: .atomic
+ )
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ #expect(manager.status(id: "dev")?.state == .created)
+ }
+ }
+
+ @Test("required resolved launch rejects a missing pre-spawn authorization")
+ func missingPreSpawnAuthorizationFailsClosed() throws {
+ try withHarness("missing-pre-spawn") { manager, starter, _ in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ let resolver = ClosureLaunchResolver { request in
+ var resolution = try exactResolution(request: request)
+ plans.set(resolution.resolvedPlan)
+ resolution.preSpawnAuthorization = nil
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: try rawRegistry(operations: operations),
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ }
+ }
+
@Test("adapter-issued executable is launched instead of manager backend lookup")
func adapterExecutableBindingIsUsed() throws {
try withHarness(
@@ -482,7 +560,8 @@ struct MachineManagerResolvedPlanIntegrationTests {
private func exactResolution(
request: DoryDaemonVirtualMachineLaunchPlanRequest,
- componentSHA256: String? = nil
+ componentSHA256: String? = nil,
+ preSpawnRevalidation: @escaping @Sendable () throws -> Void = {}
) throws -> DoryDaemonVirtualMachineLaunchPlanResolution {
let definitionDigest = SHA256.hash(data: request.canonicalDefinitionData)
.map { String(format: "%02x", $0) }.joined()
@@ -614,6 +693,9 @@ struct MachineManagerResolvedPlanIntegrationTests {
backend: RawHVLinuxMachineBackend.backendDescriptor,
machine: request.machine,
capability: capability
+ ),
+ preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization(
+ revalidate: preSpawnRevalidation
)
)
}
From f2a652778f74f3f6de915387834005d1898b2cfb Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 16:02:54 +0000
Subject: [PATCH 031/338] feat(vm): persist typed machine settings
---
Dory/Features/Machines/MachinesView.swift | 50 +-
Dory/Features/Sheets/NewMachineSheet.swift | 38 +-
Dory/Models/AppStore.swift | 63 +-
Dory/Runtime/Doryd/DorydClient.swift | 280 ++++++-
Dory/Runtime/Machines/MachineService.swift | 3 +
DoryTests/DorydClientTests.swift | 343 ++++++++-
DoryTests/NewMachineSettingsTests.swift | 44 +-
.../DoryMachineTypedWriteAuthority.swift | 689 ++++++++++++++++++
.../Sources/DorydKit/DorydService.swift | 46 +-
.../Sources/DorydKit/MachineManager.swift | 12 +
dory-core-swift/Sources/dorydctl/main.swift | 69 +-
.../DoryMachineTypedWriteAuthorityTests.swift | 285 ++++++++
.../DorydKitTests/DorydServiceTests.swift | 173 ++++-
13 files changed, 1915 insertions(+), 180 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift
diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift
index 9bba664b..45cbc1fa 100644
--- a/Dory/Features/Machines/MachinesView.swift
+++ b/Dory/Features/Machines/MachinesView.swift
@@ -365,7 +365,7 @@ private struct MachineEditSheet: View {
@State private var clipboardPolicy = DoryDesktopClipboardPolicy.bidirectional
@State private var runtimePreference = DoryDesktopVMMPreference.automatic
@State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic
- @State private var environment: [String: String] = [:]
+ @State private var typedSettings = DorydMachineTypedSettings()
private struct MountRow: Identifiable, Hashable {
let id = UUID()
@@ -406,11 +406,22 @@ private struct MachineEditSheet: View {
memoryGB = max(1, min(16, settings.memoryMB.map { $0 / 1024 } ?? 4))
address = settings.address ?? ""
displayMode = settings.displayMode
- environment = settings.env
- guestUsername = settings.env["DORY_GUEST_USER"] ?? "dory"
- clipboardPolicy = DoryDesktopClipboardPolicy(environment: settings.env)
- runtimePreference = (try? DoryDesktopVMMPreference(environment: settings.env)) ?? .automatic
- graphicsPreference = (try? DoryDesktopGraphicsPreference(environment: settings.env)) ?? .automatic
+ typedSettings = settings.virtualMachineSettings ?? DorydMachineTypedSettings(
+ legacyEnvironment: settings.env,
+ displayMode: settings.displayMode
+ )
+ // Keep an unrepresentable legacy username visible and invalid until the user explicitly
+ // corrects that field. Hiding it behind a default would turn an unrelated edit into a
+ // destructive rewrite.
+ guestUsername = settings.env[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey]
+ ?? typedSettings.guestIdentityIntent.account?.username
+ ?? "dory"
+ clipboardPolicy = typedSettings.clipboardPolicy.flatMap {
+ guard $0.text == $0.image, $0.files == .off else { return nil }
+ return DoryDesktopClipboardPolicy(rawValue: $0.text.rawValue)
+ } ?? .bidirectional
+ runtimePreference = typedSettings.runtimePreference ?? .automatic
+ graphicsPreference = typedSettings.graphicsPreference ?? .automatic
mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly) }
}
@@ -688,7 +699,7 @@ private struct MachineEditSheet: View {
return MountPair(host: host, guest: guest, readOnly: row.readOnly)
}
if displayMode == .desktop, machine.bootMode != .efi {
- let previousUsername = environment["DORY_GUEST_USER"] ?? "dory"
+ let previousUsername = typedSettings.guestIdentityIntent.account?.username ?? "dory"
if previousUsername != normalizedGuestUsername {
let previousHome = "/home/\(previousUsername)"
let updatedHome = "/home/\(normalizedGuestUsername)"
@@ -703,18 +714,29 @@ private struct MachineEditSheet: View {
)
}
}
- environment["DORY_GUEST_USER"] = normalizedGuestUsername
- environment["DORY_GUEST_UID"] = environment["DORY_GUEST_UID"] ?? String(getuid())
- environment[DoryDesktopClipboardPolicy.environmentKey] = clipboardPolicy.rawValue
- environment[DoryDesktopVMMPreference.environmentKey] = runtimePreference.rawValue
- environment[DoryDesktopGraphicsPreference.environmentKey] = graphicsPreference.rawValue
- environment.removeValue(forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey)
+ typedSettings.guestIdentityIntent.account = DoryVMGuestAccountIntent(
+ username: normalizedGuestUsername,
+ numericUserID: typedSettings.guestIdentityIntent.account?.numericUserID
+ )
+ if typedSettings.clipboardPolicy != nil || clipboardPolicy != .bidirectional {
+ typedSettings.clipboardPolicy = .legacyDesktop(
+ DoryVMClipboardDirection(rawValue: clipboardPolicy.rawValue)
+ ?? .bidirectional
+ )
+ }
+ if typedSettings.runtimePreference != nil || runtimePreference != .automatic {
+ typedSettings.runtimePreference = runtimePreference
+ }
+ if typedSettings.graphicsPreference != nil || graphicsPreference != .automatic {
+ typedSettings.graphicsPreference = graphicsPreference
+ }
}
let settings = MachineSettings(
cpus: cpus,
memoryMB: memoryGB * 1024,
mounts: mounts,
- env: environment,
+ env: [:],
+ virtualMachineSettings: machine.bootMode == .efi ? nil : typedSettings,
address: address.trimmingCharacters(in: .whitespacesAndNewlines),
displayMode: displayMode,
bootMode: machine.bootMode
diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift
index 2593fab4..b5cea12b 100644
--- a/Dory/Features/Sheets/NewMachineSheet.swift
+++ b/Dory/Features/Sheets/NewMachineSheet.swift
@@ -871,23 +871,34 @@ struct NewMachineSheet: View {
guestUsername: String = "dory",
guestUID: uid_t = getuid()
) -> MachineSettings {
- var environment: [String: String] = [:]
+ let typedSettings: DorydMachineTypedSettings?
if displayMode == .desktop {
- environment["DORY_GUEST_USER"] = guestUsername
- environment["DORY_GUEST_UID"] = String(guestUID)
- environment["DORY_DESKTOP_DISTRO"] = desktopDistro.rawValue
- environment["DORY_DESKTOP_NAME"] = desktopDistro.displayName
- environment["DORY_DESKTOP_VERSION"] = desktopDistro.version
- environment["DORY_DESKTOP_ENVIRONMENT"] = desktopDistro.desktopName
- environment[DoryDesktopClipboardPolicy.environmentKey] = DoryDesktopClipboardPolicy.bidirectional.rawValue
- environment[DoryDesktopVMMPreference.environmentKey] = DoryDesktopVMMPreference.automatic.rawValue
- environment[DoryDesktopGraphicsPreference.environmentKey] = DoryDesktopGraphicsPreference.automatic.rawValue
+ typedSettings = DorydMachineTypedSettings(
+ guestIdentityIntent: DoryVMGuestIdentityIntent(
+ account: DoryVMGuestAccountIntent(
+ username: guestUsername,
+ numericUserID: UInt32(guestUID)
+ ),
+ desktop: DoryVMDesktopIdentityIntent(
+ distributionIdentifier: desktopDistro.rawValue,
+ displayName: desktopDistro.displayName,
+ version: desktopDistro.version,
+ desktopEnvironment: desktopDistro.desktopName
+ )
+ ),
+ clipboardPolicy: .legacyDesktop(.bidirectional),
+ runtimePreference: .automatic,
+ graphicsPreference: .automatic
+ )
+ } else {
+ typedSettings = nil
}
return MachineSettings(
cpus: cpus,
memoryMB: memoryGB * 1024,
mounts: mounts,
- env: environment,
+ env: [:],
+ virtualMachineSettings: typedSettings,
address: address,
displayMode: displayMode
)
@@ -930,7 +941,10 @@ struct NewMachineSheet: View {
settings.bootMode = .efi
settings.installerISOPath = installerISOPath
settings.diskSizeGB = diskSizeGB
- settings.env = ["DORY_CUSTOM_LINUX": "1"]
+ // EFI boot/media authority is explicit. It must never be inferred from a reserved
+ // environment marker or carry managed-desktop provisioning intent.
+ settings.env = [:]
+ settings.virtualMachineSettings = nil
}
return settings
}
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index b6362224..751e2420 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5079,6 +5079,10 @@ final class AppStore {
memoryMB: status.memoryMB.flatMap { Int(exactly: $0) },
mounts: status.shares.map(Self.mountPair(fromDoryd:)),
env: status.environment,
+ virtualMachineSettings: DorydMachineTypedSettings(
+ legacyEnvironment: status.environment,
+ displayMode: status.displayMode
+ ),
address: status.configuredAddress,
displayMode: status.displayMode,
bootMode: status.bootMode
@@ -5157,8 +5161,15 @@ final class AppStore {
.first { !$0.isEmpty } ?? status.state
let isDesktop = status.displayMode == .desktop
let isCustomLinux = status.bootMode == .efi
- let desktopDistro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"])
- let guestUsername = status.environment["DORY_GUEST_USER"] ?? (isCustomLinux ? "installer" : (isDesktop ? "dory" : "root"))
+ let typedSettings = DorydMachineTypedSettings(
+ legacyEnvironment: status.environment,
+ displayMode: status.displayMode
+ )
+ let desktopDistro = DesktopMachineDistro.resolve(
+ typedSettings.guestIdentityIntent.desktop?.distributionIdentifier
+ )
+ let guestUsername = typedSettings.guestIdentityIntent.account?.username
+ ?? (isCustomLinux ? "installer" : (isDesktop ? "dory" : "root"))
return Machine(
name: status.id,
distro: isCustomLinux ? "Custom Linux" : (isDesktop ? desktopDistro.displayName : "Dory Linux"),
@@ -5453,8 +5464,16 @@ final class AppStore {
var results: [DorydDesktopUpdateResult] = []
for status in desktopStatuses {
- guard status.environment["DORY_CUSTOM_LINUX"] != "1" else { continue }
- let distro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"])
+ // EFI machines are user-provided installer workspaces. Boot mode is authoritative;
+ // do not depend on the legacy DORY_CUSTOM_LINUX compatibility marker.
+ guard status.bootMode != .efi else { continue }
+ let typedSettings = DorydMachineTypedSettings(
+ legacyEnvironment: status.environment,
+ displayMode: status.displayMode
+ )
+ let distro = DesktopMachineDistro.resolve(
+ typedSettings.guestIdentityIntent.desktop?.distributionIdentifier
+ )
guard runtimeChanged || components.contains(distro.componentID) else { continue }
guard let distroRelease = try store.installedComponent(distro.componentID) else {
if components.contains(distro.componentID) {
@@ -5514,6 +5533,9 @@ final class AppStore {
nonisolated static func preservingHiddenMachineSettings(_ settings: MachineSettings, existing: MachineSettings) -> MachineSettings {
var copy = settings
if copy.env.isEmpty { copy.env = existing.env }
+ if copy.virtualMachineSettings == nil {
+ copy.virtualMachineSettings = existing.virtualMachineSettings
+ }
if copy.identity == nil { copy.identity = existing.identity }
if copy.address == nil { copy.address = existing.address }
return copy
@@ -5608,7 +5630,13 @@ final class AppStore {
address: address,
displayMode: settings.displayMode,
shares: dorydShares(from: settings.mounts),
- environment: sanitizedNewMachineEnvironment(settings.env)
+ typedSettings: settings.virtualMachineSettings
+ ?? (settings.bootMode == .efi
+ ? DorydMachineTypedSettings()
+ : DorydMachineTypedSettings(
+ legacyEnvironment: sanitizedNewMachineEnvironment(settings.env),
+ displayMode: settings.displayMode
+ ))
)
}
@@ -5632,7 +5660,10 @@ final class AppStore {
return message
}
} else if settings.displayMode == .desktop {
- let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"])
+ let distro = DesktopMachineDistro.resolve(
+ settings.virtualMachineSettings?.guestIdentityIntent.desktop?
+ .distributionIdentifier ?? settings.env["DORY_DESKTOP_DISTRO"]
+ )
guard AppInfo.componentAvailable(.linuxDesktop),
AppInfo.componentAvailable(distro.componentID) else {
let message = "Install the Linux Desktop runtime and \(distro.displayName) in Components first."
@@ -5719,7 +5750,10 @@ final class AppStore {
appendMachineCreationLog("Importing the installer ISO and creating a thin-provisioned virtual disk…")
desktopAssets = nil
} else if settings.displayMode == .desktop {
- let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"])
+ let distro = DesktopMachineDistro.resolve(
+ settings.virtualMachineSettings?.guestIdentityIntent.desktop?
+ .distributionIdentifier ?? settings.env["DORY_DESKTOP_DISTRO"]
+ )
appendMachineCreationLog("Preparing \(distro.displayName) \(distro.version) Desktop in the selected Dory data drive…")
let home = environment["HOME"] ?? NSHomeDirectory()
let resourceDirectory = Bundle.main.resourcePath
@@ -5810,11 +5844,21 @@ final class AppStore {
memoryMB: current?.memoryMB.flatMap { Int(exactly: $0) },
mounts: current?.shares.map(Self.mountPair(fromDoryd:)) ?? [],
env: current?.environment ?? [:],
+ virtualMachineSettings: current.map {
+ DorydMachineTypedSettings(
+ legacyEnvironment: $0.environment,
+ displayMode: $0.displayMode
+ )
+ },
address: current?.configuredAddress,
displayMode: current?.displayMode ?? machine.displayMode,
bootMode: current?.bootMode ?? machine.bootMode
)
let effectiveSettings = Self.preservingHiddenMachineSettings(settings, existing: currentSettings)
+ let baselineTypedSettings = currentSettings.virtualMachineSettings
+ ?? DorydMachineTypedSettings()
+ let desiredTypedSettings = effectiveSettings.virtualMachineSettings
+ ?? baselineTypedSettings
let memory = effectiveSettings.memoryMB.flatMap { UInt64(exactly: $0) } ?? current?.memoryMB
let cpus = effectiveSettings.cpus ?? current?.cpuCount
let address = Self.trimmedNonEmpty(effectiveSettings.address)
@@ -5825,7 +5869,10 @@ final class AppStore {
address: address,
updatesAddress: true,
shares: Self.dorydShares(from: effectiveSettings.mounts),
- environment: effectiveSettings.env
+ typedSettingsPatch: DorydMachineTypedSettingsPatch(
+ baseline: baselineTypedSettings,
+ desired: desiredTypedSettings
+ )
)
appendMachineCreationLog("Settings applied to doryd VM definition.")
activeSheet = nil
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index 243bcf45..ee8904df 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -1,5 +1,6 @@
@preconcurrency import Foundation
@preconcurrency import Security
+import DoryOperations
@objc(DorydHealthControl)
nonisolated protocol DorydControlXPC {
@@ -83,6 +84,256 @@ nonisolated struct DorydMachineShareConfiguration: Sendable, Equatable {
}
}
+nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable {
+ var guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified
+ var clipboardPolicy: DoryVMClipboardPolicy? = nil
+ var runtimePreference: DoryDesktopVMMPreference? = nil
+ var graphicsPreference: DoryDesktopGraphicsPreference? = nil
+
+ init(
+ guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified,
+ clipboardPolicy: DoryVMClipboardPolicy? = nil,
+ runtimePreference: DoryDesktopVMMPreference? = nil,
+ graphicsPreference: DoryDesktopGraphicsPreference? = nil
+ ) {
+ self.guestIdentityIntent = guestIdentityIntent
+ self.clipboardPolicy = clipboardPolicy
+ self.runtimePreference = runtimePreference
+ self.graphicsPreference = graphicsPreference
+ }
+
+ init(legacyEnvironment: [String: String], displayMode: MachineDisplayMode) {
+ let username = legacyEnvironment[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey]
+ .flatMap { DoryVMGuestAccountIntent.isValidUsername($0) ? $0 : nil }
+ let numericUserID = legacyEnvironment[
+ DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey
+ ].flatMap(UInt32.init).flatMap {
+ DoryVMGuestAccountIntent.isValidNumericUserID($0) ? $0 : nil
+ }
+ let account = DoryVMGuestAccountIntent(username: username, numericUserID: numericUserID)
+ let desktop: DoryVMDesktopIdentityIntent?
+ if displayMode == .desktop {
+ func safeLabel(_ key: String) -> String? {
+ legacyEnvironment[key].flatMap {
+ DoryVMDesktopIdentityIntent.isValidLabel($0) ? $0 : nil
+ }
+ }
+ let distribution = legacyEnvironment[
+ DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey
+ ].flatMap {
+ DoryVMDesktopIdentityIntent.isValidDistributionIdentifier($0) ? $0 : nil
+ }
+ let candidate = DoryVMDesktopIdentityIntent(
+ distributionIdentifier: distribution,
+ displayName: safeLabel(DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey),
+ version: safeLabel(DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey),
+ desktopEnvironment: safeLabel(
+ DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey
+ )
+ )
+ desktop = candidate.isValidForPersistence ? candidate : nil
+ } else {
+ desktop = nil
+ }
+ guestIdentityIntent = DoryVMGuestIdentityIntent(
+ account: account.isValidForPersistence ? account : nil,
+ desktop: desktop
+ )
+ if displayMode == .desktop {
+ let effectiveClipboard = DoryDesktopClipboardPolicy(
+ environment: legacyEnvironment
+ )
+ clipboardPolicy = DoryVMClipboardDirection(
+ rawValue: effectiveClipboard.rawValue
+ ).map(DoryVMClipboardPolicy.legacyDesktop)
+ runtimePreference = (try? DoryDesktopVMMPreference(
+ environment: legacyEnvironment
+ )) ?? .automatic
+ graphicsPreference = (try? DoryDesktopGraphicsPreference(
+ environment: legacyEnvironment
+ )) ?? .automatic
+ } else {
+ clipboardPolicy = nil
+ runtimePreference = nil
+ graphicsPreference = nil
+ }
+ }
+
+ var isEmpty: Bool {
+ guestIdentityIntent.isEmpty
+ && clipboardPolicy == nil
+ && runtimePreference == nil
+ && graphicsPreference == nil
+ }
+
+ var xpcDictionary: NSDictionary {
+ var result: [String: Any] = [:]
+ var identity: [String: Any] = [:]
+ if let account = guestIdentityIntent.account, !account.isEmpty {
+ var value: [String: Any] = [:]
+ if let username = account.username { value["username"] = username }
+ if let numericUserID = account.numericUserID {
+ value["numericUserID"] = numericUserID
+ }
+ identity["account"] = value as NSDictionary
+ }
+ if let desktop = guestIdentityIntent.desktop, !desktop.isEmpty {
+ var value: [String: Any] = [:]
+ if let distributionIdentifier = desktop.distributionIdentifier {
+ value["distributionIdentifier"] = distributionIdentifier
+ }
+ if let displayName = desktop.displayName { value["displayName"] = displayName }
+ if let version = desktop.version { value["version"] = version }
+ if let desktopEnvironment = desktop.desktopEnvironment {
+ value["desktopEnvironment"] = desktopEnvironment
+ }
+ identity["desktop"] = value as NSDictionary
+ }
+ if !identity.isEmpty { result["guestIdentityIntent"] = identity as NSDictionary }
+ if let clipboardPolicy {
+ result["clipboardPolicy"] = [
+ "text": clipboardPolicy.text.rawValue,
+ "image": clipboardPolicy.image.rawValue,
+ "files": clipboardPolicy.files.rawValue,
+ ] as NSDictionary
+ }
+ if let runtimePreference {
+ result["desktopRuntimePreference"] = runtimePreference.rawValue
+ }
+ if let graphicsPreference {
+ result["desktopGraphicsPreference"] = graphicsPreference.rawValue
+ }
+ return result as NSDictionary
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(guestIdentityIntent.account?.username)
+ hasher.combine(guestIdentityIntent.account?.numericUserID)
+ hasher.combine(guestIdentityIntent.desktop?.distributionIdentifier)
+ hasher.combine(guestIdentityIntent.desktop?.displayName)
+ hasher.combine(guestIdentityIntent.desktop?.version)
+ hasher.combine(guestIdentityIntent.desktop?.desktopEnvironment)
+ hasher.combine(clipboardPolicy?.text.rawValue)
+ hasher.combine(clipboardPolicy?.image.rawValue)
+ hasher.combine(clipboardPolicy?.files.rawValue)
+ hasher.combine(runtimePreference?.rawValue)
+ hasher.combine(graphicsPreference?.rawValue)
+ }
+}
+
+/// Leaf-level update authority. Comparing a safely migrated baseline to the desired typed state
+/// means an unrelated edit never clears an opaque or invalid legacy value that was intentionally
+/// omitted during migration.
+nonisolated struct DorydMachineTypedSettingsPatch: Sendable, Equatable {
+ var baseline: DorydMachineTypedSettings
+ var desired: DorydMachineTypedSettings
+
+ var isEmpty: Bool { xpcDictionary.count == 0 }
+
+ var xpcDictionary: NSDictionary {
+ var result: [String: Any] = [:]
+ var account: [String: Any] = [:]
+ Self.encode(
+ baseline.guestIdentityIntent.account?.username,
+ desired.guestIdentityIntent.account?.username,
+ key: "username",
+ into: &account
+ )
+ Self.encode(
+ baseline.guestIdentityIntent.account?.numericUserID,
+ desired.guestIdentityIntent.account?.numericUserID,
+ key: "numericUserID",
+ into: &account
+ )
+ var desktop: [String: Any] = [:]
+ Self.encode(
+ baseline.guestIdentityIntent.desktop?.distributionIdentifier,
+ desired.guestIdentityIntent.desktop?.distributionIdentifier,
+ key: "distributionIdentifier",
+ into: &desktop
+ )
+ Self.encode(
+ baseline.guestIdentityIntent.desktop?.displayName,
+ desired.guestIdentityIntent.desktop?.displayName,
+ key: "displayName",
+ into: &desktop
+ )
+ Self.encode(
+ baseline.guestIdentityIntent.desktop?.version,
+ desired.guestIdentityIntent.desktop?.version,
+ key: "version",
+ into: &desktop
+ )
+ Self.encode(
+ baseline.guestIdentityIntent.desktop?.desktopEnvironment,
+ desired.guestIdentityIntent.desktop?.desktopEnvironment,
+ key: "desktopEnvironment",
+ into: &desktop
+ )
+ if !account.isEmpty || !desktop.isEmpty {
+ var identity: [String: Any] = [:]
+ if !account.isEmpty { identity["account"] = account as NSDictionary }
+ if !desktop.isEmpty { identity["desktop"] = desktop as NSDictionary }
+ result["guestIdentityIntent"] = identity as NSDictionary
+ }
+ Self.encodePolicy(
+ baseline.clipboardPolicy,
+ desired.clipboardPolicy,
+ into: &result
+ )
+ Self.encodeEnum(
+ baseline.runtimePreference,
+ desired.runtimePreference,
+ key: "desktopRuntimePreference",
+ into: &result
+ )
+ Self.encodeEnum(
+ baseline.graphicsPreference,
+ desired.graphicsPreference,
+ key: "desktopGraphicsPreference",
+ into: &result
+ )
+ return result as NSDictionary
+ }
+
+ private static func encode(
+ _ baseline: Value?,
+ _ desired: Value?,
+ key: String,
+ into dictionary: inout [String: Any]
+ ) {
+ guard baseline != desired else { return }
+ dictionary[key] = desired ?? NSNull()
+ }
+
+ private static func encodePolicy(
+ _ baseline: DoryVMClipboardPolicy?,
+ _ desired: DoryVMClipboardPolicy?,
+ into dictionary: inout [String: Any]
+ ) {
+ guard baseline != desired else { return }
+ guard let desired else {
+ dictionary["clipboardPolicy"] = NSNull()
+ return
+ }
+ dictionary["clipboardPolicy"] = [
+ "text": desired.text.rawValue,
+ "image": desired.image.rawValue,
+ "files": desired.files.rawValue,
+ ] as NSDictionary
+ }
+
+ private static func encodeEnum(
+ _ baseline: Value?,
+ _ desired: Value?,
+ key: String,
+ into dictionary: inout [String: Any]
+ ) where Value.RawValue == String {
+ guard baseline != desired else { return }
+ dictionary[key] = desired?.rawValue ?? NSNull()
+ }
+}
+
nonisolated struct DorydMachineConfiguration: Sendable, Equatable {
var id: String
var kernelPath: String
@@ -95,7 +346,7 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable {
var address: String? = nil
var displayMode: MachineDisplayMode = .headless
var shares: [DorydMachineShareConfiguration] = []
- var environment: [String: String] = [:]
+ var typedSettings: DorydMachineTypedSettings = DorydMachineTypedSettings()
var xpcDictionary: NSDictionary {
var dictionary: [String: Any] = [
@@ -119,13 +370,8 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable {
if !shares.isEmpty {
dictionary["shares"] = shares.map(\.xpcDictionary)
}
- if !environment.isEmpty {
- dictionary["env"] = environment.sorted(by: { $0.key < $1.key }).map { key, value in
- [
- "key": key,
- "value": value,
- ] as NSDictionary
- }
+ for (rawKey, value) in typedSettings.xpcDictionary {
+ if let key = rawKey as? String { dictionary[key] = value }
}
return dictionary as NSDictionary
}
@@ -920,7 +1166,8 @@ nonisolated final class DorydClient: @unchecked Sendable {
address: String? = nil,
updatesAddress: Bool = false,
shares: [DorydMachineShareConfiguration]? = nil,
- environment: [String: String]? = nil,
+ typedSettings: DorydMachineTypedSettings? = nil,
+ typedSettingsPatch: DorydMachineTypedSettingsPatch? = nil,
installerMediaAttached: Bool? = nil
) async throws -> DorydMachineStatus {
var config: [String: Any] = [:]
@@ -938,12 +1185,15 @@ nonisolated final class DorydClient: @unchecked Sendable {
if let shares {
config["shares"] = shares.map(\.xpcDictionary)
}
- if let environment {
- config["env"] = environment.sorted(by: { $0.key < $1.key }).map { key, value in
- [
- "key": key,
- "value": value,
- ] as NSDictionary
+ if let typedSettings {
+ for (rawKey, value) in typedSettings.xpcDictionary {
+ if let key = rawKey as? String { config[key] = value }
+ }
+ }
+ if let typedSettingsPatch {
+ precondition(typedSettings == nil, "use either full typed settings or a typed patch")
+ for (rawKey, value) in typedSettingsPatch.xpcDictionary {
+ if let key = rawKey as? String { config[key] = value }
}
}
if let installerMediaAttached {
diff --git a/Dory/Runtime/Machines/MachineService.swift b/Dory/Runtime/Machines/MachineService.swift
index c09cb253..eed8562a 100644
--- a/Dory/Runtime/Machines/MachineService.swift
+++ b/Dory/Runtime/Machines/MachineService.swift
@@ -17,6 +17,9 @@ nonisolated struct MachineSettings: Sendable, Hashable {
var ports: [PortPair] = []
var identity: MacIdentity? = nil
var env: [String: String] = [:]
+ /// Closed, non-secret VM intent used by new doryd writes. `env` remains only so older
+ /// machine.json records and container recipes can be read without data loss.
+ var virtualMachineSettings: DorydMachineTypedSettings? = nil
var address: String? = nil
var displayMode: MachineDisplayMode = .headless
var bootMode: MachineBootMode = .linuxKernel
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index c947ca60..4e4c897d 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -1,9 +1,64 @@
import Foundation
+import DoryOperations
import Testing
@testable import Dory
@Suite(.serialized)
struct DorydClientTests {
+ @Test func legacyDesktopDefaultsAndTogglesRemainFieldLocalInTypedEdits() throws {
+ let defaults = DorydMachineTypedSettings(
+ legacyEnvironment: [:],
+ displayMode: .desktop
+ )
+ #expect(defaults.clipboardPolicy == .legacyDesktop(.bidirectional))
+ #expect(defaults.runtimePreference == .automatic)
+ #expect(defaults.graphicsPreference == .automatic)
+
+ for (legacy, expected) in [("1", DoryDesktopGraphicsPreference.virgl),
+ ("0", DoryDesktopGraphicsPreference.virglVenus)] {
+ let settings = DorydMachineTypedSettings(
+ legacyEnvironment: ["DORY_VIRGL_CLASSIC_ONLY": legacy],
+ displayMode: .desktop
+ )
+ #expect(settings.graphicsPreference == expected)
+ #expect(DorydMachineTypedSettingsPatch(
+ baseline: settings,
+ desired: settings
+ ).xpcDictionary["desktopGraphicsPreference"] == nil)
+ }
+
+ let hostile = [
+ "DORY_GUEST_USER": "../../unsafe-user",
+ "DORY_GUEST_UID": "not-a-uid",
+ "DORY_CLIPBOARD_POLICY": "invalid-and-must-remain",
+ "DORY_DESKTOP_VMM": "invalid-and-must-remain",
+ "DORY_DESKTOP_GRAPHICS": "invalid-and-must-remain",
+ ]
+ let baseline = DorydMachineTypedSettings(
+ legacyEnvironment: hostile,
+ displayMode: .desktop
+ )
+ #expect(baseline.guestIdentityIntent.account == nil)
+ #expect(baseline.clipboardPolicy == .disabled)
+ #expect(baseline.runtimePreference == .automatic)
+ #expect(baseline.graphicsPreference == .automatic)
+ var desired = baseline
+ desired.guestIdentityIntent.account = DoryVMGuestAccountIntent(
+ username: "developer"
+ )
+ let wire = DorydMachineTypedSettingsPatch(
+ baseline: baseline,
+ desired: desired
+ ).xpcDictionary
+ let identity = try #require(wire["guestIdentityIntent"] as? NSDictionary)
+ let account = try #require(identity["account"] as? NSDictionary)
+ #expect(account["username"] as? String == "developer")
+ #expect(account["numericUserID"] == nil)
+ #expect(wire["clipboardPolicy"] == nil)
+ #expect(wire["desktopRuntimePreference"] == nil)
+ #expect(wire["desktopGraphicsPreference"] == nil)
+ }
+
@Test func desktopAssetEnvironmentUsesSelectedDistribution() {
for distro in DesktopMachineDistro.allCases {
let environment = AppStore.desktopAssetEnvironment(
@@ -144,7 +199,23 @@ struct DorydClientTests {
shares: [
DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true),
],
- environment: ["FOO": "bar"]
+ typedSettings: DorydMachineTypedSettings(
+ guestIdentityIntent: DoryVMGuestIdentityIntent(
+ account: DoryVMGuestAccountIntent(
+ username: "developer",
+ numericUserID: 1_000
+ ),
+ desktop: DoryVMDesktopIdentityIntent(
+ distributionIdentifier: "ubuntu",
+ displayName: "Ubuntu",
+ version: "24.04",
+ desktopEnvironment: "GNOME"
+ )
+ ),
+ clipboardPolicy: .legacyDesktop(.bidirectional),
+ runtimePreference: .accelerated,
+ graphicsPreference: .virglVenus
+ )
))
let startedMachine = try await client.machineStart("dev")
let machineStats = try await client.machineStats("dev")
@@ -178,7 +249,14 @@ struct DorydClientTests {
memoryMB: 4096,
cpuCount: 4,
address: "192.168.215.41",
- environment: ["BAR": "baz"]
+ typedSettings: DorydMachineTypedSettings(
+ guestIdentityIntent: DoryVMGuestIdentityIntent(
+ account: DoryVMGuestAccountIntent(username: "builder")
+ ),
+ clipboardPolicy: .legacyDesktop(.hostToGuest),
+ runtimePreference: .compatible,
+ graphicsPreference: .software
+ )
)
let machines = try await client.machineList()
let deletedMachine = try await client.machineDelete("dev")
@@ -247,7 +325,8 @@ struct DorydClientTests {
#expect(startedMachine.shares == [
DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true),
])
- #expect(startedMachine.environment == ["FOO": "bar"])
+ #expect(startedMachine.environment["DORY_GUEST_USER"] == "developer")
+ #expect(startedMachine.environment["DORY_DESKTOP_DISTRO"] == "ubuntu")
#expect(startedMachine.displayMode == .desktop)
#expect(execResult.stdout == "cargo 1.0\n")
#expect(execResult.exitCode == 0)
@@ -276,7 +355,10 @@ struct DorydClientTests {
#expect(updatedMachine.memoryMB == 4096)
#expect(updatedMachine.cpuCount == 4)
#expect(updatedMachine.address == "192.168.215.41")
- #expect(updatedMachine.environment == ["BAR": "baz"])
+ #expect(updatedMachine.environment["DORY_GUEST_USER"] == "builder")
+ #expect(updatedMachine.environment["DORY_CLIPBOARD_POLICY"] == "host-to-guest")
+ #expect(updatedMachine.environment["DORY_DESKTOP_VMM"] == "compatible")
+ #expect(updatedMachine.environment["DORY_DESKTOP_GRAPHICS"] == "software")
#expect(machines.map(\.id) == ["dev", "dev-copy"])
#expect(deletedMachine == DorydCommandResult(ok: true, message: ""))
#expect(remoteInfo.agentBuild == "remote-agent")
@@ -669,9 +751,7 @@ struct DorydClientTests {
#expect(updateShares.first?["hostPath"] as? String == "/Users/me/app")
#expect(updateShares.first?["guestPath"] as? String == "/workspace/app")
#expect(updateShares.first?["readOnly"] as? Bool == false)
- let updateEnv = try #require(service.latestMachineUpdateConfig?["env"] as? [NSDictionary])
- #expect(updateEnv.first?["key"] as? String == "ANTHROPIC_API_KEY")
- #expect(updateEnv.first?["value"] as? String == "test-token")
+ #expect(service.latestMachineUpdateConfig?["env"] == nil)
machine = try #require(store.machines.first { $0.name == "dev" })
let clearAddressResult = await store.editMachine(
@@ -900,16 +980,10 @@ struct DorydClientTests {
let createShares = try #require(config["shares"] as? [NSDictionary])
#expect(createShares.first?["hostPath"] as? String == "/Users/me/project")
#expect(createShares.first?["guestPath"] as? String == "/workspace/project")
- let createEnv = try #require(config["env"] as? [NSDictionary])
- let createEnvValues: [String: String] = Dictionary(
- uniqueKeysWithValues: createEnv.compactMap { entry -> (String, String)? in
- guard let key = entry["key"] as? String,
- let value = entry["value"] as? String else { return nil }
- return (key, value)
- }
- )
- #expect(createEnvValues["APP_ENV"] == "dev")
- #expect(createEnvValues["DORY_DESKTOP_DISTRO"] == "ubuntu")
+ #expect(config["env"] == nil)
+ let identity = try #require(config["guestIdentityIntent"] as? NSDictionary)
+ let desktop = try #require(identity["desktop"] as? NSDictionary)
+ #expect(desktop["distributionIdentifier"] as? String == "ubuntu")
try await waitUntil {
store.machines.first { $0.name == "vmdev" }?.status == .running
@@ -919,6 +993,113 @@ struct DorydClientTests {
#expect(store.machineCreationLog.contains("cargo 1.0"))
}
+ @MainActor
+ @Test func appStoreEditsTypedLeavesWithoutRewritingLegacyEnvironment() async throws {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService()
+ service.setMachineEnvironment("dev", [
+ "DORY_GUEST_USER": "dory",
+ "DORY_GUEST_UID": "not-a-uid",
+ "DORY_DESKTOP_DISTRO": "ubuntu",
+ "DORY_DESKTOP_NAME": "Ubuntu",
+ "DORY_DESKTOP_VERSION": "24.04 LTS",
+ "DORY_DESKTOP_ENVIRONMENT": "GNOME",
+ "DORY_CLIPBOARD_POLICY": "invalid-clipboard",
+ "DORY_DESKTOP_VMM": "invalid-runtime",
+ "DORY_DESKTOP_GRAPHICS": "invalid-graphics",
+ "OPAQUE_LEGACY": "preserve-exactly",
+ ])
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+
+ let store = AppStore(
+ dorydClient: DorydClient(endpoint: listener.endpoint),
+ useDorydEngine: true
+ )
+ store.routeDockerCLI = false
+ await store.connectBackend()
+ let machine = try #require(store.machines.first { $0.name == "dev" })
+ let baseline = await store.machineSettings("dev")
+ let desktop = try #require(
+ baseline.virtualMachineSettings?.guestIdentityIntent.desktop
+ )
+ var desired = try #require(baseline.virtualMachineSettings)
+ desired.guestIdentityIntent = DoryVMGuestIdentityIntent(
+ account: DoryVMGuestAccountIntent(username: "builder"),
+ desktop: desktop
+ )
+
+ let result = await store.editMachine(
+ machine,
+ settings: MachineSettings(
+ cpus: baseline.cpus,
+ memoryMB: baseline.memoryMB,
+ mounts: baseline.mounts,
+ env: [:],
+ virtualMachineSettings: desired,
+ address: baseline.address,
+ displayMode: .desktop
+ )
+ )
+
+ #expect(result == nil)
+ let update = try #require(service.latestMachineUpdateConfig)
+ #expect(update["env"] == nil)
+ let identity = try #require(update["guestIdentityIntent"] as? NSDictionary)
+ let account = try #require(identity["account"] as? NSDictionary)
+ #expect(account["username"] as? String == "builder")
+ #expect(account["numericUserID"] == nil)
+ #expect(identity["desktop"] == nil)
+ #expect(update["clipboardPolicy"] == nil)
+ #expect(update["desktopRuntimePreference"] == nil)
+ #expect(update["desktopGraphicsPreference"] == nil)
+
+ let persisted = try #require(
+ (try await DorydClient(endpoint: listener.endpoint).machineList())
+ .first { $0.id == "dev" }
+ )
+ #expect(persisted.environment["DORY_GUEST_USER"] == "builder")
+ #expect(persisted.environment["DORY_GUEST_UID"] == "not-a-uid")
+ #expect(persisted.environment["DORY_DESKTOP_DISTRO"] == "ubuntu")
+ #expect(persisted.environment["DORY_DESKTOP_NAME"] == "Ubuntu")
+ #expect(persisted.environment["DORY_DESKTOP_VERSION"] == "24.04 LTS")
+ #expect(persisted.environment["DORY_DESKTOP_ENVIRONMENT"] == "GNOME")
+ #expect(persisted.environment["DORY_CLIPBOARD_POLICY"] == "invalid-clipboard")
+ #expect(persisted.environment["DORY_DESKTOP_VMM"] == "invalid-runtime")
+ #expect(persisted.environment["DORY_DESKTOP_GRAPHICS"] == "invalid-graphics")
+ #expect(persisted.environment["OPAQUE_LEGACY"] == "preserve-exactly")
+
+ let afterUsernameEdit = await store.machineSettings("dev")
+ var explicitRuntimeEdit = try #require(
+ afterUsernameEdit.virtualMachineSettings
+ )
+ explicitRuntimeEdit.clipboardPolicy = .legacyDesktop(.hostToGuest)
+ explicitRuntimeEdit.runtimePreference = .compatible
+ explicitRuntimeEdit.graphicsPreference = .software
+ let secondResult = await store.editMachine(
+ machine,
+ settings: MachineSettings(
+ cpus: afterUsernameEdit.cpus,
+ memoryMB: afterUsernameEdit.memoryMB,
+ mounts: afterUsernameEdit.mounts,
+ env: [:],
+ virtualMachineSettings: explicitRuntimeEdit,
+ address: afterUsernameEdit.address,
+ displayMode: .desktop
+ )
+ )
+ #expect(secondResult == nil)
+ let explicitUpdate = try #require(service.latestMachineUpdateConfig)
+ #expect(explicitUpdate["desktopRuntimePreference"] as? String == "compatible")
+ #expect(explicitUpdate["desktopGraphicsPreference"] as? String == "software")
+ let clipboard = try #require(
+ explicitUpdate["clipboardPolicy"] as? NSDictionary
+ )
+ #expect(clipboard["text"] as? String == "host-to-guest")
+ }
+
@MainActor
@Test func failedRequiredMachineProvisioningRollsBackNewDefinition() async throws {
let base = "/tmp/damc-provision-failure-\(getpid())-\(UInt32.random(in: 0.. (String, String)? in
- guard let key = row["key"] as? String, let value = row["value"] as? String else { return nil }
- return (key, value)
- })
- #expect(env["ANTHROPIC_API_KEY"] == nil)
- #expect(env["GH_TOKEN"] == nil)
- #expect(env["EMPTY_TOKEN"] == nil)
- #expect(env["DORY_DESKTOP_DISTRO"] == "ubuntu")
+ #expect(config["env"] == nil)
+ #expect(config["guestIdentityIntent"] == nil)
}
@MainActor
- @Test func dorydMachineConfigurationRequiresKernelAndRootfsAndUsesSettingsDefaults() {
+ @Test func dorydMachineConfigurationRequiresKernelAndRootfsAndUsesSettingsDefaults() throws {
#expect(AppStore.dorydMachineConfiguration(
name: "vmdev",
settings: .default,
@@ -1050,7 +1224,7 @@ struct DorydClientTests {
rootfsPath: "/vm/rootfs.raw",
memoryMB: 3072,
cpuCount: 3,
- environment: ["APP_ENV": "dev"]
+ typedSettings: DorydMachineTypedSettings()
))
let invalidResources = AppStore.dorydMachineConfiguration(
@@ -1078,6 +1252,26 @@ struct DorydClientTests {
)
#expect(malformedResources?.memoryMB == 0)
#expect(malformedResources?.cpuCount == 0)
+
+ let customEFI = AppStore.dorydMachineConfiguration(
+ name: "omarchy",
+ settings: MachineSettings(
+ cpus: 4,
+ memoryMB: 4_096,
+ env: ["DORY_CUSTOM_LINUX": "1", "TOKEN": "must-not-cross"],
+ displayMode: .desktop,
+ bootMode: .efi,
+ installerISOPath: "/staged/omarchy.iso",
+ diskSizeGB: 64
+ ),
+ environment: [:]
+ )
+ let custom = try #require(customEFI)
+ #expect(custom.bootMode == .efi)
+ #expect(custom.installerISOPath == "/staged/omarchy.iso")
+ #expect(custom.typedSettings.isEmpty)
+ #expect(custom.xpcDictionary["env"] == nil)
+ #expect(custom.xpcDictionary["guestIdentityIntent"] == nil)
}
@MainActor
@@ -1975,6 +2169,27 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
lock.lock(); defer { lock.unlock() }
return _machineStartCount
}
+
+ func setMachineEnvironment(_ machineID: String, _ environment: [String: String]) {
+ lock.lock()
+ defer { lock.unlock() }
+ guard let current = machines[machineID] else { return }
+ machines[machineID] = Self.machineRow(
+ id: machineID,
+ state: current["state"] as? String ?? "stopped",
+ pid: (current["pid"] as? NSNumber)?.int32Value,
+ agentBuild: current["agentBuild"] as? String,
+ handoffFDCount: (current["handoffFDCount"] as? NSNumber)?.intValue ?? 0,
+ memoryMB: Self.uint64(current["memoryMB"]) ?? 2_048,
+ cpuCount: Self.int(current["cpuCount"]) ?? 2,
+ address: current["address"] as? String,
+ displayMode: current["displayMode"] as? String ?? "headless",
+ shares: Self.shareRows(current["shares"]),
+ environment: environment.sorted { $0.key < $1.key }.map {
+ ["key": $0.key, "value": $0.value] as NSDictionary
+ }
+ )
+ }
var machineStopCount: Int {
lock.lock(); defer { lock.unlock() }
return _machineStopCount
@@ -2183,7 +2398,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
address: config["address"] as? String,
displayMode: config["displayMode"] as? String ?? "headless",
shares: Self.shareRows(config["shares"]),
- environment: Self.environmentRows(config["env"])
+ environment: Self.typedEnvironment(config, baseline: [])
)
lock.lock()
_machineCreateCount += 1
@@ -2251,7 +2466,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
?? 2
let address = config["address"] == nil ? current["address"] as? String : config["address"] as? String
let shares = config["shares"] == nil ? Self.shareRows(current["shares"]) : Self.shareRows(config["shares"])
- let environment = config["env"] == nil ? Self.environmentRows(current["env"]) : Self.environmentRows(config["env"])
+ let environment = Self.typedEnvironment(
+ config,
+ baseline: Self.environmentRows(current["env"])
+ )
let state = current["state"] as? String ?? "stopped"
let row = Self.machineRow(
id: machineID,
@@ -2844,6 +3062,71 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
return []
}
+ private static func typedEnvironment(
+ _ config: NSDictionary,
+ baseline: [NSDictionary]
+ ) -> [NSDictionary] {
+ var environment = Dictionary(uniqueKeysWithValues: baseline.compactMap {
+ row -> (String, String)? in
+ guard let key = row["key"] as? String,
+ let value = row["value"] as? String else { return nil }
+ return (key, value)
+ })
+ if let identity = config["guestIdentityIntent"] as? NSDictionary {
+ if let account = identity["account"] as? NSDictionary {
+ apply(account["username"], key: "DORY_GUEST_USER", to: &environment)
+ let uid = (account["numericUserID"] as? NSNumber)?.stringValue
+ ?? (account["numericUserID"] as? UInt32).map(String.init)
+ if account["numericUserID"] != nil {
+ apply(uid ?? NSNull(), key: "DORY_GUEST_UID", to: &environment)
+ }
+ }
+ if let desktop = identity["desktop"] as? NSDictionary {
+ apply(
+ desktop["distributionIdentifier"],
+ key: "DORY_DESKTOP_DISTRO",
+ to: &environment
+ )
+ apply(desktop["displayName"], key: "DORY_DESKTOP_NAME", to: &environment)
+ apply(desktop["version"], key: "DORY_DESKTOP_VERSION", to: &environment)
+ apply(
+ desktop["desktopEnvironment"],
+ key: "DORY_DESKTOP_ENVIRONMENT",
+ to: &environment
+ )
+ }
+ }
+ if let clipboard = config["clipboardPolicy"] as? NSDictionary {
+ apply(clipboard["text"], key: "DORY_CLIPBOARD_POLICY", to: &environment)
+ }
+ apply(
+ config["desktopRuntimePreference"],
+ key: "DORY_DESKTOP_VMM",
+ to: &environment
+ )
+ apply(
+ config["desktopGraphicsPreference"],
+ key: "DORY_DESKTOP_GRAPHICS",
+ to: &environment
+ )
+ return environment.sorted { $0.key < $1.key }.map { key, value in
+ ["key": key, "value": value] as NSDictionary
+ }
+ }
+
+ private static func apply(
+ _ raw: Any?,
+ key: String,
+ to environment: inout [String: String]
+ ) {
+ guard let raw else { return }
+ if raw is NSNull {
+ environment.removeValue(forKey: key)
+ } else if let value = raw as? String {
+ environment[key] = value
+ }
+ }
+
private static func uint64(_ value: Any?) -> UInt64? {
if let number = value as? NSNumber { return number.uint64Value }
return value as? UInt64
diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift
index 6ca32090..073b6b17 100644
--- a/DoryTests/NewMachineSettingsTests.swift
+++ b/DoryTests/NewMachineSettingsTests.swift
@@ -41,13 +41,14 @@ struct NewMachineSettingsTests {
#expect(s.mounts.count == 1)
#expect(s.address == "192.168.215.40")
#expect(s.displayMode == .desktop)
- #expect(s.env["DORY_GUEST_USER"] == "dory")
- #expect(s.env["DORY_GUEST_UID"] == String(getuid()))
- #expect(s.env["DORY_DESKTOP_DISTRO"] == "debian")
- #expect(s.env["DORY_DESKTOP_VERSION"] == "13")
- #expect(s.env[DoryDesktopClipboardPolicy.environmentKey] == "bidirectional")
- #expect(s.env[DoryDesktopVMMPreference.environmentKey] == "auto")
- #expect(s.env[DoryDesktopGraphicsPreference.environmentKey] == "auto")
+ #expect(s.env.isEmpty)
+ #expect(s.virtualMachineSettings?.guestIdentityIntent.account?.username == "dory")
+ #expect(s.virtualMachineSettings?.guestIdentityIntent.account?.numericUserID == UInt32(getuid()))
+ #expect(s.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "debian")
+ #expect(s.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "13")
+ #expect(s.virtualMachineSettings?.clipboardPolicy == .legacyDesktop(.bidirectional))
+ #expect(s.virtualMachineSettings?.runtimePreference == .automatic)
+ #expect(s.virtualMachineSettings?.graphicsPreference == .automatic)
#expect(s.ports.isEmpty)
}
@@ -62,12 +63,13 @@ struct NewMachineSettingsTests {
guestUID: 1_001
)
- #expect(settings.env["DORY_DESKTOP_DISTRO"] == "kali")
- #expect(settings.env["DORY_DESKTOP_NAME"] == "Kali Linux")
- #expect(settings.env["DORY_DESKTOP_VERSION"] == "Rolling")
- #expect(settings.env["DORY_DESKTOP_ENVIRONMENT"] == "Xfce")
- #expect(settings.env["DORY_GUEST_USER"] == "analyst")
- #expect(settings.env["DORY_GUEST_UID"] == "1001")
+ #expect(settings.env.isEmpty)
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "kali")
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.displayName == "Kali Linux")
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "Rolling")
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.desktopEnvironment == "Xfce")
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.account?.username == "analyst")
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.account?.numericUserID == 1_001)
}
@Test func recordsUbuntuAsTheCanonicalGnomeDesktop() {
@@ -81,10 +83,11 @@ struct NewMachineSettingsTests {
guestUID: 1_002
)
- #expect(settings.env["DORY_DESKTOP_DISTRO"] == "ubuntu")
- #expect(settings.env["DORY_DESKTOP_NAME"] == "Ubuntu")
- #expect(settings.env["DORY_DESKTOP_VERSION"] == "24.04 LTS")
- #expect(settings.env["DORY_DESKTOP_ENVIRONMENT"] == "GNOME")
+ #expect(settings.env.isEmpty)
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "ubuntu")
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.displayName == "Ubuntu")
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "24.04 LTS")
+ #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.desktopEnvironment == "GNOME")
}
@Test func headlessServersDoNotCarryDesktopMetadata() {
@@ -95,10 +98,7 @@ struct NewMachineSettingsTests {
displayMode: .headless
)
- #expect(settings.env["DORY_DESKTOP_DISTRO"] == nil)
- #expect(settings.env["DORY_GUEST_USER"] == nil)
- #expect(settings.env[DoryDesktopClipboardPolicy.environmentKey] == nil)
- #expect(settings.env[DoryDesktopVMMPreference.environmentKey] == nil)
- #expect(settings.env[DoryDesktopGraphicsPreference.environmentKey] == nil)
+ #expect(settings.env.isEmpty)
+ #expect(settings.virtualMachineSettings == nil)
}
}
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
new file mode 100644
index 00000000..b7aaf3fc
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
@@ -0,0 +1,689 @@
+import DoryOperations
+import Foundation
+
+public enum DoryMachineTypedWriteAuthorityError: Error, Sendable, Equatable, CustomStringConvertible {
+ case rawEnvironmentForbidden
+ case invalidField(String)
+ case unsupportedForDisplay(String)
+ case unsupportedByLegacyRuntime(String)
+
+ public var description: String {
+ switch self {
+ case .rawEnvironmentForbidden:
+ "raw machine environment input is not accepted; use typed machine settings"
+ case let .invalidField(field):
+ "invalid typed machine setting: \(field)"
+ case let .unsupportedForDisplay(field):
+ "typed machine setting \(field) is not supported by this display mode"
+ case let .unsupportedByLegacyRuntime(field):
+ "typed machine setting \(field) cannot be represented by the current compatibility runtime"
+ }
+ }
+}
+
+public enum DoryMachineTypedSettingUpdate: Sendable, Equatable {
+ case unchanged
+ case set(Value)
+ case clear
+
+ public var isChanged: Bool {
+ if case .unchanged = self { return false }
+ return true
+ }
+}
+
+/// Typed public write authority for the compatibility MachineManager.
+///
+/// MachineManager still reads legacy `machine.json` environment values while M0 migration is in
+/// progress. Public callers never provide that dictionary: this patch accepts only non-secret,
+/// bounded intent and back-projects the seven explicitly owned compatibility keys. Unchanged
+/// fields preserve their exact legacy bytes, including values too old or unsafe to migrate.
+public struct DoryMachineTypedSettingsPatch: Sendable, Equatable {
+ public var guestUsername: DoryMachineTypedSettingUpdate
+ public var guestNumericUserID: DoryMachineTypedSettingUpdate
+ public var desktopDistributionIdentifier: DoryMachineTypedSettingUpdate
+ public var desktopDisplayName: DoryMachineTypedSettingUpdate
+ public var desktopVersion: DoryMachineTypedSettingUpdate
+ public var desktopEnvironment: DoryMachineTypedSettingUpdate
+ public var clipboardPolicy: DoryMachineTypedSettingUpdate
+ public var runtimePreference: DoryMachineTypedSettingUpdate
+ public var graphicsPreference: DoryMachineTypedSettingUpdate
+
+ public init(
+ guestUsername: DoryMachineTypedSettingUpdate = .unchanged,
+ guestNumericUserID: DoryMachineTypedSettingUpdate = .unchanged,
+ desktopDistributionIdentifier: DoryMachineTypedSettingUpdate = .unchanged,
+ desktopDisplayName: DoryMachineTypedSettingUpdate = .unchanged,
+ desktopVersion: DoryMachineTypedSettingUpdate = .unchanged,
+ desktopEnvironment: DoryMachineTypedSettingUpdate = .unchanged,
+ clipboardPolicy: DoryMachineTypedSettingUpdate = .unchanged,
+ runtimePreference: DoryMachineTypedSettingUpdate = .unchanged,
+ graphicsPreference: DoryMachineTypedSettingUpdate = .unchanged
+ ) {
+ self.guestUsername = guestUsername
+ self.guestNumericUserID = guestNumericUserID
+ self.desktopDistributionIdentifier = desktopDistributionIdentifier
+ self.desktopDisplayName = desktopDisplayName
+ self.desktopVersion = desktopVersion
+ self.desktopEnvironment = desktopEnvironment
+ self.clipboardPolicy = clipboardPolicy
+ self.runtimePreference = runtimePreference
+ self.graphicsPreference = graphicsPreference
+ }
+
+ public var isEmpty: Bool {
+ !guestUsername.isChanged
+ && !guestNumericUserID.isChanged
+ && !desktopDistributionIdentifier.isChanged
+ && !desktopDisplayName.isChanged
+ && !desktopVersion.isChanged
+ && !desktopEnvironment.isChanged
+ && !clipboardPolicy.isChanged
+ && !runtimePreference.isChanged
+ && !graphicsPreference.isChanged
+ }
+
+ /// Consume the typed persistent-machine options shared by dorydctl create and update. Other
+ /// arguments remain in place for the command parser. This intentionally has no `--env`
+ /// escape hatch; execution-scoped command environments use a separate API.
+ public static func consumeCLIArguments(
+ _ arguments: inout [String],
+ allowsClears: Bool
+ ) throws -> Self {
+ var patch = Self()
+ if let value = try takeOption("--guest-user", from: &arguments) {
+ guard DoryVMGuestAccountIntent.isValidUsername(value) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--guest-user")
+ }
+ patch.guestUsername = .set(value)
+ }
+ if let raw = try takeOption("--guest-uid", from: &arguments) {
+ guard let value = UInt32(raw),
+ DoryVMGuestAccountIntent.isValidNumericUserID(value) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--guest-uid")
+ }
+ patch.guestNumericUserID = .set(value)
+ }
+ if let value = try takeOption("--desktop-distro", from: &arguments) {
+ guard DoryVMDesktopIdentityIntent.isValidDistributionIdentifier(value) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--desktop-distro")
+ }
+ patch.desktopDistributionIdentifier = .set(value)
+ }
+ let labelOptions: [(String, WritableKeyPath>)] = [
+ ("--desktop-name", \Self.desktopDisplayName),
+ ("--desktop-version", \Self.desktopVersion),
+ ("--desktop-environment", \Self.desktopEnvironment),
+ ]
+ for (option, keyPath) in labelOptions {
+ if let value = try takeOption(option, from: &arguments) {
+ guard DoryVMDesktopIdentityIntent.isValidLabel(value) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(option)
+ }
+ patch[keyPath: keyPath] = .set(value)
+ }
+ }
+ if let raw = try takeOption("--clipboard", from: &arguments) {
+ guard let direction = DoryVMClipboardDirection(rawValue: raw) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--clipboard")
+ }
+ patch.clipboardPolicy = .set(.legacyDesktop(direction))
+ }
+ if let raw = try takeOption("--runtime", from: &arguments) {
+ guard let preference = DoryDesktopVMMPreference(rawValue: raw) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--runtime")
+ }
+ patch.runtimePreference = .set(preference)
+ }
+ if let raw = try takeOption("--graphics", from: &arguments) {
+ guard let preference = DoryDesktopGraphicsPreference(rawValue: raw) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--graphics")
+ }
+ patch.graphicsPreference = .set(preference)
+ }
+
+ let clearsAccount = takeFlag("--clear-guest-account", from: &arguments)
+ let clearsDesktop = takeFlag("--clear-desktop-identity", from: &arguments)
+ let clearsClipboard = takeFlag("--clear-clipboard", from: &arguments)
+ let clearsRuntime = takeFlag("--clear-runtime", from: &arguments)
+ let clearsGraphics = takeFlag("--clear-graphics", from: &arguments)
+ guard allowsClears || (!clearsAccount && !clearsDesktop && !clearsClipboard
+ && !clearsRuntime && !clearsGraphics) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("clear options")
+ }
+ if clearsAccount {
+ guard !patch.guestUsername.isChanged, !patch.guestNumericUserID.isChanged else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "--clear-guest-account"
+ )
+ }
+ patch.guestUsername = .clear
+ patch.guestNumericUserID = .clear
+ }
+ if clearsDesktop {
+ guard !patch.desktopDistributionIdentifier.isChanged,
+ !patch.desktopDisplayName.isChanged,
+ !patch.desktopVersion.isChanged,
+ !patch.desktopEnvironment.isChanged else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "--clear-desktop-identity"
+ )
+ }
+ patch.desktopDistributionIdentifier = .clear
+ patch.desktopDisplayName = .clear
+ patch.desktopVersion = .clear
+ patch.desktopEnvironment = .clear
+ }
+ if clearsClipboard {
+ guard !patch.clipboardPolicy.isChanged else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-clipboard")
+ }
+ patch.clipboardPolicy = .clear
+ }
+ if clearsRuntime {
+ guard !patch.runtimePreference.isChanged else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-runtime")
+ }
+ patch.runtimePreference = .clear
+ }
+ if clearsGraphics {
+ guard !patch.graphicsPreference.isChanged else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("--clear-graphics")
+ }
+ patch.graphicsPreference = .clear
+ }
+ return patch
+ }
+
+ /// Decode the exact XPC write shape. Create requests disallow clears; update requests encode
+ /// a deliberate clear as `NSNull`. Unknown nested keys are rejected rather than ignored.
+ public init(xpcDictionary dictionary: NSDictionary, allowsClears: Bool) throws {
+ guard dictionary["env"] == nil else {
+ throw DoryMachineTypedWriteAuthorityError.rawEnvironmentForbidden
+ }
+ self.init()
+ if let rawGuestIdentity = dictionary["guestIdentityIntent"] {
+ try decodeGuestIdentity(rawGuestIdentity, allowsClears: allowsClears)
+ }
+ if let rawClipboard = dictionary["clipboardPolicy"] {
+ clipboardPolicy = try Self.decodeClipboardPolicy(
+ rawClipboard,
+ allowsClears: allowsClears
+ )
+ }
+ runtimePreference = try Self.decodeEnum(
+ dictionary["desktopRuntimePreference"],
+ field: "desktopRuntimePreference",
+ allowsClears: allowsClears,
+ type: DoryDesktopVMMPreference.self
+ )
+ graphicsPreference = try Self.decodeEnum(
+ dictionary["desktopGraphicsPreference"],
+ field: "desktopGraphicsPreference",
+ allowsClears: allowsClears,
+ type: DoryDesktopGraphicsPreference.self
+ )
+ }
+
+ /// Canonical XPC representation used by dorydctl and future typed clients.
+ public var xpcDictionary: NSDictionary {
+ var result: [String: Any] = [:]
+ var account: [String: Any] = [:]
+ Self.encode(guestUsername, key: "username", into: &account)
+ Self.encode(guestNumericUserID, key: "numericUserID", into: &account)
+ var desktop: [String: Any] = [:]
+ Self.encode(
+ desktopDistributionIdentifier,
+ key: "distributionIdentifier",
+ into: &desktop
+ )
+ Self.encode(desktopDisplayName, key: "displayName", into: &desktop)
+ Self.encode(desktopVersion, key: "version", into: &desktop)
+ Self.encode(desktopEnvironment, key: "desktopEnvironment", into: &desktop)
+ if !account.isEmpty || !desktop.isEmpty {
+ var identity: [String: Any] = [:]
+ if !account.isEmpty { identity["account"] = account as NSDictionary }
+ if !desktop.isEmpty { identity["desktop"] = desktop as NSDictionary }
+ result["guestIdentityIntent"] = identity as NSDictionary
+ }
+ switch clipboardPolicy {
+ case .unchanged:
+ break
+ case .clear:
+ result["clipboardPolicy"] = NSNull()
+ case let .set(policy):
+ result["clipboardPolicy"] = [
+ "text": policy.text.rawValue,
+ "image": policy.image.rawValue,
+ "files": policy.files.rawValue,
+ ] as NSDictionary
+ }
+ Self.encodeEnum(
+ runtimePreference,
+ key: "desktopRuntimePreference",
+ into: &result
+ )
+ Self.encodeEnum(
+ graphicsPreference,
+ key: "desktopGraphicsPreference",
+ into: &result
+ )
+ return result as NSDictionary
+ }
+
+ public func applying(
+ to legacyEnvironment: [String: String],
+ displayMode: DoryMachineDisplayMode
+ ) throws -> [String: String] {
+ try validate(displayMode: displayMode)
+ var environment = legacyEnvironment
+ Self.apply(
+ guestUsername,
+ key: DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey,
+ to: &environment
+ )
+ Self.apply(
+ guestNumericUserID.map(String.init),
+ key: DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey,
+ to: &environment
+ )
+ Self.apply(
+ desktopDistributionIdentifier,
+ key: DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey,
+ to: &environment
+ )
+ Self.apply(
+ desktopDisplayName,
+ key: DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey,
+ to: &environment
+ )
+ Self.apply(
+ desktopVersion,
+ key: DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey,
+ to: &environment
+ )
+ Self.apply(
+ desktopEnvironment,
+ key: DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey,
+ to: &environment
+ )
+ switch clipboardPolicy {
+ case .unchanged:
+ break
+ case .clear:
+ environment.removeValue(forKey: DoryDesktopClipboardPolicy.environmentKey)
+ case let .set(policy):
+ environment[DoryDesktopClipboardPolicy.environmentKey] = policy.text.rawValue
+ }
+ Self.apply(
+ runtimePreference.map(\.rawValue),
+ key: DoryDesktopVMMPreference.environmentKey,
+ to: &environment
+ )
+ Self.apply(
+ graphicsPreference.map(\.rawValue),
+ key: DoryDesktopGraphicsPreference.environmentKey,
+ to: &environment
+ )
+ if graphicsPreference.isChanged {
+ environment.removeValue(
+ forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey
+ )
+ }
+ return environment
+ }
+
+ private mutating func decodeGuestIdentity(_ raw: Any, allowsClears: Bool) throws {
+ if raw is NSNull {
+ guard allowsClears else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("guestIdentityIntent")
+ }
+ guestUsername = .clear
+ guestNumericUserID = .clear
+ desktopDistributionIdentifier = .clear
+ desktopDisplayName = .clear
+ desktopVersion = .clear
+ desktopEnvironment = .clear
+ return
+ }
+ guard let identity = Self.dictionary(raw),
+ Self.hasOnlyKeys(identity, allowed: ["account", "desktop"]),
+ identity.count > 0 else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("guestIdentityIntent")
+ }
+ if let rawAccount = identity["account"] {
+ if rawAccount is NSNull {
+ guard allowsClears else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "guestIdentityIntent.account"
+ )
+ }
+ guestUsername = .clear
+ guestNumericUserID = .clear
+ } else {
+ guard let account = Self.dictionary(rawAccount),
+ Self.hasOnlyKeys(account, allowed: ["username", "numericUserID"]),
+ account.count > 0 else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "guestIdentityIntent.account"
+ )
+ }
+ guestUsername = try Self.decodeString(
+ account,
+ key: "username",
+ field: "guestIdentityIntent.account.username",
+ allowsClears: allowsClears,
+ validator: DoryVMGuestAccountIntent.isValidUsername
+ )
+ guestNumericUserID = try Self.decodeUInt32(
+ account,
+ key: "numericUserID",
+ field: "guestIdentityIntent.account.numericUserID",
+ allowsClears: allowsClears
+ )
+ }
+ }
+ if let rawDesktop = identity["desktop"] {
+ if rawDesktop is NSNull {
+ guard allowsClears else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "guestIdentityIntent.desktop"
+ )
+ }
+ desktopDistributionIdentifier = .clear
+ desktopDisplayName = .clear
+ desktopVersion = .clear
+ desktopEnvironment = .clear
+ } else {
+ guard let desktop = Self.dictionary(rawDesktop),
+ Self.hasOnlyKeys(
+ desktop,
+ allowed: [
+ "distributionIdentifier",
+ "displayName",
+ "version",
+ "desktopEnvironment",
+ ]
+ ),
+ desktop.count > 0 else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "guestIdentityIntent.desktop"
+ )
+ }
+ desktopDistributionIdentifier = try Self.decodeString(
+ desktop,
+ key: "distributionIdentifier",
+ field: "guestIdentityIntent.desktop.distributionIdentifier",
+ allowsClears: allowsClears,
+ validator: DoryVMDesktopIdentityIntent.isValidDistributionIdentifier
+ )
+ desktopDisplayName = try Self.decodeString(
+ desktop,
+ key: "displayName",
+ field: "guestIdentityIntent.desktop.displayName",
+ allowsClears: allowsClears,
+ validator: DoryVMDesktopIdentityIntent.isValidLabel
+ )
+ desktopVersion = try Self.decodeString(
+ desktop,
+ key: "version",
+ field: "guestIdentityIntent.desktop.version",
+ allowsClears: allowsClears,
+ validator: DoryVMDesktopIdentityIntent.isValidLabel
+ )
+ desktopEnvironment = try Self.decodeString(
+ desktop,
+ key: "desktopEnvironment",
+ field: "guestIdentityIntent.desktop.desktopEnvironment",
+ allowsClears: allowsClears,
+ validator: DoryVMDesktopIdentityIntent.isValidLabel
+ )
+ }
+ }
+ }
+
+ private static func decodeClipboardPolicy(
+ _ raw: Any,
+ allowsClears: Bool
+ ) throws -> DoryMachineTypedSettingUpdate {
+ if raw is NSNull {
+ guard allowsClears else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("clipboardPolicy")
+ }
+ return .clear
+ }
+ guard let dictionary = dictionary(raw),
+ hasOnlyKeys(dictionary, allowed: ["text", "image", "files"]),
+ dictionary.count == 3,
+ let text = direction(dictionary["text"]),
+ let image = direction(dictionary["image"]),
+ let files = direction(dictionary["files"]) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField("clipboardPolicy")
+ }
+ return .set(DoryVMClipboardPolicy(text: text, image: image, files: files))
+ }
+
+ private func validate(displayMode: DoryMachineDisplayMode) throws {
+ if case let .set(value) = guestUsername,
+ !DoryVMGuestAccountIntent.isValidUsername(value) {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "guestIdentityIntent.account.username"
+ )
+ }
+ if case let .set(value) = guestNumericUserID,
+ !DoryVMGuestAccountIntent.isValidNumericUserID(value) {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "guestIdentityIntent.account.numericUserID"
+ )
+ }
+ let desktopFields: [(String, DoryMachineTypedSettingUpdate, (String) -> Bool)] = [
+ (
+ "distributionIdentifier",
+ desktopDistributionIdentifier,
+ DoryVMDesktopIdentityIntent.isValidDistributionIdentifier
+ ),
+ ("displayName", desktopDisplayName, DoryVMDesktopIdentityIntent.isValidLabel),
+ ("version", desktopVersion, DoryVMDesktopIdentityIntent.isValidLabel),
+ (
+ "desktopEnvironment",
+ desktopEnvironment,
+ DoryVMDesktopIdentityIntent.isValidLabel
+ ),
+ ]
+ for (field, update, validator) in desktopFields {
+ if case let .set(value) = update, !validator(value) {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ "guestIdentityIntent.desktop.\(field)"
+ )
+ }
+ }
+ let setsDesktopValue = desktopFields.contains { _, update, _ in
+ if case .set = update { return true }
+ return false
+ }
+ if setsDesktopValue, displayMode != .desktop {
+ throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay(
+ "guestIdentityIntent.desktop"
+ )
+ }
+ if case let .set(policy) = clipboardPolicy {
+ guard policy.text == policy.image, policy.files == .off else {
+ throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime(
+ "clipboardPolicy"
+ )
+ }
+ if policy.isEnabled, displayMode != .desktop {
+ throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay(
+ "clipboardPolicy"
+ )
+ }
+ }
+ if (runtimePreference.isSet || graphicsPreference.isSet), displayMode != .desktop {
+ throw DoryMachineTypedWriteAuthorityError.unsupportedForDisplay(
+ "desktopRuntimePreference"
+ )
+ }
+ }
+
+ private static func dictionary(_ raw: Any) -> NSDictionary? {
+ if let dictionary = raw as? NSDictionary { return dictionary }
+ if let dictionary = raw as? [String: Any] { return dictionary as NSDictionary }
+ return nil
+ }
+
+ private static func hasOnlyKeys(_ dictionary: NSDictionary, allowed: Set) -> Bool {
+ dictionary.allKeys.allSatisfy { key in
+ guard let key = key as? String else { return false }
+ return allowed.contains(key)
+ }
+ }
+
+ private static func decodeString(
+ _ dictionary: NSDictionary,
+ key: String,
+ field: String,
+ allowsClears: Bool,
+ validator: (String) -> Bool
+ ) throws -> DoryMachineTypedSettingUpdate {
+ guard let raw = dictionary[key] else { return .unchanged }
+ if raw is NSNull {
+ guard allowsClears else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(field)
+ }
+ return .clear
+ }
+ guard let value = raw as? String, validator(value) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(field)
+ }
+ return .set(value)
+ }
+
+ private static func decodeUInt32(
+ _ dictionary: NSDictionary,
+ key: String,
+ field: String,
+ allowsClears: Bool
+ ) throws -> DoryMachineTypedSettingUpdate {
+ guard let raw = dictionary[key] else { return .unchanged }
+ if raw is NSNull {
+ guard allowsClears else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(field)
+ }
+ return .clear
+ }
+ guard !(raw is Bool),
+ let number = raw as? NSNumber,
+ number.doubleValue.rounded(.towardZero) == number.doubleValue,
+ number.doubleValue >= 0,
+ number.doubleValue <= Double(UInt32.max) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(field)
+ }
+ let value = UInt32(number.uint64Value)
+ guard DoryVMGuestAccountIntent.isValidNumericUserID(value) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(field)
+ }
+ return .set(value)
+ }
+
+ private static func direction(_ raw: Any?) -> DoryVMClipboardDirection? {
+ guard let raw = raw as? String else { return nil }
+ return DoryVMClipboardDirection(rawValue: raw)
+ }
+
+ private static func decodeEnum(
+ _ raw: Any?,
+ field: String,
+ allowsClears: Bool,
+ type: Value.Type
+ ) throws -> DoryMachineTypedSettingUpdate where Value.RawValue == String {
+ guard let raw else { return .unchanged }
+ if raw is NSNull {
+ guard allowsClears else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(field)
+ }
+ return .clear
+ }
+ guard let raw = raw as? String, let value = Value(rawValue: raw) else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(field)
+ }
+ return .set(value)
+ }
+
+ private static func encodeEnum(
+ _ update: DoryMachineTypedSettingUpdate,
+ key: String,
+ into dictionary: inout [String: Any]
+ ) where Value.RawValue == String {
+ switch update {
+ case .unchanged: break
+ case .clear: dictionary[key] = NSNull()
+ case let .set(value): dictionary[key] = value.rawValue
+ }
+ }
+
+ private static func takeOption(
+ _ name: String,
+ from arguments: inout [String]
+ ) throws -> String? {
+ guard let index = arguments.firstIndex(of: name) else { return nil }
+ guard index + 1 < arguments.count else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(name)
+ }
+ let value = arguments[index + 1]
+ arguments.removeSubrange(index...(index + 1))
+ return value
+ }
+
+ private static func takeFlag(_ name: String, from arguments: inout [String]) -> Bool {
+ guard let index = arguments.firstIndex(of: name) else { return false }
+ arguments.remove(at: index)
+ return true
+ }
+
+ private static func encode(
+ _ update: DoryMachineTypedSettingUpdate,
+ key: String,
+ into dictionary: inout [String: Any]
+ ) {
+ switch update {
+ case .unchanged:
+ break
+ case .clear:
+ dictionary[key] = NSNull()
+ case let .set(value):
+ dictionary[key] = value
+ }
+ }
+
+ private static func apply(
+ _ update: DoryMachineTypedSettingUpdate,
+ key: String,
+ to environment: inout [String: String]
+ ) {
+ switch update {
+ case .unchanged:
+ break
+ case .clear:
+ environment.removeValue(forKey: key)
+ case let .set(value):
+ environment[key] = value
+ }
+ }
+}
+
+private extension DoryMachineTypedSettingUpdate {
+ var isSet: Bool {
+ if case .set = self { return true }
+ return false
+ }
+
+ func map(
+ _ transform: (Value) -> Mapped
+ ) -> DoryMachineTypedSettingUpdate {
+ switch self {
+ case .unchanged: .unchanged
+ case .clear: .clear
+ case let .set(value): .set(transform(value))
+ }
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index 014db761..defa41f8 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -232,7 +232,15 @@ public final class DorydService: NSObject, DorydControl {
return
}
do {
- let machine = try DoryMachineConfiguration(xpcDictionary: config)
+ let typedSettings = try DoryMachineTypedSettingsPatch(
+ xpcDictionary: config,
+ allowsClears: false
+ )
+ var machine = try DoryMachineConfiguration(xpcDictionary: config)
+ machine.environment = try typedSettings.applying(
+ to: [:],
+ displayMode: machine.displayMode
+ )
let status = try machineManager.create(machine)
incidentWriter?.record(type: "machine.create", detail: machine.id)
reply(true, status.xpcDictionary, "")
@@ -279,8 +287,7 @@ public final class DorydService: NSObject, DorydControl {
updatesAddress: update.updatesAddress,
shares: update.shares,
updatesShares: update.updatesShares,
- environment: update.environment,
- updatesEnvironment: update.updatesEnvironment,
+ typedSettingsPatch: update.typedSettings.isEmpty ? nil : update.typedSettings,
installerMediaAttached: update.installerMediaAttached
)
incidentWriter?.record(type: "machine.update", detail: machineID)
@@ -1200,8 +1207,7 @@ private struct MachineUpdateRequest {
var updatesAddress: Bool
var shares: [DoryMachineShareConfiguration]?
var updatesShares: Bool
- var environment: [String: String]?
- var updatesEnvironment: Bool
+ var typedSettings: DoryMachineTypedSettingsPatch
var installerMediaAttached: Bool?
init(xpcDictionary dictionary: NSDictionary) throws {
@@ -1216,10 +1222,12 @@ private struct MachineUpdateRequest {
}
self.shares = dictionary["shares"] == nil ? nil : try dictionary.optionalMachineShares("shares")
self.updatesShares = dictionary["shares"] != nil
- self.environment = dictionary["env"] == nil ? nil : try dictionary.optionalEnvironmentDictionary("env")
- self.updatesEnvironment = dictionary["env"] != nil
+ self.typedSettings = try DoryMachineTypedSettingsPatch(
+ xpcDictionary: dictionary,
+ allowsClears: true
+ )
self.installerMediaAttached = dictionary.optionalBool("installerMediaAttached")
- if memoryMB == nil, cpuCount == nil, !updatesAddress, !updatesShares, !updatesEnvironment,
+ if memoryMB == nil, cpuCount == nil, !updatesAddress, !updatesShares, typedSettings.isEmpty,
installerMediaAttached == nil {
throw XPCRemoteConfigError.invalid("config")
}
@@ -1288,7 +1296,7 @@ private extension DoryMachineConfiguration {
address: dictionary.optionalString("address"),
displayMode: displayMode,
shares: try dictionary.optionalMachineShares("shares"),
- environment: try dictionary.optionalEnvironmentDictionary("env")
+ environment: [:]
)
}
}
@@ -1432,26 +1440,6 @@ private extension NSDictionary {
}
}
- func optionalEnvironmentDictionary(_ key: String) throws -> [String: String] {
- guard let raw = self[key] else { return [:] }
- guard let rows = raw as? [NSDictionary] else {
- throw XPCRemoteConfigError.invalid(key)
- }
- var result: [String: String] = [:]
- for row in rows {
- let key = try row.requiredString("key")
- guard key.wholeMatch(of: /[A-Za-z_][A-Za-z0-9_]*/) != nil else {
- throw XPCRemoteConfigError.invalid("env")
- }
- let value = row.optionalString("value") ?? ""
- guard !value.contains("\0") else {
- throw XPCRemoteConfigError.invalid("env")
- }
- result[key] = value
- }
- return result
- }
-
func optionalString(_ key: String) -> String? {
self[key] as? String
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 7b60e8bc..fac1d2cd 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -1766,10 +1766,16 @@ public final class MachineManager: @unchecked Sendable {
updatesShares: Bool = false,
environment: [String: String]? = nil,
updatesEnvironment: Bool = false,
+ typedSettingsPatch: DoryMachineTypedSettingsPatch? = nil,
installerMediaAttached: Bool? = nil
) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
+ guard !(updatesEnvironment && typedSettingsPatch != nil) else {
+ throw MachineManagerError.persistence(
+ "raw environment replacement and typed machine settings are mutually exclusive"
+ )
+ }
let (current, wasRunning) = try configurationAndRunningState(id: id)
var updated = current
if let memoryMB {
@@ -1787,6 +1793,12 @@ public final class MachineManager: @unchecked Sendable {
if updatesEnvironment {
updated.environment = environment ?? [:]
}
+ if let typedSettingsPatch {
+ updated.environment = try typedSettingsPatch.applying(
+ to: current.environment,
+ displayMode: updated.displayMode
+ )
+ }
if let installerMediaAttached {
guard updated.bootMode == .efi else {
throw MachineManagerError.persistence("installer media is only available for EFI machines")
diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift
index 04e15936..9a034a1e 100644
--- a/dory-core-swift/Sources/dorydctl/main.swift
+++ b/dory-core-swift/Sources/dorydctl/main.swift
@@ -164,8 +164,8 @@ func usage(exitCode: Int32 = 2) -> Never {
dorydctl [global] machine list
dorydctl [global] machine status NAME
dorydctl [global] machine stats NAME
- dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--env KEY=VALUE]
- dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] [--attach-installer | --eject-installer]
+ dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software]
+ dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics] [--attach-installer | --eject-installer]
dorydctl [global] machine start|stop|delete NAME
dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...]
dorydctl [global] machine shell NAME
@@ -504,20 +504,6 @@ func nonNegativeUInt64(_ raw: String, option: String) throws -> UInt64 {
return value
}
-func parseEnvironmentRow(_ raw: String) throws -> NSDictionary {
- guard let equals = raw.firstIndex(of: "="), equals != raw.startIndex else {
- throw DorydCtlError.usage("--env must be KEY=VALUE")
- }
- let key = String(raw[.. [NSDictionary] {
let data = FileHandle.standardInput.readDataToEndOfFile()
guard !data.isEmpty,
@@ -533,6 +519,30 @@ func readEnvironmentJSONFromStandardInput() throws -> [NSDictionary] {
}
}
+func parseMachineTypedSettings(
+ cursor: inout ArgumentCursor,
+ allowsClears: Bool
+) throws -> DoryMachineTypedSettingsPatch {
+ do {
+ return try DoryMachineTypedSettingsPatch.consumeCLIArguments(
+ &cursor.values,
+ allowsClears: allowsClears
+ )
+ } catch {
+ throw DorydCtlError.usage("\(error)")
+ }
+}
+
+func mergeMachineTypedSettings(
+ _ patch: DoryMachineTypedSettingsPatch,
+ into configuration: inout [String: Any]
+) {
+ for (rawKey, value) in patch.xpcDictionary {
+ guard let key = rawKey as? String else { continue }
+ configuration[key] = value
+ }
+}
+
func machineExecControlTimeout(timeoutMs: UInt64) -> TimeInterval {
let effectiveTimeoutMs: UInt64
switch timeoutMs {
@@ -1090,7 +1100,11 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
throw DorydCtlError.usage("--display-mode must be headless or desktop")
}
let shares = try cursor.optionValues("--share").map { try DoryMachineShareConfiguration(argument: $0) }
- let env = try cursor.optionValues("--env").map(parseEnvironmentRow)
+ let typedSettings = try parseMachineTypedSettings(cursor: &cursor, allowsClears: false)
+ let address = try cursor.optionValue("--dns-target")
+ guard cursor.values.isEmpty else {
+ throw DorydCtlError.usage("unexpected machine create argument: \(cursor.values[0])")
+ }
var stagedInstallerISOPath: String?
defer {
if let stagedInstallerISOPath {
@@ -1117,7 +1131,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
config["installerISOPath"] = stagedInstallerISOPath
config["diskSizeBytes"] = diskSizeGB * 1024 * 1024 * 1024
}
- if let address = try cursor.optionValue("--dns-target") {
+ if let address {
config["address"] = address
}
if !shares.isEmpty {
@@ -1130,9 +1144,7 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
] as NSDictionary
}
}
- if !env.isEmpty {
- config["env"] = env
- }
+ mergeMachineTypedSettings(typedSettings, into: &config)
// Creating a machine can copy a multi-gigabyte root disk or installer ISO.
// Keep the request alive long enough for that transactional mutation to
// finish so the CLI cannot report a timeout after the daemon has succeeded.
@@ -1301,7 +1313,7 @@ func runMachineBackup(cursor: inout ArgumentCursor, client: DorydCtlClient) thro
}
func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
- let usage = "usage: dorydctl machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [--env KEY=VALUE ... | --clear-env] [--attach-installer | --eject-installer]"
+ let usage = "usage: dorydctl machine update NAME [resource/network options] [typed guest/desktop/clipboard options] [--attach-installer | --eject-installer]"
let name = try cursor.take(usage)
var config: [String: Any] = [:]
if let memory = try cursor.optionValue("--memory-mb") {
@@ -1342,17 +1354,8 @@ func runMachineUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) thro
cursor.values.removeAll { $0 == "--clear-shares" }
config["shares"] = [] as [NSDictionary]
}
- let envValues = try cursor.optionValues("--env")
- if !envValues.isEmpty {
- config["env"] = try envValues.map(parseEnvironmentRow)
- }
- if cursor.values.contains("--clear-env") {
- guard config["env"] == nil else {
- throw DorydCtlError.usage("use either --env or --clear-env, not both")
- }
- cursor.values.removeAll { $0 == "--clear-env" }
- config["env"] = [] as [NSDictionary]
- }
+ let typedSettings = try parseMachineTypedSettings(cursor: &cursor, allowsClears: true)
+ mergeMachineTypedSettings(typedSettings, into: &config)
let attachInstaller = cursor.values.contains("--attach-installer")
let ejectInstaller = cursor.values.contains("--eject-installer")
guard !attachInstaller || !ejectInstaller else {
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift
new file mode 100644
index 00000000..01296261
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift
@@ -0,0 +1,285 @@
+import DoryOperations
+import Foundation
+import Testing
+@testable import DorydKit
+
+@Suite("Typed machine write authority")
+struct DoryMachineTypedWriteAuthorityTests {
+ @Test("typed wire round trip is exact and contains no raw environment")
+ func wireRoundTrip() throws {
+ let source = DoryMachineTypedSettingsPatch(
+ guestUsername: .set("developer"),
+ guestNumericUserID: .set(1_000),
+ desktopDistributionIdentifier: .set("ubuntu"),
+ desktopDisplayName: .set("Ubuntu"),
+ desktopVersion: .set("24.04"),
+ desktopEnvironment: .set("GNOME"),
+ clipboardPolicy: .set(.legacyDesktop(.hostToGuest)),
+ runtimePreference: .set(.accelerated),
+ graphicsPreference: .set(.virglVenus)
+ )
+
+ let wire = source.xpcDictionary
+ #expect(wire["env"] == nil)
+ #expect(try DoryMachineTypedSettingsPatch(
+ xpcDictionary: wire,
+ allowsClears: false
+ ) == source)
+ }
+
+ @Test("raw environment write authority is rejected even when empty")
+ func rejectsRawEnvironment() {
+ for raw: Any in [NSArray(), NSNull(), [["key": "SAFE", "value": "value"]]] {
+ let dictionary: NSDictionary = ["env": raw]
+ #expect(throws: DoryMachineTypedWriteAuthorityError.rawEnvironmentForbidden) {
+ try DoryMachineTypedSettingsPatch(
+ xpcDictionary: dictionary,
+ allowsClears: true
+ )
+ }
+ }
+ }
+
+ @Test("update applies fields locally and preserves opaque legacy values")
+ func fieldLocalBackProjection() throws {
+ let legacy = [
+ "DORY_GUEST_USER": "../../unsafe-old-user",
+ "DORY_GUEST_UID": "not-a-uid",
+ "DORY_DESKTOP_DISTRO": "unsafe://old",
+ "DORY_VIRGL_CLASSIC_ONLY": "1",
+ "PRIVATE_TOKEN": "must-remain-opaque",
+ ]
+ let patch = DoryMachineTypedSettingsPatch(
+ desktopDisplayName: .set("Ubuntu")
+ )
+
+ let projected = try patch.applying(to: legacy, displayMode: .desktop)
+
+ #expect(projected["DORY_DESKTOP_NAME"] == "Ubuntu")
+ #expect(projected["DORY_GUEST_USER"] == "../../unsafe-old-user")
+ #expect(projected["DORY_GUEST_UID"] == "not-a-uid")
+ #expect(projected["DORY_DESKTOP_DISTRO"] == "unsafe://old")
+ #expect(projected["DORY_VIRGL_CLASSIC_ONLY"] == "1")
+ #expect(projected["PRIVATE_TOKEN"] == "must-remain-opaque")
+
+ let runtime = try DoryMachineTypedSettingsPatch(
+ runtimePreference: .set(.compatible),
+ graphicsPreference: .set(.software)
+ ).applying(to: legacy, displayMode: .desktop)
+ #expect(runtime["DORY_DESKTOP_VMM"] == "compatible")
+ #expect(runtime["DORY_DESKTOP_GRAPHICS"] == "software")
+ }
+
+ @Test("update clear is explicit and field scoped")
+ func explicitClear() throws {
+ let source = DoryMachineTypedSettingsPatch(
+ guestUsername: .clear,
+ desktopVersion: .clear,
+ clipboardPolicy: .clear
+ )
+ let wire = source.xpcDictionary
+ let decoded = try DoryMachineTypedSettingsPatch(
+ xpcDictionary: wire,
+ allowsClears: true
+ )
+ let projected = try decoded.applying(
+ to: [
+ "DORY_GUEST_USER": "developer",
+ "DORY_GUEST_UID": "1000",
+ "DORY_DESKTOP_VERSION": "24.04",
+ "DORY_DESKTOP_NAME": "Ubuntu",
+ "DORY_CLIPBOARD_POLICY": "bidirectional",
+ ],
+ displayMode: .desktop
+ )
+
+ #expect(projected["DORY_GUEST_USER"] == nil)
+ #expect(projected["DORY_DESKTOP_VERSION"] == nil)
+ #expect(projected["DORY_CLIPBOARD_POLICY"] == nil)
+ #expect(projected["DORY_GUEST_UID"] == "1000")
+ #expect(projected["DORY_DESKTOP_NAME"] == "Ubuntu")
+ #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField(
+ "guestIdentityIntent.account.username"
+ )) {
+ try DoryMachineTypedSettingsPatch(
+ xpcDictionary: wire,
+ allowsClears: false
+ )
+ }
+ }
+
+ @Test("hostile and out of range guest fields fail closed")
+ func hostileGuestFields() {
+ let cases: [NSDictionary] = [
+ ["guestIdentityIntent": ["account": ["username": "../../host"]]],
+ ["guestIdentityIntent": ["account": ["numericUserID": 99]]],
+ ["guestIdentityIntent": ["account": ["numericUserID": 60_001]]],
+ ["guestIdentityIntent": ["desktop": ["displayName": "token\nvalue"]]],
+ ["guestIdentityIntent": ["desktop": ["displayName": "a" + String(repeating: "\u{301}", count: 128)]]],
+ ["guestIdentityIntent": ["unexpected": "value"]],
+ ["guestIdentityIntent": ["account": ["username": "developer", "secret": "x"]]],
+ ]
+ for dictionary in cases {
+ #expect(throws: (any Error).self) {
+ try DoryMachineTypedSettingsPatch(
+ xpcDictionary: dictionary,
+ allowsClears: false
+ )
+ }
+ }
+ }
+
+ @Test("current compatibility runtime rejects widening clipboard or desktop on headless")
+ func runtimeRepresentability() throws {
+ let widened = DoryMachineTypedSettingsPatch(clipboardPolicy: .set(
+ DoryVMClipboardPolicy(
+ text: .hostToGuest,
+ image: .guestToHost,
+ files: .off
+ )
+ ))
+ #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime(
+ "clipboardPolicy"
+ )) {
+ try widened.applying(to: [:], displayMode: .desktop)
+ }
+
+ let files = DoryMachineTypedSettingsPatch(clipboardPolicy: .set(
+ DoryVMClipboardPolicy(text: .off, image: .off, files: .hostToGuest)
+ ))
+ #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime(
+ "clipboardPolicy"
+ )) {
+ try files.applying(to: [:], displayMode: .desktop)
+ }
+
+ let desktop = DoryMachineTypedSettingsPatch(desktopDisplayName: .set("Ubuntu"))
+ #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedForDisplay(
+ "guestIdentityIntent.desktop"
+ )) {
+ try desktop.applying(to: [:], displayMode: .headless)
+ }
+
+ let enabledClipboard = DoryMachineTypedSettingsPatch(
+ clipboardPolicy: .set(.legacyDesktop(.bidirectional))
+ )
+ #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedForDisplay(
+ "clipboardPolicy"
+ )) {
+ try enabledClipboard.applying(to: [:], displayMode: .headless)
+ }
+ #expect(try DoryMachineTypedSettingsPatch(
+ clipboardPolicy: .set(.disabled)
+ ).applying(to: [:], displayMode: .headless)["DORY_CLIPBOARD_POLICY"] == "off")
+ #expect(throws: DoryMachineTypedWriteAuthorityError.unsupportedForDisplay(
+ "desktopRuntimePreference"
+ )) {
+ try DoryMachineTypedSettingsPatch(
+ runtimePreference: .set(.accelerated)
+ ).applying(to: [:], displayMode: .headless)
+ }
+ }
+
+ @Test("clipboard wire shape is complete and exact")
+ func exactClipboardWireShape() {
+ let cases: [NSDictionary] = [
+ ["clipboardPolicy": ["text": "off", "image": "off"]],
+ [
+ "clipboardPolicy": [
+ "text": "off",
+ "image": "off",
+ "files": "off",
+ "token": "secret",
+ ],
+ ],
+ [
+ "clipboardPolicy": [
+ "text": "invalid",
+ "image": "off",
+ "files": "off",
+ ],
+ ],
+ ]
+ for dictionary in cases {
+ #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField(
+ "clipboardPolicy"
+ )) {
+ try DoryMachineTypedSettingsPatch(
+ xpcDictionary: dictionary,
+ allowsClears: false
+ )
+ }
+ }
+ }
+
+ @Test("CLI consumes typed persistence options and leaves raw env unconsumed")
+ func cliTypedOptions() throws {
+ var arguments = [
+ "--memory-mb", "4096",
+ "--guest-user", "developer",
+ "--guest-uid", "1000",
+ "--desktop-distro", "ubuntu",
+ "--desktop-name", "Ubuntu",
+ "--desktop-version", "24.04",
+ "--desktop-environment", "GNOME",
+ "--clipboard", "guest-to-host",
+ "--runtime", "compatible",
+ "--graphics", "software",
+ "--env", "TOKEN=secret",
+ ]
+
+ let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments(
+ &arguments,
+ allowsClears: false
+ )
+
+ #expect(patch.guestUsername == .set("developer"))
+ #expect(patch.guestNumericUserID == .set(1_000))
+ #expect(patch.desktopDistributionIdentifier == .set("ubuntu"))
+ #expect(patch.clipboardPolicy == .set(.legacyDesktop(.guestToHost)))
+ #expect(patch.runtimePreference == .set(.compatible))
+ #expect(patch.graphicsPreference == .set(.software))
+ #expect(arguments == ["--memory-mb", "4096", "--env", "TOKEN=secret"])
+ #expect(patch.xpcDictionary["env"] == nil)
+ }
+
+ @Test("CLI clear groups are explicit and conflict with replacement values")
+ func cliClearSemantics() throws {
+ var clearing = [
+ "--clear-guest-account",
+ "--clear-desktop-identity",
+ "--clear-clipboard",
+ "--clear-runtime",
+ "--clear-graphics",
+ ]
+ let patch = try DoryMachineTypedSettingsPatch.consumeCLIArguments(
+ &clearing,
+ allowsClears: true
+ )
+ #expect(clearing.isEmpty)
+ #expect(patch.guestUsername == .clear)
+ #expect(patch.guestNumericUserID == .clear)
+ #expect(patch.desktopDistributionIdentifier == .clear)
+ #expect(patch.desktopDisplayName == .clear)
+ #expect(patch.clipboardPolicy == .clear)
+ #expect(patch.runtimePreference == .clear)
+ #expect(patch.graphicsPreference == .clear)
+
+ var createClear = ["--clear-clipboard"]
+ #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField("clear options")) {
+ try DoryMachineTypedSettingsPatch.consumeCLIArguments(
+ &createClear,
+ allowsClears: false
+ )
+ }
+ var conflict = ["--guest-user", "developer", "--clear-guest-account"]
+ #expect(throws: DoryMachineTypedWriteAuthorityError.invalidField(
+ "--clear-guest-account"
+ )) {
+ try DoryMachineTypedSettingsPatch.consumeCLIArguments(
+ &conflict,
+ allowsClears: true
+ )
+ }
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 36b9bf35..ed488636 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -3,6 +3,16 @@ import DoryCore
import XCTest
final class DorydServiceTests: XCTestCase {
+ private static func environmentValues(_ body: NSDictionary) -> [String: String] {
+ let rows = body["env"] as? [NSDictionary] ?? []
+ return Dictionary(uniqueKeysWithValues: rows.compactMap { row in
+ guard let key = row["key"] as? String, let value = row["value"] as? String else {
+ return nil
+ }
+ return (key, value)
+ })
+ }
+
func testPublishedPortRepairDetailUsesValidatedGvproxyReceiptCounts() {
let startedAt = Date()
let receipt = PublishedPortReconcileReceipt(
@@ -1076,19 +1086,25 @@ final class DorydServiceTests: XCTestCase {
"memoryMB": 1024,
"cpuCount": 2,
"address": "192.168.215.40",
- "env": [
- [
- "key": "APP_ENV",
- "value": "dev",
+ "guestIdentityIntent": [
+ "account": [
+ "username": "developer",
+ "numericUserID": UInt32(1_000),
] as NSDictionary,
- ],
+ ] as NSDictionary,
+ "clipboardPolicy": [
+ "text": "off",
+ "image": "off",
+ "files": "off",
+ ] as NSDictionary,
]) { ok, body, message in
XCTAssertTrue(ok, message)
XCTAssertEqual(body["state"] as? String, "created")
XCTAssertEqual(body["address"] as? String, "192.168.215.40")
- let env = body["env"] as? [NSDictionary]
- XCTAssertEqual(env?.first?["key"] as? String, "APP_ENV")
- XCTAssertEqual(env?.first?["value"] as? String, "dev")
+ let values = Self.environmentValues(body)
+ XCTAssertEqual(values["DORY_GUEST_USER"], "developer")
+ XCTAssertEqual(values["DORY_GUEST_UID"], "1000")
+ XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "off")
create.fulfill()
}
wait(for: [create], timeout: 5)
@@ -1113,7 +1129,7 @@ final class DorydServiceTests: XCTestCase {
XCTAssertEqual(statuses?.first?["id"] as? String, "dev")
XCTAssertEqual(statuses?.first?["address"] as? String, "192.168.215.40")
let env = statuses?.first?["env"] as? [NSDictionary]
- XCTAssertEqual(env?.first?["value"] as? String, "dev")
+ XCTAssertTrue(env?.contains { $0["value"] as? String == "developer" } == true)
list.fulfill()
}
wait(for: [list], timeout: 5)
@@ -1139,12 +1155,11 @@ final class DorydServiceTests: XCTestCase {
"readOnly": true,
] as NSDictionary,
],
- "env": [
- [
- "key": "NODE_ENV",
- "value": "production",
+ "guestIdentityIntent": [
+ "account": [
+ "username": "builder",
] as NSDictionary,
- ],
+ ] as NSDictionary,
]) { ok, body, message in
XCTAssertTrue(ok, message)
XCTAssertEqual(body["state"] as? String, "stopped")
@@ -1155,9 +1170,10 @@ final class DorydServiceTests: XCTestCase {
XCTAssertEqual(shares?.first?["hostPath"] as? String, share)
XCTAssertEqual(shares?.first?["guestPath"] as? String, "/workspace/src")
XCTAssertEqual(shares?.first?["readOnly"] as? Bool, true)
- let env = body["env"] as? [NSDictionary]
- XCTAssertEqual(env?.first?["key"] as? String, "NODE_ENV")
- XCTAssertEqual(env?.first?["value"] as? String, "production")
+ let values = Self.environmentValues(body)
+ XCTAssertEqual(values["DORY_GUEST_USER"], "builder")
+ XCTAssertEqual(values["DORY_GUEST_UID"], "1000")
+ XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "off")
update.fulfill()
}
wait(for: [update], timeout: 5)
@@ -1181,6 +1197,129 @@ final class DorydServiceTests: XCTestCase {
wait(for: [delete], timeout: 5)
}
+ func testMachineWritesRequireTypedIntentAndPreserveLegacyEnvironmentFieldLocally() throws {
+ let base = "/tmp/doryd-service-typed-write-\(getpid())-\(UInt32.random(in: 0..
Date: Thu, 20 Aug 2026 16:39:07 +0000
Subject: [PATCH 032/338] feat(vm): add atomic planning transactions
---
.../DoryVirtualMachineBackendPlanner.swift | 43 +-
.../DoryVirtualMachineCapabilities.swift | 21 +
...yVirtualMachineQualificationManifest.swift | 6 +
...achinePlanningTransactionCoordinator.swift | 1313 +++++++++++++++++
...yDaemonVirtualMachineProductionTrust.swift | 263 ++++
...yDaemonVirtualMachineRuntimePlanning.swift | 27 +-
...irtualMachineResourceAdmissionLedger.swift | 87 ++
...oryVirtualMachineBackendPlannerTests.swift | 66 +
...ePlanningTransactionCoordinatorTests.swift | 651 ++++++++
...onVirtualMachineProductionTrustTests.swift | 97 ++
...lMachineResourceAdmissionLedgerTests.swift | 81 +
11 files changed, 2646 insertions(+), 9 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
index d00b0d60..70ce50d2 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
@@ -158,7 +158,9 @@ public enum DoryAppleSiliconVirtualMachineBackendPlanner {
trustedGuestImageGraphicsQualification: DoryTrustedGuestImageGraphicsQualification? = nil,
trustedBootMediaInspection: DoryTrustedBootMediaInspection? = nil,
trustedMutableBootMediaProvenance: DoryTrustedMutableBootMediaProvenance? = nil,
- trustedRuntimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = []
+ trustedRuntimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [],
+ trustedCapabilityQualifications:
+ [DoryTrustedVirtualMachineCapabilityQualification] = []
) -> DoryVirtualMachineBackendPlanResult {
if request.acceptableGraphics.isEmpty {
return invalidPreference(
@@ -222,17 +224,27 @@ public enum DoryAppleSiliconVirtualMachineBackendPlanner {
devices: request.devices,
virtualHardwareABIVersion: request.virtualHardwareABIVersion
)
+ let exactQualification = matchingCapabilityQualification(
+ for: capabilityRequest,
+ host: host,
+ in: trustedCapabilityQualifications
+ )
evaluated.append(DoryAppleSiliconCapabilityEvaluator.evaluate(
capabilityRequest,
host: host,
- trustedGuestImageGraphicsQualification: trustedGuestImageGraphicsQualification,
+ trustedGuestImageGraphicsQualification:
+ exactQualification?.graphics
+ ?? (trustedCapabilityQualifications.isEmpty
+ ? trustedGuestImageGraphicsQualification : nil),
trustedBootMediaInspection: trustedBootMediaInspection,
trustedMutableBootMediaProvenance: trustedMutableBootMediaProvenance,
- trustedRuntimeQualification: matchingRuntimeQualification(
- for: capabilityRequest,
- host: host,
- in: trustedRuntimeQualifications
- )
+ trustedRuntimeQualification: exactQualification?.runtime
+ ?? (trustedCapabilityQualifications.isEmpty
+ ? matchingRuntimeQualification(
+ for: capabilityRequest,
+ host: host,
+ in: trustedRuntimeQualifications
+ ) : nil)
))
}
}
@@ -320,6 +332,23 @@ public enum DoryAppleSiliconVirtualMachineBackendPlanner {
}
}
+ private static func matchingCapabilityQualification(
+ for request: DoryVirtualMachineCapabilityRequest,
+ host: DoryAppleSiliconHostFacts,
+ in qualifications: [DoryTrustedVirtualMachineCapabilityQualification]
+ ) -> DoryTrustedVirtualMachineCapabilityQualification? {
+ guard let hostContext = host.runtimeQualificationContext else { return nil }
+ let matches = qualifications.filter { qualification in
+ guard qualification.request == request else { return false }
+ let evidence = qualification.runtime.auditEvidence
+ return evidence.backendRuntimeBuildID
+ == hostContext.runtimeBuildID(for: request.backend)
+ && evidence.virtualHardwareABIVersion
+ == hostContext.virtualHardwareABIVersion
+ }
+ return matches.count == 1 ? matches[0] : nil
+ }
+
private static func invalidPreference(
field: DoryVirtualMachineBackendPreferenceField,
issue: DoryVirtualMachineBackendPreferenceIssue,
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
index c03b3069..8f7b18a7 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
@@ -505,6 +505,27 @@ public struct DoryTrustedVirtualMachineRuntimeQualification: Sendable, Equatable
}
}
+/// Opaque authority bundle for one exact capability request. Keeping runtime and guest-graphics
+/// facts paired prevents inventory order from applying one signed image qualification to a
+/// different backend, graphics level, device contract, or runtime build.
+public struct DoryTrustedVirtualMachineCapabilityQualification:
+ Sendable, Equatable, Hashable
+{
+ let request: DoryVirtualMachineCapabilityRequest
+ let runtime: DoryTrustedVirtualMachineRuntimeQualification
+ let graphics: DoryTrustedGuestImageGraphicsQualification?
+
+ init(
+ request: DoryVirtualMachineCapabilityRequest,
+ runtime: DoryTrustedVirtualMachineRuntimeQualification,
+ graphics: DoryTrustedGuestImageGraphicsQualification?
+ ) {
+ self.request = request
+ self.runtime = runtime
+ self.graphics = graphics
+ }
+}
+
/// A request is deliberately free of detected host state so it can cross process and API
/// boundaries and be evaluated by the daemon that owns the virtualization components.
public struct DoryVirtualMachineCapabilityRequest: Codable, Sendable, Equatable, Hashable {
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
index 8267611f..6c4d57f9 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineQualificationManifest.swift
@@ -246,6 +246,11 @@ public struct DoryVerifiedVirtualMachineQualificationAuthority: Sendable {
record: record,
runtime: runtime,
graphics: graphics,
+ capabilityQualification: DoryTrustedVirtualMachineCapabilityQualification(
+ request: request,
+ runtime: runtime,
+ graphics: graphics
+ ),
manifestIdentity: manifestIdentity,
manifestSHA256: manifestSHA256,
signingKeyID: signingKeyID
@@ -267,6 +272,7 @@ public struct DoryResolvedTrustedVirtualMachineQualification: Sendable {
public let record: DoryVirtualMachineQualificationRecord
public let runtime: DoryTrustedVirtualMachineRuntimeQualification
public let graphics: DoryTrustedGuestImageGraphicsQualification?
+ public let capabilityQualification: DoryTrustedVirtualMachineCapabilityQualification
fileprivate let manifestIdentity: String
fileprivate let manifestSHA256: String
fileprivate let signingKeyID: String
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
new file mode 100644
index 00000000..01f6dea1
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
@@ -0,0 +1,1313 @@
+import CryptoKit
+import Darwin
+import DoryOperations
+import Foundation
+
+public enum DoryDaemonVirtualMachineWorkspacePublication: Codable, Sendable, Equatable {
+ case create
+ case replace(expectedRevision: UInt64)
+ /// The exact canonical definition is already authoritative (including a facts-bound legacy
+ /// projection). Planning publishes only the resolved plan and never spuriously bumps it.
+ case retainExistingExact
+}
+
+public struct DoryDaemonVirtualMachinePlanningTransactionRequest: Sendable {
+ public var planning: DoryDaemonVirtualMachinePlanningRequest
+ public var workspacePublication: DoryDaemonVirtualMachineWorkspacePublication
+ public var resourceRequirements: [DoryVMResourceRequirement]
+ public var startingLeaseDurationMilliseconds: Int64
+
+ public init(
+ planning: DoryDaemonVirtualMachinePlanningRequest,
+ workspacePublication: DoryDaemonVirtualMachineWorkspacePublication,
+ resourceRequirements: [DoryVMResourceRequirement] = [],
+ startingLeaseDurationMilliseconds: Int64 = 120_000
+ ) {
+ self.planning = planning
+ self.workspacePublication = workspacePublication
+ self.resourceRequirements = resourceRequirements
+ self.startingLeaseDurationMilliseconds = startingLeaseDurationMilliseconds
+ }
+}
+
+/// One-use daemon authority. It re-resolves media, helper, host identity, and signed
+/// qualification immediately before desired-state publication. Start obtains a separate token.
+public final class DoryDaemonVirtualMachinePlanningPublicationAuthorization:
+ @unchecked Sendable
+{
+ private let lock = NSLock()
+ private var consumed = false
+ private let operation: @Sendable () throws -> Void
+
+ init(operation: @escaping @Sendable () throws -> Void) {
+ self.operation = operation
+ }
+
+ public func authorize() throws {
+ try lock.withLock {
+ guard !consumed else {
+ throw DoryDaemonVirtualMachinePlanningTransactionFailure(
+ code: .publicationAuthorizationRejected,
+ message: "Planning publication authorization was already consumed."
+ )
+ }
+ consumed = true
+ try operation()
+ }
+ }
+}
+
+/// Daemon-owned, non-Codable resolution. API/UI callers cannot supply trusted facts or tokens.
+public struct DoryDaemonVirtualMachinePlanningTrustPreparation: Sendable {
+ public var hostResources: DoryVMHostResources
+ public var snapshot: @Sendable (
+ DoryResolvedMachineResourceAdmissionEvidence
+ ) -> DoryDaemonVirtualMachineTrustedInventorySnapshot
+ public var publicationAuthorization:
+ DoryDaemonVirtualMachinePlanningPublicationAuthorization
+
+ public init(
+ hostResources: DoryVMHostResources,
+ snapshot: @escaping @Sendable (
+ DoryResolvedMachineResourceAdmissionEvidence
+ ) -> DoryDaemonVirtualMachineTrustedInventorySnapshot,
+ publicationAuthorization:
+ DoryDaemonVirtualMachinePlanningPublicationAuthorization
+ ) {
+ self.hostResources = hostResources
+ self.snapshot = snapshot
+ self.publicationAuthorization = publicationAuthorization
+ }
+}
+
+public protocol DoryDaemonVirtualMachinePlanningTrustPreparing: Sendable {
+ func preparePlanningTrust(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation
+}
+
+/// Held for the whole transaction. A MachineManager-backed implementation owns the lifecycle
+/// operation fence and validates authoritative machine bytes/migration facts against `definition`.
+/// The coordinator has no unsafe default: production composition must provide this authority.
+public final class DoryDaemonVirtualMachinePlanningMutationFence: @unchecked Sendable {
+ public let authoritySHA256: String
+ private let validation: @Sendable () throws -> Void
+ private let retainedAuthority: any Sendable
+
+ public init(
+ authoritySHA256: String,
+ retainedAuthority: any Sendable,
+ validation: @escaping @Sendable () throws -> Void
+ ) {
+ self.authoritySHA256 = authoritySHA256.lowercased()
+ self.retainedAuthority = retainedAuthority
+ self.validation = validation
+ }
+
+ public func revalidate() throws {
+ _ = retainedAuthority
+ try validation()
+ }
+}
+
+public protocol DoryDaemonVirtualMachinePlanningMutationAuthorizing: Sendable {
+ func acquirePlanningMutationFence(
+ machine: DoryMachineConfiguration,
+ definition: DoryVirtualMachineDefinition,
+ canonicalDefinitionData: Data
+ ) throws -> DoryDaemonVirtualMachinePlanningMutationFence
+}
+
+public enum DoryDaemonVirtualMachinePlanningTransactionFailureCode:
+ String, Sendable, Equatable
+{
+ case invalidRequest = "invalid-request"
+ case mutationAuthorityRejected = "mutation-authority-rejected"
+ case transactionConflict = "transaction-conflict"
+ case transactionAborted = "transaction-aborted"
+ case recoveryRequired = "recovery-required"
+ case trustUnavailable = "trust-unavailable"
+ case resourceReservationRejected = "resource-reservation-rejected"
+ case planConstructionRejected = "plan-construction-rejected"
+ case planBindingRejected = "plan-binding-rejected"
+ case publicationAuthorizationRejected = "publication-authorization-rejected"
+ case workspacePublicationRejected = "workspace-publication-rejected"
+ case planPublicationRejected = "plan-publication-rejected"
+ case invalidJournal = "invalid-journal"
+ case filesystem = "filesystem"
+}
+
+public struct DoryDaemonVirtualMachinePlanningTransactionFailure:
+ Error, Sendable, Equatable
+{
+ public var code: DoryDaemonVirtualMachinePlanningTransactionFailureCode
+ public var message: String
+
+ public init(
+ code: DoryDaemonVirtualMachinePlanningTransactionFailureCode,
+ message: String
+ ) {
+ self.code = code
+ self.message = message
+ }
+}
+
+public struct DoryDaemonVirtualMachinePlanningTransactionResult: Sendable {
+ public var planning: DoryDaemonVirtualMachinePlanningResult
+ public var lease: DoryVirtualMachineResourceAdmissionLease
+ public var transactionID: String
+
+ public init(
+ planning: DoryDaemonVirtualMachinePlanningResult,
+ lease: DoryVirtualMachineResourceAdmissionLease,
+ transactionID: String
+ ) {
+ self.planning = planning
+ self.lease = lease
+ self.transactionID = transactionID
+ }
+}
+
+public protocol DoryWorkspaceDefinitionStoring: Sendable {
+ func create(_ definition: DoryVirtualMachineDefinition) throws
+ func replace(_ definition: DoryVirtualMachineDefinition, expectedRevision: UInt64) throws
+ func read(id: String) throws -> DoryVirtualMachineDefinition
+ func readIfPresent(id: String) throws -> DoryVirtualMachineDefinition?
+}
+
+extension DoryWorkspaceRepository: DoryWorkspaceDefinitionStoring {
+ public func readIfPresent(id: String) throws -> DoryVirtualMachineDefinition? {
+ do { return try read(id: id) }
+ catch DoryWorkspaceRepositoryError.workspaceNotFound { return nil }
+ }
+}
+
+public protocol DoryPlanningTransactionResolvedPlanStoring:
+ DoryResolvedMachinePlanStoring
+{
+ func readIfPresent(id: String) throws -> DoryResolvedMachinePlan?
+}
+
+extension DoryResolvedMachinePlanRepository: DoryPlanningTransactionResolvedPlanStoring {
+ public func readIfPresent(id: String) throws -> DoryResolvedMachinePlan? {
+ do { return try read(id: id) }
+ catch DoryResolvedMachinePlanRepositoryError.planNotFound { return nil }
+ }
+}
+
+/// Crash-safe reserve -> exact-plan-bind -> desired-state publication transaction.
+///
+/// `DoryWorkspaceRepository` and `DoryResolvedMachinePlanRepository` remain independently atomic.
+/// This coordinator serializes all participating transaction writers with a per-workspace flock;
+/// the durable journal makes the two publications recoverable and every intermediate state fails
+/// closed because definition and plan digests/revisions cannot both match until completion.
+public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
+ @unchecked Sendable
+{
+ public enum PublicationStage: Sendable, Equatable {
+ case preparedJournalPublished
+ case resourceReservationCommitted
+ case reservedJournalPublished
+ case candidateJournalPublished
+ case planBindingCommitted
+ case boundJournalPublished
+ case publicationAuthorized
+ case workspacePublished
+ case workspaceJournalPublished
+ case planPublished
+ case planJournalPublished
+ case completeJournalPublished
+ }
+
+ private enum Phase: String, Codable, Sendable {
+ case prepared
+ case reserved
+ case candidatePrepared = "candidate-prepared"
+ case bound
+ case workspacePublished = "workspace-published"
+ case planPublished = "plan-published"
+ case complete
+ case aborted
+ case recoveryRequired = "recovery-required"
+ }
+
+ private struct Journal: Codable, Sendable, Equatable {
+ static let schemaVersion: UInt16 = 1
+ var schemaVersion: UInt16 = schemaVersion
+ var transactionID: String
+ var phase: Phase
+ var requestSHA256: String
+ var machineAuthoritySHA256: String
+ var definition: DoryVirtualMachineDefinition
+ var definitionSHA256: String
+ var sourceDefinitionSHA256: String?
+ var sourceDefinitionRevision: UInt64?
+ var workspacePublication: DoryDaemonVirtualMachineWorkspacePublication
+ var planPublication: PlanPublicationRecord
+ var sourcePlanSHA256: String?
+ var sourcePlanRevision: UInt64?
+ var plannedAtUnixMilliseconds: Int64
+ var leaseID: String?
+ var leaseRevision: UInt64?
+ /// Exact sorted-key plan bytes. Recovery publishes these bytes, never a live re-plan.
+ var candidatePlanData: Data?
+ var candidatePlanSHA256: String?
+ var candidatePlannerRequest: DoryVirtualMachineBackendPlanRequest?
+ var candidatePlannerResult: DoryVirtualMachineBackendPlanResult?
+ }
+
+ private enum PlanPublicationRecord: Codable, Sendable, Equatable {
+ case create
+ case replace(expectedRevision: UInt64)
+
+ init(_ publication: DoryDaemonVirtualMachinePlanPublication) {
+ switch publication {
+ case .create: self = .create
+ case let .replace(expected): self = .replace(expectedRevision: expected)
+ }
+ }
+ }
+
+ private struct JournalEnvelope: Codable {
+ var journalSHA256: String
+ var journal: Journal
+ }
+
+ private final class CapturePlanStore: DoryResolvedMachinePlanStoring,
+ @unchecked Sendable
+ {
+ private let source: any DoryPlanningTransactionResolvedPlanStoring
+ private(set) var captured: DoryResolvedMachinePlan?
+
+ init(source: any DoryPlanningTransactionResolvedPlanStoring) { self.source = source }
+ func create(_ plan: DoryResolvedMachinePlan) throws { captured = plan }
+ func replace(_ plan: DoryResolvedMachinePlan, expectedPlanRevision: UInt64) throws {
+ _ = expectedPlanRevision
+ captured = plan
+ }
+ func read(id: String) throws -> DoryResolvedMachinePlan { try source.read(id: id) }
+ }
+
+ private struct FixedInventory: DoryDaemonVirtualMachineTrustInventory {
+ var snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ func planningInventory(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot {
+ _ = request
+ return snapshot
+ }
+ func startInventory(
+ for request: DoryDaemonVirtualMachineStartInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot {
+ _ = request
+ throw DoryDaemonVirtualMachinePlanningTransactionFailure(
+ code: .invalidRequest,
+ message: "Planning inventory cannot authorize start."
+ )
+ }
+ }
+
+ private static let journalFileName = "planning-transaction-v1.json"
+ private static let lockFileName = ".planning-transaction.lock"
+ private static let temporaryPrefix = ".planning-transaction."
+ private static let maximumJournalBytes = 16 * 1_024 * 1_024
+
+ private let stateDirectory: String
+ private let registry: BackendRegistry
+ private let trust: any DoryDaemonVirtualMachinePlanningTrustPreparing
+ private let mutationAuthority: any DoryDaemonVirtualMachinePlanningMutationAuthorizing
+ private let workspaces: any DoryWorkspaceDefinitionStoring
+ private let plans: any DoryPlanningTransactionResolvedPlanStoring
+ private let ledger: DoryVirtualMachineResourceAdmissionLedger
+ private let capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning
+ private let now: @Sendable () -> Int64
+ private let faultInjector: (@Sendable (PublicationStage) throws -> Void)?
+ private let instanceLock = NSLock()
+
+ public init(
+ stateDirectory: String,
+ registry: BackendRegistry,
+ trust: any DoryDaemonVirtualMachinePlanningTrustPreparing,
+ mutationAuthority: any DoryDaemonVirtualMachinePlanningMutationAuthorizing,
+ workspaces: any DoryWorkspaceDefinitionStoring,
+ plans: any DoryPlanningTransactionResolvedPlanStoring,
+ ledger: DoryVirtualMachineResourceAdmissionLedger,
+ capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning =
+ DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner(),
+ now: @escaping @Sendable () -> Int64 = {
+ Int64((Date().timeIntervalSince1970 * 1_000).rounded(.towardZero))
+ }
+ ) {
+ self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path
+ self.registry = registry
+ self.trust = trust
+ self.mutationAuthority = mutationAuthority
+ self.workspaces = workspaces
+ self.plans = plans
+ self.ledger = ledger
+ self.capabilityPlanner = capabilityPlanner
+ self.now = now
+ faultInjector = nil
+ }
+
+ init(
+ stateDirectory: String,
+ registry: BackendRegistry,
+ trust: any DoryDaemonVirtualMachinePlanningTrustPreparing,
+ mutationAuthority: any DoryDaemonVirtualMachinePlanningMutationAuthorizing,
+ workspaces: any DoryWorkspaceDefinitionStoring,
+ plans: any DoryPlanningTransactionResolvedPlanStoring,
+ ledger: DoryVirtualMachineResourceAdmissionLedger,
+ capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning,
+ now: @escaping @Sendable () -> Int64,
+ faultInjector: @escaping @Sendable (PublicationStage) throws -> Void
+ ) {
+ self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path
+ self.registry = registry
+ self.trust = trust
+ self.mutationAuthority = mutationAuthority
+ self.workspaces = workspaces
+ self.plans = plans
+ self.ledger = ledger
+ self.capabilityPlanner = capabilityPlanner
+ self.now = now
+ self.faultInjector = faultInjector
+ }
+
+ public func resolveReserveAndPublish(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult {
+ try instanceLock.withLock {
+ try withWorkspaceLock(machineID: request.planning.definition.identity.id) {
+ try execute(request)
+ }
+ }
+ }
+
+ private func execute(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult {
+ try validate(request)
+ let canonicalDefinition = DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(request.planning.definition)
+ let mutationFence: DoryDaemonVirtualMachinePlanningMutationFence
+ do {
+ mutationFence = try mutationAuthority.acquirePlanningMutationFence(
+ machine: request.planning.machine,
+ definition: request.planning.definition,
+ canonicalDefinitionData: canonicalDefinition
+ )
+ try mutationFence.revalidate()
+ } catch {
+ throw failure(
+ .mutationAuthorityRejected,
+ "Authoritative machine state is live, stale, or inconsistent with the workspace."
+ )
+ }
+ guard Self.isSHA256(mutationFence.authoritySHA256) else {
+ throw failure(.mutationAuthorityRejected, "Machine authority digest is invalid.")
+ }
+ let requestDigest = Self.requestDigest(
+ request,
+ canonicalDefinition: canonicalDefinition,
+ machineAuthoritySHA256: mutationFence.authoritySHA256
+ )
+ var journal = try loadOrPrepare(
+ request,
+ requestDigest: requestDigest,
+ machineAuthoritySHA256: mutationFence.authoritySHA256,
+ canonicalDefinition: canonicalDefinition
+ )
+ switch journal.phase {
+ case .aborted:
+ throw failure(.transactionAborted, "The exact planning transaction was aborted.")
+ case .recoveryRequired:
+ try resumeRecoveryRequired(&journal)
+ case .complete:
+ return try completedResult(request, journal: journal)
+ default: break
+ }
+
+ let inventoryRequest = try makeInventoryRequest(request.planning.definition)
+ let preparation: DoryDaemonVirtualMachinePlanningTrustPreparation
+ do { preparation = try trust.preparePlanningTrust(for: inventoryRequest) }
+ catch {
+ try abortOrRetainAfterTrustFailure(&journal)
+ throw failure(.trustUnavailable, "Exact daemon planning trust is unavailable.")
+ }
+
+ var lease = try reserveOrAdopt(
+ request,
+ journal: &journal,
+ hostResources: preparation.hostResources,
+ canonicalDefinition: canonicalDefinition,
+ mutationFence: mutationFence
+ )
+
+ let planning: DoryDaemonVirtualMachinePlanningResult
+ do {
+ planning = try constructOrRecoverCandidate(
+ request,
+ journal: &journal,
+ admission: lease.evidence,
+ preparation: preparation
+ )
+ } catch {
+ if lease.boundPlanSHA256 == nil { try? abortUnboundIfPresent(&journal) }
+ throw error
+ }
+ try faultInjector?(.candidateJournalPublished)
+
+ if lease.boundPlanSHA256 == nil {
+ do {
+ lease = try ledger.bind(
+ leaseID: lease.leaseID,
+ to: planning.resolvedPlan,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ } catch {
+ try? abortUnboundIfPresent(&journal)
+ throw failure(.planBindingRejected, "The resource lease could not bind exact plan bytes.")
+ }
+ try faultInjector?(.planBindingCommitted)
+ } else if lease.boundPlanSHA256 != planning.resolvedPlanSHA256 {
+ try markRecoveryRequired(&journal)
+ throw failure(.recoveryRequired, "The resource lease is bound to different plan bytes.")
+ }
+ journal.leaseRevision = lease.leaseRevision
+ journal.phase = .bound
+ try publishJournal(journal)
+ try faultInjector?(.boundJournalPublished)
+
+ do {
+ try preparation.publicationAuthorization.authorize()
+ try mutationFence.revalidate()
+ } catch {
+ try markRecoveryRequired(&journal)
+ throw failure(
+ .publicationAuthorizationRejected,
+ "Fresh media, runtime, host, or qualification evidence changed before publication."
+ )
+ }
+ try faultInjector?(.publicationAuthorized)
+
+ try publishWorkspace(request, journal: &journal)
+ try publishPlan(request, planning: planning, journal: &journal)
+ journal.phase = .complete
+ try publishJournal(journal)
+ try faultInjector?(.completeJournalPublished)
+ return DoryDaemonVirtualMachinePlanningTransactionResult(
+ planning: planning,
+ lease: lease,
+ transactionID: journal.transactionID
+ )
+ }
+
+ private func loadOrPrepare(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ requestDigest: String,
+ machineAuthoritySHA256: String,
+ canonicalDefinition: Data
+ ) throws -> Journal {
+ if let existing = try readJournal(machineID: request.planning.definition.identity.id) {
+ if existing.requestSHA256 == requestDigest { return existing }
+ guard existing.phase == .complete || existing.phase == .aborted else {
+ throw failure(.transactionConflict, "Another planning transaction is incomplete.")
+ }
+ }
+ let timestamp = now()
+ guard timestamp > 0 else { throw failure(.invalidRequest, "Planning time is invalid.") }
+ let sourcePlan: DoryResolvedMachinePlan?
+ let existingDefinition = try workspaces.readIfPresent(
+ id: request.planning.definition.identity.id
+ )
+ sourcePlan = try plans.readIfPresent(id: request.planning.definition.identity.id)
+ switch request.workspacePublication {
+ case .create:
+ guard existingDefinition == nil else {
+ throw failure(.transactionConflict, "Workspace already exists before create.")
+ }
+ case let .replace(expected):
+ guard let existingDefinition,
+ existingDefinition.lifecycle.revision == expected else {
+ throw failure(.transactionConflict, "Workspace replacement source is stale.")
+ }
+ case .retainExistingExact:
+ guard let existingDefinition,
+ existingDefinition == request.planning.definition else {
+ throw failure(.transactionConflict, "Retained workspace is not exact.")
+ }
+ }
+ switch request.planning.publication {
+ case .create:
+ guard sourcePlan == nil else {
+ throw failure(.transactionConflict, "Resolved plan already exists before create.")
+ }
+ case let .replace(expected):
+ guard sourcePlan?.planRevision == expected else {
+ throw failure(.transactionConflict, "Resolved-plan replacement source is stale.")
+ }
+ }
+ let journal = Journal(
+ transactionID: "planning-transaction-\(UUID().uuidString.lowercased())",
+ phase: .prepared,
+ requestSHA256: requestDigest,
+ machineAuthoritySHA256: machineAuthoritySHA256,
+ definition: request.planning.definition,
+ definitionSHA256: Self.sha256(canonicalDefinition),
+ sourceDefinitionSHA256: existingDefinition.map {
+ Self.sha256(DoryDaemonVirtualMachinePlanningCoordinator.canonicalDefinitionData($0))
+ },
+ sourceDefinitionRevision: existingDefinition?.lifecycle.revision,
+ workspacePublication: request.workspacePublication,
+ planPublication: PlanPublicationRecord(request.planning.publication),
+ sourcePlanSHA256: sourcePlan.map(DoryDaemonVirtualMachinePlanningCoordinator.planSHA256),
+ sourcePlanRevision: sourcePlan?.planRevision,
+ plannedAtUnixMilliseconds: timestamp,
+ leaseID: nil,
+ leaseRevision: nil,
+ candidatePlanData: nil,
+ candidatePlanSHA256: nil,
+ candidatePlannerRequest: nil,
+ candidatePlannerResult: nil
+ )
+ try publishJournal(journal)
+ try faultInjector?(.preparedJournalPublished)
+ return journal
+ }
+
+ private func reserveOrAdopt(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ journal: inout Journal,
+ hostResources: DoryVMHostResources,
+ canonicalDefinition: Data,
+ mutationFence: DoryDaemonVirtualMachinePlanningMutationFence
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ let binding = DoryVirtualMachineResourceAdmissionPlanBinding(
+ machineID: request.planning.definition.identity.id,
+ definitionRevision: request.planning.definition.lifecycle.revision,
+ definitionSHA256: Self.sha256(canonicalDefinition),
+ plannedPlanRevision: Self.plannedPlanRevision(request.planning.publication)
+ )
+ if let leaseID = journal.leaseID {
+ let candidates = try ledger.snapshot().leases.filter { $0.leaseID == leaseID }
+ guard candidates.count == 1, let lease = candidates.first,
+ lease.binding == binding,
+ Self.sameStableAdmissionHost(lease.hostFacts, hostResources),
+ lease.resources == request.planning.definition.resources,
+ lease.workload == request.planning.definition.workload,
+ lease.requirements == request.resourceRequirements.sorted(by: Self.requirementOrder) else {
+ throw failure(.recoveryRequired, "The journaled resource lease cannot be adopted exactly.")
+ }
+ if lease.state == .stopped, lease.boundPlanSHA256 == nil {
+ let reactivated: DoryVirtualMachineResourceAdmissionLease
+ do {
+ reactivated = try ledger.reserveStarting(
+ binding: binding,
+ hostFacts: hostResources,
+ workload: request.planning.definition.workload,
+ requirements: request.resourceRequirements,
+ resources: request.planning.definition.resources,
+ startingLeaseDurationMilliseconds:
+ request.startingLeaseDurationMilliseconds
+ )
+ } catch {
+ throw failure(.resourceReservationRejected, "Stopped planning lease cannot be reactivated.")
+ }
+ guard reactivated.leaseID == leaseID,
+ reactivated.boundPlanSHA256 == nil else {
+ throw failure(.recoveryRequired, "Reactivated lease identity changed.")
+ }
+ journal.leaseRevision = reactivated.leaseRevision
+ try publishJournal(journal)
+ return reactivated
+ }
+ if lease.state == .recoveryRequired,
+ let planData = journal.candidatePlanData,
+ let planDigest = journal.candidatePlanSHA256,
+ lease.boundPlanSHA256 == planDigest,
+ let plan = try? JSONDecoder().decode(
+ DoryResolvedMachinePlan.self, from: planData
+ ) {
+ do {
+ try mutationFence.revalidate()
+ let recovered = try ledger.recoverBoundPlanningLease(
+ leaseID: leaseID,
+ plan: plan,
+ authorization:
+ DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization(
+ machineID: plan.machineID,
+ planSHA256: planDigest
+ ),
+ startingLeaseDurationMilliseconds:
+ request.startingLeaseDurationMilliseconds,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ journal.leaseRevision = recovered.leaseRevision
+ try publishJournal(journal)
+ return recovered
+ } catch {
+ try markRecoveryRequired(&journal)
+ throw failure(.recoveryRequired, "Expired bound planning lease cannot be proven unlaunched.")
+ }
+ }
+ guard lease.state == .starting else {
+ try markRecoveryRequired(&journal)
+ throw failure(.recoveryRequired, "The journaled resource lease is no longer starting.")
+ }
+ return lease
+ }
+ let existing = try ledger.snapshot().leases.filter {
+ $0.binding == binding && $0.state == .starting
+ && Self.sameStableAdmissionHost($0.hostFacts, hostResources)
+ && $0.resources == request.planning.definition.resources
+ && $0.workload == request.planning.definition.workload
+ && $0.requirements == request.resourceRequirements.sorted(by: Self.requirementOrder)
+ }
+ let lease: DoryVirtualMachineResourceAdmissionLease
+ if existing.count == 1, let adopted = existing.first {
+ lease = adopted
+ } else if existing.isEmpty {
+ do {
+ lease = try ledger.reserveStarting(
+ binding: binding,
+ hostFacts: hostResources,
+ workload: request.planning.definition.workload,
+ requirements: request.resourceRequirements,
+ resources: request.planning.definition.resources,
+ startingLeaseDurationMilliseconds:
+ request.startingLeaseDurationMilliseconds
+ )
+ } catch {
+ throw failure(.resourceReservationRejected, "Resources could not be reserved atomically.")
+ }
+ try faultInjector?(.resourceReservationCommitted)
+ } else {
+ throw failure(.recoveryRequired, "Resource lease adoption is ambiguous.")
+ }
+ journal.leaseID = lease.leaseID
+ journal.leaseRevision = lease.leaseRevision
+ journal.phase = .reserved
+ try publishJournal(journal)
+ try faultInjector?(.reservedJournalPublished)
+ return lease
+ }
+
+ private func constructOrRecoverCandidate(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ journal: inout Journal,
+ admission: DoryResolvedMachineResourceAdmissionEvidence,
+ preparation: DoryDaemonVirtualMachinePlanningTrustPreparation
+ ) throws -> DoryDaemonVirtualMachinePlanningResult {
+ if journal.candidatePlanData != nil {
+ return try planningResultFromCandidate(request, journal: journal)
+ }
+ let capture = CapturePlanStore(source: plans)
+ let plannedAt = journal.plannedAtUnixMilliseconds
+ let coordinator = DoryDaemonVirtualMachinePlanningCoordinator(
+ registry: registry,
+ inventory: FixedInventory(snapshot: preparation.snapshot(admission)),
+ plans: capture,
+ capabilityPlanner: capabilityPlanner,
+ now: { plannedAt }
+ )
+ let result: DoryDaemonVirtualMachinePlanningResult
+ do { result = try coordinator.resolveAndPersist(request.planning) }
+ catch {
+ throw failure(.planConstructionRejected, "Exact trusted facts could not construct a plan.")
+ }
+ let candidateData = Self.canonicalData(result.resolvedPlan)
+ guard !candidateData.isEmpty, Self.sha256(candidateData) == result.resolvedPlanSHA256 else {
+ throw failure(.planConstructionRejected, "Candidate plan encoding is not deterministic.")
+ }
+ journal.candidatePlanData = candidateData
+ journal.candidatePlanSHA256 = result.resolvedPlanSHA256
+ journal.candidatePlannerRequest = result.plannerRequest
+ journal.candidatePlannerResult = result.plannerResult
+ journal.phase = .candidatePrepared
+ try publishJournal(journal)
+ return result
+ }
+
+ private func planningResultFromCandidate(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ journal: Journal
+ ) throws -> DoryDaemonVirtualMachinePlanningResult {
+ guard let data = journal.candidatePlanData,
+ let digest = journal.candidatePlanSHA256,
+ let plan = try? JSONDecoder().decode(DoryResolvedMachinePlan.self, from: data),
+ let plannerRequest = journal.candidatePlannerRequest,
+ let plannerResult = journal.candidatePlannerResult,
+ let selected = plannerResult.selectedDescriptor else {
+ throw failure(.invalidJournal, "Journaled plan selection is incomplete.")
+ }
+ let backendResult = registry.plan(MachineBackendPlanRequest(
+ machine: request.planning.machine,
+ capabilityPlan: plannerResult
+ ))
+ guard let backendPlan = backendResult.plan,
+ backendResult.failure == nil,
+ backendPlan.machine == request.planning.machine,
+ backendPlan.capability == selected,
+ backendPlan.backend.identity == plan.backend,
+ backendPlan.backend.implementationIdentifier
+ == plan.backendImplementationIdentifier else {
+ throw failure(.planConstructionRejected, "Journaled plan no longer maps to its exact adapter.")
+ }
+ return DoryDaemonVirtualMachinePlanningResult(
+ plannerRequest: plannerRequest,
+ plannerResult: plannerResult,
+ resolvedPlan: plan,
+ resolvedPlanSHA256: digest,
+ backendPlan: backendPlan
+ )
+ }
+
+ private func publishWorkspace(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ journal: inout Journal
+ ) throws {
+ let target = request.planning.definition
+ let current = try workspaces.readIfPresent(id: target.identity.id)
+ if current != target {
+ do {
+ switch request.workspacePublication {
+ case .create:
+ guard current == nil else { throw failure(.transactionConflict, "Workspace differs from target.") }
+ try workspaces.create(target)
+ case let .replace(expected):
+ guard let current,
+ Self.sha256(DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(current)) == journal.sourceDefinitionSHA256 else {
+ throw failure(.transactionConflict, "Workspace source changed during planning.")
+ }
+ try workspaces.replace(target, expectedRevision: expected)
+ case .retainExistingExact:
+ throw failure(.transactionConflict, "Retained workspace differs from target.")
+ }
+ } catch let error as DoryDaemonVirtualMachinePlanningTransactionFailure { throw error }
+ catch { throw failure(.workspacePublicationRejected, "Workspace publication failed.") }
+ }
+ try faultInjector?(.workspacePublished)
+ journal.phase = .workspacePublished
+ try publishJournal(journal)
+ try faultInjector?(.workspaceJournalPublished)
+ }
+
+ private func publishPlan(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ planning: DoryDaemonVirtualMachinePlanningResult,
+ journal: inout Journal
+ ) throws {
+ let current = try plans.readIfPresent(id: planning.resolvedPlan.machineID)
+ if current != planning.resolvedPlan {
+ do {
+ switch request.planning.publication {
+ case .create:
+ guard current == nil else { throw failure(.transactionConflict, "Resolved plan differs from target.") }
+ try plans.create(planning.resolvedPlan)
+ case let .replace(expected):
+ guard let current,
+ DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(current)
+ == journal.sourcePlanSHA256 else {
+ throw failure(.transactionConflict, "Resolved plan source changed during planning.")
+ }
+ try plans.replace(planning.resolvedPlan, expectedPlanRevision: expected)
+ }
+ } catch let error as DoryDaemonVirtualMachinePlanningTransactionFailure { throw error }
+ catch { throw failure(.planPublicationRejected, "Resolved-plan publication failed.") }
+ }
+ try faultInjector?(.planPublished)
+ journal.phase = .planPublished
+ try publishJournal(journal)
+ try faultInjector?(.planJournalPublished)
+ }
+
+ private func completedResult(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ journal: Journal
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult {
+ let planning = try planningResultFromCandidate(request, journal: journal)
+ let plan = planning.resolvedPlan
+ guard
+ let currentDefinition = try workspaces.readIfPresent(id: plan.machineID),
+ currentDefinition == journal.definition,
+ let currentPlan = try plans.readIfPresent(id: plan.machineID),
+ currentPlan == plan,
+ let leaseID = journal.leaseID,
+ let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }),
+ lease.boundPlanSHA256 == journal.candidatePlanSHA256 else {
+ throw failure(.recoveryRequired, "Completed transaction authorities no longer match.")
+ }
+ return DoryDaemonVirtualMachinePlanningTransactionResult(
+ planning: planning,
+ lease: lease,
+ transactionID: journal.transactionID
+ )
+ }
+
+ /// A post-bind failure is retained, never aborted. Retrying through this coordinator is the
+ /// explicit daemon recovery operation: it reacquires the mutation fence in `execute`, proves
+ /// the same exact bound lease/candidate and source-or-target publication authorities here,
+ /// then obtains a newly collected, single-use publication authorization before resuming.
+ private func resumeRecoveryRequired(_ journal: inout Journal) throws {
+ guard let leaseID = journal.leaseID,
+ let candidateDigest = journal.candidatePlanSHA256,
+ let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }),
+ lease.binding.machineID == journal.definition.identity.id,
+ lease.boundPlanSHA256 == candidateDigest,
+ lease.state == .starting || lease.state == .recoveryRequired else {
+ throw failure(.recoveryRequired, "The retained bound lease cannot be recovered exactly.")
+ }
+
+ let currentDefinition = try workspaces.readIfPresent(
+ id: journal.definition.identity.id
+ )
+ let definitionIsTarget = currentDefinition == journal.definition
+ let definitionIsSource: Bool
+ switch journal.workspacePublication {
+ case .create:
+ definitionIsSource = currentDefinition == nil
+ case .replace:
+ definitionIsSource = currentDefinition.map {
+ Self.sha256(DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData($0)) == journal.sourceDefinitionSHA256
+ } ?? false
+ case .retainExistingExact:
+ definitionIsSource = definitionIsTarget
+ }
+ guard definitionIsSource || definitionIsTarget else {
+ throw failure(.recoveryRequired, "Workspace authority changed during bound recovery.")
+ }
+
+ let currentPlan = try plans.readIfPresent(id: journal.definition.identity.id)
+ let planIsTarget = currentPlan.map {
+ DoryDaemonVirtualMachinePlanningCoordinator.planSHA256($0) == candidateDigest
+ } ?? false
+ let planIsSource: Bool
+ switch journal.planPublication {
+ case .create:
+ planIsSource = currentPlan == nil
+ case .replace:
+ planIsSource = currentPlan.map {
+ DoryDaemonVirtualMachinePlanningCoordinator.planSHA256($0)
+ == journal.sourcePlanSHA256
+ } ?? false
+ }
+ guard planIsSource || planIsTarget,
+ !planIsTarget || definitionIsTarget else {
+ throw failure(.recoveryRequired, "Publication authorities cannot be resumed safely.")
+ }
+
+ journal.leaseRevision = lease.leaseRevision
+ journal.phase = .bound
+ try publishJournal(journal)
+ }
+
+ private func abortUnboundIfPresent(_ journal: inout Journal) throws {
+ if let leaseID = journal.leaseID,
+ let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }),
+ lease.state == .starting, lease.boundPlanSHA256 == nil {
+ _ = try ledger.cancelUnboundStarting(
+ leaseID: leaseID,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ }
+ journal.phase = .aborted
+ try publishJournal(journal)
+ }
+
+ private func abortOrRetainAfterTrustFailure(_ journal: inout Journal) throws {
+ guard let leaseID = journal.leaseID,
+ let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }) else {
+ journal.phase = .aborted
+ try publishJournal(journal)
+ return
+ }
+ if lease.state == .starting, lease.boundPlanSHA256 == nil {
+ _ = try ledger.cancelUnboundStarting(
+ leaseID: leaseID,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ journal.phase = .aborted
+ } else if lease.boundPlanSHA256 != nil {
+ journal.phase = .recoveryRequired
+ } else {
+ journal.phase = .aborted
+ }
+ try publishJournal(journal)
+ }
+
+ private func markRecoveryRequired(_ journal: inout Journal) throws {
+ journal.phase = .recoveryRequired
+ try publishJournal(journal)
+ }
+
+ private func validate(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest
+ ) throws {
+ guard request.planning.canonicalDefinitionData
+ == DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(request.planning.definition),
+ request.planning.machine.id == request.planning.definition.identity.id,
+ request.startingLeaseDurationMilliseconds > 0 else {
+ throw failure(.invalidRequest, "Planning transaction request is inconsistent.")
+ }
+ switch request.workspacePublication {
+ case .create:
+ guard request.planning.definition.lifecycle.revision == 1 else {
+ throw failure(.invalidRequest, "Created workspace must start at revision one.")
+ }
+ case let .replace(expected):
+ guard expected < .max,
+ request.planning.definition.lifecycle.revision == expected + 1 else {
+ throw failure(.invalidRequest, "Workspace replacement revision is invalid.")
+ }
+ case .retainExistingExact: break
+ }
+ }
+
+ private func makeInventoryRequest(
+ _ definition: DoryVirtualMachineDefinition
+ ) throws -> DoryDaemonVirtualMachineInventoryRequest {
+ guard let boot = DoryDaemonVirtualMachinePlanningCoordinator
+ .primaryBootMedia(in: definition) else {
+ throw failure(.invalidRequest, "Workspace has no primary boot media.")
+ }
+ return DoryDaemonVirtualMachineInventoryRequest(
+ machineID: definition.identity.id,
+ definitionRevision: definition.lifecycle.revision,
+ guest: definition.guest,
+ bootMedia: boot,
+ resources: definition.resources,
+ devices: DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition),
+ acceptableGraphics: definition.graphics.acceptableLevels,
+ virtualHardwareABIVersion: definition.virtualHardwareABIVersion
+ )
+ }
+
+ private static func plannedPlanRevision(
+ _ publication: DoryDaemonVirtualMachinePlanPublication
+ ) -> UInt64 {
+ switch publication {
+ case .create: 1
+ case let .replace(expected): expected == .max ? .max : expected + 1
+ }
+ }
+
+ private static func requestDigest(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ canonicalDefinition: Data,
+ machineAuthoritySHA256: String
+ ) -> String {
+ struct DigestInput: Codable {
+ var definitionSHA256: String
+ var machineSHA256: String
+ var machineAuthoritySHA256: String
+ var workspacePublication: DoryDaemonVirtualMachineWorkspacePublication
+ var planPublication: PlanPublicationRecord
+ var requirements: [DoryVMResourceRequirement]
+ var leaseDurationMilliseconds: Int64
+ var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization?
+ var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization?
+ }
+ return sha256(canonicalData(DigestInput(
+ definitionSHA256: sha256(canonicalDefinition),
+ machineSHA256: sha256(canonicalData(request.planning.machine)),
+ machineAuthoritySHA256: machineAuthoritySHA256,
+ workspacePublication: request.workspacePublication,
+ planPublication: PlanPublicationRecord(request.planning.publication),
+ requirements: request.resourceRequirements.sorted(by: requirementOrder),
+ leaseDurationMilliseconds: request.startingLeaseDurationMilliseconds,
+ fallbackAuthorization: request.planning.fallbackAuthorization,
+ experimentalAuthorization: request.planning.experimentalAuthorization
+ )))
+ }
+
+ private static func requirementOrder(
+ _ lhs: DoryVMResourceRequirement,
+ _ rhs: DoryVMResourceRequirement
+ ) -> Bool {
+ canonicalData(lhs).lexicographicallyPrecedes(canonicalData(rhs))
+ }
+
+ private func journalDirectory(machineID: String) -> String {
+ stateDirectory + "/" + machineID
+ }
+
+ private func journalPath(machineID: String) -> String {
+ journalDirectory(machineID: machineID) + "/" + Self.journalFileName
+ }
+
+ private func withWorkspaceLock(
+ machineID: String,
+ _ operation: () throws -> T
+ ) throws -> T {
+ guard Self.isValidMachineID(machineID) else {
+ throw failure(.invalidRequest, "Machine identifier is invalid.")
+ }
+ try ensurePrivateDirectory(stateDirectory)
+ let directory = journalDirectory(machineID: machineID)
+ try ensurePrivateDirectory(directory)
+ let path = directory + "/" + Self.lockFileName
+ let descriptor = open(
+ path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600)
+ )
+ guard descriptor >= 0 else {
+ throw failure(.filesystem, "Planning transaction lock cannot be opened.")
+ }
+ defer { close(descriptor) }
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ info.st_mode & S_IFMT == S_IFREG,
+ info.st_uid == geteuid(), info.st_nlink == 1,
+ info.st_mode & 0o077 == 0 else {
+ throw failure(.filesystem, "Planning transaction lock is insecure.")
+ }
+ while flock(descriptor, LOCK_EX) != 0 {
+ guard errno == EINTR else {
+ throw failure(.filesystem, "Planning transaction lock cannot be acquired.")
+ }
+ }
+ defer { _ = flock(descriptor, LOCK_UN) }
+ return try operation()
+ }
+
+ private func readJournal(machineID: String) throws -> Journal? {
+ let path = journalPath(machineID: machineID)
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
+ if descriptor < 0 {
+ if errno == ENOENT { return nil }
+ throw failure(.filesystem, "Planning journal cannot be opened.")
+ }
+ defer { close(descriptor) }
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ info.st_mode & S_IFMT == S_IFREG,
+ info.st_uid == geteuid(), info.st_nlink == 1,
+ info.st_mode & 0o077 == 0,
+ info.st_size > 0, info.st_size <= Self.maximumJournalBytes else {
+ throw failure(.invalidJournal, "Planning journal metadata is invalid.")
+ }
+ var data = Data()
+ var buffer = [UInt8](repeating: 0, count: 16 * 1_024)
+ while true {
+ let count = buffer.withUnsafeMutableBytes {
+ Darwin.read(descriptor, $0.baseAddress, $0.count)
+ }
+ if count < 0, errno == EINTR { continue }
+ guard count >= 0, data.count + count <= Self.maximumJournalBytes else {
+ throw failure(.invalidJournal, "Planning journal cannot be read safely.")
+ }
+ if count == 0 { break }
+ data.append(contentsOf: buffer.prefix(count))
+ }
+ guard let envelope = try? JSONDecoder().decode(JournalEnvelope.self, from: data),
+ data == Self.canonicalData(envelope) + Data("\n".utf8),
+ envelope.journalSHA256 == Self.sha256(Self.canonicalData(envelope.journal)) else {
+ throw failure(.invalidJournal, "Planning journal integrity check failed.")
+ }
+ try validate(envelope.journal)
+ return envelope.journal
+ }
+
+ private func publishJournal(_ journal: Journal) throws {
+ try validate(journal)
+ let directory = journalDirectory(machineID: journal.definition.identity.id)
+ let envelope = JournalEnvelope(
+ journalSHA256: Self.sha256(Self.canonicalData(journal)),
+ journal: journal
+ )
+ let data = Self.canonicalData(envelope) + Data("\n".utf8)
+ guard data.count <= Self.maximumJournalBytes else {
+ throw failure(.invalidJournal, "Planning journal is too large.")
+ }
+ let temporary = directory + "/\(Self.temporaryPrefix)\(UUID().uuidString)"
+ let descriptor = open(
+ temporary, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
+ mode_t(0o600)
+ )
+ guard descriptor >= 0 else { throw failure(.filesystem, "Planning journal cannot be created.") }
+ var openDescriptor = true
+ do {
+ try data.withUnsafeBytes { bytes in
+ var offset = 0
+ while offset < bytes.count {
+ let count = Darwin.write(
+ descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset
+ )
+ if count < 0, errno == EINTR { continue }
+ guard count > 0 else { throw failure(.filesystem, "Planning journal cannot be written.") }
+ offset += count
+ }
+ }
+ guard fsync(descriptor) == 0 else { throw failure(.filesystem, "Planning journal cannot be synced.") }
+ close(descriptor)
+ openDescriptor = false
+ guard rename(temporary, journalPath(machineID: journal.definition.identity.id)) == 0 else {
+ throw failure(.filesystem, "Planning journal cannot be published.")
+ }
+ try syncDirectory(directory)
+ } catch {
+ if openDescriptor { close(descriptor) }
+ unlink(temporary)
+ throw error
+ }
+ }
+
+ private func validate(_ journal: Journal) throws {
+ guard journal.schemaVersion == Journal.schemaVersion,
+ Self.isValidMachineID(journal.definition.identity.id),
+ journal.transactionID.wholeMatch(
+ of: /planning-transaction-[0-9a-f-]{36}/
+ ) != nil,
+ Self.isSHA256(journal.requestSHA256),
+ Self.isSHA256(journal.machineAuthoritySHA256),
+ Self.isSHA256(journal.definitionSHA256),
+ journal.definitionSHA256 == Self.sha256(
+ DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(journal.definition)
+ ),
+ journal.plannedAtUnixMilliseconds > 0,
+ (journal.leaseID == nil) == (journal.leaseRevision == nil),
+ (journal.candidatePlanData == nil) == (journal.candidatePlanSHA256 == nil),
+ (journal.candidatePlanData == nil)
+ == (journal.candidatePlannerRequest == nil),
+ (journal.candidatePlanData == nil)
+ == (journal.candidatePlannerResult == nil) else {
+ throw failure(.invalidJournal, "Planning journal fields are invalid.")
+ }
+ if let data = journal.candidatePlanData,
+ let digest = journal.candidatePlanSHA256 {
+ guard Self.isSHA256(digest), Self.sha256(data) == digest,
+ let plan = try? JSONDecoder().decode(DoryResolvedMachinePlan.self, from: data),
+ Self.canonicalData(plan) == data,
+ plan.machineID == journal.definition.identity.id,
+ plan.definitionSHA256 == journal.definitionSHA256,
+ plan.planRevision == Self.plannedPlanRevision(
+ Self.planPublication(from: journal.planPublication)
+ ),
+ plan.validate().isEmpty else {
+ throw failure(.invalidJournal, "Journaled candidate plan is invalid.")
+ }
+ }
+ switch journal.workspacePublication {
+ case .create:
+ guard journal.sourceDefinitionSHA256 == nil,
+ journal.sourceDefinitionRevision == nil else {
+ throw failure(.invalidJournal, "Workspace create has a source authority.")
+ }
+ case let .replace(expected):
+ guard Self.optionalSHA256IsValid(journal.sourceDefinitionSHA256),
+ journal.sourceDefinitionRevision == expected else {
+ throw failure(.invalidJournal, "Workspace replacement source is invalid.")
+ }
+ case .retainExistingExact:
+ guard journal.sourceDefinitionSHA256 == journal.definitionSHA256,
+ journal.sourceDefinitionRevision
+ == journal.definition.lifecycle.revision else {
+ throw failure(.invalidJournal, "Retained workspace authority is not exact.")
+ }
+ }
+ switch journal.planPublication {
+ case .create:
+ guard journal.sourcePlanSHA256 == nil, journal.sourcePlanRevision == nil else {
+ throw failure(.invalidJournal, "Resolved-plan create has a source authority.")
+ }
+ case let .replace(expected):
+ guard Self.optionalSHA256IsValid(journal.sourcePlanSHA256),
+ journal.sourcePlanRevision == expected else {
+ throw failure(.invalidJournal, "Resolved-plan replacement source is invalid.")
+ }
+ }
+ switch journal.phase {
+ case .prepared:
+ guard journal.leaseID == nil, journal.candidatePlanData == nil else {
+ throw failure(.invalidJournal, "Prepared journal contains later-phase authority.")
+ }
+ case .reserved:
+ guard journal.leaseID != nil, journal.candidatePlanData == nil else {
+ throw failure(.invalidJournal, "Reserved journal fields are inconsistent.")
+ }
+ case .candidatePrepared, .bound, .workspacePublished, .planPublished, .complete,
+ .recoveryRequired:
+ guard journal.leaseID != nil, journal.candidatePlanData != nil else {
+ throw failure(.invalidJournal, "Plan-bearing journal fields are incomplete.")
+ }
+ case .aborted: break
+ }
+ }
+
+ private func ensurePrivateDirectory(_ path: String) throws {
+ if mkdir(path, mode_t(0o700)) != 0, errno != EEXIST {
+ throw failure(.filesystem, "Planning transaction directory cannot be created.")
+ }
+ var info = stat()
+ guard lstat(path, &info) == 0,
+ info.st_mode & S_IFMT == S_IFDIR,
+ info.st_uid == geteuid(),
+ info.st_mode & 0o077 == 0 else {
+ throw failure(.filesystem, "Planning transaction directory is insecure.")
+ }
+ }
+
+ private func syncDirectory(_ path: String) throws {
+ let descriptor = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC)
+ guard descriptor >= 0 else { throw failure(.filesystem, "Planning directory cannot be opened.") }
+ defer { close(descriptor) }
+ guard fsync(descriptor) == 0 else { throw failure(.filesystem, "Planning directory cannot be synced.") }
+ }
+
+ private static func canonicalData(_ value: T) -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return (try? encoder.encode(value)) ?? Data()
+ }
+
+ private static func sha256(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ /// CPU and physical memory describe the capacity model under which a lease was admitted.
+ /// Free disk space and existing commitments are volatile observations; the durable ledger's
+ /// exact reservation is their authority after reserve commits and transaction recovery must
+ /// not attempt a second admission from a later free-space sample.
+ private static func sameStableAdmissionHost(
+ _ lhs: DoryVMHostResources,
+ _ rhs: DoryVMHostResources
+ ) -> Bool {
+ lhs.logicalCPUCount == rhs.logicalCPUCount
+ && lhs.physicalMemoryBytes == rhs.physicalMemoryBytes
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy {
+ (48...57).contains($0) || (97...102).contains($0)
+ }
+ }
+
+ private static func optionalSHA256IsValid(_ value: String?) -> Bool {
+ value.map(isSHA256) ?? false
+ }
+
+ private static func planPublication(
+ from record: PlanPublicationRecord
+ ) -> DoryDaemonVirtualMachinePlanPublication {
+ switch record {
+ case .create: .create
+ case let .replace(expected): .replace(expectedPlanRevision: expected)
+ }
+ }
+
+ private static func isValidMachineID(_ value: String) -> Bool {
+ value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil
+ && !value.hasPrefix(".")
+ }
+
+ private func failure(
+ _ code: DoryDaemonVirtualMachinePlanningTransactionFailureCode,
+ _ message: String
+ ) -> DoryDaemonVirtualMachinePlanningTransactionFailure {
+ DoryDaemonVirtualMachinePlanningTransactionFailure(code: code, message: message)
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
index 03862832..1a92adc9 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
@@ -410,12 +410,53 @@ enum DoryDaemonProductionTrustInventoryError:
case resourceAdmissionUnavailable
}
+private struct DoryDaemonProductionPlanningHostIdentity: Sendable, Equatable {
+ var hardwareModelIdentifier: String
+ var operatingSystemBuild: String
+ var macOSMajorVersion: Int
+ var virtualizationFrameworkAvailable: Bool
+ var hypervisorFrameworkAvailable: Bool
+ var metalAvailable: Bool
+ var logicalCPUCount: UInt64
+ var physicalMemoryBytes: UInt64
+}
+
+private struct DoryDaemonProductionPlanningMaterial: Sendable {
+ var host: DoryDaemonProductionHostObservation
+ var artifact: DoryVerifiedVirtualMachineArtifact
+ var runtimes: [DoryDaemonVerifiedBackendRuntime]
+ var qualifications: [DoryResolvedTrustedVirtualMachineQualification]
+ var media: DoryDaemonVirtualMachineResolvedMedia
+ var backendInventories: [DoryDaemonVirtualMachineBackendRuntimeInventory]
+ var hostFacts: DoryAppleSiliconHostFacts
+
+ var stableHostIdentity: DoryDaemonProductionPlanningHostIdentity {
+ DoryDaemonProductionPlanningHostIdentity(
+ hardwareModelIdentifier: host.hardwareModelIdentifier,
+ operatingSystemBuild: host.operatingSystemBuild,
+ macOSMajorVersion: host.macOSMajorVersion,
+ virtualizationFrameworkAvailable: host.virtualizationFrameworkAvailable,
+ hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable,
+ metalAvailable: host.metalAvailable,
+ logicalCPUCount: host.resources.logicalCPUCount,
+ physicalMemoryBytes: host.resources.physicalMemoryBytes
+ )
+ }
+
+ var qualificationRecords: [DoryVirtualMachineQualificationRecord] {
+ qualifications.map(\.record).sorted {
+ $0.qualificationIdentity < $1.qualificationIdentity
+ }
+ }
+}
+
/// Production inventory backed exclusively by opaque verified authorities. It deliberately does
/// not implement admission reservation yet: the current coordinator has no atomic reserve -> plan
/// bind -> publish transaction. Start-time verification is complete for previously published and
/// bound plans; planning fails explicitly instead of returning invented admission evidence.
final class DoryProductionDaemonVirtualMachineTrustInventory:
DoryDaemonVirtualMachineTrustInventory,
+ DoryDaemonVirtualMachinePlanningTrustPreparing,
DoryDaemonVirtualMachinePreSpawnAuthorizationProviding,
@unchecked Sendable
{
@@ -457,6 +498,184 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
.planningRequiresAdmissionTransaction
}
+ func preparePlanningTrust(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation {
+ let initial = try resolvePlanningMaterial(for: request)
+ return DoryDaemonVirtualMachinePlanningTrustPreparation(
+ hostResources: initial.host.resources,
+ snapshot: { admission in
+ DoryDaemonVirtualMachineTrustedInventorySnapshot(
+ hostFacts: initial.hostFacts,
+ media: initial.media,
+ backendRuntimes: initial.backendInventories,
+ resourceAdmission: admission,
+ runtimeQualifications: initial.qualifications.map(\.runtime),
+ capabilityQualifications: initial.qualifications.map(
+ \.capabilityQualification
+ )
+ )
+ },
+ publicationAuthorization:
+ DoryDaemonVirtualMachinePlanningPublicationAuthorization { [weak self] in
+ guard let self else {
+ throw DoryDaemonProductionTrustInventoryError.invalidRequest
+ }
+ let current = try self.resolvePlanningMaterial(for: request)
+ guard current.stableHostIdentity == initial.stableHostIdentity,
+ current.artifact.reference == initial.artifact.reference,
+ current.artifact.media == initial.artifact.media,
+ current.artifact.authorityRevision
+ == initial.artifact.authorityRevision,
+ current.runtimes == initial.runtimes,
+ current.qualificationRecords == initial.qualificationRecords else {
+ throw DoryDaemonProductionTrustInventoryError.invalidRequest
+ }
+ }
+ )
+ }
+
+ private func resolvePlanningMaterial(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonProductionPlanningMaterial {
+ guard !request.acceptableGraphics.isEmpty,
+ request.machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil,
+ request.definitionRevision > 0 else {
+ throw DoryDaemonProductionTrustInventoryError.invalidRequest
+ }
+ let host: DoryDaemonProductionHostObservation
+ do { host = try hostProbe(stateDirectory) }
+ catch { throw DoryDaemonProductionTrustInventoryError.invalidRequest }
+ let artifact: DoryVerifiedVirtualMachineArtifact
+ do {
+ artifact = try artifactAuthority.resolve(
+ reference: request.bootMedia.artifact,
+ kind: request.bootMedia.kind,
+ source: request.bootMedia.source
+ )
+ } catch {
+ throw DoryDaemonProductionTrustInventoryError.mediaUnavailable
+ }
+
+ var runtimes: [DoryDaemonVerifiedBackendRuntime] = []
+ for specification in runtimeSpecifications.values.sorted(by: {
+ $0.descriptor.identity.rawValue < $1.descriptor.identity.rawValue
+ }) {
+ guard let runtime = try? runtimeVerifier(
+ specification.executablePath,
+ specification.descriptor,
+ specification.componentIdentifier
+ ), runtime.descriptor == specification.descriptor else { continue }
+ runtimes.append(runtime)
+ }
+ guard !runtimes.isEmpty else {
+ throw DoryDaemonProductionTrustInventoryError.backendUnavailable
+ }
+
+ var qualifications: [DoryResolvedTrustedVirtualMachineQualification] = []
+ for runtime in runtimes {
+ for graphics in request.acceptableGraphics {
+ let capability = DoryVirtualMachineCapabilityRequest(
+ guest: request.guest,
+ bootMedia: artifact.media,
+ backend: runtime.descriptor.identity,
+ graphics: graphics,
+ devices: request.devices,
+ virtualHardwareABIVersion: request.virtualHardwareABIVersion
+ )
+ if let qualification = try? qualificationAuthority.resolve(
+ request: capability,
+ backendImplementationIdentifier:
+ runtime.descriptor.implementationIdentifier,
+ backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier,
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ installedComponents: runtime.components
+ ) {
+ qualifications.append(qualification)
+ }
+ }
+ }
+ guard !qualifications.isEmpty else {
+ throw DoryDaemonProductionTrustInventoryError.qualificationUnavailable
+ }
+
+ let inspection: DoryTrustedBootMediaInspection?
+ switch artifact.media.kind {
+ case .installerISO:
+ guard let qualification = qualifications.first else {
+ throw DoryDaemonProductionTrustInventoryError.qualificationUnavailable
+ }
+ do {
+ inspection = try DoryQualifiedBootMediaInspector.inspectInstallerISO(
+ atPath: artifact.path,
+ qualification: qualification
+ ).inspection
+ } catch {
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+ case .installedLinuxBootBundle:
+ do { _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: artifact.path) }
+ catch { throw DoryDaemonProductionTrustInventoryError.mediaInvalid }
+ inspection = nil
+ case .virtualDisk:
+ guard artifact.mutableProvenance != nil else {
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+ inspection = nil
+ case .macOSRestoreImage:
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+
+ let runtimeByIdentity = Dictionary(uniqueKeysWithValues: runtimes.map {
+ ($0.descriptor.identity, $0)
+ })
+ let inventories = qualifications.compactMap { qualification
+ -> DoryDaemonVirtualMachineBackendRuntimeInventory? in
+ let record = qualification.record
+ guard let runtime = runtimeByIdentity[record.backend],
+ runtime.runtimeBuildIdentifier == record.backendRuntimeBuildIdentifier else {
+ return nil
+ }
+ return DoryDaemonVirtualMachineBackendRuntimeInventory(
+ backend: record.backend,
+ runtimeBuildIdentifier: runtime.runtimeBuildIdentifier,
+ components: runtime.componentEvidence,
+ hostQualification: DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: record.qualificationIdentity,
+ qualificationReportSHA256: Self.digest(Self.canonicalData(record)),
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ backend: record.backend,
+ backendRuntimeBuildIdentifier: record.backendRuntimeBuildIdentifier,
+ virtualHardwareABIVersion: record.virtualHardwareABIVersion,
+ qualifierIdentifier: "dory.catalog-v2.virtual-machine-qualification",
+ qualifierVersion: DoryVirtualMachineQualificationManifest.schemaVersion
+ )
+ )
+ }
+ guard inventories.count == qualifications.count else {
+ throw DoryDaemonProductionTrustInventoryError.backendUnavailable
+ }
+ return DoryDaemonProductionPlanningMaterial(
+ host: host,
+ artifact: artifact,
+ runtimes: runtimes,
+ qualifications: qualifications,
+ media: DoryDaemonVirtualMachineResolvedMedia(
+ reference: artifact.reference,
+ media: artifact.media,
+ // Graphics trust is capability-specific. Supplying one media-wide record here
+ // would let array order authorize a different backend/graphics candidate.
+ guestGraphicsQualification: nil,
+ bootInspection: inspection,
+ mutableProvenance: artifact.mutableProvenance
+ ),
+ backendInventories: inventories,
+ hostFacts: hostFacts(host: host, runtimes: runtimes)
+ )
+ }
+
func startInventory(
for request: DoryDaemonVirtualMachineStartInventoryRequest
) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot {
@@ -668,6 +887,50 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
)
}
+ private func hostFacts(
+ host: DoryDaemonProductionHostObservation,
+ runtimes: [DoryDaemonVerifiedBackendRuntime]
+ ) -> DoryAppleSiliconHostFacts {
+ let rawBuild = runtimes.first {
+ $0.descriptor.identity == .doryHypervisor
+ }?.runtimeBuildIdentifier ?? ""
+ let vzBuild = runtimes.first {
+ $0.descriptor.identity == .appleVirtualizationFramework
+ }?.runtimeBuildIdentifier ?? ""
+ return DoryAppleSiliconHostFacts(
+ macOSMajorVersion: host.macOSMajorVersion,
+ virtualizationFrameworkAvailable:
+ host.virtualizationFrameworkAvailable && !vzBuild.isEmpty,
+ hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable,
+ doryHypervisorAvailable: !rawBuild.isEmpty,
+ qemuHypervisorFrameworkAvailable: false,
+ windowsUEFIFirmwareAvailable: false,
+ windowsSecureBootAvailable: false,
+ windowsSBSADeviceModelAvailable: false,
+ virtualTPM20Available: false,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: false,
+ networkAvailable: false,
+ displayAvailable: false,
+ inputAvailable: false
+ ),
+ macOSGuestVirtualizationSupported: false,
+ macOSRestoreImageInstallationSupported: false,
+ doryMacOSBackendAvailable: false,
+ doryMacOSBackendQualified: false,
+ metalAvailable: host.metalAvailable,
+ doryAcceleratedRendererAvailable:
+ host.metalAvailable && !rawBuild.isEmpty,
+ runtimeQualificationContext:
+ DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: rawBuild,
+ virtualizationFrameworkAdapterBuildID: vzBuild,
+ qemuRuntimeBuildID: ""
+ )
+ )
+ }
+
private static func canonicalData(_ value: T) -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
index a4ca9581..2b6344ec 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
@@ -51,6 +51,8 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable {
public var backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory]
public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence
public var runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification]
+ public var capabilityQualifications:
+ [DoryTrustedVirtualMachineCapabilityQualification]
/// Resolver-selected record for one exact start request. The evaluator still verifies every
/// media, backend-build, ABI, graphics, and device binding before treating it as authority.
public var exactStartRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification?
@@ -61,6 +63,8 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable {
backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory],
resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence,
runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [],
+ capabilityQualifications:
+ [DoryTrustedVirtualMachineCapabilityQualification] = [],
exactStartRuntimeQualification: DoryTrustedVirtualMachineRuntimeQualification? = nil
) {
self.hostFacts = hostFacts
@@ -68,6 +72,7 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable {
self.backendRuntimes = backendRuntimes
self.resourceAdmission = resourceAdmission
self.runtimeQualifications = runtimeQualifications
+ self.capabilityQualifications = capabilityQualifications
self.exactStartRuntimeQualification = exactStartRuntimeQualification
}
@@ -77,6 +82,23 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable {
let matches = backendRuntimes.filter { $0.backend == backend }
return matches.count == 1 ? matches[0] : nil
}
+
+ /// Selects the runtime record bound to the exact qualification chosen by the planner. A
+ /// backend may have several signed media/graphics qualifications; array order is not trust.
+ public func backendRuntime(
+ for descriptor: DoryVirtualMachineCapabilityDescriptor
+ ) -> DoryDaemonVirtualMachineBackendRuntimeInventory? {
+ let candidates = backendRuntimes.filter { runtime in
+ guard runtime.backend == descriptor.request.backend else { return false }
+ guard let evidence = descriptor.runtimeQualificationEvidence else { return true }
+ return runtime.runtimeBuildIdentifier == evidence.backendRuntimeBuildID
+ && runtime.hostQualification.qualificationIdentity
+ == evidence.qualificationIdentity
+ && runtime.hostQualification.qualificationReportSHA256
+ == evidence.qualificationReportSHA256
+ }
+ return candidates.count == 1 ? candidates[0] : nil
+ }
}
public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable {
@@ -172,7 +194,8 @@ public struct DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner:
trustedGuestImageGraphicsQualification: inventory.media.guestGraphicsQualification,
trustedBootMediaInspection: inventory.media.bootInspection,
trustedMutableBootMediaProvenance: inventory.media.mutableProvenance,
- trustedRuntimeQualifications: inventory.runtimeQualifications
+ trustedRuntimeQualifications: inventory.runtimeQualifications,
+ trustedCapabilityQualifications: inventory.capabilityQualifications
)
}
}
@@ -353,7 +376,7 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda
guard let backend = registry.backend(for: selected.request.backend) else {
throw failure(.backendUnavailable, "The selected backend is not registered.")
}
- guard let runtime = snapshot.backendRuntime(for: selected.request.backend) else {
+ guard let runtime = snapshot.backendRuntime(for: selected) else {
throw failure(.backendInventoryUnavailable, "Exact backend runtime evidence is unavailable.")
}
let backendResult = registry.plan(MachineBackendPlanRequest(
diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
index c75643d1..47a4f8fa 100644
--- a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
@@ -107,6 +107,19 @@ public struct DoryVirtualMachineResourceAdmissionLedgerSnapshot: Sendable, Equat
}
}
+/// Opaque daemon-only proof that a lifecycle fence observed no process using a bound planning
+/// lease. It is not Codable and has no public initializer, so API/UI callers cannot recover an
+/// ambiguous lease.
+public struct DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization: Sendable {
+ let machineID: String
+ let planSHA256: String
+
+ init(machineID: String, planSHA256: String) {
+ self.machineID = machineID
+ self.planSHA256 = planSHA256
+ }
+}
+
public enum DoryVirtualMachineResourceAdmissionLedgerError:
Error, Sendable, Equatable, CustomStringConvertible
{
@@ -315,6 +328,33 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl
}
}
+ /// Compensates a planning attempt that failed before its lease was bound to exact plan bytes.
+ /// CPU and memory are released, while the durable storage reservation is retained. A bound
+ /// lease is never cancelled here because a process may already have consumed its authority.
+ public func cancelUnboundStarting(
+ leaseID: String,
+ expectedLeaseRevision: UInt64
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ try withExclusiveAccess {
+ let timestamp = now()
+ var record = try readRecord()
+ _ = try recoverExpired(in: &record, at: timestamp)
+ let index = try leaseIndex(leaseID, in: record)
+ var lease = record.leases[index]
+ try Self.validateLeaseRevision(expectedLeaseRevision, lease)
+ guard lease.state == .starting, lease.boundPlanSHA256 == nil else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state)
+ }
+ lease.state = .stopped
+ lease.startingExpiresAtUnixMilliseconds = nil
+ lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
+ lease.updatedAtUnixMilliseconds = timestamp
+ record.leases[index] = lease
+ try advanceAndPublish(&record)
+ return lease
+ }
+ }
+
/// Revalidates an already bound starting lease against exact plan bytes and the same daemon
/// host snapshot. It never replans or silently refreshes evidence.
public func revalidateForStart(
@@ -343,6 +383,53 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl
}
}
+ /// Renews a bound planning lease that expired before definition/plan publication. The caller
+ /// must hold a daemon lifecycle fence proving the exact plan never launched. Admission facts,
+ /// evidence, and bound plan bytes remain unchanged; no capacity is released or re-granted.
+ public func recoverBoundPlanningLease(
+ leaseID: String,
+ plan: DoryResolvedMachinePlan,
+ authorization: DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization,
+ startingLeaseDurationMilliseconds: Int64,
+ expectedLeaseRevision: UInt64
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ try withExclusiveAccess {
+ let timestamp = now()
+ guard startingLeaseDurationMilliseconds > 0,
+ startingLeaseDurationMilliseconds
+ <= Self.maximumStartingLeaseDurationMilliseconds,
+ timestamp > 0,
+ timestamp <= Int64.max - startingLeaseDurationMilliseconds else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseDuration
+ }
+ var record = try readRecord()
+ _ = try recoverExpired(in: &record, at: timestamp)
+ let index = try leaseIndex(leaseID, in: record)
+ var lease = record.leases[index]
+ try Self.validateLeaseRevision(expectedLeaseRevision, lease)
+ guard lease.state == .recoveryRequired else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state)
+ }
+ let digest = Self.planSHA256(plan)
+ guard authorization.machineID == lease.binding.machineID,
+ authorization.machineID == plan.machineID,
+ authorization.planSHA256 == digest,
+ lease.boundPlanSHA256 == digest else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.planMismatch
+ }
+ try Self.validate(plan, against: lease, requireBoundDigest: true)
+ try Self.validateCapacity(record.leases, hostFacts: lease.hostFacts)
+ lease.state = .starting
+ lease.startingExpiresAtUnixMilliseconds = timestamp
+ + startingLeaseDurationMilliseconds
+ lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
+ lease.updatedAtUnixMilliseconds = timestamp
+ record.leases[index] = lease
+ try advanceAndPublish(&record)
+ return lease
+ }
+ }
+
public func markRunning(
leaseID: String,
plan: DoryResolvedMachinePlan,
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
index da290f8b..710e98d9 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
@@ -376,6 +376,72 @@ struct DoryVirtualMachineBackendPlannerTests {
== "linux-runtime-qualification-v1")
}
+ @Test("capability qualification order cannot authorize a different graphics candidate")
+ func capabilityQualificationSelectionIsExactAndOrderIndependent() throws {
+ let media = DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifactSHA256: Self.guestArtifactSHA256
+ )
+ let request = DoryVirtualMachineBackendPlanRequest(
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ bootMedia: media,
+ acceptableGraphics: [.software, .hardwareAccelerated3D],
+ backendPreferences: [.doryHypervisor],
+ backendPreferencePolicy: .required
+ )
+ let runtimeByGraphics = Dictionary(uniqueKeysWithValues:
+ makeRuntimeQualifications(request: request, host: Self.host).filter {
+ $0.auditEvidence.backend == .doryHypervisor
+ }.map {
+ ($0.auditEvidence.graphics, $0)
+ }
+ )
+ let unsupportedSoftware = DoryTrustedGuestImageGraphicsQualification(
+ auditEvidence: DorySignedArtifactQualificationEvidence(
+ manifestIdentity: "dory-linux-software-unqualified-v1",
+ artifactSHA256: Self.guestArtifactSHA256,
+ manifestSHA256: String(repeating: "3", count: 64),
+ signingKeyID: "dory-release-2026",
+ manifestFormatVersion: 1
+ ),
+ virtioGPUKernelAndDeviceSupportQualified: false,
+ venusVulkanGuestRuntimeQualified: false
+ )
+ func exact(
+ _ graphics: DoryGraphicsAccelerationLevel,
+ _ qualification: DoryTrustedGuestImageGraphicsQualification
+ ) throws -> DoryTrustedVirtualMachineCapabilityQualification {
+ let capabilityRequest = DoryVirtualMachineCapabilityRequest(
+ guest: request.guest,
+ bootMedia: media,
+ backend: .doryHypervisor,
+ graphics: graphics,
+ devices: request.devices
+ )
+ return DoryTrustedVirtualMachineCapabilityQualification(
+ request: capabilityRequest,
+ runtime: try #require(runtimeByGraphics[graphics]),
+ graphics: qualification
+ )
+ }
+ let software = try exact(.software, unsupportedSoftware)
+ let accelerated = try exact(.hardwareAccelerated3D, Self.qualifiedLinuxGraphics)
+
+ for inventory in [[software, accelerated], [accelerated, software]] {
+ let result = DoryAppleSiliconVirtualMachineBackendPlanner.plan(
+ request,
+ host: Self.host,
+ trustedCapabilityQualifications: inventory
+ )
+ #expect(result.selectedDescriptor?.request.graphics == .hardwareAccelerated3D)
+ #expect(result.evaluatedDescriptors.first?.availability.reason?.code
+ == .linuxVirtioGPUKernelDeviceUnqualified)
+ #expect(result.selectedDescriptor?.graphicsQualificationEvidence
+ == Self.qualifiedLinuxGraphics.auditEvidence)
+ }
+ }
+
@Test("runtime inventory selection is order independent for ISO digest and runtime build")
func runtimeInventoryMatchesExactISORecordInEitherOrder() throws {
let exact = runtimeQualification(
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
new file mode 100644
index 00000000..c6613d76
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
@@ -0,0 +1,651 @@
+import DoryOperations
+@testable import DorydKit
+import Foundation
+import Testing
+
+@Suite("Daemon VM planning publication transaction")
+struct DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests {
+ @Test("reserve bind publish is exact and restart-idempotent")
+ func successAndIdempotence() throws {
+ let fixture = try TransactionFixture()
+ let first = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ let second = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+
+ #expect(first.transactionID == second.transactionID)
+ #expect(first.planning.resolvedPlan == second.planning.resolvedPlan)
+ #expect(try fixture.workspaces.read(id: fixture.definition.identity.id)
+ == fixture.definition)
+ #expect(try fixture.plans.read(id: fixture.definition.identity.id)
+ == first.planning.resolvedPlan)
+ let lease = try #require(try fixture.ledger.snapshot().leases.first)
+ #expect(lease.state == .starting)
+ #expect(lease.boundPlanSHA256 == first.planning.resolvedPlanSHA256)
+ }
+
+ @Test("crash after workspace publication resumes exact candidate bytes")
+ func workspacePublicationRecovery() throws {
+ let fixture = try TransactionFixture()
+ let fault = TransactionFault(.workspacePublished)
+ do {
+ _ = try fixture.coordinator(fault: fault).resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected injected crash")
+ } catch is TransactionInjectedFailure {}
+
+ #expect(try fixture.workspaces.read(id: fixture.definition.identity.id)
+ == fixture.definition)
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ _ = try fixture.plans.read(id: fixture.definition.identity.id)
+ }
+ let recovered = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ #expect(try fixture.plans.read(id: fixture.definition.identity.id)
+ == recovered.planning.resolvedPlan)
+ #expect(try #require(try fixture.ledger.snapshot().leases.first).boundPlanSHA256
+ == recovered.planning.resolvedPlanSHA256)
+ }
+
+ @Test("crash after bind adopts exact bound lease without a second reservation")
+ func bindRecovery() throws {
+ let fixture = try TransactionFixture()
+ let fault = TransactionFault(.planBindingCommitted)
+ do {
+ _ = try fixture.coordinator(fault: fault).resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected injected crash")
+ } catch is TransactionInjectedFailure {}
+ let before = try #require(try fixture.ledger.snapshot().leases.first)
+ #expect(before.boundPlanSHA256 != nil)
+
+ let result = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ let after = try #require(try fixture.ledger.snapshot().leases.first)
+ #expect(after.leaseID == before.leaseID)
+ #expect(after.boundPlanSHA256 == result.planning.resolvedPlanSHA256)
+ #expect(try fixture.ledger.snapshot().leases.count == 1)
+ }
+
+ @Test("bound recovery ignores volatile free-storage samples without readmission")
+ func boundRecoveryWithChangedFreeStorage() throws {
+ let fixture = try TransactionFixture()
+ do {
+ _ = try fixture.coordinator(fault: TransactionFault(.planBindingCommitted))
+ .resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected injected crash")
+ } catch is TransactionInjectedFailure {}
+ let before = try #require(try fixture.ledger.snapshot().leases.first)
+ fixture.trust.updateFreeStorage(before.hostFacts.freeStorageBytes / 2)
+
+ let recovered = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ #expect(recovered.lease.leaseID == before.leaseID)
+ #expect(recovered.lease.leaseRevision == before.leaseRevision)
+ #expect(recovered.lease.hostFacts == before.hostFacts)
+ #expect(try fixture.ledger.snapshot().leases.count == 1)
+ }
+
+ @Test("stale publication evidence retains bound capacity and publishes nothing")
+ func staleTrustFailsClosed() throws {
+ let fixture = try TransactionFixture(rejectPublication: true)
+ do {
+ _ = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected stale trust rejection")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .publicationAuthorizationRejected)
+ }
+ let lease = try #require(try fixture.ledger.snapshot().leases.first)
+ #expect(lease.state == .starting)
+ #expect(lease.boundPlanSHA256 != nil)
+ #expect(throws: DoryWorkspaceRepositoryError.self) {
+ _ = try fixture.workspaces.read(id: fixture.definition.identity.id)
+ }
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ _ = try fixture.plans.read(id: fixture.definition.identity.id)
+ }
+ fixture.trust.rejectPublication = false
+ let recovered = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ #expect(recovered.lease.leaseID == lease.leaseID)
+ #expect(recovered.lease.boundPlanSHA256 == lease.boundPlanSHA256)
+ #expect(try fixture.plans.read(id: fixture.definition.identity.id)
+ == recovered.planning.resolvedPlan)
+ }
+
+ @Test("two coordinator instances serialize one workspace transaction")
+ func concurrentInstances() async throws {
+ let fixture = try TransactionFixture()
+ let results = TransactionResultBox()
+ await withTaskGroup(of: Void.self) { group in
+ for _ in 0..<2 {
+ group.addTask {
+ do {
+ let value = try fixture.coordinator()
+ .resolveReserveAndPublish(fixture.request())
+ results.append(.success(value.transactionID))
+ } catch {
+ results.append(.failure(error))
+ }
+ }
+ }
+ }
+ let values = results.values
+ #expect(values.count == 2)
+ let ids = try values.map { try $0.get() }
+ #expect(Set(ids).count == 1)
+ #expect(try fixture.ledger.snapshot().leases.count == 1)
+ }
+
+ @Test("stale create source is rejected before resource reservation")
+ func staleSourceBeforeReserve() throws {
+ let fixture = try TransactionFixture()
+ try fixture.workspaces.create(fixture.definition)
+ do {
+ _ = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected create conflict")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .transactionConflict)
+ }
+ #expect(try fixture.ledger.snapshot().leases.isEmpty)
+ }
+
+ @Test("changed machine authority cannot join an existing transaction")
+ func changedMachineAuthority() throws {
+ let fixture = try TransactionFixture()
+ let fault = TransactionFault(.preparedJournalPublished)
+ do {
+ _ = try fixture.coordinator(fault: fault).resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected injected crash")
+ } catch is TransactionInjectedFailure {}
+ fixture.mutationAuthority.authoritySHA256 = transactionDigest("f")
+ do {
+ _ = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected transaction conflict")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .transactionConflict)
+ }
+ #expect(try fixture.ledger.snapshot().leases.isEmpty)
+ }
+
+ @Test("every committed publication boundary recovers one byte-exact plan and lease")
+ func publicationBoundaryMatrix() throws {
+ let stages: [DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage] = [
+ .preparedJournalPublished,
+ .resourceReservationCommitted,
+ .reservedJournalPublished,
+ .candidateJournalPublished,
+ .planBindingCommitted,
+ .boundJournalPublished,
+ .publicationAuthorized,
+ .workspacePublished,
+ .workspaceJournalPublished,
+ .planPublished,
+ .planJournalPublished,
+ .completeJournalPublished,
+ ]
+ for stage in stages {
+ let fixture = try TransactionFixture()
+ let fault = TransactionFault(stage)
+ do {
+ _ = try fixture.coordinator(fault: fault)
+ .resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected injected crash at \(stage)")
+ } catch is TransactionInjectedFailure {}
+ let recovered = try fixture.coordinator()
+ .resolveReserveAndPublish(fixture.request())
+ #expect(try fixture.workspaces.read(id: fixture.definition.identity.id)
+ == fixture.definition)
+ #expect(try fixture.plans.read(id: fixture.definition.identity.id)
+ == recovered.planning.resolvedPlan)
+ let leases = try fixture.ledger.snapshot().leases
+ #expect(leases.count == 1)
+ #expect(leases.first?.boundPlanSHA256
+ == recovered.planning.resolvedPlanSHA256)
+ }
+ }
+
+ @Test("completed transaction rejects later plan authority substitution")
+ func completedTamper() throws {
+ let fixture = try TransactionFixture()
+ let completed = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ var replacement = completed.planning.resolvedPlan
+ replacement.planRevision = 2
+ replacement.updatedAtUnixMilliseconds += 1
+ try fixture.plans.replace(replacement, expectedPlanRevision: 1)
+ do {
+ _ = try fixture.coordinator().resolveReserveAndPublish(fixture.request())
+ Issue.record("Expected completed authority mismatch")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .recoveryRequired)
+ }
+ #expect(try fixture.ledger.snapshot().leases.count == 1)
+ }
+
+ @Test("expired unbound and lifecycle-proven bound planning leases recover exactly")
+ func expiryRecovery() throws {
+ for stage in [
+ DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage
+ .candidateJournalPublished,
+ .planBindingCommitted,
+ ] {
+ let clock = TransactionClock(50_000)
+ let fixture = try TransactionFixture(clock: clock)
+ do {
+ _ = try fixture.coordinator(fault: TransactionFault(stage))
+ .resolveReserveAndPublish(fixture.request(leaseDuration: 100))
+ Issue.record("Expected injected crash")
+ } catch is TransactionInjectedFailure {}
+ clock.advance(100)
+ let expired = try #require(try fixture.ledger.snapshot().leases.first)
+ #expect(expired.state == (stage == .planBindingCommitted
+ ? .recoveryRequired : .stopped))
+ let recovered = try fixture.coordinator()
+ .resolveReserveAndPublish(fixture.request(leaseDuration: 100))
+ #expect(recovered.lease.leaseID == expired.leaseID)
+ #expect(recovered.lease.state == .starting)
+ #expect(recovered.lease.boundPlanSHA256
+ == recovered.planning.resolvedPlanSHA256)
+ }
+ }
+}
+
+private final class TransactionFixture: @unchecked Sendable {
+ let root: String
+ let definition: DoryVirtualMachineDefinition
+ let machine: DoryMachineConfiguration
+ let media: DoryBootMedia
+ let capability: DoryVirtualMachineCapabilityDescriptor
+ let resources: DoryVMHostResources
+ let workspaces: DoryWorkspaceRepository
+ let plans: DoryResolvedMachinePlanRepository
+ let ledger: DoryVirtualMachineResourceAdmissionLedger
+ let registry: BackendRegistry
+ let trust: TransactionTrust
+ let mutationAuthority = TransactionMutationAuthority()
+
+ init(
+ rejectPublication: Bool = false,
+ clock: TransactionClock? = nil
+ ) throws {
+ root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-planning-tx-\(UUID().uuidString)").path
+ try FileManager.default.createDirectory(
+ atPath: root,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let bootArtifact = DoryVMResolverReference(
+ namespace: "artifact", identifier: "ubuntu-runtime"
+ )
+ let diskArtifact = DoryVMResolverReference(
+ namespace: "artifact", identifier: "ubuntu-disk"
+ )
+ let requested = DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ diskBytes: 32 * 1_024 * 1_024 * 1_024
+ )
+ definition = DoryVirtualMachineDefinition(
+ identity: DoryVirtualMachineIdentity(id: "transaction-linux", name: "Linux"),
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ workload: .desktop,
+ boot: DoryVMBootConfiguration(
+ phase: .normal,
+ devices: [DoryVMBootMediaReference(
+ id: "system", role: .system,
+ kind: .installedLinuxBootBundle, source: .bundledByDory,
+ artifact: bootArtifact, removable: false
+ )],
+ order: ["system"]
+ ),
+ backendPreference: DoryVMBackendPreference(
+ mode: .required, backend: .doryHypervisor
+ ),
+ graphics: DoryVMGraphicsPolicy(acceptableLevels: [.none]),
+ resources: requested,
+ storage: [DoryVMStorageAttachment(
+ id: "system-disk", role: .system, artifact: diskArtifact,
+ capacityBytes: requested.diskBytes
+ )],
+ audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false),
+ input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false),
+ lifecycle: DoryVMLifecycleMetadata(
+ revision: 1,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000
+ )
+ )
+ machine = DoryMachineConfiguration(
+ id: definition.identity.id,
+ kernelPath: "/fixture/direct-kernel",
+ rootfsPath: "/fixture/linux.raw",
+ bootMode: .linuxKernel,
+ displayMode: .desktop
+ )
+ media = DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifactSHA256: transactionDigest("a")
+ )
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let runtimeEvidence = DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: "runtime-qualification-1",
+ qualificationReportSHA256: transactionDigest("b"),
+ signingKeyID: "dory-runtime-1",
+ qualificationFormatVersion: 1,
+ guest: definition.guest,
+ bootMediaKind: media.kind,
+ immutableArtifactSHA256: media.artifactSHA256,
+ backend: .doryHypervisor,
+ backendRuntimeBuildID: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ graphics: .none,
+ devices: devices
+ )
+ capability = DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion: DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: DoryVirtualMachineCapabilityRequest(
+ guest: definition.guest, bootMedia: media,
+ backend: .doryHypervisor, graphics: .none, devices: devices
+ ),
+ availability: DoryCapabilityAvailability(
+ supportTier: .supported, state: .available
+ ),
+ resolvedDevices: devices,
+ runtimeQualificationEvidence: runtimeEvidence
+ )
+ resources = DoryVMHostResources(
+ logicalCPUCount: 12,
+ physicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024,
+ freeStorageBytes: 512 * 1_024 * 1_024 * 1_024
+ )
+ let snapshot = DoryDaemonVirtualMachineTrustedInventorySnapshot(
+ hostFacts: transactionHostFacts(),
+ media: DoryDaemonVirtualMachineResolvedMedia(
+ reference: bootArtifact, media: media
+ ),
+ backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory(
+ backend: .doryHypervisor,
+ runtimeBuildIdentifier: "raw-runtime-1",
+ components: [DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: "raw-runtime-1",
+ artifactSHA256: transactionDigest("c")
+ )],
+ hostQualification: transactionHostQualification()
+ )],
+ resourceAdmission: transactionDummyAdmission(requested)
+ )
+ trust = TransactionTrust(
+ hostResources: resources,
+ snapshot: snapshot,
+ rejectPublication: rejectPublication
+ )
+ workspaces = DoryWorkspaceRepository(root: root)
+ plans = DoryResolvedMachinePlanRepository(root: root)
+ if let clock {
+ ledger = DoryVirtualMachineResourceAdmissionLedger(
+ root: root + "/.resource-admissions",
+ now: clock.read
+ )
+ } else {
+ ledger = DoryVirtualMachineResourceAdmissionLedger(
+ root: root + "/.resource-admissions"
+ )
+ }
+ registry = try BackendRegistry(backends: [RawHVLinuxMachineBackend(
+ executablePath: "/fixture/dory-hv",
+ operations: MachineBackendCompatibilityOperations(
+ start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) },
+ stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }
+ ),
+ executableIsAvailable: { _ in true }
+ )])
+ }
+
+ deinit { try? FileManager.default.removeItem(atPath: root) }
+
+ func request(
+ leaseDuration: Int64 = 120_000
+ ) -> DoryDaemonVirtualMachinePlanningTransactionRequest {
+ DoryDaemonVirtualMachinePlanningTransactionRequest(
+ planning: DoryDaemonVirtualMachinePlanningRequest(
+ definition: definition,
+ canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(definition),
+ machine: machine,
+ publication: .create
+ ),
+ workspacePublication: .create,
+ startingLeaseDurationMilliseconds: leaseDuration
+ )
+ }
+
+ func coordinator(
+ fault: TransactionFault? = nil
+ ) -> DoryDaemonVirtualMachinePlanningTransactionCoordinator {
+ if let fault {
+ return DoryDaemonVirtualMachinePlanningTransactionCoordinator(
+ stateDirectory: root,
+ registry: registry,
+ trust: trust,
+ mutationAuthority: mutationAuthority,
+ workspaces: workspaces,
+ plans: plans,
+ ledger: ledger,
+ capabilityPlanner: TransactionPlanner(capability: capability),
+ now: { 1_700_000_000_100 },
+ faultInjector: fault.inject
+ )
+ }
+ return DoryDaemonVirtualMachinePlanningTransactionCoordinator(
+ stateDirectory: root,
+ registry: registry,
+ trust: trust,
+ mutationAuthority: mutationAuthority,
+ workspaces: workspaces,
+ plans: plans,
+ ledger: ledger,
+ capabilityPlanner: TransactionPlanner(capability: capability),
+ now: { 1_700_000_000_100 }
+ )
+ }
+}
+
+private final class TransactionTrust:
+ DoryDaemonVirtualMachinePlanningTrustPreparing, @unchecked Sendable
+{
+ private let lock = NSLock()
+ private var storedHostResources: DoryVMHostResources
+ let trustedSnapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ private var storedRejectPublication: Bool
+
+ var rejectPublication: Bool {
+ get { lock.withLock { storedRejectPublication } }
+ set { lock.withLock { storedRejectPublication = newValue } }
+ }
+
+ init(
+ hostResources: DoryVMHostResources,
+ snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot,
+ rejectPublication: Bool
+ ) {
+ storedHostResources = hostResources
+ trustedSnapshot = snapshot
+ storedRejectPublication = rejectPublication
+ }
+
+ func updateFreeStorage(_ freeStorageBytes: UInt64) {
+ lock.withLock {
+ storedHostResources = DoryVMHostResources(
+ logicalCPUCount: storedHostResources.logicalCPUCount,
+ physicalMemoryBytes: storedHostResources.physicalMemoryBytes,
+ freeStorageBytes: freeStorageBytes,
+ admittedVirtualCPUCount: storedHostResources.admittedVirtualCPUCount,
+ admittedMemoryBytes: storedHostResources.admittedMemoryBytes,
+ reservedStorageBytes: storedHostResources.reservedStorageBytes
+ )
+ }
+ }
+
+ func preparePlanningTrust(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation {
+ _ = request
+ let currentHostResources = lock.withLock { storedHostResources }
+ return DoryDaemonVirtualMachinePlanningTrustPreparation(
+ hostResources: currentHostResources,
+ snapshot: { admission in
+ var snapshot = self.trustedSnapshot
+ snapshot.resourceAdmission = admission
+ return snapshot
+ },
+ publicationAuthorization:
+ DoryDaemonVirtualMachinePlanningPublicationAuthorization {
+ if self.rejectPublication { throw TransactionInjectedFailure() }
+ }
+ )
+ }
+}
+
+private final class TransactionMutationAuthority:
+ DoryDaemonVirtualMachinePlanningMutationAuthorizing, @unchecked Sendable
+{
+ private let lock = NSLock()
+ private var storedAuthoritySHA256 = transactionDigest("e")
+ var authoritySHA256: String {
+ get { lock.withLock { storedAuthoritySHA256 } }
+ set { lock.withLock { storedAuthoritySHA256 = newValue } }
+ }
+
+ func acquirePlanningMutationFence(
+ machine: DoryMachineConfiguration,
+ definition: DoryVirtualMachineDefinition,
+ canonicalDefinitionData: Data
+ ) throws -> DoryDaemonVirtualMachinePlanningMutationFence {
+ guard machine.id == definition.identity.id, !canonicalDefinitionData.isEmpty else {
+ throw TransactionInjectedFailure()
+ }
+ let digest = authoritySHA256
+ return DoryDaemonVirtualMachinePlanningMutationFence(
+ authoritySHA256: digest,
+ retainedAuthority: digest,
+ validation: {}
+ )
+ }
+}
+
+private struct TransactionPlanner: DoryDaemonVirtualMachineCapabilityPlanning {
+ let capability: DoryVirtualMachineCapabilityDescriptor
+ func plan(
+ _ request: DoryVirtualMachineBackendPlanRequest,
+ inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ ) -> DoryVirtualMachineBackendPlanResult {
+ _ = request
+ _ = inventory
+ return DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: capability,
+ evaluatedDescriptors: [capability],
+ failure: nil
+ )
+ }
+}
+
+private final class TransactionFault: @unchecked Sendable {
+ private let lock = NSLock()
+ private var stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage?
+ init(_ stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage) {
+ self.stage = stage
+ }
+ func inject(
+ _ value: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage
+ ) throws {
+ try lock.withLock {
+ if stage == value {
+ stage = nil
+ throw TransactionInjectedFailure()
+ }
+ }
+ }
+}
+
+private final class TransactionResultBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var storage: [Result] = []
+ var values: [Result] { lock.withLock { storage } }
+ func append(_ value: Result) { lock.withLock { storage.append(value) } }
+}
+
+private struct TransactionInjectedFailure: Error {}
+
+private final class TransactionClock: @unchecked Sendable {
+ private let lock = NSLock()
+ private var value: Int64
+ init(_ value: Int64) { self.value = value }
+ var read: @Sendable () -> Int64 { { [self] in lock.withLock { value } } }
+ func advance(_ milliseconds: Int64) { lock.withLock { value += milliseconds } }
+}
+
+private func transactionHostFacts() -> DoryAppleSiliconHostFacts {
+ DoryAppleSiliconHostFacts(
+ macOSMajorVersion: 26,
+ virtualizationFrameworkAvailable: true,
+ hypervisorFrameworkAvailable: true,
+ doryHypervisorAvailable: true,
+ qemuHypervisorFrameworkAvailable: false,
+ windowsUEFIFirmwareAvailable: false,
+ windowsSecureBootAvailable: false,
+ windowsSBSADeviceModelAvailable: false,
+ virtualTPM20Available: false,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: false, networkAvailable: false,
+ displayAvailable: false, inputAvailable: false
+ ),
+ macOSGuestVirtualizationSupported: false,
+ macOSRestoreImageInstallationSupported: false,
+ doryMacOSBackendAvailable: false,
+ doryMacOSBackendQualified: false,
+ metalAvailable: true,
+ doryAcceleratedRendererAvailable: true,
+ runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: "raw-runtime-1",
+ virtualizationFrameworkAdapterBuildID: "vz-runtime-1",
+ qemuRuntimeBuildID: ""
+ )
+ )
+}
+
+private func transactionHostQualification() -> DoryResolvedHostQualificationEvidence {
+ DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: "runtime-qualification-1",
+ qualificationReportSHA256: transactionDigest("b"),
+ hostHardwareModelIdentifier: "Mac16.1",
+ hostOperatingSystemBuild: "26A5406c",
+ backend: .doryHypervisor,
+ backendRuntimeBuildIdentifier: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ qualifierIdentifier: "dory-host-qualifier",
+ qualifierVersion: 1
+ )
+}
+
+private func transactionDummyAdmission(
+ _ resources: DoryVMResourceRequest
+) -> DoryResolvedMachineResourceAdmissionEvidence {
+ DoryResolvedMachineResourceAdmissionEvidence(
+ admittedVirtualCPUCount: resources.virtualCPUCount,
+ admittedMemoryBytes: resources.memoryBytes,
+ admittedStorageBytes: resources.diskBytes,
+ hostLogicalCPUCount: 12,
+ hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024,
+ hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024,
+ existingVirtualCPUCommitment: 0,
+ existingMemoryCommitmentBytes: 0,
+ existingStorageReservationBytes: 0,
+ hostReservedLogicalCPUCount: 2,
+ hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024,
+ admissionIdentity: "dummy-admission",
+ admissionReportSHA256: transactionDigest("d"),
+ assessorIdentifier: "dory.resource-admission-ledger",
+ assessorVersion: 1
+ )
+}
+
+private func transactionDigest(_ value: Character) -> String {
+ String(repeating: String(value), count: 64)
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
index 0d2efe50..b8aa7ea8 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
@@ -348,6 +348,76 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
}
}
+ @Test("production planning preparation resolves exact signed candidates before admission")
+ func planningPreparationUsesQualificationAuthority() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ guard case let .ready(context) = fixture.resolve(),
+ let preparer = context.inventory
+ as? any DoryDaemonVirtualMachinePlanningTrustPreparing else {
+ Issue.record("Expected production planning trust preparer")
+ return
+ }
+ let start = try fixture.makeBoundStartRequest()
+ let request = productionPlanningRequest(start)
+ let preparation = try preparer.preparePlanningTrust(for: request)
+ let snapshot = preparation.snapshot(start.resolvedPlan.resourceAdmission!)
+
+ #expect(snapshot.media.reference == start.bootMediaReference)
+ #expect(snapshot.backendRuntimes.count == 2)
+ #expect(snapshot.runtimeQualifications.count == 2)
+ #expect(snapshot.backendRuntime(for: .doryHypervisor) != nil)
+ }
+
+ @Test("publication authorization refreshes immutable host identity but not volatile free bytes")
+ func planningPublicationFreshness() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ guard case let .ready(context) = fixture.resolve(),
+ let preparer = context.inventory
+ as? any DoryDaemonVirtualMachinePlanningTrustPreparing else {
+ Issue.record("Expected production planning trust preparer")
+ return
+ }
+ let start = try fixture.makeBoundStartRequest()
+ let freeStoragePreparation = try preparer.preparePlanningTrust(
+ for: productionPlanningRequest(start)
+ )
+ var host = fixture.host
+ host = DoryDaemonProductionHostObservation(
+ hardwareModelIdentifier: host.hardwareModelIdentifier,
+ operatingSystemBuild: host.operatingSystemBuild,
+ macOSMajorVersion: host.macOSMajorVersion,
+ virtualizationFrameworkAvailable: host.virtualizationFrameworkAvailable,
+ hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable,
+ metalAvailable: host.metalAvailable,
+ resources: DoryVMHostResources(
+ logicalCPUCount: host.resources.logicalCPUCount,
+ physicalMemoryBytes: host.resources.physicalMemoryBytes,
+ freeStorageBytes: host.resources.freeStorageBytes - 1
+ )
+ )
+ fixture.hostState.set(host)
+ try freeStoragePreparation.publicationAuthorization.authorize()
+
+ let staleIdentityPreparation = try preparer.preparePlanningTrust(
+ for: productionPlanningRequest(start)
+ )
+ host = DoryDaemonProductionHostObservation(
+ hardwareModelIdentifier: host.hardwareModelIdentifier,
+ operatingSystemBuild: host.operatingSystemBuild + "-changed",
+ macOSMajorVersion: host.macOSMajorVersion,
+ virtualizationFrameworkAvailable: host.virtualizationFrameworkAvailable,
+ hypervisorFrameworkAvailable: host.hypervisorFrameworkAvailable,
+ metalAvailable: host.metalAvailable,
+ resources: host.resources
+ )
+ fixture.hostState.set(host)
+ #expect(throws: DoryDaemonProductionTrustInventoryError.self) {
+ try staleIdentityPreparation.publicationAuthorization.authorize()
+ }
+ }
+
@Test("installed Linux boot verification hashes the embedded kernel and initrd")
func installedBootBundleContentVerification() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
@@ -421,6 +491,33 @@ private final class ProductionHostState: @unchecked Sendable {
}
}
+private func productionPlanningRequest(
+ _ start: DoryDaemonVirtualMachineStartInventoryRequest
+) -> DoryDaemonVirtualMachineInventoryRequest {
+ let plan = start.resolvedPlan
+ return DoryDaemonVirtualMachineInventoryRequest(
+ machineID: plan.machineID,
+ definitionRevision: plan.definitionRevision,
+ guest: plan.guest,
+ bootMedia: DoryVMBootMediaReference(
+ id: "system",
+ role: .system,
+ kind: plan.bootMedia.media.kind,
+ source: plan.bootMedia.media.source,
+ artifact: plan.bootMedia.resolverReference!,
+ removable: false
+ ),
+ resources: DoryVMResourceRequest(
+ virtualCPUCount: plan.resourceAdmission!.admittedVirtualCPUCount,
+ memoryBytes: plan.resourceAdmission!.admittedMemoryBytes,
+ diskBytes: plan.resourceAdmission!.admittedStorageBytes
+ ),
+ devices: plan.devices,
+ acceptableGraphics: [plan.graphics],
+ virtualHardwareABIVersion: plan.virtualHardwareABIVersion
+ )
+}
+
private final class ProductionTrustFixture: @unchecked Sendable {
let root: URL
let drive: DoryDataDrive
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
index df7429bf..fe166483 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
@@ -344,6 +344,87 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase {
))
}
+ func testPlanningCompensationCancelsOnlyUnboundAndRetainsStorage() throws {
+ let fixture = try ResourceLedgerFixture("planning-cancel")
+ defer { fixture.cleanup() }
+ let reserved = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let stopped = try fixture.ledger.cancelUnboundStarting(
+ leaseID: reserved.leaseID,
+ expectedLeaseRevision: reserved.leaseRevision
+ )
+ XCTAssertEqual(stopped.state, .stopped)
+ XCTAssertNil(stopped.boundPlanSHA256)
+ XCTAssertEqual(stopped.resources.diskBytes, fixture.resources.diskBytes)
+
+ let restarted = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let plan = fixture.plan(binding: restarted.binding, evidence: restarted.evidence)
+ let bound = try fixture.ledger.bind(
+ leaseID: restarted.leaseID,
+ to: plan,
+ expectedLeaseRevision: restarted.leaseRevision
+ )
+ XCTAssertThrowsError(try fixture.ledger.cancelUnboundStarting(
+ leaseID: bound.leaseID,
+ expectedLeaseRevision: bound.leaseRevision
+ ))
+ }
+
+ func testExpiredBoundPlanningLeaseRequiresExactUnlaunchedAuthorization() throws {
+ let clock = ResourceLedgerClock(now: 40_000)
+ let fixture = try ResourceLedgerFixture("bound-planning-recovery", clock: clock)
+ defer { fixture.cleanup() }
+ let reserved = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources,
+ startingLeaseDurationMilliseconds: 100
+ )
+ let plan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence)
+ _ = try fixture.ledger.bind(
+ leaseID: reserved.leaseID,
+ to: plan,
+ expectedLeaseRevision: reserved.leaseRevision
+ )
+ clock.advance(by: 100)
+ let expired = try XCTUnwrap(try fixture.ledger.snapshot().leases.first)
+ XCTAssertEqual(expired.state, .recoveryRequired)
+ XCTAssertThrowsError(try fixture.ledger.recoverBoundPlanningLease(
+ leaseID: expired.leaseID,
+ plan: plan,
+ authorization: DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization(
+ machineID: plan.machineID,
+ planSHA256: digest("f")
+ ),
+ startingLeaseDurationMilliseconds: 100,
+ expectedLeaseRevision: expired.leaseRevision
+ ))
+ let exact = DoryVirtualMachineBoundPlanningLeaseRecoveryAuthorization(
+ machineID: plan.machineID,
+ planSHA256: DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(plan)
+ )
+ let recovered = try fixture.ledger.recoverBoundPlanningLease(
+ leaseID: expired.leaseID,
+ plan: plan,
+ authorization: exact,
+ startingLeaseDurationMilliseconds: 100,
+ expectedLeaseRevision: expired.leaseRevision
+ )
+ XCTAssertEqual(recovered.state, .starting)
+ XCTAssertEqual(recovered.boundPlanSHA256,
+ DoryDaemonVirtualMachinePlanningCoordinator.planSHA256(plan))
+ }
+
func testBoundPlanAndHostFactsAreExactStartGates() throws {
try withFixture("exact-binding") { fixture in
let reserved = try fixture.ledger.reserveStarting(
From a29641208ae978d5bb567db4f051a07d4629e19a Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 16:41:11 +0000
Subject: [PATCH 033/338] test(vm): bind exact runtime qualification fixtures
---
.../DoryDaemonVirtualMachineRuntimePlanningTests.swift | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
index 08014aa7..c4aab26e 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
@@ -514,8 +514,8 @@ private func resourceAdmission(
private func hostQualification() -> DoryResolvedHostQualificationEvidence {
DoryResolvedHostQualificationEvidence(
- qualificationIdentity: "host-qualification-1",
- qualificationReportSHA256: digest("e"),
+ qualificationIdentity: "runtime-qualification-1",
+ qualificationReportSHA256: digest("b"),
hostHardwareModelIdentifier: "Mac16.1",
hostOperatingSystemBuild: "26A5406c",
backend: .doryHypervisor,
From 087040e790126317a5e1a62f1c2edbed21638029 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 17:05:07 +0000
Subject: [PATCH 034/338] feat(vm): verify installed desktop payloads
---
Dory/Models/AppStore.swift | 92 ++-
Dory/Runtime/Doryd/DorydClient.swift | 205 +++++-
DoryTests/DorydClientTests.swift | 192 +++++-
.../DoryInstalledDesktopPayloadReceipt.swift | 413 ++++++++++++
.../DoryMachineConfigurationMigration.swift | 4 +-
.../Sources/DorydKit/DorydService.swift | 70 +-
.../Sources/DorydKit/MachineManager.swift | 543 +++++++++++++--
dory-core-swift/Sources/doryd/main.swift | 8 +-
dory-core-swift/Sources/dorydctl/main.swift | 14 +-
...yInstalledDesktopPayloadReceiptTests.swift | 273 ++++++++
...ryMachineConfigurationMigrationTests.swift | 19 +-
.../DorydKitTests/DorydServiceTests.swift | 120 ++++
.../DorydKitTests/MachineManagerTests.swift | 630 +++++++++++++++++-
13 files changed, 2471 insertions(+), 112 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index 751e2420..c16278ac 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5459,8 +5459,6 @@ final class AppStore {
throw DesktopMachineAssetError.missingAsset("runtime update")
}
_ = try store.verify(.linuxDesktop)
- let home = environment["HOME"] ?? NSHomeDirectory()
- var prepared: [DesktopMachineDistro: DesktopMachineAssets] = [:]
var results: [DorydDesktopUpdateResult] = []
for status in desktopStatuses {
@@ -5472,7 +5470,11 @@ final class AppStore {
displayMode: status.displayMode
)
let distro = DesktopMachineDistro.resolve(
- typedSettings.guestIdentityIntent.desktop?.distributionIdentifier
+ Self.managedDesktopDistributionIdentifier(
+ configuredIdentifier:
+ typedSettings.guestIdentityIntent.desktop?.distributionIdentifier,
+ receipt: status.installedDesktopPayloadReceipt
+ )
)
guard runtimeChanged || components.contains(distro.componentID) else { continue }
guard let distroRelease = try store.installedComponent(distro.componentID) else {
@@ -5485,25 +5487,32 @@ final class AppStore {
}
_ = try store.verify(distro.componentID)
let targetVersion = distroRelease.version + "+runtime." + runtimeRelease.version
- if status.environment["DORY_DESKTOP_RELEASE_VERSION"] == targetVersion {
- continue
- }
- guard let bundlePath = DoryComponentStore.activeAssetPath(
- component: distro.componentID,
- path: "dory-desktop-" + distro.rawValue + "-update-arm64.tar"
- ) else {
+ let bundleAssetIdentifier = "dory-desktop-" + distro.rawValue
+ + "-update-arm64.tar"
+ let kernelAssetIdentifier = "dory-desktop-kernel-arm64.lzfse"
+ guard let bundleAsset = distroRelease.assets.first(where: {
+ $0.path == bundleAssetIdentifier
+ }),
+ let kernelAsset = runtimeRelease.assets.first(where: {
+ $0.path == kernelAssetIdentifier
+ }) else {
throw DesktopMachineAssetError.missingAsset(distro.displayName + " in-place update")
}
- let assets: DesktopMachineAssets
- if let cached = prepared[distro] {
- assets = cached
- } else {
- assets = try await desktopMachineAssetPreparer(
- home,
- Self.desktopAssetEnvironment(processEnvironment: environment, distro: distro),
- Bundle.main.resourcePath
- )
- prepared[distro] = assets
+ if Self.desktopReceiptMatchesActiveComponents(
+ status.installedDesktopPayloadReceipt,
+ distributionIdentifier: distro.rawValue,
+ releaseVersion: targetVersion,
+ distributionComponentIdentifier: distro.componentID.rawValue,
+ distributionInstallationName: distroRelease.installationName,
+ distributionCatalogSHA256: distroRelease.catalogDigest,
+ bundleAssetIdentifier: bundleAsset.path,
+ bundleSHA256: bundleAsset.installedSHA256,
+ runtimeInstallationName: runtimeRelease.installationName,
+ runtimeCatalogSHA256: runtimeRelease.catalogDigest,
+ kernelAssetIdentifier: kernelAsset.path,
+ kernelSHA256: kernelAsset.installedSHA256
+ ) {
+ continue
}
busyMachines.insert(status.id)
@@ -5513,8 +5522,8 @@ final class AppStore {
status.id,
distro: distro.rawValue,
version: targetVersion,
- bundlePath: bundlePath,
- kernelPath: assets.kernelPath
+ distributionInstallationName: distroRelease.installationName,
+ runtimeInstallationName: runtimeRelease.installationName
)
results.append(result)
} catch {
@@ -5530,6 +5539,45 @@ final class AppStore {
return results
}
+ /// Portable snapshots intentionally omit raw environment. Preserve an explicit typed guest
+ /// identity when one exists; otherwise use the validated installed-payload observation.
+ nonisolated static func managedDesktopDistributionIdentifier(
+ configuredIdentifier: String?,
+ receipt: DorydInstalledDesktopPayloadReceipt?
+ ) -> String? {
+ configuredIdentifier ?? (receipt?.isValid == true ? receipt?.distributionIdentifier : nil)
+ }
+
+ nonisolated static func desktopReceiptMatchesActiveComponents(
+ _ receipt: DorydInstalledDesktopPayloadReceipt?,
+ distributionIdentifier: String,
+ releaseVersion: String,
+ distributionComponentIdentifier: String,
+ distributionInstallationName: String,
+ distributionCatalogSHA256: String,
+ bundleAssetIdentifier: String,
+ bundleSHA256: String,
+ runtimeInstallationName: String,
+ runtimeCatalogSHA256: String,
+ kernelAssetIdentifier: String,
+ kernelSHA256: String
+ ) -> Bool {
+ guard let receipt, receipt.isValid else { return false }
+ return receipt.provenance == "verified-update-bundle"
+ && receipt.distributionIdentifier == distributionIdentifier
+ && receipt.releaseVersion == releaseVersion
+ && receipt.distributionComponentIdentifier == distributionComponentIdentifier
+ && receipt.distributionInstallationName == distributionInstallationName
+ && receipt.distributionCatalogSHA256 == distributionCatalogSHA256.lowercased()
+ && receipt.bundleAssetIdentifier == bundleAssetIdentifier
+ && receipt.bundleSHA256 == bundleSHA256.lowercased()
+ && receipt.runtimeComponentIdentifier == DoryComponentID.linuxDesktop.rawValue
+ && receipt.runtimeInstallationName == runtimeInstallationName
+ && receipt.runtimeCatalogSHA256 == runtimeCatalogSHA256.lowercased()
+ && receipt.kernelAssetIdentifier == kernelAssetIdentifier
+ && receipt.kernelSHA256 == kernelSHA256.lowercased()
+ }
+
nonisolated static func preservingHiddenMachineSettings(_ settings: MachineSettings, existing: MachineSettings) -> MachineSettings {
var copy = settings
if copy.env.isEmpty { copy.env = existing.env }
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index ee8904df..0879e3e7 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -572,6 +572,109 @@ nonisolated private extension String {
|| byte == 58 || byte == 64 || byte == 95
}
}
+
+ var isSafeComponentInstallationName: Bool {
+ let bytes = Array(utf8)
+ guard (1...255).contains(bytes.count),
+ let first = bytes.first,
+ (first >= 48 && first <= 57)
+ || (first >= 65 && first <= 90)
+ || (first >= 97 && first <= 122) else {
+ return false
+ }
+ return bytes.dropFirst().allSatisfy { byte in
+ (byte >= 48 && byte <= 57)
+ || (byte >= 65 && byte <= 90)
+ || (byte >= 97 && byte <= 122)
+ || byte == 43 || byte == 45 || byte == 46 || byte == 95
+ }
+ }
+}
+
+nonisolated struct DorydInstalledDesktopPayloadReceipt: Codable, Sendable, Equatable, Hashable {
+ var schemaVersion: UInt16
+ var provenance: String
+ var distributionIdentifier: String
+ var releaseVersion: String
+ var inputSHA256: String
+ var bundleSHA256: String?
+ var distributionComponentIdentifier: String?
+ var distributionInstallationName: String?
+ var distributionCatalogSHA256: String?
+ var bundleAssetIdentifier: String?
+ var runtimeComponentIdentifier: String?
+ var runtimeInstallationName: String?
+ var runtimeCatalogSHA256: String?
+ var kernelAssetIdentifier: String?
+ var kernelSHA256: String?
+
+ var isValid: Bool {
+ guard schemaVersion == 1,
+ ["debian", "kali", "ubuntu"].contains(distributionIdentifier),
+ releaseVersion.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil,
+ inputSHA256.isLowercaseSHA256 else {
+ return false
+ }
+ switch provenance {
+ case "legacy-environment":
+ return verifiedAuthorityFieldsAreNil
+ case "legacy-snapshot-migration":
+ return verifiedAuthorityFieldsAreNil
+ case "verified-update-bundle":
+ return bundleSHA256?.isLowercaseSHA256 == true
+ && kernelSHA256?.isLowercaseSHA256 == true
+ && distributionComponentIdentifier == "desktop-" + distributionIdentifier
+ && distributionInstallationName?.isSafeComponentInstallationName == true
+ && distributionCatalogSHA256?.isLowercaseSHA256 == true
+ && bundleAssetIdentifier
+ == "dory-desktop-" + distributionIdentifier + "-update-arm64.tar"
+ && runtimeComponentIdentifier == "linux-desktop"
+ && runtimeInstallationName?.isSafeComponentInstallationName == true
+ && runtimeCatalogSHA256?.isLowercaseSHA256 == true
+ && kernelAssetIdentifier == "dory-desktop-kernel-arm64.lzfse"
+ default:
+ return false
+ }
+ }
+
+ private var verifiedAuthorityFieldsAreNil: Bool {
+ bundleSHA256 == nil
+ && distributionComponentIdentifier == nil
+ && distributionInstallationName == nil
+ && distributionCatalogSHA256 == nil
+ && bundleAssetIdentifier == nil
+ && runtimeComponentIdentifier == nil
+ && runtimeInstallationName == nil
+ && runtimeCatalogSHA256 == nil
+ && kernelAssetIdentifier == nil
+ && kernelSHA256 == nil
+ }
+
+ static func legacyEnvironment(_ environment: [String: String]) -> Self? {
+ guard let distributionIdentifier = environment["DORY_DESKTOP_DISTRO"],
+ let releaseVersion = environment["DORY_DESKTOP_RELEASE_VERSION"],
+ let inputSHA256 = environment["DORY_DESKTOP_INPUT_SHA256"] else {
+ return nil
+ }
+ let receipt = Self(
+ schemaVersion: 1,
+ provenance: "legacy-environment",
+ distributionIdentifier: distributionIdentifier,
+ releaseVersion: releaseVersion,
+ inputSHA256: inputSHA256,
+ bundleSHA256: nil,
+ distributionComponentIdentifier: nil,
+ distributionInstallationName: nil,
+ distributionCatalogSHA256: nil,
+ bundleAssetIdentifier: nil,
+ runtimeComponentIdentifier: nil,
+ runtimeInstallationName: nil,
+ runtimeCatalogSHA256: nil,
+ kernelAssetIdentifier: nil,
+ kernelSHA256: nil
+ )
+ return receipt.isValid ? receipt : nil
+ }
}
nonisolated private extension DorydMachineRuntimeBootMediaIdentity {
@@ -660,6 +763,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable {
var shares: [DorydMachineShareConfiguration] = []
var environment: [String: String] = [:]
var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
+ var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil
}
nonisolated struct DorydMachineExecResult: Sendable, Equatable {
@@ -713,6 +817,7 @@ nonisolated struct DorydMachineSnapshot: Sendable, Equatable {
var cpuCount: Int
var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
var artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil
+ var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil
}
nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable {
@@ -1254,14 +1359,14 @@ nonisolated final class DorydClient: @unchecked Sendable {
_ machineID: String,
distro: String,
version: String,
- bundlePath: String,
- kernelPath: String
+ distributionInstallationName: String,
+ runtimeInstallationName: String
) async throws -> DorydDesktopUpdateResult {
let request: NSDictionary = [
"distro": distro,
"version": version,
- "bundlePath": bundlePath,
- "kernelPath": kernelPath,
+ "distributionInstallationName": distributionInstallationName,
+ "runtimeInstallationName": runtimeInstallationName,
]
return try await withTimeout(atLeast: 3_900).statusCommand { proxy, reply in
proxy.machineDesktopUpdate(machineID, request: request, reply: reply)
@@ -1702,6 +1807,13 @@ nonisolated final class DorydClient: @unchecked Sendable {
let runtimeIdentity = machineRuntimeIdentity(from: dictionary) else {
return nil
}
+ let environment = machineEnvironment(from: dictionary["env"])
+ guard let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt(
+ from: dictionary,
+ legacyEnvironment: environment
+ ) else {
+ return nil
+ }
return DorydMachineStatus(
id: id,
state: state,
@@ -1726,9 +1838,84 @@ nonisolated final class DorydClient: @unchecked Sendable {
?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue
?? false,
shares: machineShares(from: dictionary["shares"]),
- environment: machineEnvironment(from: dictionary["env"]),
- runtimeIdentity: runtimeIdentity
+ environment: environment,
+ runtimeIdentity: runtimeIdentity,
+ installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value
+ )
+ }
+
+ private struct ParsedInstalledDesktopPayloadReceipt {
+ var value: DorydInstalledDesktopPayloadReceipt?
+ }
+
+ /// Only absence gets legacy compatibility. A present typed receipt is an evidence claim and
+ /// malformed or future-schema values fail the entire row instead of downgrading to env.
+ nonisolated private static func machineInstalledDesktopPayloadReceipt(
+ from dictionary: NSDictionary,
+ legacyEnvironment: [String: String]? = nil
+ ) -> ParsedInstalledDesktopPayloadReceipt? {
+ guard let encoded = dictionary["installedDesktopPayloadReceipt"] else {
+ return ParsedInstalledDesktopPayloadReceipt(
+ value: legacyEnvironment.flatMap(
+ DorydInstalledDesktopPayloadReceipt.legacyEnvironment
+ )
+ )
+ }
+ guard let receiptDictionary = encoded as? NSDictionary,
+ let receipt = strictInstalledDesktopPayloadReceipt(from: receiptDictionary),
+ receipt.isValid else {
+ return nil
+ }
+ return ParsedInstalledDesktopPayloadReceipt(value: receipt)
+ }
+
+ nonisolated private static func strictInstalledDesktopPayloadReceipt(
+ from dictionary: NSDictionary
+ ) -> DorydInstalledDesktopPayloadReceipt? {
+ let allowed: Set = [
+ "schemaVersion", "provenance", "distributionIdentifier", "releaseVersion",
+ "inputSHA256", "bundleSHA256", "distributionComponentIdentifier",
+ "distributionInstallationName", "distributionCatalogSHA256",
+ "bundleAssetIdentifier", "runtimeComponentIdentifier", "runtimeInstallationName",
+ "runtimeCatalogSHA256", "kernelAssetIdentifier", "kernelSHA256",
+ ]
+ guard let keys = dictionary.allKeys as? [String], Set(keys).isSubset(of: allowed),
+ keys.count == Set(keys).count,
+ let schemaNumber = dictionary["schemaVersion"] as? NSNumber,
+ CFGetTypeID(schemaNumber) != CFBooleanGetTypeID(),
+ schemaNumber.uint64Value == 1,
+ schemaNumber.doubleValue == 1,
+ let provenance = dictionary["provenance"] as? String,
+ let distributionIdentifier = dictionary["distributionIdentifier"] as? String,
+ let releaseVersion = dictionary["releaseVersion"] as? String,
+ let inputSHA256 = dictionary["inputSHA256"] as? String else {
+ return nil
+ }
+ let optionalKeys = allowed.subtracting([
+ "schemaVersion", "provenance", "distributionIdentifier", "releaseVersion",
+ "inputSHA256",
+ ])
+ for key in optionalKeys where dictionary[key] != nil {
+ guard dictionary[key] is String else { return nil }
+ }
+ let receipt = DorydInstalledDesktopPayloadReceipt(
+ schemaVersion: 1,
+ provenance: provenance,
+ distributionIdentifier: distributionIdentifier,
+ releaseVersion: releaseVersion,
+ inputSHA256: inputSHA256,
+ bundleSHA256: dictionary["bundleSHA256"] as? String,
+ distributionComponentIdentifier: dictionary["distributionComponentIdentifier"] as? String,
+ distributionInstallationName: dictionary["distributionInstallationName"] as? String,
+ distributionCatalogSHA256: dictionary["distributionCatalogSHA256"] as? String,
+ bundleAssetIdentifier: dictionary["bundleAssetIdentifier"] as? String,
+ runtimeComponentIdentifier: dictionary["runtimeComponentIdentifier"] as? String,
+ runtimeInstallationName: dictionary["runtimeInstallationName"] as? String,
+ runtimeCatalogSHA256: dictionary["runtimeCatalogSHA256"] as? String,
+ kernelAssetIdentifier: dictionary["kernelAssetIdentifier"] as? String,
+ kernelSHA256: dictionary["kernelSHA256"] as? String
)
+ return receipt.isValid ? receipt : nil
}
/// Only an absent key is compatible with an older daemon. A present identity is an evidence
@@ -1893,6 +2080,9 @@ nonisolated final class DorydClient: @unchecked Sendable {
let memoryMB = uint64(dictionary["memoryMB"]),
let cpuCount = int(dictionary["cpuCount"]),
let runtimeIdentity = machineRuntimeIdentity(from: dictionary),
+ let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt(
+ from: dictionary
+ ),
let artifactEvidence = machineSnapshotArtifactEvidence(
from: dictionary,
runtimeIdentity: runtimeIdentity
@@ -1911,7 +2101,8 @@ nonisolated final class DorydClient: @unchecked Sendable {
memoryMB: memoryMB,
cpuCount: cpuCount,
runtimeIdentity: runtimeIdentity,
- artifactEvidence: artifactEvidence.value
+ artifactEvidence: artifactEvidence.value,
+ installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value
)
}
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 4e4c897d..c46d5f22 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -524,6 +524,169 @@ struct DorydClientTests {
}
}
+ @Test func installedDesktopPayloadReceiptUsesAbsentOnlyLegacyCompatibilityAndRejectsMalformedClaims() async throws {
+ let digest = String(repeating: "a", count: 64)
+ let valid: [String: Any] = [
+ "schemaVersion": 1,
+ "provenance": "verified-update-bundle",
+ "distributionIdentifier": "ubuntu",
+ "releaseVersion": "24.04+runtime.7",
+ "inputSHA256": digest,
+ "bundleSHA256": String(repeating: "b", count: 64),
+ "distributionComponentIdentifier": "desktop-ubuntu",
+ "distributionInstallationName": "ubuntu-installation",
+ "distributionCatalogSHA256": String(repeating: "c", count: 64),
+ "bundleAssetIdentifier": "dory-desktop-ubuntu-update-arm64.tar",
+ "runtimeComponentIdentifier": "linux-desktop",
+ "runtimeInstallationName": "runtime-installation",
+ "runtimeCatalogSHA256": String(repeating: "d", count: 64),
+ "kernelAssetIdentifier": "dory-desktop-kernel-arm64.lzfse",
+ "kernelSHA256": String(repeating: "e", count: 64),
+ ]
+ do {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService(
+ installedDesktopPayloadReceiptOverride: valid as NSDictionary
+ )
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+ let client = DorydClient(endpoint: listener.endpoint)
+ let status = try #require(try await client.machineList().first)
+ #expect(status.installedDesktopPayloadReceipt?.releaseVersion == "24.04+runtime.7")
+ #expect(status.installedDesktopPayloadReceipt?.bundleSHA256 == String(repeating: "b", count: 64))
+ let snapshot = try await client.machineSnapshot(
+ "dev",
+ note: "receipt",
+ createdISO: "2026-07-07T00:00:00Z",
+ snapshotID: "receipt"
+ )
+ #expect(snapshot.installedDesktopPayloadReceipt == status.installedDesktopPayloadReceipt)
+ }
+
+ do {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService()
+ service.setMachineEnvironment("dev", [
+ "DORY_DESKTOP_DISTRO": "ubuntu",
+ "DORY_DESKTOP_RELEASE_VERSION": "24.04+runtime.6",
+ "DORY_DESKTOP_INPUT_SHA256": digest,
+ ])
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+ let status = try #require(
+ try await DorydClient(endpoint: listener.endpoint).machineList().first
+ )
+ #expect(status.installedDesktopPayloadReceipt?.provenance == "legacy-environment")
+ #expect(status.installedDesktopPayloadReceipt?.releaseVersion == "24.04+runtime.6")
+ #expect(status.installedDesktopPayloadReceipt?.bundleSHA256 == nil)
+ }
+
+ for malformed in [
+ [
+ "schemaVersion": 2,
+ "provenance": "verified-update-bundle",
+ "distributionIdentifier": "ubuntu",
+ "releaseVersion": "24.04+runtime.7",
+ "inputSHA256": digest,
+ "bundleSHA256": String(repeating: "b", count: 64),
+ ],
+ [
+ "schemaVersion": 1,
+ "provenance": "verified-update-bundle",
+ "distributionIdentifier": "ubuntu",
+ "releaseVersion": "24.04+runtime.7",
+ "inputSHA256": digest,
+ ],
+ valid.merging(["unknownEvidence": "must-reject"]) { _, new in new },
+ valid.merging(["schemaVersion": "1"]) { _, new in new },
+ valid.merging(["bundleSHA256": NSNumber(value: 7)]) { _, new in new },
+ valid.merging(["distributionInstallationName": "../../outside-store"]) { _, new in new },
+ ] as [[String: Any]] {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService(
+ installedDesktopPayloadReceiptOverride: malformed as NSDictionary
+ )
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+ let client = DorydClient(endpoint: listener.endpoint)
+ await #expect(throws: DorydClientError.self) {
+ _ = try await client.machineList()
+ }
+ await #expect(throws: DorydClientError.self) {
+ _ = try await client.machineSnapshot(
+ "dev",
+ note: "invalid receipt",
+ createdISO: "2026-07-07T00:00:00Z",
+ snapshotID: "invalid-receipt"
+ )
+ }
+ }
+ }
+
+ @Test func desktopUpdateSkipRequiresExactVerifiedActiveComponentProvenance() {
+ let receipt = DorydInstalledDesktopPayloadReceipt(
+ schemaVersion: 1,
+ provenance: "verified-update-bundle",
+ distributionIdentifier: "ubuntu",
+ releaseVersion: "24.04+runtime.7",
+ inputSHA256: String(repeating: "a", count: 64),
+ bundleSHA256: String(repeating: "b", count: 64),
+ distributionComponentIdentifier: "desktop-ubuntu",
+ distributionInstallationName: "ubuntu-installation",
+ distributionCatalogSHA256: String(repeating: "c", count: 64),
+ bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar",
+ runtimeComponentIdentifier: "linux-desktop",
+ runtimeInstallationName: "runtime-installation",
+ runtimeCatalogSHA256: String(repeating: "d", count: 64),
+ kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse",
+ kernelSHA256: String(repeating: "e", count: 64)
+ )
+ let matches: (DorydInstalledDesktopPayloadReceipt?) -> Bool = { candidate in
+ AppStore.desktopReceiptMatchesActiveComponents(
+ candidate,
+ distributionIdentifier: "ubuntu",
+ releaseVersion: "24.04+runtime.7",
+ distributionComponentIdentifier: "desktop-ubuntu",
+ distributionInstallationName: "ubuntu-installation",
+ distributionCatalogSHA256: String(repeating: "c", count: 64),
+ bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar",
+ bundleSHA256: String(repeating: "b", count: 64),
+ runtimeInstallationName: "runtime-installation",
+ runtimeCatalogSHA256: String(repeating: "d", count: 64),
+ kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse",
+ kernelSHA256: String(repeating: "e", count: 64)
+ )
+ }
+ #expect(matches(receipt))
+ var wrongDistro = receipt
+ wrongDistro.distributionIdentifier = "kali"
+ #expect(!matches(wrongDistro))
+ var staleBundle = receipt
+ staleBundle.bundleSHA256 = String(repeating: "f", count: 64)
+ #expect(!matches(staleBundle))
+ var legacy = receipt
+ legacy.provenance = "legacy-environment"
+ #expect(!matches(legacy))
+ #expect(
+ AppStore.managedDesktopDistributionIdentifier(
+ configuredIdentifier: nil,
+ receipt: receipt
+ ) == "ubuntu"
+ )
+ #expect(
+ AppStore.managedDesktopDistributionIdentifier(
+ configuredIdentifier: "kali",
+ receipt: receipt
+ ) == "kali"
+ )
+ }
+
private func validResolvedRuntimeIdentity() -> NSDictionary {
[
"schemaVersion": 1,
@@ -2147,6 +2310,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
private var _latestMachineProvisionRecipe: String?
private var runtimeIdentityOverride: NSDictionary?
private var artifactEvidenceOverride: NSDictionary?
+ private var installedDesktopPayloadReceiptOverride: NSDictionary?
private var snapshots: [String: [NSDictionary]] = [:]
private var backupStatuses: [String: NSDictionary] = [:]
var engineStartCount: Int {
@@ -2251,15 +2415,22 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
socketPath: String = "/tmp/doryd-test.sock",
engineShutdownReplyDelay: TimeInterval = 0,
runtimeIdentityOverride: NSDictionary? = nil,
- artifactEvidenceOverride: NSDictionary? = nil
+ artifactEvidenceOverride: NSDictionary? = nil,
+ installedDesktopPayloadReceiptOverride: NSDictionary? = nil
) {
self.socketPath = socketPath
self.engineShutdownReplyDelay = engineShutdownReplyDelay
self.runtimeIdentityOverride = runtimeIdentityOverride
self.artifactEvidenceOverride = artifactEvidenceOverride
- if let runtimeIdentityOverride,
- let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary {
- existing["runtimeIdentity"] = runtimeIdentityOverride
+ self.installedDesktopPayloadReceiptOverride = installedDesktopPayloadReceiptOverride
+ if let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary {
+ if let runtimeIdentityOverride {
+ existing["runtimeIdentity"] = runtimeIdentityOverride
+ }
+ if let installedDesktopPayloadReceiptOverride {
+ existing["installedDesktopPayloadReceipt"] =
+ installedDesktopPayloadReceiptOverride
+ }
machines["dev"] = existing.copy() as? NSDictionary
}
}
@@ -2586,10 +2757,23 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
if let artifactEvidenceOverride {
mutable["artifactEvidence"] = artifactEvidenceOverride
}
+ if let installedDesktopPayloadReceiptOverride {
+ mutable["installedDesktopPayloadReceipt"] =
+ installedDesktopPayloadReceiptOverride
+ }
row = mutable.copy() as? NSDictionary ?? baseRow
} else if let artifactEvidenceOverride,
let mutable = baseRow.mutableCopy() as? NSMutableDictionary {
mutable["artifactEvidence"] = artifactEvidenceOverride
+ if let installedDesktopPayloadReceiptOverride {
+ mutable["installedDesktopPayloadReceipt"] =
+ installedDesktopPayloadReceiptOverride
+ }
+ row = mutable.copy() as? NSDictionary ?? baseRow
+ } else if let installedDesktopPayloadReceiptOverride,
+ let mutable = baseRow.mutableCopy() as? NSMutableDictionary {
+ mutable["installedDesktopPayloadReceipt"] =
+ installedDesktopPayloadReceiptOverride
row = mutable.copy() as? NSDictionary ?? baseRow
} else {
row = baseRow
diff --git a/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift b/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift
new file mode 100644
index 00000000..45d74b1a
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryInstalledDesktopPayloadReceipt.swift
@@ -0,0 +1,413 @@
+import Foundation
+import DoryOperations
+
+/// Identifies how an installed desktop payload receipt was established.
+///
+/// Legacy receipts are a read-only compatibility projection of the two historical environment
+/// fields. Newly installed payloads are always bound to the exact verified update bundle.
+public enum DoryInstalledDesktopPayloadReceiptProvenance: String, Codable, Sendable, Equatable, Hashable {
+ case legacyEnvironment = "legacy-environment"
+ case legacySnapshotMigration = "legacy-snapshot-migration"
+ case verifiedUpdateBundle = "verified-update-bundle"
+}
+
+/// Non-secret, versioned evidence describing the desktop payload installed in a Linux guest.
+///
+/// This is observed durable state rather than desired VM intent. It deliberately contains no
+/// host path, token, credential, or guest output beyond bounded identifiers and SHA-256 digests.
+public struct DoryInstalledDesktopPayloadReceipt: Codable, Sendable, Equatable, Hashable {
+ public static let currentSchemaVersion: UInt16 = 1
+ public static let legacyReleaseVersionEnvironmentKey = "DORY_DESKTOP_RELEASE_VERSION"
+ public static let legacyInputSHA256EnvironmentKey = "DORY_DESKTOP_INPUT_SHA256"
+
+ public var schemaVersion: UInt16
+ public var provenance: DoryInstalledDesktopPayloadReceiptProvenance
+ public var distributionIdentifier: String
+ public var releaseVersion: String
+ public var inputSHA256: String
+ public var bundleSHA256: String?
+ public var distributionComponentIdentifier: String?
+ public var distributionInstallationName: String?
+ public var distributionCatalogSHA256: String?
+ public var bundleAssetIdentifier: String?
+ public var runtimeComponentIdentifier: String?
+ public var runtimeInstallationName: String?
+ public var runtimeCatalogSHA256: String?
+ public var kernelAssetIdentifier: String?
+ public var kernelSHA256: String?
+
+ public init(
+ schemaVersion: UInt16 = Self.currentSchemaVersion,
+ provenance: DoryInstalledDesktopPayloadReceiptProvenance,
+ distributionIdentifier: String,
+ releaseVersion: String,
+ inputSHA256: String,
+ bundleSHA256: String? = nil,
+ distributionComponentIdentifier: String? = nil,
+ distributionInstallationName: String? = nil,
+ distributionCatalogSHA256: String? = nil,
+ bundleAssetIdentifier: String? = nil,
+ runtimeComponentIdentifier: String? = nil,
+ runtimeInstallationName: String? = nil,
+ runtimeCatalogSHA256: String? = nil,
+ kernelAssetIdentifier: String? = nil,
+ kernelSHA256: String? = nil
+ ) {
+ self.schemaVersion = schemaVersion
+ self.provenance = provenance
+ self.distributionIdentifier = distributionIdentifier
+ self.releaseVersion = releaseVersion
+ self.inputSHA256 = inputSHA256
+ self.bundleSHA256 = bundleSHA256
+ self.distributionComponentIdentifier = distributionComponentIdentifier
+ self.distributionInstallationName = distributionInstallationName
+ self.distributionCatalogSHA256 = distributionCatalogSHA256
+ self.bundleAssetIdentifier = bundleAssetIdentifier
+ self.runtimeComponentIdentifier = runtimeComponentIdentifier
+ self.runtimeInstallationName = runtimeInstallationName
+ self.runtimeCatalogSHA256 = runtimeCatalogSHA256
+ self.kernelAssetIdentifier = kernelAssetIdentifier
+ self.kernelSHA256 = kernelSHA256
+ }
+
+ public static func verifiedUpdate(
+ distributionIdentifier: String,
+ releaseVersion: String,
+ inputSHA256: String,
+ bundleSHA256: String,
+ distributionComponentIdentifier: String,
+ distributionInstallationName: String,
+ distributionCatalogSHA256: String,
+ bundleAssetIdentifier: String,
+ runtimeComponentIdentifier: String,
+ runtimeInstallationName: String,
+ runtimeCatalogSHA256: String,
+ kernelAssetIdentifier: String,
+ kernelSHA256: String
+ ) -> Self {
+ Self(
+ provenance: .verifiedUpdateBundle,
+ distributionIdentifier: distributionIdentifier,
+ releaseVersion: releaseVersion,
+ inputSHA256: inputSHA256,
+ bundleSHA256: bundleSHA256,
+ distributionComponentIdentifier: distributionComponentIdentifier,
+ distributionInstallationName: distributionInstallationName,
+ distributionCatalogSHA256: distributionCatalogSHA256,
+ bundleAssetIdentifier: bundleAssetIdentifier,
+ runtimeComponentIdentifier: runtimeComponentIdentifier,
+ runtimeInstallationName: runtimeInstallationName,
+ runtimeCatalogSHA256: runtimeCatalogSHA256,
+ kernelAssetIdentifier: kernelAssetIdentifier,
+ kernelSHA256: kernelSHA256
+ )
+ }
+
+ /// Projects an old machine's receipt without mutating or canonicalizing its raw metadata.
+ public static func legacyEnvironment(_ environment: [String: String]) -> Self? {
+ guard let distributionIdentifier = environment["DORY_DESKTOP_DISTRO"],
+ let releaseVersion = environment[legacyReleaseVersionEnvironmentKey],
+ let inputSHA256 = environment[legacyInputSHA256EnvironmentKey] else {
+ return nil
+ }
+ let receipt = Self(
+ provenance: .legacyEnvironment,
+ distributionIdentifier: distributionIdentifier,
+ releaseVersion: releaseVersion,
+ inputSHA256: inputSHA256
+ )
+ return receipt.isValid ? receipt : nil
+ }
+
+ public var isValid: Bool {
+ guard schemaVersion == Self.currentSchemaVersion,
+ Self.isValidDistributionIdentifier(distributionIdentifier),
+ Self.isValidReleaseVersion(releaseVersion),
+ Self.isLowercaseSHA256(inputSHA256) else {
+ return false
+ }
+ switch provenance {
+ case .legacyEnvironment:
+ return allVerifiedAuthorityFieldsAreNil
+ case .legacySnapshotMigration:
+ return allVerifiedAuthorityFieldsAreNil
+ case .verifiedUpdateBundle:
+ guard bundleSHA256.map(Self.isLowercaseSHA256) == true,
+ kernelSHA256.map(Self.isLowercaseSHA256) == true,
+ distributionComponentIdentifier == Self.componentIdentifier(for: distributionIdentifier),
+ runtimeComponentIdentifier == DoryComponentID.linuxDesktop.rawValue,
+ distributionInstallationName.map(Self.isSafeIdentifier) == true,
+ runtimeInstallationName.map(Self.isSafeIdentifier) == true,
+ distributionCatalogSHA256.map(Self.isLowercaseSHA256) == true,
+ runtimeCatalogSHA256.map(Self.isLowercaseSHA256) == true,
+ bundleAssetIdentifier == Self.bundleAssetIdentifier(for: distributionIdentifier),
+ kernelAssetIdentifier == Self.kernelAssetIdentifier else {
+ return false
+ }
+ return true
+ }
+ }
+
+ public func matchesLegacyEnvironment(_ environment: [String: String]) -> Bool {
+ provenance == .legacyEnvironment && Self.legacyEnvironment(environment) == self
+ }
+
+ /// Converts a raw legacy projection into an explicitly historical, path-free snapshot claim.
+ /// It is never treated as verified active-component provenance.
+ public var archivedLegacySnapshotReceipt: Self? {
+ guard provenance == .legacyEnvironment, isValid else { return nil }
+ var copy = self
+ copy.provenance = .legacySnapshotMigration
+ return copy
+ }
+
+ /// Portable bundles are integrity checked but are not signed component-store authority.
+ /// Preserve bounded historical identity while discarding any active-verification claim.
+ public var portableSnapshotReceipt: Self? {
+ guard isValid else { return nil }
+ if provenance == .legacySnapshotMigration { return self }
+ return Self(
+ provenance: .legacySnapshotMigration,
+ distributionIdentifier: distributionIdentifier,
+ releaseVersion: releaseVersion,
+ inputSHA256: inputSHA256
+ )
+ }
+
+ public func hasCoherentAuthority(environment: [String: String]) -> Bool {
+ guard isValid else { return false }
+ let hasLegacyRelease = environment[Self.legacyReleaseVersionEnvironmentKey] != nil
+ let hasLegacyInput = environment[Self.legacyInputSHA256EnvironmentKey] != nil
+ switch provenance {
+ case .legacyEnvironment:
+ return matchesLegacyEnvironment(environment)
+ case .legacySnapshotMigration:
+ return !hasLegacyRelease && !hasLegacyInput
+ && (environment["DORY_DESKTOP_DISTRO"] == nil
+ || environment["DORY_DESKTOP_DISTRO"] == distributionIdentifier)
+ case .verifiedUpdateBundle:
+ return !hasLegacyRelease && !hasLegacyInput
+ && (environment["DORY_DESKTOP_DISTRO"] == nil
+ || environment["DORY_DESKTOP_DISTRO"] == distributionIdentifier)
+ }
+ }
+
+ public static func componentIdentifier(for distributionIdentifier: String) -> String? {
+ switch distributionIdentifier {
+ case "debian": DoryComponentID.desktopDebian.rawValue
+ case "ubuntu": DoryComponentID.desktopUbuntu.rawValue
+ case "kali": DoryComponentID.desktopKali.rawValue
+ default: nil
+ }
+ }
+
+ public static func bundleAssetIdentifier(for distributionIdentifier: String) -> String {
+ "dory-desktop-\(distributionIdentifier)-update-arm64.tar"
+ }
+
+ public static let kernelAssetIdentifier = "dory-desktop-kernel-arm64.lzfse"
+
+ private var allVerifiedAuthorityFieldsAreNil: Bool {
+ bundleSHA256 == nil
+ && distributionComponentIdentifier == nil
+ && distributionInstallationName == nil
+ && distributionCatalogSHA256 == nil
+ && bundleAssetIdentifier == nil
+ && runtimeComponentIdentifier == nil
+ && runtimeInstallationName == nil
+ && runtimeCatalogSHA256 == nil
+ && kernelAssetIdentifier == nil
+ && kernelSHA256 == nil
+ }
+
+ private static func isValidDistributionIdentifier(_ value: String) -> Bool {
+ ["debian", "kali", "ubuntu"].contains(value)
+ }
+
+ private static func isValidReleaseVersion(_ value: String) -> Bool {
+ value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil
+ }
+
+ private static func isLowercaseSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy { byte in
+ (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
+ }
+ }
+
+ private static func isSafeIdentifier(_ value: String) -> Bool {
+ let bytes = Array(value.utf8)
+ guard (1...255).contains(bytes.count),
+ let first = bytes.first,
+ (first >= 48 && first <= 57)
+ || (first >= 65 && first <= 90)
+ || (first >= 97 && first <= 122) else {
+ return false
+ }
+ return bytes.dropFirst().allSatisfy { byte in
+ (byte >= 48 && byte <= 57)
+ || (byte >= 65 && byte <= 90)
+ || (byte >= 97 && byte <= 122)
+ || byte == 43 || byte == 45 || byte == 46 || byte == 95
+ }
+ }
+}
+
+/// Stable, caller-visible selection identity. Host paths never cross XPC.
+public struct DoryDesktopUpdateRequest: Sendable, Equatable {
+ public var distro: String
+ public var version: String
+ public var distributionInstallationName: String
+ public var runtimeInstallationName: String
+
+ public init(
+ distro: String,
+ version: String,
+ distributionInstallationName: String,
+ runtimeInstallationName: String
+ ) {
+ self.distro = distro
+ self.version = version
+ self.distributionInstallationName = distributionInstallationName
+ self.runtimeInstallationName = runtimeInstallationName
+ }
+}
+
+public struct DoryDesktopUpdateArtifactAuthority: Sendable, Equatable {
+ public var receipt: DoryInstalledDesktopPayloadReceipt
+ public var bundlePath: String
+ public var bundleByteCount: UInt64
+ public var kernelPath: String
+ public var kernelByteCount: UInt64
+
+ public init(
+ receipt: DoryInstalledDesktopPayloadReceipt,
+ bundlePath: String,
+ bundleByteCount: UInt64,
+ kernelPath: String,
+ kernelByteCount: UInt64
+ ) {
+ self.receipt = receipt
+ self.bundlePath = bundlePath
+ self.bundleByteCount = bundleByteCount
+ self.kernelPath = kernelPath
+ self.kernelByteCount = kernelByteCount
+ }
+}
+
+public protocol DoryDesktopUpdateArtifactResolving: Sendable {
+ func resolve(
+ _ request: DoryDesktopUpdateRequest,
+ guestArchitecture: String
+ ) throws -> DoryDesktopUpdateArtifactAuthority
+}
+
+/// Resolves only the active, already signature-qualified component generations. This type never
+/// accepts a caller-controlled path and re-verifies every byte immediately before returning it.
+public struct DoryComponentStoreDesktopUpdateArtifactResolver: DoryDesktopUpdateArtifactResolving {
+ public let store: DoryComponentStore
+ public let publicKey: String
+ public let expectedArchitecture: String
+ public let appVersion: String
+
+ /// Production construction is pinned to daemon-compiled trust inputs. The helper executable
+ /// must not derive qualification authority from its mutable bundle metadata.
+ public init(store: DoryComponentStore) {
+ self.init(
+ store: store,
+ publicKey: DoryComponentDefaults.publicKey,
+ expectedArchitecture: DoryComponentDefaults.architecture,
+ appVersion: DoryDaemonVirtualMachineProductionTrustFactory.compiledDaemonVersion
+ )
+ }
+
+ /// Internal seam for signed-catalog fixtures; production callers use the pinned initializer.
+ init(
+ store: DoryComponentStore,
+ publicKey: String,
+ expectedArchitecture: String,
+ appVersion: String
+ ) {
+ self.store = store
+ self.publicKey = publicKey
+ self.expectedArchitecture = expectedArchitecture
+ self.appVersion = appVersion
+ }
+
+ public func resolve(
+ _ request: DoryDesktopUpdateRequest,
+ guestArchitecture: String
+ ) throws -> DoryDesktopUpdateArtifactAuthority {
+ guard guestArchitecture == expectedArchitecture,
+ let rawComponentID = DoryInstalledDesktopPayloadReceipt.componentIdentifier(
+ for: request.distro
+ ),
+ let distributionID = DoryComponentID(rawValue: rawComponentID) else {
+ throw MachineManagerError.persistence("desktop update platform is unsupported")
+ }
+ let distribution = try store.verify(distributionID)
+ let runtime = try store.verify(.linuxDesktop)
+ guard let signed = try store.cachedCatalog(
+ publicKey: publicKey,
+ expectedArchitecture: expectedArchitecture,
+ appVersion: appVersion
+ ) else {
+ throw MachineManagerError.persistence("signed desktop component catalog is unavailable")
+ }
+ let signedCatalogSHA256 = DoryComponentCatalogVerifier.digest(signed.data)
+ guard distribution.catalogDigest.lowercased() == signedCatalogSHA256,
+ runtime.catalogDigest.lowercased() == signedCatalogSHA256,
+ let signedDistribution = signed.catalog.components.first(where: {
+ $0.id == distributionID && $0.version == distribution.version
+ }),
+ let signedRuntime = signed.catalog.components.first(where: {
+ $0.id == .linuxDesktop && $0.version == runtime.version
+ }),
+ signedDistribution.assets == distribution.assets,
+ signedRuntime.assets == runtime.assets else {
+ throw MachineManagerError.persistence("active desktop components do not match the signed catalog")
+ }
+ guard distribution.installationName == request.distributionInstallationName,
+ runtime.installationName == request.runtimeInstallationName,
+ request.version == distribution.version + "+runtime." + runtime.version else {
+ throw MachineManagerError.persistence("desktop update component selection is stale")
+ }
+ let bundleAssetID = DoryInstalledDesktopPayloadReceipt.bundleAssetIdentifier(
+ for: request.distro
+ )
+ let kernelAssetID = DoryInstalledDesktopPayloadReceipt.kernelAssetIdentifier
+ guard let bundle = distribution.assets.first(where: { $0.path == bundleAssetID }),
+ let kernel = runtime.assets.first(where: { $0.path == kernelAssetID }),
+ bundle.compression == .none,
+ kernel.installedBytes > 0,
+ let bundlePath = store.assetPath(component: distributionID, path: bundleAssetID),
+ let kernelPath = store.assetPath(component: .linuxDesktop, path: kernelAssetID) else {
+ throw MachineManagerError.persistence("desktop update component assets are invalid")
+ }
+ let placeholderInput = String(repeating: "0", count: 64)
+ let receipt = DoryInstalledDesktopPayloadReceipt.verifiedUpdate(
+ distributionIdentifier: request.distro,
+ releaseVersion: request.version,
+ inputSHA256: placeholderInput,
+ bundleSHA256: bundle.installedSHA256,
+ distributionComponentIdentifier: distribution.id.rawValue,
+ distributionInstallationName: distribution.installationName,
+ distributionCatalogSHA256: distribution.catalogDigest.lowercased(),
+ bundleAssetIdentifier: bundle.path,
+ runtimeComponentIdentifier: runtime.id.rawValue,
+ runtimeInstallationName: runtime.installationName,
+ runtimeCatalogSHA256: runtime.catalogDigest.lowercased(),
+ kernelAssetIdentifier: kernel.path,
+ kernelSHA256: kernel.installedSHA256
+ )
+ guard receipt.isValid else {
+ throw MachineManagerError.persistence("desktop update component evidence is invalid")
+ }
+ return DoryDesktopUpdateArtifactAuthority(
+ receipt: receipt,
+ bundlePath: bundlePath,
+ bundleByteCount: bundle.installedBytes,
+ kernelPath: kernelPath,
+ kernelByteCount: kernel.installedBytes
+ )
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
index 34edd429..06733161 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
@@ -268,7 +268,9 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable {
address: authoritativeLegacyConfiguration.address,
displayMode: authoritativeLegacyConfiguration.displayMode,
shares: shares,
- environment: environment
+ environment: environment,
+ installedDesktopPayloadReceipt:
+ authoritativeLegacyConfiguration.installedDesktopPayloadReceipt
)
}
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index defa41f8..a44d42b9 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -1191,12 +1191,26 @@ private struct MachineProvisionRequest {
private extension DoryDesktopUpdateRequest {
init(xpcDictionary dictionary: NSDictionary) throws {
+ let allowed: Set = [
+ "distro", "version", "distributionInstallationName", "runtimeInstallationName",
+ ]
+ guard let keys = dictionary.allKeys as? [String], Set(keys) == allowed else {
+ throw XPCRemoteConfigError.invalid("desktopUpdateAuthority")
+ }
self.init(
distro: try dictionary.requiredString("distro"),
version: try dictionary.requiredString("version"),
- bundlePath: try dictionary.requiredString("bundlePath"),
- kernelPath: try dictionary.requiredString("kernelPath")
+ distributionInstallationName: try dictionary.requiredString(
+ "distributionInstallationName"
+ ),
+ runtimeInstallationName: try dictionary.requiredString("runtimeInstallationName")
)
+ guard ["debian", "kali", "ubuntu"].contains(distro),
+ version.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil,
+ distributionInstallationName.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,254}/) != nil,
+ runtimeInstallationName.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,254}/) != nil else {
+ throw XPCRemoteConfigError.invalid("desktopUpdateAuthority")
+ }
}
}
@@ -1590,6 +1604,53 @@ private extension DoryMachineStatus {
dictionary["bootMode"] = bootMode.rawValue
dictionary["installerMediaAttached"] = installerMediaAttached
dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary
+ if let installedDesktopPayloadReceipt {
+ dictionary["installedDesktopPayloadReceipt"] =
+ installedDesktopPayloadReceipt.xpcDictionary
+ }
+ return dictionary as NSDictionary
+ }
+}
+
+private extension DoryInstalledDesktopPayloadReceipt {
+ var xpcDictionary: NSDictionary {
+ var dictionary: [String: Any] = [
+ "schemaVersion": schemaVersion,
+ "provenance": provenance.rawValue,
+ "distributionIdentifier": distributionIdentifier,
+ "releaseVersion": releaseVersion,
+ "inputSHA256": inputSHA256,
+ ]
+ if let bundleSHA256 {
+ dictionary["bundleSHA256"] = bundleSHA256
+ }
+ if let distributionComponentIdentifier {
+ dictionary["distributionComponentIdentifier"] = distributionComponentIdentifier
+ }
+ if let distributionInstallationName {
+ dictionary["distributionInstallationName"] = distributionInstallationName
+ }
+ if let distributionCatalogSHA256 {
+ dictionary["distributionCatalogSHA256"] = distributionCatalogSHA256
+ }
+ if let bundleAssetIdentifier {
+ dictionary["bundleAssetIdentifier"] = bundleAssetIdentifier
+ }
+ if let runtimeComponentIdentifier {
+ dictionary["runtimeComponentIdentifier"] = runtimeComponentIdentifier
+ }
+ if let runtimeInstallationName {
+ dictionary["runtimeInstallationName"] = runtimeInstallationName
+ }
+ if let runtimeCatalogSHA256 {
+ dictionary["runtimeCatalogSHA256"] = runtimeCatalogSHA256
+ }
+ if let kernelAssetIdentifier {
+ dictionary["kernelAssetIdentifier"] = kernelAssetIdentifier
+ }
+ if let kernelSHA256 {
+ dictionary["kernelSHA256"] = kernelSHA256
+ }
return dictionary as NSDictionary
}
}
@@ -1791,6 +1852,11 @@ private extension DoryMachineSnapshot {
if let artifactEvidence {
dictionary["artifactEvidence"] = artifactEvidence.xpcDictionary
}
+ let receipt = installedDesktopPayloadReceipt
+ ?? DoryInstalledDesktopPayloadReceipt.legacyEnvironment(environment)
+ if let receipt {
+ dictionary["installedDesktopPayloadReceipt"] = receipt.xpcDictionary
+ }
return dictionary as NSDictionary
}
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index fac1d2cd..7d643066 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -218,6 +218,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
public var displayMode: DoryMachineDisplayMode
public var shares: [DoryMachineShareConfiguration]
public var environment: [String: String]
+ public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt?
public init(
id: String,
@@ -231,7 +232,8 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
address: String? = nil,
displayMode: DoryMachineDisplayMode = .headless,
shares: [DoryMachineShareConfiguration] = [],
- environment: [String: String] = [:]
+ environment: [String: String] = [:],
+ installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil
) {
self.id = id
self.kernelPath = kernelPath
@@ -245,6 +247,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
self.displayMode = displayMode
self.shares = shares
self.environment = environment
+ self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt
}
private enum CodingKeys: String, CodingKey {
@@ -260,6 +263,7 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
case displayMode
case shares
case environment
+ case installedDesktopPayloadReceipt
}
public init(from decoder: Decoder) throws {
@@ -276,9 +280,18 @@ public struct DoryMachineConfiguration: Sendable, Equatable, Hashable, Codable {
address: try container.decodeIfPresent(String.self, forKey: .address),
displayMode: try container.decodeIfPresent(DoryMachineDisplayMode.self, forKey: .displayMode) ?? .headless,
shares: try container.decodeIfPresent([DoryMachineShareConfiguration].self, forKey: .shares) ?? [],
- environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:]
+ environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:],
+ installedDesktopPayloadReceipt: try container.decodeIfPresent(
+ DoryInstalledDesktopPayloadReceipt.self,
+ forKey: .installedDesktopPayloadReceipt
+ )
)
}
+
+ public var effectiveInstalledDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? {
+ installedDesktopPayloadReceipt
+ ?? DoryInstalledDesktopPayloadReceipt.legacyEnvironment(environment)
+ }
}
public enum DoryMachineState: String, Sendable, Equatable {
@@ -315,6 +328,7 @@ public struct DoryMachineStatus: Sendable, Equatable {
public var shares: [DoryMachineShareConfiguration]
public var environment: [String: String]
public var runtimeIdentity: DoryMachineRuntimeIdentity
+ public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt?
public init(
id: String,
@@ -342,7 +356,8 @@ public struct DoryMachineStatus: Sendable, Equatable {
runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility(
virtualHardwareABIVersion:
DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
- )
+ ),
+ installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil
) {
self.id = id
self.state = state
@@ -367,6 +382,7 @@ public struct DoryMachineStatus: Sendable, Equatable {
self.shares = shares
self.environment = environment
self.runtimeIdentity = runtimeIdentity
+ self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt
}
}
@@ -390,6 +406,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
public var nvramPath: String?
public var runtimeIdentity: DoryMachineRuntimeIdentity
public var artifactEvidence: DoryMachineSnapshotArtifactEvidence?
+ public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt?
public init(
id: String,
@@ -413,7 +430,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
virtualHardwareABIVersion:
DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
),
- artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil
+ artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil,
+ installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil
) {
self.id = id
self.machineID = machineID
@@ -434,6 +452,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
self.nvramPath = nvramPath
self.runtimeIdentity = runtimeIdentity
self.artifactEvidence = artifactEvidence
+ self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt
}
private enum CodingKeys: String, CodingKey {
@@ -456,6 +475,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
case nvramPath
case runtimeIdentity
case artifactEvidence
+ case installedDesktopPayloadReceipt
}
public init(from decoder: Decoder) throws {
@@ -488,25 +508,15 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
artifactEvidence: try container.decodeIfPresent(
DoryMachineSnapshotArtifactEvidence.self,
forKey: .artifactEvidence
+ ),
+ installedDesktopPayloadReceipt: try container.decodeIfPresent(
+ DoryInstalledDesktopPayloadReceipt.self,
+ forKey: .installedDesktopPayloadReceipt
)
)
}
}
-public struct DoryDesktopUpdateRequest: Sendable, Equatable {
- public var distro: String
- public var version: String
- public var bundlePath: String
- public var kernelPath: String
-
- public init(distro: String, version: String, bundlePath: String, kernelPath: String) {
- self.distro = distro
- self.version = version
- self.bundlePath = bundlePath
- self.kernelPath = kernelPath
- }
-}
-
public struct DoryDesktopUpdateResult: Sendable, Equatable {
public var machineID: String
public var distro: String
@@ -538,6 +548,13 @@ public struct DoryDesktopUpdateResult: Sendable, Equatable {
}
}
+private enum DesktopUpdateJournalStage: String, Codable, Sendable {
+ case snapshotReady = "snapshot-ready"
+ case installing
+ case qualifying
+ case committed
+}
+
private struct DesktopUpdateJournal: Codable, Sendable, Equatable {
var schema: Int
var machineID: String
@@ -545,7 +562,37 @@ private struct DesktopUpdateJournal: Codable, Sendable, Equatable {
var version: String
var snapshotID: String
var originalWasRunning: Bool
- var stage: String
+ var stage: DesktopUpdateJournalStage
+ var sourceConfigurationSHA256: String?
+ var updateAuthority: DoryInstalledDesktopPayloadReceipt?
+
+ var isValid: Bool {
+ guard [1, 2].contains(schema),
+ Self.isValidIdentifier(machineID),
+ Self.isValidIdentifier(snapshotID),
+ ["debian", "kali", "ubuntu"].contains(distro),
+ version.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil else {
+ return false
+ }
+ if schema == 1 {
+ return sourceConfigurationSHA256 == nil && updateAuthority == nil
+ }
+ return sourceConfigurationSHA256.map(Self.isLowercaseSHA256) == true
+ && updateAuthority?.isValid == true
+ && updateAuthority?.provenance == .verifiedUpdateBundle
+ && updateAuthority?.distributionIdentifier == distro
+ && updateAuthority?.releaseVersion == version
+ }
+
+ private static func isLowercaseSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy { byte in
+ (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
+ }
+ }
+
+ private static func isValidIdentifier(_ value: String) -> Bool {
+ value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil
+ }
}
public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvertible {
@@ -668,6 +715,7 @@ public final class MachineManager: @unchecked Sendable {
private static let snapshotNVRAMTemporaryMarker = ".nvram.tmp-"
private static let snapshotMetadataTemporaryPrefix = ".dory-snapshot-metadata-"
private static let desktopUpdateJournalName = "desktop-update.json"
+ private static let desktopUpdateStagingPrefix = ".desktop-update-stage-"
private static let maximumPersistedMetadataBytes: Int64 = 16 * 1024 * 1024
/// Public Apple-Silicon machine resource contract. These match the app's steppers; enforcing
/// them again in doryd prevents CLI/XPC callers from persisting values that the VMM would later
@@ -699,6 +747,7 @@ public final class MachineManager: @unchecked Sendable {
private var pendingResolvedStart: PendingResolvedMachineStart?
private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:]
private var activeLifecycleOperations: [String: MachineLifecycleJournalContext] = [:]
+ private var desktopUpdateArtifactResolver: (any DoryDesktopUpdateArtifactResolving)?
#if DEBUG
private var lifecycleFaultInjector: (@Sendable (MachineLifecycleFaultPoint) throws -> Void)?
#endif
@@ -761,6 +810,16 @@ public final class MachineManager: @unchecked Sendable {
recoverInterruptedDesktopUpdates()
}
+ /// Installs the daemon-owned active-component resolver. Desktop update writes fail closed until
+ /// this is installed; caller-supplied filesystem paths are never accepted as update authority.
+ public func installDesktopUpdateArtifactResolver(
+ _ resolver: any DoryDesktopUpdateArtifactResolving
+ ) {
+ operationLock.lock()
+ desktopUpdateArtifactResolver = resolver
+ operationLock.unlock()
+ }
+
/// Enables the explicit plan-driven launch path exactly once. Machines keep using the
/// labeled legacy compatibility path until this production trust infrastructure is injected;
/// once installed, a start never falls back when plan validation or adapter dispatch fails.
@@ -1960,7 +2019,9 @@ public final class MachineManager: @unchecked Sendable {
machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil,
nvramPath: machine.bootMode == .efi ? nvramPath : nil,
runtimeIdentity: snapshotRuntimeIdentity,
- artifactEvidence: artifactEvidence
+ artifactEvidence: artifactEvidence,
+ installedDesktopPayloadReceipt:
+ machine.effectiveInstalledDesktopPayloadReceipt
)
let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot)
let lifecycle = try beginLifecycleSnapshot(
@@ -2046,7 +2107,8 @@ public final class MachineManager: @unchecked Sendable {
}
/// Applies a signed desktop component to an existing persistent guest. The caller provides
- /// component-store paths, but doryd owns the entire transaction: a last-good disk/kernel
+ /// only stable active-component generation identifiers; doryd resolves and re-verifies the
+ /// exact component-store bytes and owns the entire transaction: a last-good disk/kernel
/// snapshot is durable before the guest is mutated, and any failed install or post-reboot
/// qualification restores that snapshot and the machine's original running state.
public func updateDesktop(
@@ -2063,23 +2125,37 @@ public final class MachineManager: @unchecked Sendable {
guard original.displayMode == .desktop else {
throw MachineManagerError.persistence("desktop updates require a graphical machine")
}
+ let persistedDistro = original.environment["DORY_DESKTOP_DISTRO"]
+ ?? original.effectiveInstalledDesktopPayloadReceipt?.distributionIdentifier
guard ["debian", "ubuntu", "kali"].contains(request.distro),
- original.environment["DORY_DESKTOP_DISTRO"] == request.distro else {
+ persistedDistro == request.distro else {
throw MachineManagerError.persistence("desktop update distribution does not match " + id)
}
guard request.version.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil else {
throw MachineManagerError.persistence("desktop update version is invalid")
}
- guard Self.isRegularNonemptyFile(path: request.bundlePath),
- Self.isRegularNonemptyFile(path: request.kernelPath) else {
- throw MachineManagerError.persistence("desktop update assets are missing or invalid")
- }
- let bundleURL = URL(fileURLWithPath: request.bundlePath).standardizedFileURL
- let bundleDirectory = bundleURL.deletingLastPathComponent().path
- guard Self.isDirectory(path: bundleDirectory), bundleURL.lastPathComponent != "." else {
- throw MachineManagerError.persistence("desktop update bundle directory is invalid")
+ guard let desktopUpdateArtifactResolver else {
+ throw MachineManagerError.persistence(
+ "desktop updates require daemon component-store authority"
+ )
}
- let bundleSHA256 = try Self.sha256(path: request.bundlePath)
+ let authority = try desktopUpdateArtifactResolver.resolve(
+ request,
+ guestArchitecture: configuration.guestArchitecture
+ )
+ guard authority.receipt.isValid,
+ authority.receipt.provenance == .verifiedUpdateBundle,
+ authority.receipt.distributionIdentifier == request.distro,
+ authority.receipt.releaseVersion == request.version,
+ authority.receipt.distributionInstallationName
+ == request.distributionInstallationName,
+ authority.receipt.runtimeInstallationName == request.runtimeInstallationName,
+ let bundleSHA256 = authority.receipt.bundleSHA256,
+ let kernelSHA256 = authority.receipt.kernelSHA256 else {
+ throw MachineManagerError.persistence("desktop update authority is inconsistent")
+ }
+ let staged = try stageDesktopUpdateAuthority(authority, machineID: id)
+ defer { try? FileManager.default.removeItem(atPath: staged.directory) }
let snapshotID = Self.generatedSnapshotID(prefix: "du")
let snapshot = try snapshot(
id: id,
@@ -2087,13 +2163,17 @@ public final class MachineManager: @unchecked Sendable {
snapshotID: snapshotID
)
var journal = DesktopUpdateJournal(
- schema: 1,
+ schema: 2,
machineID: id,
distro: request.distro,
version: request.version,
snapshotID: snapshot.id,
originalWasRunning: originallyRunning,
- stage: "snapshot-ready"
+ stage: .snapshotReady,
+ sourceConfigurationSHA256: Self.sha256(
+ data: try DoryMachineConfigurationMigrationBridge.encodeLegacy(original)
+ ),
+ updateAuthority: authority.receipt
)
try persistDesktopUpdateJournal(journal)
@@ -2102,11 +2182,11 @@ public final class MachineManager: @unchecked Sendable {
let guestStage = "/var/lib/dory/update-" + token
let updateShare = DoryMachineShareConfiguration(
tag: "dory-update-" + token,
- hostPath: bundleDirectory,
+ hostPath: staged.directory,
guestPath: mountPath,
readOnly: true
)
- let bundleGuestPath = mountPath + "/" + bundleURL.lastPathComponent
+ let bundleGuestPath = mountPath + "/payload.tar"
do {
var transientShares = original.shares.filter { $0.tag != updateShare.tag }
@@ -2115,7 +2195,7 @@ public final class MachineManager: @unchecked Sendable {
if status(id: id)?.state != .running {
_ = try startAndWaitUntilReady(id: id)
}
- journal.stage = "installing"
+ journal.stage = .installing
try persistDesktopUpdateJournal(journal)
try requireSuccessfulDesktopUpdateExec(
@@ -2134,6 +2214,11 @@ public final class MachineManager: @unchecked Sendable {
timeoutMs: 120_000,
stage: "extract signed payload"
)
+ guard try Self.sha256(path: staged.bundlePath) == bundleSHA256 else {
+ throw MachineManagerError.persistence(
+ "desktop update staged bundle changed while the guest read it"
+ )
+ }
let install = try requireSuccessfulDesktopUpdateExec(
id: id,
argv: [guestStage + "/apply.sh", request.distro, request.version],
@@ -2168,22 +2253,24 @@ public final class MachineManager: @unchecked Sendable {
stage: "clear applied payload"
)
_ = try stop(id: id)
- var updatedEnvironment = original.environment
- updatedEnvironment["DORY_DESKTOP_RELEASE_VERSION"] = request.version
- updatedEnvironment["DORY_DESKTOP_INPUT_SHA256"] = inputSHA256
- _ = try update(
- id: id,
- shares: original.shares,
- updatesShares: true,
- environment: updatedEnvironment,
- updatesEnvironment: true
+ var installedReceipt = authority.receipt
+ installedReceipt.inputSHA256 = inputSHA256
+ try publishInstalledDesktopPayloadReceipt(
+ original: original,
+ receipt: installedReceipt
)
try Self.cloneOrCopyFile(
- source: request.kernelPath,
+ source: staged.kernelPath,
destination: original.kernelPath,
replaceExisting: true
)
- journal.stage = "qualifying"
+ guard try Self.sha256(path: staged.kernelPath) == kernelSHA256,
+ try Self.sha256(path: original.kernelPath) == kernelSHA256 else {
+ throw MachineManagerError.persistence(
+ "desktop update kernel authority changed before commit"
+ )
+ }
+ journal.stage = .qualifying
try persistDesktopUpdateJournal(journal)
_ = try startAndWaitUntilReady(id: id)
try qualifyUpdatedDesktop(
@@ -2199,7 +2286,7 @@ public final class MachineManager: @unchecked Sendable {
} else {
finalStatus = try stop(id: id)
}
- journal.stage = "committed"
+ journal.stage = .committed
try persistDesktopUpdateJournal(journal)
try removeDesktopUpdateJournal(machineID: id)
return DoryDesktopUpdateResult(
@@ -2287,7 +2374,9 @@ public final class MachineManager: @unchecked Sendable {
address: nil,
displayMode: snapshot.displayMode,
shares: snapshot.shares,
- environment: snapshot.environment
+ environment: snapshot.environment,
+ installedDesktopPayloadReceipt:
+ Self.configurationReceipt(restoring: snapshot)
)
_ = try create(machine)
do {
@@ -2335,6 +2424,8 @@ public final class MachineManager: @unchecked Sendable {
restoredMachine.address = address
restoredMachine.shares = snapshot.shares
restoredMachine.environment = snapshot.environment
+ restoredMachine.installedDesktopPayloadReceipt =
+ Self.configurationReceipt(restoring: snapshot)
let sourceIdentity = try currentRuntimeIdentity(id: machineID)
let targetIdentity = runtimeIdentityAfterSnapshotRestore(snapshot.runtimeIdentity)
let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot)
@@ -2452,7 +2543,20 @@ public final class MachineManager: @unchecked Sendable {
public func exportSnapshot(machineID: String, snapshotID: String, toPath path: String) throws {
operationLock.lock()
defer { operationLock.unlock() }
- let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID)
+ var snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID)
+ if snapshot.installedDesktopPayloadReceipt == nil {
+ snapshot.installedDesktopPayloadReceipt =
+ DoryInstalledDesktopPayloadReceipt.legacyEnvironment(snapshot.environment)
+ }
+ if let portable = snapshot.installedDesktopPayloadReceipt?.portableSnapshotReceipt {
+ snapshot.installedDesktopPayloadReceipt = portable
+ snapshot.environment.removeValue(
+ forKey: DoryInstalledDesktopPayloadReceipt.legacyReleaseVersionEnvironmentKey
+ )
+ snapshot.environment.removeValue(
+ forKey: DoryInstalledDesktopPayloadReceipt.legacyInputSHA256EnvironmentKey
+ )
+ }
do {
try MachineSnapshotBundle.write(snapshot: snapshot, toPath: path)
} catch let error as MachineManagerError {
@@ -2483,6 +2587,8 @@ public final class MachineManager: @unchecked Sendable {
}
try Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount)
try Self.validateSnapshotRuntimeIdentity(snapshot)
+ snapshot.installedDesktopPayloadReceipt =
+ snapshot.installedDesktopPayloadReceipt?.portableSnapshotReceipt
snapshot.address = nil
snapshot.shares = []
snapshot.environment = [:]
@@ -2611,7 +2717,9 @@ public final class MachineManager: @unchecked Sendable {
installerMediaAttached: entry.configuration.installerISOPath != nil,
shares: entry.configuration.shares,
environment: entry.configuration.environment,
- runtimeIdentity: entry.runtimeIdentity
+ runtimeIdentity: entry.runtimeIdentity,
+ installedDesktopPayloadReceipt:
+ entry.configuration.effectiveInstalledDesktopPayloadReceipt
)
}
return DoryMachineStatus(
@@ -2637,7 +2745,9 @@ public final class MachineManager: @unchecked Sendable {
installerMediaAttached: entry.configuration.installerISOPath != nil,
shares: entry.configuration.shares,
environment: entry.configuration.environment,
- runtimeIdentity: entry.runtimeIdentity
+ runtimeIdentity: entry.runtimeIdentity,
+ installedDesktopPayloadReceipt:
+ entry.configuration.effectiveInstalledDesktopPayloadReceipt
)
}
@@ -2832,7 +2942,126 @@ public final class MachineManager: @unchecked Sendable {
"\(machineStateDirectory(id: machineID))/\(Self.desktopUpdateJournalName)"
}
+ private struct StagedDesktopUpdateAuthority {
+ var directory: String
+ var bundlePath: String
+ var kernelPath: String
+ }
+
+ private func stageDesktopUpdateAuthority(
+ _ authority: DoryDesktopUpdateArtifactAuthority,
+ machineID: String
+ ) throws -> StagedDesktopUpdateAuthority {
+ guard let bundleSHA256 = authority.receipt.bundleSHA256,
+ let kernelSHA256 = authority.receipt.kernelSHA256 else {
+ throw MachineManagerError.persistence("desktop update authority lacks artifact digests")
+ }
+ let directory = machineStateDirectory(id: machineID)
+ + "/" + Self.desktopUpdateStagingPrefix + UUID().uuidString.lowercased()
+ guard mkdir(directory, 0o700) == 0 else {
+ throw MachineManagerError.persistence("could not create private desktop update staging")
+ }
+ let bundlePath = directory + "/payload.tar"
+ let kernelPath = directory + "/kernel"
+ do {
+ try Self.copyExactDesktopUpdateArtifact(
+ source: authority.bundlePath,
+ destination: bundlePath,
+ expectedByteCount: authority.bundleByteCount,
+ expectedSHA256: bundleSHA256
+ )
+ try Self.copyExactDesktopUpdateArtifact(
+ source: authority.kernelPath,
+ destination: kernelPath,
+ expectedByteCount: authority.kernelByteCount,
+ expectedSHA256: kernelSHA256
+ )
+ try Self.syncDirectory(path: directory)
+ return StagedDesktopUpdateAuthority(
+ directory: directory,
+ bundlePath: bundlePath,
+ kernelPath: kernelPath
+ )
+ } catch {
+ try? FileManager.default.removeItem(atPath: directory)
+ throw error
+ }
+ }
+
+ private static func copyExactDesktopUpdateArtifact(
+ source: String,
+ destination: String,
+ expectedByteCount: UInt64,
+ expectedSHA256: String
+ ) throws {
+ let sourceFD = open(source, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
+ guard sourceFD >= 0 else {
+ throw MachineManagerError.persistence("could not open verified desktop update artifact")
+ }
+ defer { close(sourceFD) }
+ var before = stat()
+ guard fstat(sourceFD, &before) == 0,
+ (before.st_mode & S_IFMT) == S_IFREG,
+ before.st_size > 0,
+ UInt64(before.st_size) == expectedByteCount else {
+ throw MachineManagerError.persistence("verified desktop update artifact identity is invalid")
+ }
+ let destinationFD = open(
+ destination,
+ O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
+ mode_t(0o400)
+ )
+ guard destinationFD >= 0 else {
+ throw MachineManagerError.persistence("could not create private desktop update artifact")
+ }
+ var copied: UInt64 = 0
+ var hasher = SHA256()
+ var copyError: Error?
+ do {
+ let input = FileHandle(fileDescriptor: sourceFD, closeOnDealloc: false)
+ let output = FileHandle(fileDescriptor: destinationFD, closeOnDealloc: false)
+ while true {
+ let chunk = try input.read(upToCount: 1024 * 1024) ?? Data()
+ if chunk.isEmpty { break }
+ copied = copied.addingReportingOverflow(UInt64(chunk.count)).partialValue
+ guard copied <= expectedByteCount else {
+ throw MachineManagerError.persistence("verified desktop update artifact grew while staging")
+ }
+ hasher.update(data: chunk)
+ try output.write(contentsOf: chunk)
+ }
+ guard fsync(destinationFD) == 0 else {
+ throw MachineManagerError.persistence("could not sync private desktop update artifact")
+ }
+ } catch {
+ copyError = error
+ }
+ close(destinationFD)
+ if let copyError {
+ try? FileManager.default.removeItem(atPath: destination)
+ throw copyError
+ }
+ var after = stat()
+ let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ guard fstat(sourceFD, &after) == 0,
+ before.st_dev == after.st_dev,
+ before.st_ino == after.st_ino,
+ before.st_size == after.st_size,
+ before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec,
+ before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec,
+ before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec,
+ before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec,
+ copied == expectedByteCount,
+ digest == expectedSHA256 else {
+ try? FileManager.default.removeItem(atPath: destination)
+ throw MachineManagerError.persistence("verified desktop update artifact changed while staging")
+ }
+ }
+
private func persistDesktopUpdateJournal(_ journal: DesktopUpdateJournal) throws {
+ guard journal.isValid else {
+ throw MachineManagerError.persistence("invalid desktop update journal")
+ }
let directory = machineStateDirectory(id: journal.machineID)
guard Self.isPrivateDirectory(path: directory) else {
throw MachineManagerError.persistence("desktop update journal owner is not private")
@@ -2842,13 +3071,13 @@ public final class MachineManager: @unchecked Sendable {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(journal)
- try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic)
- try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath)
+ try Self.writeDurablePrivateData(data, toPath: temporaryPath)
guard rename(temporaryPath, desktopUpdateJournalPath(machineID: journal.machineID)) == 0 else {
throw MachineManagerError.persistence(
"could not publish desktop update journal: \(String(cString: strerror(errno)))"
)
}
+ try Self.syncDirectory(path: directory)
} catch {
try? FileManager.default.removeItem(atPath: temporaryPath)
if let error = error as? MachineManagerError { throw error }
@@ -2861,29 +3090,141 @@ public final class MachineManager: @unchecked Sendable {
guard Self.pathEntryExists(path) else { return }
do {
try FileManager.default.removeItem(atPath: path)
+ try Self.syncDirectory(path: machineStateDirectory(id: machineID))
} catch {
throw MachineManagerError.persistence("could not remove desktop update journal: \(error)")
}
}
+ /// Commits the verified installed-state receipt and removes only its two superseded legacy
+ /// fields. The update transaction's last-good snapshot owns rollback of this exact change.
+ private func publishInstalledDesktopPayloadReceipt(
+ original: DoryMachineConfiguration,
+ receipt: DoryInstalledDesktopPayloadReceipt
+ ) throws {
+ guard receipt.isValid, receipt.provenance == .verifiedUpdateBundle else {
+ throw MachineManagerError.persistence(
+ "desktop update produced an invalid installed payload receipt"
+ )
+ }
+ var updated = original
+ updated.shares = original.shares
+ updated.environment.removeValue(
+ forKey: DoryInstalledDesktopPayloadReceipt.legacyReleaseVersionEnvironmentKey
+ )
+ updated.environment.removeValue(
+ forKey: DoryInstalledDesktopPayloadReceipt.legacyInputSHA256EnvironmentKey
+ )
+ updated.installedDesktopPayloadReceipt = receipt
+ try Self.validateLaunchConfiguration(updated)
+ try persist(updated)
+ try publishConfiguration(updated)
+ }
+
private func recoverInterruptedDesktopUpdates() {
lock.lock()
let machineIDs = Array(machines.keys)
lock.unlock()
for machineID in machineIDs {
let path = desktopUpdateJournalPath(machineID: machineID)
+ guard Self.pathEntryExists(path) else {
+ continue
+ }
guard let data = Self.readPrivateMetadata(path: path),
let journal = try? JSONDecoder().decode(DesktopUpdateJournal.self, from: data),
- journal.schema == 1,
journal.machineID == machineID,
- Self.isValidID(journal.snapshotID) else {
+ journal.isValid else {
+ lock.lock()
+ if var entry = machines[machineID] {
+ entry.state = .failed
+ entry.lastError = "Desktop update recovery is required: the durable update journal is invalid."
+ machines[machineID] = entry
+ }
+ lock.unlock()
continue
}
- if journal.stage == "committed" {
- try? removeDesktopUpdateJournal(machineID: machineID)
+ if journal.stage == .committed {
+ // Schema 1 was authored by the pre-receipt updater after its machine.json write
+ // had committed. It carried no component authority to revalidate, and historical
+ // recovery semantics treated this marker as cleanup-only. Preserve that exact
+ // upgrade behavior instead of relabeling a working legacy machine as failed.
+ if journal.schema == 1 {
+ do {
+ try removeDesktopUpdateJournal(machineID: machineID)
+ } catch {
+ lock.lock()
+ machines[machineID]?.lastError = "Legacy desktop update committed, but journal cleanup requires retry: \(error)"
+ lock.unlock()
+ }
+ continue
+ }
+ lock.lock()
+ let receipt = machines[machineID]?.configuration.installedDesktopPayloadReceipt
+ let environment = machines[machineID]?.configuration.environment
+ lock.unlock()
+ var expectedReceipt = journal.updateAuthority
+ expectedReceipt?.inputSHA256 = receipt?.inputSHA256 ?? ""
+ guard let receipt,
+ let environment,
+ receipt.provenance == .verifiedUpdateBundle,
+ receipt.hasCoherentAuthority(environment: environment),
+ receipt == expectedReceipt else {
+ lock.lock()
+ if var entry = machines[machineID] {
+ entry.state = .failed
+ entry.lastError = "Desktop update recovery is required: committed component evidence does not match machine state."
+ machines[machineID] = entry
+ }
+ lock.unlock()
+ continue
+ }
+ do {
+ try removeDesktopUpdateJournal(machineID: machineID)
+ } catch {
+ lock.lock()
+ machines[machineID]?.lastError = "Desktop update committed, but journal cleanup requires retry: \(error)"
+ lock.unlock()
+ }
continue
}
do {
+ if journal.schema == 2 {
+ let snapshot = try loadSnapshot(
+ machineID: machineID,
+ snapshotID: journal.snapshotID
+ )
+ lock.lock()
+ let current = machines[machineID]?.configuration
+ lock.unlock()
+ guard let current,
+ let expectedSourceSHA256 = journal.sourceConfigurationSHA256 else {
+ throw MachineManagerError.persistence(
+ "desktop update journal source authority is unavailable"
+ )
+ }
+ let source = DoryMachineConfiguration(
+ id: current.id,
+ kernelPath: current.kernelPath,
+ rootfsPath: current.rootfsPath,
+ bootMode: snapshot.bootMode,
+ memoryMB: snapshot.memoryMB,
+ cpuCount: snapshot.cpuCount,
+ address: snapshot.address,
+ displayMode: snapshot.displayMode,
+ shares: snapshot.shares,
+ environment: snapshot.environment,
+ installedDesktopPayloadReceipt:
+ Self.configurationReceipt(restoring: snapshot)
+ )
+ let actualSourceSHA256 = Self.sha256(
+ data: try DoryMachineConfigurationMigrationBridge.encodeLegacy(source)
+ )
+ guard actualSourceSHA256 == expectedSourceSHA256 else {
+ throw MachineManagerError.persistence(
+ "desktop update journal does not match its last-good snapshot"
+ )
+ }
+ }
_ = try restoreSnapshot(machineID: machineID, snapshotID: journal.snapshotID)
try removeDesktopUpdateJournal(machineID: machineID)
lock.lock()
@@ -3665,13 +4006,13 @@ public final class MachineManager: @unchecked Sendable {
)
let data = try DoryMachineConfigurationMigrationBridge.encodeLegacy(machine)
let path = machineConfigPath(id: machine.id)
- try data.write(to: URL(fileURLWithPath: temporaryPath), options: .atomic)
- try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporaryPath)
+ try Self.writeDurablePrivateData(data, toPath: temporaryPath)
guard rename(temporaryPath, path) == 0 else {
throw MachineManagerError.persistence(
"could not publish machine metadata: \(String(cString: strerror(errno)))"
)
}
+ try Self.syncDirectory(path: directory)
authoritativeLegacyData = data
} catch let error as MachineManagerError {
try? fileManager.removeItem(atPath: temporaryPath)
@@ -3958,6 +4299,22 @@ public final class MachineManager: @unchecked Sendable {
fileprivate static func validateSnapshotRuntimeIdentity(
_ snapshot: DoryMachineSnapshot
) throws {
+ guard snapshot.installedDesktopPayloadReceipt?.hasCoherentAuthority(
+ environment: snapshot.environment
+ ) ?? true else {
+ throw MachineManagerError.persistence(
+ "invalid installed desktop payload receipt"
+ )
+ }
+ if let receipt = snapshot.installedDesktopPayloadReceipt,
+ receipt.provenance == .verifiedUpdateBundle {
+ guard let artifactEvidence = snapshot.artifactEvidence,
+ receipt.kernelSHA256 == artifactEvidence.kernel.sha256 else {
+ throw MachineManagerError.persistence(
+ "installed desktop kernel receipt does not match snapshot evidence"
+ )
+ }
+ }
guard snapshot.runtimeIdentity.validate().isEmpty,
snapshot.runtimeIdentity.virtualHardwareABIVersion
== DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion else {
@@ -4298,6 +4655,22 @@ public final class MachineManager: @unchecked Sendable {
}
try validateShares(machine.shares)
try validateEnvironment(machine.environment)
+ guard machine.installedDesktopPayloadReceipt?.hasCoherentAuthority(
+ environment: machine.environment
+ ) ?? true else {
+ throw MachineManagerError.persistence(
+ "installed desktop payload receipt is invalid"
+ )
+ }
+ }
+
+ /// A local legacy snapshot retains the original raw receipt fields byte-for-byte. Imported
+ /// snapshots have no environment authority, so their typed receipt becomes authoritative.
+ private static func configurationReceipt(
+ restoring snapshot: DoryMachineSnapshot
+ ) -> DoryInstalledDesktopPayloadReceipt? {
+ guard let receipt = snapshot.installedDesktopPayloadReceipt else { return nil }
+ return receipt.matchesLegacyEnvironment(snapshot.environment) ? nil : receipt
}
private static func validateResources(memoryMB: UInt64, cpuCount: Int) throws {
@@ -4617,6 +4990,52 @@ public final class MachineManager: @unchecked Sendable {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
+ private static func syncDirectory(path: String) throws {
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_DIRECTORY)
+ guard descriptor >= 0 else {
+ throw MachineManagerError.persistence("could not open metadata directory for sync")
+ }
+ defer { close(descriptor) }
+ guard fsync(descriptor) == 0 else {
+ throw MachineManagerError.persistence("could not sync metadata directory")
+ }
+ }
+
+ private static func writeDurablePrivateData(_ data: Data, toPath path: String) throws {
+ let descriptor = open(
+ path,
+ O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
+ mode_t(0o600)
+ )
+ guard descriptor >= 0 else {
+ throw MachineManagerError.persistence("could not create durable private metadata")
+ }
+ var succeeded = false
+ defer {
+ close(descriptor)
+ if !succeeded { _ = unlink(path) }
+ }
+ try data.withUnsafeBytes { raw in
+ guard var address = raw.baseAddress else { return }
+ var remaining = raw.count
+ while remaining > 0 {
+ let written = Darwin.write(descriptor, address, remaining)
+ if written > 0 {
+ remaining -= written
+ address = address.advanced(by: written)
+ } else if written < 0, errno == EINTR {
+ continue
+ } else {
+ throw MachineManagerError.persistence("could not write durable private metadata")
+ }
+ }
+ }
+ guard fsync(descriptor) == 0 else {
+ throw MachineManagerError.persistence("could not sync durable private metadata")
+ }
+ succeeded = true
+ }
+
private static func isPrivateDirectory(info: stat) -> Bool {
(info.st_mode & S_IFMT) == S_IFDIR
&& info.st_uid == getuid()
@@ -4649,6 +5068,9 @@ public final class MachineManager: @unchecked Sendable {
let machine = try? decoder.decode(DoryMachineConfiguration.self, from: data),
machine.id == id,
isValidID(machine.id),
+ machine.installedDesktopPayloadReceipt?.hasCoherentAuthority(
+ environment: machine.environment
+ ) ?? true,
isPrivateDirectory(path: "\(root)/\(id)"),
machine.rootfsPath == rootfsPath,
machine.kernelPath == kernelPath,
@@ -5531,6 +5953,7 @@ public final class MachineManager: @unchecked Sendable {
for entry in entries where entry.hasPrefix(machineMetadataTemporaryPrefix)
|| entry.hasPrefix(machineDiskTemporaryPrefix)
|| entry.hasPrefix(machineKernelTemporaryPrefix)
+ || entry.hasPrefix(desktopUpdateStagingPrefix)
|| (entry.hasPrefix(".") && entry.contains(machineRestoreBackupMarker)) {
try? fileManager.removeItem(atPath: "\(directory)/\(entry)")
}
diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift
index ea7e74c7..d28603f9 100644
--- a/dory-core-swift/Sources/doryd/main.swift
+++ b/dory-core-swift/Sources/doryd/main.swift
@@ -64,6 +64,9 @@ let idleController = IdleController()
let dockerTier = dorydEnvironment.dockerTierConfiguration().map {
DockerTier(configuration: $0, idleController: idleController)
}
+let desktopUpdateArtifactResolver = DoryComponentStoreDesktopUpdateArtifactResolver(
+ store: DoryComponentStore(drive: dataDrive)
+)
let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { configuration -> MachineManager? in
let readiness = DoryDaemonVirtualMachineProductionTrustFactory().resolve(
store: DoryComponentStore(drive: dataDrive),
@@ -74,6 +77,7 @@ let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { co
FileHandle.standardError.write(Data(
"doryd: VM launch policy requireResolvedPlan (production trust inventory ready)\n".utf8
))
+ context.machineManager.installDesktopUpdateArtifactResolver(desktopUpdateArtifactResolver)
return context.machineManager
case let .unavailable(reason):
guard reason.permitsLegacyCompatibilityMigration else {
@@ -91,10 +95,12 @@ let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { co
"doryd: VM launch policy legacyCompatibility "
.appending("(\(reason.code.rawValue): \(reason.message))\n").utf8
))
- return MachineManager(
+ let manager = MachineManager(
configuration: configuration,
launchPolicy: .legacyCompatibility
)
+ manager.installDesktopUpdateArtifactResolver(desktopUpdateArtifactResolver)
+ return manager
}
}
let sandboxTTLReconciler = machineManager.map { manager in
diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift
index 9a034a1e..f065238f 100644
--- a/dory-core-swift/Sources/dorydctl/main.swift
+++ b/dory-core-swift/Sources/dorydctl/main.swift
@@ -1412,23 +1412,27 @@ func runMachineProvision(cursor: inout ArgumentCursor, client: DorydCtlClient) t
}
func runMachineDesktopUpdate(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
- let usage = "usage: dorydctl machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --bundle PATH --kernel PATH"
+ let usage = "usage: dorydctl machine desktop-update NAME --distro debian|ubuntu|kali --version VERSION --distribution-installation ID --runtime-installation ID"
let name = try cursor.take(usage)
let distro = try requiredOption("--distro", cursor: &cursor, usage: usage)
guard ["debian", "ubuntu", "kali"].contains(distro) else {
throw DorydCtlError.usage("--distro must be debian, ubuntu, or kali")
}
let version = try requiredOption("--version", cursor: &cursor, usage: usage)
- let bundlePath = try requiredOption("--bundle", cursor: &cursor, usage: usage)
- let kernelPath = try requiredOption("--kernel", cursor: &cursor, usage: usage)
+ let distributionInstallationName = try requiredOption(
+ "--distribution-installation", cursor: &cursor, usage: usage
+ )
+ let runtimeInstallationName = try requiredOption(
+ "--runtime-installation", cursor: &cursor, usage: usage
+ )
guard cursor.values.isEmpty else {
throw DorydCtlError.usage("unexpected machine desktop-update argument: " + cursor.values[0])
}
let request: NSDictionary = [
"distro": distro,
"version": version,
- "bundlePath": bundlePath,
- "kernelPath": kernelPath,
+ "distributionInstallationName": distributionInstallationName,
+ "runtimeInstallationName": runtimeInstallationName,
]
let result = try client.withTimeout(atLeast: 3_900).statusCommand { proxy, reply in
proxy.machineDesktopUpdate(name, request: request, reply: reply)
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift
new file mode 100644
index 00000000..41f7c9cf
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift
@@ -0,0 +1,273 @@
+@testable import DorydKit
+import CryptoKit
+import DoryOperations
+import Foundation
+import Testing
+
+@Suite("Installed desktop payload receipt")
+struct DoryInstalledDesktopPayloadReceiptTests {
+ private let digest = String(repeating: "a", count: 64)
+
+ @Test("verified receipts round trip with exact bundle evidence")
+ func verifiedRoundTrip() throws {
+ let receipt = DoryInstalledDesktopPayloadReceipt.verifiedUpdate(
+ distributionIdentifier: "ubuntu",
+ releaseVersion: "24.04.3+runtime.4.5.6",
+ inputSHA256: digest,
+ bundleSHA256: String(repeating: "b", count: 64),
+ distributionComponentIdentifier: "desktop-ubuntu",
+ distributionInstallationName: "ubuntu-installation",
+ distributionCatalogSHA256: String(repeating: "c", count: 64),
+ bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar",
+ runtimeComponentIdentifier: "linux-desktop",
+ runtimeInstallationName: "runtime-installation",
+ runtimeCatalogSHA256: String(repeating: "d", count: 64),
+ kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse",
+ kernelSHA256: String(repeating: "e", count: 64)
+ )
+ #expect(receipt.isValid)
+ #expect(
+ try JSONDecoder().decode(
+ DoryInstalledDesktopPayloadReceipt.self,
+ from: JSONEncoder().encode(receipt)
+ ) == receipt
+ )
+ }
+
+ @Test("legacy projection requires a complete valid receipt and never invents bundle evidence")
+ func legacyProjection() {
+ let receipt = DoryInstalledDesktopPayloadReceipt.legacyEnvironment([
+ "DORY_DESKTOP_DISTRO": "debian",
+ "DORY_DESKTOP_RELEASE_VERSION": "13+runtime.7",
+ "DORY_DESKTOP_INPUT_SHA256": digest,
+ "OPAQUE": "preserve",
+ ])
+ #expect(receipt?.provenance == .legacyEnvironment)
+ #expect(receipt?.bundleSHA256 == nil)
+ #expect(receipt?.matchesLegacyEnvironment([
+ "DORY_DESKTOP_DISTRO": "debian",
+ "DORY_DESKTOP_RELEASE_VERSION": "13+runtime.7",
+ "DORY_DESKTOP_INPUT_SHA256": digest,
+ ]) == true)
+ #expect(DoryInstalledDesktopPayloadReceipt.legacyEnvironment([
+ "DORY_DESKTOP_DISTRO": "debian",
+ "DORY_DESKTOP_RELEASE_VERSION": "13+runtime.7",
+ ]) == nil)
+ #expect(DoryInstalledDesktopPayloadReceipt.legacyEnvironment([
+ "DORY_DESKTOP_DISTRO": "debian",
+ "DORY_DESKTOP_RELEASE_VERSION": "13+runtime.7",
+ "DORY_DESKTOP_INPUT_SHA256": String(repeating: "A", count: 64),
+ ]) == nil)
+ }
+
+ @Test("receipt shapes fail closed")
+ func invalidShapes() {
+ let valid = DoryInstalledDesktopPayloadReceipt.verifiedUpdate(
+ distributionIdentifier: "kali",
+ releaseVersion: "rolling+runtime.1",
+ inputSHA256: digest,
+ bundleSHA256: String(repeating: "b", count: 64),
+ distributionComponentIdentifier: "desktop-kali",
+ distributionInstallationName: "kali-installation",
+ distributionCatalogSHA256: String(repeating: "c", count: 64),
+ bundleAssetIdentifier: "dory-desktop-kali-update-arm64.tar",
+ runtimeComponentIdentifier: "linux-desktop",
+ runtimeInstallationName: "runtime-installation",
+ runtimeCatalogSHA256: String(repeating: "d", count: 64),
+ kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse",
+ kernelSHA256: String(repeating: "e", count: 64)
+ )
+ var mutations: [DoryInstalledDesktopPayloadReceipt] = []
+ var value = valid
+ value.schemaVersion = 2
+ mutations.append(value)
+ value = valid
+ value.distributionIdentifier = "windows"
+ mutations.append(value)
+ value = valid
+ value.releaseVersion = "../unsafe"
+ mutations.append(value)
+ value = valid
+ value.inputSHA256 = String(repeating: "A", count: 64)
+ mutations.append(value)
+ value = valid
+ value.bundleSHA256 = nil
+ mutations.append(value)
+ value = valid
+ value.provenance = .legacyEnvironment
+ mutations.append(value)
+ value = valid
+ value.distributionInstallationName = ".."
+ mutations.append(value)
+ for mutation in mutations {
+ #expect(!mutation.isValid)
+ }
+ }
+
+ @Test("typed and legacy authorities cannot conflict")
+ func authorityCoherence() {
+ let verified = DoryInstalledDesktopPayloadReceipt.verifiedUpdate(
+ distributionIdentifier: "ubuntu",
+ releaseVersion: "24.04+runtime.7",
+ inputSHA256: digest,
+ bundleSHA256: String(repeating: "b", count: 64),
+ distributionComponentIdentifier: "desktop-ubuntu",
+ distributionInstallationName: "ubuntu-installation",
+ distributionCatalogSHA256: String(repeating: "c", count: 64),
+ bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar",
+ runtimeComponentIdentifier: "linux-desktop",
+ runtimeInstallationName: "runtime-installation",
+ runtimeCatalogSHA256: String(repeating: "d", count: 64),
+ kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse",
+ kernelSHA256: String(repeating: "e", count: 64)
+ )
+ #expect(verified.hasCoherentAuthority(environment: [:]))
+ #expect(verified.hasCoherentAuthority(environment: ["DORY_DESKTOP_DISTRO": "ubuntu"]))
+ #expect(!verified.hasCoherentAuthority(environment: ["DORY_DESKTOP_DISTRO": "kali"]))
+ #expect(!verified.hasCoherentAuthority(environment: [
+ "DORY_DESKTOP_DISTRO": "ubuntu",
+ "DORY_DESKTOP_RELEASE_VERSION": "24.04+runtime.7",
+ ]))
+
+ let legacyEnvironment = [
+ "DORY_DESKTOP_DISTRO": "ubuntu",
+ "DORY_DESKTOP_RELEASE_VERSION": "24.04+runtime.6",
+ "DORY_DESKTOP_INPUT_SHA256": digest,
+ ]
+ let legacy = DoryInstalledDesktopPayloadReceipt.legacyEnvironment(legacyEnvironment)
+ #expect(legacy?.hasCoherentAuthority(environment: legacyEnvironment) == true)
+ #expect(legacy?.hasCoherentAuthority(environment: [:]) == false)
+ #expect(legacy?.archivedLegacySnapshotReceipt?.hasCoherentAuthority(environment: [:]) == true)
+ }
+
+ @Test("production resolver binds active signed component generations and rejects stale IDs")
+ func productionResolver() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-desktop-update-resolver-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: root) }
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let drive = try DoryDataDrive(home: root.path)
+ try drive.prepare()
+ let store = DoryComponentStore(drive: drive)
+ try store.prepare()
+ let bundleData = Data("signed ubuntu update".utf8)
+ let kernelData = Data("signed desktop kernel".utf8)
+ let digest: (Data) -> String = { data in
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+ let asset: (String, Data) -> DoryComponentAsset = { path, data in
+ DoryComponentAsset(
+ path: path,
+ url: "https://components.invalid/" + path,
+ downloadBytes: UInt64(data.count),
+ installedBytes: UInt64(data.count),
+ sha256: digest(data),
+ installedSHA256: digest(data)
+ )
+ }
+ let runtime = DoryComponentRelease(
+ id: .linuxDesktop,
+ version: "4.5.6",
+ displayName: "Linux Desktop",
+ summary: "runtime",
+ downloadBytes: UInt64(kernelData.count),
+ installedBytes: UInt64(kernelData.count),
+ assets: [asset(DoryInstalledDesktopPayloadReceipt.kernelAssetIdentifier, kernelData)]
+ )
+ let bundleID = DoryInstalledDesktopPayloadReceipt.bundleAssetIdentifier(
+ for: "ubuntu"
+ )
+ let ubuntu = DoryComponentRelease(
+ id: .desktopUbuntu,
+ version: "1.2.3",
+ displayName: "Ubuntu",
+ summary: "desktop",
+ dependencies: [.dockerCore, .linuxDesktop],
+ downloadBytes: UInt64(bundleData.count),
+ installedBytes: UInt64(bundleData.count),
+ assets: [asset(bundleID, bundleData)]
+ )
+ let core = DoryComponentRelease(
+ id: .dockerCore,
+ version: "0.4.0",
+ displayName: "Docker Core",
+ summary: "Docker, Compose, Buildx, networking, and storage",
+ dependencies: [],
+ downloadBytes: 100,
+ installedBytes: 200,
+ assets: []
+ )
+ let catalog = DoryComponentCatalog(
+ releaseVersion: "0.4.0",
+ generatedAt: "2026-08-20T00:00:00Z",
+ minimumAppVersion: "0.4.5",
+ architecture: "arm64",
+ components: [core, runtime, ubuntu]
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ let catalogData = try encoder.encode(catalog) + Data("\n".utf8)
+ let signingKey = Curve25519.Signing.PrivateKey()
+ let publicKey = signingKey.publicKey.rawRepresentation.base64EncodedString()
+ _ = try store.cacheCatalog(
+ data: catalogData,
+ signature: try signingKey.signature(for: catalogData).base64EncodedString(),
+ publicKey: publicKey,
+ expectedArchitecture: "arm64",
+ appVersion: "0.4.5"
+ )
+ let sourceDirectory = root.appendingPathComponent("sources")
+ try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true)
+ let kernelSource = sourceDirectory.appendingPathComponent("kernel")
+ let bundleSource = sourceDirectory.appendingPathComponent("bundle")
+ try kernelData.write(to: kernelSource)
+ try bundleData.write(to: bundleSource)
+ let catalogSHA256 = DoryComponentCatalogVerifier.digest(catalogData)
+ let runtimeInstalled = try store.install(
+ runtime,
+ catalogDigest: catalogSHA256,
+ downloadedAssets: [runtime.assets[0].path: kernelSource.path]
+ )
+ let ubuntuInstalled = try store.install(
+ ubuntu,
+ catalogDigest: catalogSHA256,
+ downloadedAssets: [ubuntu.assets[0].path: bundleSource.path]
+ )
+ let resolver = DoryComponentStoreDesktopUpdateArtifactResolver(
+ store: store,
+ publicKey: publicKey,
+ expectedArchitecture: "arm64",
+ appVersion: "0.4.5"
+ )
+ let productionResolver = DoryComponentStoreDesktopUpdateArtifactResolver(store: store)
+ #expect(
+ productionResolver.appVersion
+ == DoryDaemonVirtualMachineProductionTrustFactory.compiledDaemonVersion
+ )
+ try DoryComponentCatalogVerifier.validate(
+ catalog,
+ expectedArchitecture: productionResolver.expectedArchitecture,
+ appVersion: productionResolver.appVersion
+ )
+ let request = DoryDesktopUpdateRequest(
+ distro: "ubuntu",
+ version: "1.2.3+runtime.4.5.6",
+ distributionInstallationName: ubuntuInstalled.installationName,
+ runtimeInstallationName: runtimeInstalled.installationName
+ )
+ let authority = try resolver.resolve(request, guestArchitecture: "arm64")
+ #expect(authority.receipt.distributionCatalogSHA256 == catalogSHA256)
+ #expect(authority.receipt.bundleSHA256 == digest(bundleData))
+ #expect(authority.receipt.kernelSHA256 == digest(kernelData))
+
+ var stale = request
+ stale.runtimeInstallationName = "4.5.6-stale"
+ #expect(throws: (any Error).self) {
+ _ = try resolver.resolve(stale, guestArchitecture: "arm64")
+ }
+ try Data("tampered".utf8).write(to: URL(fileURLWithPath: authority.bundlePath))
+ #expect(throws: (any Error).self) {
+ _ = try resolver.resolve(request, guestArchitecture: "arm64")
+ }
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
index 569b4c6a..358292fa 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
@@ -34,7 +34,22 @@ struct DoryMachineConfigurationMigrationTests {
"DORY_GUEST_UID": "1000",
"DORY_CLIPBOARD_POLICY": "host-to-guest",
"PRIVATE_RUNTIME_VALUE": "do-not-copy-into-workspace",
- ]
+ ],
+ installedDesktopPayloadReceipt: .verifiedUpdate(
+ distributionIdentifier: "ubuntu",
+ releaseVersion: "24.04+runtime.7",
+ inputSHA256: String(repeating: "a", count: 64),
+ bundleSHA256: String(repeating: "b", count: 64),
+ distributionComponentIdentifier: "desktop-ubuntu",
+ distributionInstallationName: "ubuntu-installation",
+ distributionCatalogSHA256: String(repeating: "c", count: 64),
+ bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar",
+ runtimeComponentIdentifier: "linux-desktop",
+ runtimeInstallationName: "runtime-installation",
+ runtimeCatalogSHA256: String(repeating: "d", count: 64),
+ kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse",
+ kernelSHA256: String(repeating: "e", count: 64)
+ )
)
let migrated = try migrate(legacy, capacity: 96 * gibibyte)
@@ -75,6 +90,8 @@ struct DoryMachineConfigurationMigrationTests {
#expect(!definitionJSON.contains("do-not-copy-into-workspace"))
#expect(!definitionJSON.contains("DORY_GUEST_USER"))
#expect(!definitionJSON.contains("DORY_CLIPBOARD_POLICY"))
+ #expect(!definitionJSON.contains("24.04+runtime.7"))
+ #expect(!definitionJSON.contains(String(repeating: "b", count: 64)))
#expect(try migrated.legacyConfiguration() == legacy)
#expect(try migrated.authoritativeLegacyData()
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index ed488636..d8fb79ea 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -1,5 +1,6 @@
import DoryCore
@testable import DorydKit
+import CryptoKit
import XCTest
final class DorydServiceTests: XCTestCase {
@@ -1197,6 +1198,125 @@ final class DorydServiceTests: XCTestCase {
wait(for: [delete], timeout: 5)
}
+ func testMachineStatusAndSnapshotExposeOnlySafeTypedDesktopPayloadReceipt() throws {
+ let base = "/tmp/doryd-service-desktop-receipt-\(getpid())-\(UInt32.random(in: 0.. DoryDesktopUpdateArtifactAuthority {
+ let bundleData = try Data(contentsOf: URL(fileURLWithPath: bundlePath))
+ let kernelData = try Data(contentsOf: URL(fileURLWithPath: kernelPath))
+ let digest: (Data) -> String = { data in
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+ return DoryDesktopUpdateArtifactAuthority(
+ receipt: .verifiedUpdate(
+ distributionIdentifier: request.distro,
+ releaseVersion: request.version,
+ inputSHA256: String(repeating: "0", count: 64),
+ bundleSHA256: digest(bundleData),
+ distributionComponentIdentifier: "desktop-" + request.distro,
+ distributionInstallationName: request.distributionInstallationName,
+ distributionCatalogSHA256: String(repeating: "b", count: 64),
+ bundleAssetIdentifier: "dory-desktop-" + request.distro + "-update-arm64.tar",
+ runtimeComponentIdentifier: "linux-desktop",
+ runtimeInstallationName: request.runtimeInstallationName,
+ runtimeCatalogSHA256: String(repeating: "c", count: 64),
+ kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse",
+ kernelSHA256: digest(kernelData)
+ ),
+ bundlePath: bundlePath,
+ bundleByteCount: UInt64(bundleData.count),
+ kernelPath: kernelPath,
+ kernelByteCount: UInt64(kernelData.count)
+ )
+ }
+}
+
+private func testVerifiedDesktopReceipt() -> DoryInstalledDesktopPayloadReceipt {
+ .verifiedUpdate(
+ distributionIdentifier: "ubuntu",
+ releaseVersion: "24.04+runtime.7",
+ inputSHA256: String(repeating: "a", count: 64),
+ bundleSHA256: String(repeating: "b", count: 64),
+ distributionComponentIdentifier: "desktop-ubuntu",
+ distributionInstallationName: "ubuntu-installation",
+ distributionCatalogSHA256: String(repeating: "c", count: 64),
+ bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar",
+ runtimeComponentIdentifier: "linux-desktop",
+ runtimeInstallationName: "runtime-installation",
+ runtimeCatalogSHA256: String(repeating: "d", count: 64),
+ kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse",
+ kernelSHA256: String(repeating: "e", count: 64)
+ )
+}
+
+private struct RejectingDesktopUpdateArtifactResolver: DoryDesktopUpdateArtifactResolving {
+ func resolve(
+ _ request: DoryDesktopUpdateRequest,
+ guestArchitecture: String
+ ) throws -> DoryDesktopUpdateArtifactAuthority {
+ throw MachineManagerError.persistence("resolver reached")
+ }
+}
+
private final class LockedResult: @unchecked Sendable {
private let lock = NSLock()
private var storage: Result?
From 04a36f728d50ba990558a18c9fa7af56ccf756ad Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 17:43:38 +0000
Subject: [PATCH 035/338] feat(vm): fence planning mutations
---
.../DoryOperationJournalPublication.swift | 17 +-
.../DoryWorkspaceLifecycleOperation.swift | 41 ++
...achinePlanningTransactionCoordinator.swift | 285 +++++++++++-
.../DorydKit/DoryWorkspaceRepository.swift | 10 +
.../Sources/DorydKit/MachineManager.swift | 432 +++++++++++++++++-
...ePlanningTransactionCoordinatorTests.swift | 252 +++++++++-
...anagerPlanningMutationAuthorityTests.swift | 325 +++++++++++++
7 files changed, 1322 insertions(+), 40 deletions(-)
create mode 100644 dory-core-swift/Tests/DorydKitTests/MachineManagerPlanningMutationAuthorityTests.swift
diff --git a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
index 9d8fbdd9..462e8819 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryOperationJournalPublication.swift
@@ -8,13 +8,26 @@ extension DoryOperationJournalStore {
specifications: [DoryOperationSpecification],
at date: Date,
fileManager: FileManager,
- mutationScope: String? = nil
+ mutationScope: String? = nil,
+ heldMutationLock: EngineStateDirectoryLock? = nil
) throws -> DoryOperationLease {
guard plan.isValid else {
throw DoryOperationJournalError.invalidPlan(plan.id.uuidString.lowercased())
}
try prepareRoot(fileManager: fileManager)
- let lock = try acquireMutationLock(scope: mutationScope)
+ let lock: EngineStateDirectoryLock
+ if let heldMutationLock {
+ let expectedLockName = mutationScope.map { ".mutation.\($0).lock" }
+ ?? ".mutation.lock"
+ guard heldMutationLock.path == root + "/" + expectedLockName else {
+ throw DoryOperationJournalError.invalidPlan(
+ "held mutation lock does not match authenticated scope"
+ )
+ }
+ lock = heldMutationLock
+ } else {
+ lock = try acquireMutationLock(scope: mutationScope)
+ }
let destination = operationDirectory(for: plan.id)
guard !Self.pathEntryExists(destination) else {
throw DoryOperationJournalError.operationExists(plan.id)
diff --git a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
index d59d4f76..4d24f1a6 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryWorkspaceLifecycleOperation.swift
@@ -928,6 +928,47 @@ extension DoryOperationJournalStore {
mutationScope: mutationScope
)
}
+
+ /// Publishes a lifecycle journal while retaining an already-acquired, exact workspace
+ /// mutation fence. The lock path must match the scope authenticated by the immutable plan;
+ /// callers cannot use this overload to redirect an operation to another workspace lock.
+ public func begin(
+ _ binding: DoryWorkspaceLifecycleJournalBinding,
+ holdingMutationLock: EngineStateDirectoryLock,
+ fileManager: FileManager = .default
+ ) throws -> DoryOperationLease {
+ let operation: DoryWorkspaceLifecycleOperation
+ do {
+ operation = try JSONDecoder().decode(
+ DoryWorkspaceLifecycleOperation.self,
+ from: binding.specification.data
+ )
+ } catch {
+ throw DoryOperationJournalError.invalidPlan(
+ "workspace lifecycle specification decoding"
+ )
+ }
+ let expected = try operation.journalBinding(
+ dependencyClosureDigest: binding.plan.dependencyClosureDigest
+ )
+ guard expected == binding else {
+ throw DoryOperationJournalError.invalidPlan(
+ "workspace lifecycle journal binding mismatch"
+ )
+ }
+ let mutationScope = authenticatedMutationScope(for: binding.plan)
+ return try begin(
+ binding.plan,
+ completenessPlanData: nil,
+ specifications: [binding.specification],
+ at: Date(
+ timeIntervalSince1970: Double(operation.createdAtUnixMilliseconds) / 1_000
+ ),
+ fileManager: fileManager,
+ mutationScope: mutationScope,
+ heldMutationLock: holdingMutationLock
+ )
+ }
}
extension DoryOperationLease {
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
index 01f6dea1..928db557 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
@@ -86,28 +86,119 @@ public protocol DoryDaemonVirtualMachinePlanningTrustPreparing: Sendable {
) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation
}
+/// Content-only authority captured while the MachineManager owns the workspace mutation fence.
+/// Raw legacy bytes, host paths, and migration inputs stay daemon-local; their exact digests bind
+/// the planning request and durable transaction journal without persisting secrets.
+public struct DoryDaemonVirtualMachinePlanningMachineAuthority:
+ Codable, Sendable, Equatable
+{
+ public static let schemaVersion: UInt16 = 1
+
+ public var schemaVersion: UInt16
+ public var machineID: String
+ public var legacyConfigurationSHA256: String
+ public var migrationFactsSHA256: String
+ public var sourceDefinitionRevision: UInt64
+ public var sourceDefinitionSHA256: String
+ public var runtimeIdentitySHA256: String
+
+ public init(
+ machineID: String,
+ legacyConfigurationSHA256: String,
+ migrationFactsSHA256: String,
+ sourceDefinitionRevision: UInt64,
+ sourceDefinitionSHA256: String,
+ runtimeIdentitySHA256: String
+ ) {
+ schemaVersion = Self.schemaVersion
+ self.machineID = machineID
+ self.legacyConfigurationSHA256 = legacyConfigurationSHA256.lowercased()
+ self.migrationFactsSHA256 = migrationFactsSHA256.lowercased()
+ self.sourceDefinitionRevision = sourceDefinitionRevision
+ self.sourceDefinitionSHA256 = sourceDefinitionSHA256.lowercased()
+ self.runtimeIdentitySHA256 = runtimeIdentitySHA256.lowercased()
+ }
+
+ public var isValid: Bool {
+ schemaVersion == Self.schemaVersion
+ && machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil
+ && !machineID.hasPrefix(".")
+ && sourceDefinitionRevision > 0
+ && Self.isSHA256(legacyConfigurationSHA256)
+ && Self.isSHA256(migrationFactsSHA256)
+ && Self.isSHA256(sourceDefinitionSHA256)
+ && Self.isSHA256(runtimeIdentitySHA256)
+ }
+
+ public var authoritySHA256: String {
+ guard isValid else { return "" }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ guard let data = try? encoder.encode(self) else { return "" }
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy {
+ (48...57).contains($0) || (97...102).contains($0)
+ }
+ }
+}
+
/// Held for the whole transaction. A MachineManager-backed implementation owns the lifecycle
/// operation fence and validates authoritative machine bytes/migration facts against `definition`.
/// The coordinator has no unsafe default: production composition must provide this authority.
public final class DoryDaemonVirtualMachinePlanningMutationFence: @unchecked Sendable {
- public let authoritySHA256: String
+ public let authority: DoryDaemonVirtualMachinePlanningMachineAuthority
+ public var authoritySHA256: String { authority.authoritySHA256 }
+
+ private let stateLock = NSLock()
+ private var finalized = false
private let validation: @Sendable () throws -> Void
+ private let completion: @Sendable () throws -> Void
+ private let recoveryRelease: @Sendable () -> Void
private let retainedAuthority: any Sendable
public init(
- authoritySHA256: String,
+ authority: DoryDaemonVirtualMachinePlanningMachineAuthority,
retainedAuthority: any Sendable,
- validation: @escaping @Sendable () throws -> Void
+ validation: @escaping @Sendable () throws -> Void,
+ completion: @escaping @Sendable () throws -> Void = {},
+ recoveryRelease: @escaping @Sendable () -> Void = {}
) {
- self.authoritySHA256 = authoritySHA256.lowercased()
+ self.authority = authority
self.retainedAuthority = retainedAuthority
self.validation = validation
+ self.completion = completion
+ self.recoveryRelease = recoveryRelease
}
public func revalidate() throws {
_ = retainedAuthority
try validation()
}
+
+ /// Marks both planning publication and its retained MachineManager lifecycle authority
+ /// complete. If this throws, the durable nonterminal lifecycle record remains recoverable.
+ public func complete() throws {
+ try stateLock.withLock {
+ guard !finalized else { return }
+ try completion()
+ finalized = true
+ }
+ }
+
+ /// Releases only the live lock. Durable state is intentionally left nonterminal so daemon
+ /// restart can reacquire the exact transaction rather than fabricating completion.
+ public func releaseForRecovery() {
+ stateLock.withLock {
+ guard !finalized else { return }
+ finalized = true
+ recoveryRelease()
+ }
+ }
+
+ deinit { releaseForRecovery() }
}
public protocol DoryDaemonVirtualMachinePlanningMutationAuthorizing: Sendable {
@@ -173,6 +264,7 @@ public protocol DoryWorkspaceDefinitionStoring: Sendable {
func replace(_ definition: DoryVirtualMachineDefinition, expectedRevision: UInt64) throws
func read(id: String) throws -> DoryVirtualMachineDefinition
func readIfPresent(id: String) throws -> DoryVirtualMachineDefinition?
+ func readPersistedRecordIfPresent(id: String) throws -> DoryWorkspaceRepositoryRecord?
}
extension DoryWorkspaceRepository: DoryWorkspaceDefinitionStoring {
@@ -180,6 +272,13 @@ extension DoryWorkspaceRepository: DoryWorkspaceDefinitionStoring {
do { return try read(id: id) }
catch DoryWorkspaceRepositoryError.workspaceNotFound { return nil }
}
+
+ public func readPersistedRecordIfPresent(
+ id: String
+ ) throws -> DoryWorkspaceRepositoryRecord? {
+ do { return try readPersistedRecord(id: id) }
+ catch DoryWorkspaceRepositoryError.workspaceNotFound { return nil }
+ }
}
public protocol DoryPlanningTransactionResolvedPlanStoring:
@@ -404,7 +503,14 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
"Authoritative machine state is live, stale, or inconsistent with the workspace."
)
}
- guard Self.isSHA256(mutationFence.authoritySHA256) else {
+ defer { mutationFence.releaseForRecovery() }
+ guard mutationFence.authority.isValid,
+ mutationFence.authority.machineID == request.planning.definition.identity.id,
+ mutationFence.authority.sourceDefinitionRevision
+ == request.planning.definition.lifecycle.revision,
+ mutationFence.authority.sourceDefinitionSHA256
+ == Self.sha256(canonicalDefinition),
+ Self.isSHA256(mutationFence.authoritySHA256) else {
throw failure(.mutationAuthorityRejected, "Machine authority digest is invalid.")
}
let requestDigest = Self.requestDigest(
@@ -416,15 +522,28 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
request,
requestDigest: requestDigest,
machineAuthoritySHA256: mutationFence.authoritySHA256,
- canonicalDefinition: canonicalDefinition
+ canonicalDefinition: canonicalDefinition,
+ mutationFence: mutationFence
)
switch journal.phase {
case .aborted:
throw failure(.transactionAborted, "The exact planning transaction was aborted.")
case .recoveryRequired:
- try resumeRecoveryRequired(&journal)
+ try resumeRecoveryRequired(&journal, mutationFence: mutationFence)
case .complete:
- return try completedResult(request, journal: journal)
+ let result = try completedResult(
+ request,
+ journal: journal,
+ mutationFence: mutationFence
+ )
+ do { try mutationFence.complete() }
+ catch {
+ throw failure(
+ .recoveryRequired,
+ "Planning completed, but its workspace mutation journal requires recovery."
+ )
+ }
+ return result
default: break
}
@@ -491,23 +610,40 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
}
try faultInjector?(.publicationAuthorized)
- try publishWorkspace(request, journal: &journal)
+ try publishWorkspace(request, journal: &journal, mutationFence: mutationFence)
+ // Workspace publication is not the final machine-authority boundary. Re-prove the
+ // exact legacy bytes, migration facts, projection, and runtime identity immediately
+ // before publishing the independently durable plan record too.
+ try revalidateMutationFence(
+ mutationFence,
+ journal: &journal,
+ message: "Machine authority changed before resolved-plan publication."
+ )
try publishPlan(request, planning: planning, journal: &journal)
journal.phase = .complete
try publishJournal(journal)
try faultInjector?(.completeJournalPublished)
- return DoryDaemonVirtualMachinePlanningTransactionResult(
+ let result = DoryDaemonVirtualMachinePlanningTransactionResult(
planning: planning,
lease: lease,
transactionID: journal.transactionID
)
+ do { try mutationFence.complete() }
+ catch {
+ throw failure(
+ .recoveryRequired,
+ "Planning completed, but its workspace mutation journal requires recovery."
+ )
+ }
+ return result
}
private func loadOrPrepare(
_ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
requestDigest: String,
machineAuthoritySHA256: String,
- canonicalDefinition: Data
+ canonicalDefinition: Data,
+ mutationFence: DoryDaemonVirtualMachinePlanningMutationFence
) throws -> Journal {
if let existing = try readJournal(machineID: request.planning.definition.identity.id) {
if existing.requestSHA256 == requestDigest { return existing }
@@ -518,9 +654,27 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
let timestamp = now()
guard timestamp > 0 else { throw failure(.invalidRequest, "Planning time is invalid.") }
let sourcePlan: DoryResolvedMachinePlan?
- let existingDefinition = try workspaces.readIfPresent(
+ let existingRecord = try workspaces.readPersistedRecordIfPresent(
id: request.planning.definition.identity.id
)
+ let existingDefinition = existingRecord?.definition
+ if request.workspacePublication == .retainExistingExact {
+ guard mutationFence.authority.sourceDefinitionRevision
+ == request.planning.definition.lifecycle.revision,
+ mutationFence.authority.sourceDefinitionSHA256
+ == Self.sha256(canonicalDefinition),
+ let existingRecord,
+ retainedRecordMatchesMutationAuthority(
+ existingRecord,
+ target: request.planning.definition,
+ mutationFence: mutationFence
+ ) else {
+ throw failure(
+ .mutationAuthorityRejected,
+ "Retained workspace source authority is not exact."
+ )
+ }
+ }
sourcePlan = try plans.readIfPresent(id: request.planning.definition.identity.id)
switch request.workspacePublication {
case .create:
@@ -765,10 +919,27 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
private func publishWorkspace(
_ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
- journal: inout Journal
+ journal: inout Journal,
+ mutationFence: DoryDaemonVirtualMachinePlanningMutationFence
) throws {
let target = request.planning.definition
- let current = try workspaces.readIfPresent(id: target.identity.id)
+ try revalidateMutationFence(
+ mutationFence,
+ journal: &journal,
+ message: "Machine authority changed before workspace publication."
+ )
+ let currentRecord = try workspaces.readPersistedRecordIfPresent(id: target.identity.id)
+ let current = currentRecord?.definition
+ if request.workspacePublication == .retainExistingExact {
+ guard let currentRecord,
+ retainedRecordMatchesMutationAuthority(
+ currentRecord,
+ target: target,
+ mutationFence: mutationFence
+ ) else {
+ throw failure(.transactionConflict, "Retained workspace differs from target.")
+ }
+ }
if current != target {
do {
switch request.workspacePublication {
@@ -788,6 +959,19 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
} catch let error as DoryDaemonVirtualMachinePlanningTransactionFailure { throw error }
catch { throw failure(.workspacePublicationRejected, "Workspace publication failed.") }
}
+ guard let published = try workspaces.readPersistedRecordIfPresent(id: target.identity.id),
+ published.definition == target,
+ request.workspacePublication != .retainExistingExact
+ || retainedRecordMatchesMutationAuthority(
+ published,
+ target: target,
+ mutationFence: mutationFence
+ ) else {
+ throw failure(
+ .workspacePublicationRejected,
+ "Workspace publication did not persist the exact target."
+ )
+ }
try faultInjector?(.workspacePublished)
journal.phase = .workspacePublished
try publishJournal(journal)
@@ -825,12 +1009,28 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
private func completedResult(
_ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
- journal: Journal
+ journal: Journal,
+ mutationFence: DoryDaemonVirtualMachinePlanningMutationFence
) throws -> DoryDaemonVirtualMachinePlanningTransactionResult {
let planning = try planningResultFromCandidate(request, journal: journal)
let plan = planning.resolvedPlan
- guard
- let currentDefinition = try workspaces.readIfPresent(id: plan.machineID),
+ do { try mutationFence.revalidate() }
+ catch {
+ throw failure(.recoveryRequired, "Completed machine authority no longer matches.")
+ }
+ let currentRecord = try workspaces.readPersistedRecordIfPresent(id: plan.machineID)
+ let currentDefinition = currentRecord?.definition
+ if request.workspacePublication == .retainExistingExact {
+ guard let currentRecord,
+ retainedRecordMatchesMutationAuthority(
+ currentRecord,
+ target: journal.definition,
+ mutationFence: mutationFence
+ ) else {
+ throw failure(.recoveryRequired, "Completed retained workspace is not exact.")
+ }
+ }
+ guard let currentDefinition,
currentDefinition == journal.definition,
let currentPlan = try plans.readIfPresent(id: plan.machineID),
currentPlan == plan,
@@ -850,7 +1050,10 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
/// explicit daemon recovery operation: it reacquires the mutation fence in `execute`, proves
/// the same exact bound lease/candidate and source-or-target publication authorities here,
/// then obtains a newly collected, single-use publication authorization before resuming.
- private func resumeRecoveryRequired(_ journal: inout Journal) throws {
+ private func resumeRecoveryRequired(
+ _ journal: inout Journal,
+ mutationFence: DoryDaemonVirtualMachinePlanningMutationFence
+ ) throws {
guard let leaseID = journal.leaseID,
let candidateDigest = journal.candidatePlanSHA256,
let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }),
@@ -860,9 +1063,24 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
throw failure(.recoveryRequired, "The retained bound lease cannot be recovered exactly.")
}
- let currentDefinition = try workspaces.readIfPresent(
+ do { try mutationFence.revalidate() }
+ catch {
+ throw failure(.recoveryRequired, "Machine authority changed during bound recovery.")
+ }
+ let currentRecord = try workspaces.readPersistedRecordIfPresent(
id: journal.definition.identity.id
)
+ let currentDefinition = currentRecord?.definition
+ if journal.workspacePublication == .retainExistingExact {
+ guard let currentRecord,
+ retainedRecordMatchesMutationAuthority(
+ currentRecord,
+ target: journal.definition,
+ mutationFence: mutationFence
+ ) else {
+ throw failure(.recoveryRequired, "Retained workspace is missing or substituted.")
+ }
+ }
let definitionIsTarget = currentDefinition == journal.definition
let definitionIsSource: Bool
switch journal.workspacePublication {
@@ -904,6 +1122,35 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
try publishJournal(journal)
}
+ private func retainedRecordMatchesMutationAuthority(
+ _ record: DoryWorkspaceRepositoryRecord,
+ target: DoryVirtualMachineDefinition,
+ mutationFence: DoryDaemonVirtualMachinePlanningMutationFence
+ ) -> Bool {
+ guard record.definition == target else { return false }
+ switch (record.legacyConfigurationSHA256, record.legacyMigrationFactsSHA256) {
+ case (nil, nil):
+ return true
+ case let (legacyDigest?, factsDigest?):
+ return legacyDigest == mutationFence.authority.legacyConfigurationSHA256
+ && factsDigest == mutationFence.authority.migrationFactsSHA256
+ default:
+ return false
+ }
+ }
+
+ private func revalidateMutationFence(
+ _ mutationFence: DoryDaemonVirtualMachinePlanningMutationFence,
+ journal: inout Journal,
+ message: String
+ ) throws {
+ do { try mutationFence.revalidate() }
+ catch {
+ try? markRecoveryRequired(&journal)
+ throw failure(.mutationAuthorityRejected, message)
+ }
+ }
+
private func abortUnboundIfPresent(_ journal: inout Journal) throws {
if let leaseID = journal.leaseID,
let lease = try ledger.snapshot().leases.first(where: { $0.leaseID == leaseID }),
diff --git a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
index 4bdb1307..d44ba858 100644
--- a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
@@ -373,6 +373,16 @@ public final class DoryWorkspaceRepository: @unchecked Sendable {
return record.definition
}
+ /// Reads the validated durable repository envelope without promoting a legacy projection to
+ /// native desired-state authority. Transaction coordinators use this only to prove that the
+ /// repository still contains the exact record selected while the separate legacy mutation
+ /// authority remains held.
+ public func readPersistedRecord(id: String) throws -> DoryWorkspaceRepositoryRecord {
+ lock.lock()
+ defer { lock.unlock() }
+ return try readRecordUnlocked(id: id)
+ }
+
public func readLegacyProjection(
id: String,
authoritativeLegacyData: Data
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 7d643066..9398d066 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -747,6 +747,9 @@ public final class MachineManager: @unchecked Sendable {
private var pendingResolvedStart: PendingResolvedMachineStart?
private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:]
private var activeLifecycleOperations: [String: MachineLifecycleJournalContext] = [:]
+ private var activePlanningMutationIDs: Set = []
+ private var activeDirectWorkspaceMutationLocks:
+ [String: MachineManagerDirectMutationRetention] = [:]
private var desktopUpdateArtifactResolver: (any DoryDesktopUpdateArtifactResolving)?
#if DEBUG
private var lifecycleFaultInjector: (@Sendable (MachineLifecycleFaultPoint) throws -> Void)?
@@ -986,6 +989,9 @@ public final class MachineManager: @unchecked Sendable {
public func start(id: String) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: id)
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
return try startImplementation(id: id, journalLifecycle: true)
}
@@ -1628,7 +1634,10 @@ public final class MachineManager: @unchecked Sendable {
public func stop(id: String) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: id)
try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-stop")
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
return try stopImplementation(id: id, journalLifecycle: true)
}
@@ -1721,7 +1730,10 @@ public final class MachineManager: @unchecked Sendable {
guard Self.isValidID(id) else {
throw MachineManagerError.invalidID(id)
}
+ try requireNoActivePlanningMutation(id: id)
try cancelActiveStartLifecycleIfNeeded(id: id, reason: "start.cancelled-by-delete")
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
lock.lock()
guard let sourceEntry = machines[id] else {
lock.unlock()
@@ -1830,6 +1842,9 @@ public final class MachineManager: @unchecked Sendable {
) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: id)
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
guard !(updatesEnvironment && typedSettingsPatch != nil) else {
throw MachineManagerError.persistence(
"raw environment replacement and typed machine settings are mutually exclusive"
@@ -1892,7 +1907,7 @@ public final class MachineManager: @unchecked Sendable {
return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
}
if wasRunning {
- _ = try stop(id: id)
+ _ = try stopImplementation(id: id, journalLifecycle: true)
}
do {
if current.bootMode == .efi,
@@ -1905,7 +1920,7 @@ public final class MachineManager: @unchecked Sendable {
} catch {
if wasRunning {
do {
- _ = try startAndWaitUntilReady(id: id)
+ _ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch let restartError {
throw MachineManagerError.persistence(
"could not update \(id): \(error); original configuration restart failed: \(restartError)"
@@ -1919,10 +1934,10 @@ public final class MachineManager: @unchecked Sendable {
return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
}
do {
- return try startAndWaitUntilReady(id: id)
+ return try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch {
let updateError = error
- _ = try? stop(id: id)
+ _ = try? stopImplementation(id: id, journalLifecycle: true)
do {
try persist(current)
try publishConfiguration(current)
@@ -1932,7 +1947,7 @@ public final class MachineManager: @unchecked Sendable {
)
}
do {
- _ = try startAndWaitUntilReady(id: id)
+ _ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch {
throw MachineManagerError.persistence(
"could not start updated \(id): \(updateError); original configuration was restored but restart failed: \(error)"
@@ -1952,6 +1967,9 @@ public final class MachineManager: @unchecked Sendable {
) throws -> DoryMachineSnapshot {
operationLock.lock()
defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: id)
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
let snapshotID = explicitSnapshotID ?? Self.generatedSnapshotID()
guard Self.isValidID(snapshotID) else {
throw MachineManagerError.invalidID(snapshotID)
@@ -2117,6 +2135,9 @@ public final class MachineManager: @unchecked Sendable {
) throws -> DoryDesktopUpdateResult {
operationLock.lock()
defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: id)
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
let (original, originallyRunning) = try configurationAndRunningState(id: id)
guard original.bootMode == .linuxKernel else {
@@ -2407,6 +2428,14 @@ public final class MachineManager: @unchecked Sendable {
public func restoreSnapshot(machineID: String, snapshotID: String) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: machineID)
+ let directMutation = try retainDirectWorkspaceMutationLock(id: machineID)
+ defer {
+ releaseDirectWorkspaceMutationLock(
+ id: machineID,
+ retention: directMutation
+ )
+ }
let snapshot = try loadSnapshot(machineID: machineID, snapshotID: snapshotID)
let (machine, wasRunning) = try configurationAndRunningState(id: machineID)
try validateSnapshotRuntimeCompatibility(snapshot, machine: machine, cloning: false)
@@ -5117,6 +5146,317 @@ public final class MachineManager: @unchecked Sendable {
return identity
}
+ /// Acquires the same per-workspace cross-process mutation lock used by lifecycle operations,
+ /// then derives planning authority only from the exact persisted legacy bytes and migration
+ /// facts observed under that lock. This path never starts a helper or mutates guest state.
+ public func acquirePlanningMutationFence(
+ machine: DoryMachineConfiguration,
+ definition: DoryVirtualMachineDefinition,
+ canonicalDefinitionData: Data
+ ) throws -> DoryDaemonVirtualMachinePlanningMutationFence {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+
+ guard launchPolicy == .requireResolvedPlan else {
+ throw MachineManagerError.persistence(
+ "planning promotion requires the resolved-plan launch policy"
+ )
+ }
+ guard Self.isValidID(machine.id), machine.id == definition.identity.id,
+ canonicalDefinitionData == (try Self.canonicalDefinitionData(definition)) else {
+ throw MachineManagerError.persistence("planning request identity is inconsistent")
+ }
+ guard activePlanningMutationIDs.insert(machine.id).inserted else {
+ throw MachineManagerError.persistence(
+ "machine \(machine.id) already has an active planning mutation"
+ )
+ }
+ var shouldRemoveActiveID = true
+ defer {
+ if shouldRemoveActiveID { activePlanningMutationIDs.remove(machine.id) }
+ }
+ guard activeLifecycleOperations[machine.id] == nil else {
+ throw MachineManagerError.persistence(
+ "machine \(machine.id) already has an active lifecycle mutation"
+ )
+ }
+ guard let store = lifecycleJournalStore else {
+ throw MachineManagerError.persistence(
+ "lifecycle journal is unavailable: "
+ + (lifecycleJournalInitializationError ?? "unknown error")
+ )
+ }
+
+ let workspaceLock: EngineStateDirectoryLock
+ do {
+ workspaceLock = try EngineStateDirectoryLock(
+ stateDirectory: store.root,
+ lockFileName: ".mutation.\(machine.id).lock"
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "machine \(machine.id) mutation authority is busy: \(error)"
+ )
+ }
+ if let unfinished = try store.list().first(where: {
+ $0.state.status != .completed && $0.state.status != .failed
+ && ($0.plan.source.id == machine.id || $0.plan.target.id == machine.id)
+ }) {
+ throw MachineManagerError.persistence(
+ "machine \(machine.id) lifecycle operation "
+ + unfinished.plan.id.uuidString.lowercased() + " requires recovery"
+ )
+ }
+
+ let entry: MachineEntry
+ lock.lock()
+ guard let current = machines[machine.id],
+ !deletingMachineIDs.contains(machine.id) else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(machine.id)
+ }
+ entry = current
+ lock.unlock()
+ guard entry.configuration == machine,
+ entry.process == nil, entry.handoffServer == nil,
+ entry.state == .created || entry.state == .stopped else {
+ throw MachineManagerError.persistence(
+ "machine \(machine.id) must be exactly stopped before planning"
+ )
+ }
+ guard entry.runtimeIdentity.validate().isEmpty,
+ entry.runtimeIdentity.mode != .legacyCompatibility else {
+ throw MachineManagerError.persistence(
+ "machine \(machine.id) has no resolved-policy migration authority"
+ )
+ }
+
+ guard let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: machine.id)),
+ let decoded = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: legacyData
+ ), decoded == machine else {
+ throw MachineManagerError.persistence(
+ "authoritative machine metadata changed before planning"
+ )
+ }
+ let facts = try workspaceMigrationFacts(for: machine)
+ let factsData = try Self.workspaceMigrationAuthorityData(facts)
+ let migration = try DoryMachineConfigurationMigrationBridge.migrate(
+ machine,
+ facts: facts
+ )
+ let reconciled = try workspaceRepository.reconcileLegacyProjection(
+ migration.definition,
+ authoritativeLegacyData: legacyData,
+ authoritativeMigrationFactsData: factsData
+ ).definition
+ let loaded = try workspaceRepository.readLegacyProjection(
+ id: machine.id,
+ authoritativeLegacyData: legacyData,
+ authoritativeMigrationFactsData: factsData
+ )
+ guard reconciled == loaded, loaded == definition,
+ try Self.canonicalDefinitionData(loaded) == canonicalDefinitionData else {
+ throw MachineManagerError.persistence(
+ "planning request does not match the authoritative workspace projection"
+ )
+ }
+
+ let runtimeIdentityData = try Self.canonicalPlanningRuntimeAuthorityData(
+ entry.runtimeIdentity
+ )
+ let authority = DoryDaemonVirtualMachinePlanningMachineAuthority(
+ machineID: machine.id,
+ legacyConfigurationSHA256: Self.sha256(data: legacyData),
+ migrationFactsSHA256: Self.sha256(data: factsData),
+ sourceDefinitionRevision: loaded.lifecycle.revision,
+ sourceDefinitionSHA256: Self.sha256(data: canonicalDefinitionData),
+ runtimeIdentitySHA256: Self.sha256(data: runtimeIdentityData)
+ )
+ guard authority.isValid else {
+ throw MachineManagerError.persistence("planning mutation authority is invalid")
+ }
+
+ let retention = MachineManagerPlanningMutationRetention(
+ manager: self,
+ machineID: machine.id,
+ workspaceLock: workspaceLock
+ )
+ shouldRemoveActiveID = false
+ return DoryDaemonVirtualMachinePlanningMutationFence(
+ authority: authority,
+ retainedAuthority: retention,
+ validation: { [weak self] in
+ guard let self else {
+ throw MachineManagerError.persistence("machine manager is unavailable")
+ }
+ try self.revalidatePlanningMutationAuthority(
+ machine: machine,
+ definition: definition,
+ canonicalDefinitionData: canonicalDefinitionData,
+ legacyData: legacyData,
+ migrationFactsData: factsData,
+ runtimeIdentitySHA256: authority.runtimeIdentitySHA256
+ )
+ },
+ completion: { retention.release() },
+ recoveryRelease: { retention.release() }
+ )
+ }
+
+ private func revalidatePlanningMutationAuthority(
+ machine: DoryMachineConfiguration,
+ definition: DoryVirtualMachineDefinition,
+ canonicalDefinitionData: Data,
+ legacyData: Data,
+ migrationFactsData: Data,
+ runtimeIdentitySHA256: String
+ ) throws {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ guard activePlanningMutationIDs.contains(machine.id) else {
+ throw MachineManagerError.persistence("planning mutation authority was released")
+ }
+ let entry: MachineEntry
+ lock.lock()
+ guard let current = machines[machine.id],
+ !deletingMachineIDs.contains(machine.id) else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(machine.id)
+ }
+ entry = current
+ lock.unlock()
+ guard entry.configuration == machine,
+ entry.process == nil, entry.handoffServer == nil,
+ entry.state == .created || entry.state == .stopped,
+ entry.runtimeIdentity.validate().isEmpty,
+ Self.sha256(
+ data: try Self.canonicalPlanningRuntimeAuthorityData(entry.runtimeIdentity)
+ )
+ == runtimeIdentitySHA256,
+ let currentLegacyData = Self.readPrivateMetadata(
+ path: machineConfigPath(id: machine.id)
+ ), currentLegacyData == legacyData,
+ let decoded = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: currentLegacyData
+ ), decoded == machine else {
+ throw MachineManagerError.persistence(
+ "authoritative machine state changed during planning"
+ )
+ }
+ let currentFacts = try workspaceMigrationFacts(for: machine)
+ let currentFactsData = try Self.workspaceMigrationAuthorityData(currentFacts)
+ guard currentFactsData == migrationFactsData else {
+ throw MachineManagerError.persistence(
+ "authoritative migration facts changed during planning"
+ )
+ }
+ let currentDefinition = try workspaceRepository.readLegacyProjection(
+ id: machine.id,
+ authoritativeLegacyData: currentLegacyData,
+ authoritativeMigrationFactsData: currentFactsData
+ )
+ guard currentDefinition == definition,
+ try Self.canonicalDefinitionData(currentDefinition)
+ == canonicalDefinitionData else {
+ throw MachineManagerError.persistence(
+ "authoritative workspace projection changed during planning"
+ )
+ }
+ }
+
+ private static func canonicalPlanningRuntimeAuthorityData(
+ _ identity: DoryMachineRuntimeIdentity
+ ) throws -> Data {
+ let authority = MachinePlanningRuntimeAuthority(identity: identity)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return try encoder.encode(authority)
+ }
+
+ private func requireNoActivePlanningMutation(id: String) throws {
+ guard !activePlanningMutationIDs.contains(id) else {
+ throw MachineManagerError.persistence(
+ "machine \(id) already has an active planning mutation"
+ )
+ }
+ }
+
+ private func acquireDirectWorkspaceMutationLock(
+ id: String
+ ) throws -> EngineStateDirectoryLock {
+ guard let store = lifecycleJournalStore else {
+ throw MachineManagerError.persistence(
+ "lifecycle journal is unavailable: "
+ + (lifecycleJournalInitializationError ?? "unknown error")
+ )
+ }
+ let workspaceLock: EngineStateDirectoryLock
+ do {
+ workspaceLock = try EngineStateDirectoryLock(
+ stateDirectory: store.root,
+ lockFileName: ".mutation.\(id).lock"
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "machine \(id) mutation authority is busy: \(error)"
+ )
+ }
+ if let unfinished = try store.list().first(where: {
+ $0.state.status != .completed && $0.state.status != .failed
+ && ($0.plan.source.id == id || $0.plan.target.id == id)
+ }) {
+ throw MachineManagerError.persistence(
+ "machine \(id) lifecycle operation "
+ + unfinished.plan.id.uuidString.lowercased() + " requires recovery"
+ )
+ }
+ return workspaceLock
+ }
+
+ private func retainDirectWorkspaceMutationLock(
+ id: String
+ ) throws -> MachineManagerDirectMutationRetention {
+ if let existing = activeDirectWorkspaceMutationLocks[id] {
+ existing.depth += 1
+ return existing
+ }
+ guard activeLifecycleOperations[id] == nil else {
+ throw MachineManagerError.persistence(
+ "machine \(id) already has an active lifecycle mutation"
+ )
+ }
+ let retention = MachineManagerDirectMutationRetention(
+ workspaceLock: try acquireDirectWorkspaceMutationLock(id: id)
+ )
+ activeDirectWorkspaceMutationLocks[id] = retention
+ return retention
+ }
+
+ private func releaseDirectWorkspaceMutationLock(
+ id: String,
+ retention: MachineManagerDirectMutationRetention
+ ) {
+ guard activeDirectWorkspaceMutationLocks[id] === retention,
+ retention.depth > 0 else { return }
+ retention.depth -= 1
+ if retention.depth == 0 {
+ activeDirectWorkspaceMutationLocks.removeValue(forKey: id)
+ }
+ }
+
+ fileprivate func releasePlanningMutation(
+ machineID: String,
+ releaseWorkspaceLock: () -> Void
+ ) {
+ operationLock.lock()
+ releaseWorkspaceLock()
+ activePlanningMutationIDs.remove(machineID)
+ operationLock.unlock()
+ }
+
private func lifecycleState(for state: DoryMachineState) -> DoryWorkspaceLifecycleState {
switch state {
case .starting, .running: .running
@@ -5414,10 +5754,16 @@ public final class MachineManager: @unchecked Sendable {
let binding = try operation.journalBinding(
dependencyClosureDigest: Self.sha256(data: try encoder.encode(dependency))
)
- let context = MachineLifecycleJournalContext(
- operation: operation,
- lease: try store.begin(binding)
- )
+ let lease: DoryOperationLease
+ if let retained = activeDirectWorkspaceMutationLocks[machineID] {
+ lease = try store.begin(
+ binding,
+ holdingMutationLock: retained.workspaceLock
+ )
+ } else {
+ lease = try store.begin(binding)
+ }
+ let context = MachineLifecycleJournalContext(operation: operation, lease: lease)
activeLifecycleOperations[machineID] = context
return context
}
@@ -6565,6 +6911,72 @@ private final class MachineLifecycleJournalContext: @unchecked Sendable {
}
}
+private final class MachineManagerPlanningMutationRetention: @unchecked Sendable {
+ private let stateLock = NSLock()
+ private let manager: MachineManager
+ private let machineID: String
+ private var workspaceLock: EngineStateDirectoryLock?
+
+ init(
+ manager: MachineManager,
+ machineID: String,
+ workspaceLock: EngineStateDirectoryLock
+ ) {
+ self.manager = manager
+ self.machineID = machineID
+ self.workspaceLock = workspaceLock
+ }
+
+ func release() {
+ stateLock.withLock {
+ guard workspaceLock != nil else { return }
+ manager.releasePlanningMutation(machineID: machineID) {
+ workspaceLock = nil
+ }
+ }
+ }
+
+ deinit { release() }
+}
+
+private final class MachineManagerDirectMutationRetention: @unchecked Sendable {
+ let workspaceLock: EngineStateDirectoryLock
+ var depth = 1
+
+ init(workspaceLock: EngineStateDirectoryLock) {
+ self.workspaceLock = workspaceLock
+ }
+}
+
+/// Planning recovery binds launch-relevant runtime authority, not the transient explanation for
+/// why an unplanned machine needs a plan. This stays stable across daemon restart while resolved
+/// identities retain the exact immutable plan digest and backend/runtime tuple.
+private struct MachinePlanningRuntimeAuthority: Codable {
+ var schemaVersion: UInt16
+ var mode: DoryMachineRuntimeIdentityMode
+ var virtualHardwareABIVersion: UInt16
+ var resolvedPlanSHA256: String?
+ var planRevision: UInt64?
+ var definitionRevision: UInt64?
+ var definitionSHA256: String?
+ var backend: DoryVirtualizationBackendIdentity?
+ var backendImplementationIdentifier: String?
+ var backendRuntimeBuildIdentifier: String?
+
+ init(identity: DoryMachineRuntimeIdentity) {
+ schemaVersion = identity.schemaVersion
+ mode = identity.mode
+ virtualHardwareABIVersion = identity.virtualHardwareABIVersion
+ resolvedPlanSHA256 = identity.resolvedPlanSHA256
+ planRevision = identity.planRevision
+ definitionRevision = identity.definitionRevision
+ definitionSHA256 = identity.definitionSHA256
+ backend = identity.backend
+ backendImplementationIdentifier = identity.backendImplementationIdentifier
+ backendRuntimeBuildIdentifier = identity.backendRuntimeBuildIdentifier
+ }
+}
+
private struct MachineLifecycleDependencyAuthority: Codable {
var schemaVersion: UInt16 = 2
var mutationKind: String
@@ -6580,6 +6992,8 @@ private struct MachineLifecycleDependencyAuthority: Codable {
var targetSnapshotArtifactEvidenceSHA256: String?
}
+extension MachineManager: DoryDaemonVirtualMachinePlanningMutationAuthorizing {}
+
private extension DoryOperationPhase {
var indexForMachineLifecycle: Int {
switch self {
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
index c6613d76..d5571bc2 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
@@ -1,3 +1,4 @@
+import CryptoKit
import DoryOperations
@testable import DorydKit
import Foundation
@@ -5,6 +6,23 @@ import Testing
@Suite("Daemon VM planning publication transaction")
struct DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests {
+ @Test("transaction completion and interrupted recovery release the retained machine fence")
+ func transactionFinalizesMachineFenceExactlyOnce() throws {
+ let completed = try TransactionFixture()
+ _ = try completed.coordinator().resolveReserveAndPublish(completed.request())
+ #expect(completed.mutationAuthority.completionCount == 1)
+ #expect(completed.mutationAuthority.recoveryReleaseCount == 0)
+
+ let interrupted = try TransactionFixture()
+ #expect(throws: (any Error).self) {
+ _ = try interrupted.coordinator(
+ fault: TransactionFault(.preparedJournalPublished)
+ ).resolveReserveAndPublish(interrupted.request())
+ }
+ #expect(interrupted.mutationAuthority.completionCount == 0)
+ #expect(interrupted.mutationAuthority.recoveryReleaseCount == 1)
+ }
+
@Test("reserve bind publish is exact and restart-idempotent")
func successAndIdempotence() throws {
let fixture = try TransactionFixture()
@@ -105,6 +123,144 @@ struct DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests {
== recovered.planning.resolvedPlan)
}
+ @Test("create and replace revalidate machine authority at workspace publication")
+ func createAndReplaceRevalidateAtPublication() throws {
+ let createFixture = try TransactionFixture()
+ let createFault = TransactionFault(
+ .publicationAuthorized,
+ throwsInjectedFailure: false,
+ action: { createFixture.mutationAuthority.rejectFurtherValidation() }
+ )
+ do {
+ _ = try createFixture.coordinator(fault: createFault)
+ .resolveReserveAndPublish(createFixture.request())
+ Issue.record("Expected final create authority rejection")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .mutationAuthorityRejected)
+ }
+ #expect(try createFixture.workspaces.readPersistedRecordIfPresent(
+ id: createFixture.definition.identity.id
+ ) == nil)
+ #expect(try createFixture.plans.readIfPresent(
+ id: createFixture.definition.identity.id
+ ) == nil)
+ let createLease = try #require(try createFixture.ledger.snapshot().leases.first)
+ #expect(createLease.state == .starting)
+ #expect(createLease.boundPlanSHA256 != nil)
+
+ let replaceFixture = try TransactionFixture()
+ let first = try replaceFixture.coordinator()
+ .resolveReserveAndPublish(replaceFixture.request())
+ _ = try replaceFixture.ledger.markStopped(
+ leaseID: first.lease.leaseID,
+ expectedLeaseRevision: first.lease.leaseRevision
+ )
+ let replacement = replaceFixture.replacementDefinition()
+ let replacementRequest = replaceFixture.request(
+ definition: replacement,
+ workspacePublication: .replace(expectedRevision: 1),
+ planPublication: .replace(expectedPlanRevision: 1)
+ )
+ let replaceFault = TransactionFault(
+ .publicationAuthorized,
+ throwsInjectedFailure: false,
+ action: { replaceFixture.mutationAuthority.rejectFurtherValidation() }
+ )
+ do {
+ _ = try replaceFixture.coordinator(fault: replaceFault)
+ .resolveReserveAndPublish(replacementRequest)
+ Issue.record("Expected final replace authority rejection")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .mutationAuthorityRejected)
+ }
+ #expect(try replaceFixture.workspaces.read(id: replacement.identity.id)
+ == replaceFixture.definition)
+ #expect(try replaceFixture.plans.read(id: replacement.identity.id)
+ == first.planning.resolvedPlan)
+ let replaceLease = try #require(try replaceFixture.ledger.snapshot().leases.first)
+ #expect(replaceLease.state == .starting)
+ #expect(replaceLease.boundPlanSHA256 != nil)
+ }
+
+ @Test("completed retained transaction rejects missing and substituted durable workspace")
+ func completedRetainedWorkspaceMustRemainDurableAndExact() throws {
+ for removesWorkspace in [true, false] {
+ let fixture = try TransactionFixture()
+ try fixture.workspaces.create(fixture.definition)
+ let request = fixture.request(workspacePublication: .retainExistingExact)
+ _ = try fixture.coordinator().resolveReserveAndPublish(request)
+ if removesWorkspace {
+ try fixture.workspaces.remove(id: fixture.definition.identity.id)
+ } else {
+ try fixture.workspaces.replace(
+ fixture.replacementDefinition(),
+ expectedRevision: 1
+ )
+ }
+ do {
+ _ = try fixture.coordinator().resolveReserveAndPublish(request)
+ Issue.record("Expected completed retained workspace rejection")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .recoveryRequired)
+ }
+ }
+ }
+
+ @Test("retained planning compares exact facts-bound legacy repository authority")
+ func retainedLegacyProjectionAuthorityIsExact() throws {
+ let accepted = try TransactionFixture()
+ try accepted.installLegacyProjectionAuthority()
+ let request = accepted.request(workspacePublication: .retainExistingExact)
+ _ = try accepted.coordinator().resolveReserveAndPublish(request)
+ #expect(try accepted.plans.read(id: accepted.definition.identity.id).machineID
+ == accepted.definition.identity.id)
+
+ let rejected = try TransactionFixture()
+ try rejected.installLegacyProjectionAuthority()
+ rejected.mutationAuthority.migrationFactsSHA256 = transactionDigest("0")
+ do {
+ _ = try rejected.coordinator().resolveReserveAndPublish(
+ rejected.request(workspacePublication: .retainExistingExact)
+ )
+ Issue.record("Expected mismatched facts-bound workspace rejection")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .mutationAuthorityRejected)
+ }
+ #expect(try rejected.ledger.snapshot().leases.isEmpty)
+ }
+
+ @Test("bound retained recovery rejects missing and substituted durable workspace")
+ func recoveringRetainedWorkspaceMustRemainDurableAndExact() throws {
+ for removesWorkspace in [true, false] {
+ let fixture = try TransactionFixture(rejectPublication: true)
+ try fixture.workspaces.create(fixture.definition)
+ let request = fixture.request(workspacePublication: .retainExistingExact)
+ do {
+ _ = try fixture.coordinator().resolveReserveAndPublish(request)
+ Issue.record("Expected stale publication evidence")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .publicationAuthorizationRejected)
+ }
+ if removesWorkspace {
+ try fixture.workspaces.remove(id: fixture.definition.identity.id)
+ } else {
+ try fixture.workspaces.replace(
+ fixture.replacementDefinition(),
+ expectedRevision: 1
+ )
+ }
+ fixture.trust.rejectPublication = false
+ do {
+ _ = try fixture.coordinator().resolveReserveAndPublish(request)
+ Issue.record("Expected retained recovery workspace rejection")
+ } catch let failure as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ #expect(failure.code == .recoveryRequired)
+ }
+ let lease = try #require(try fixture.ledger.snapshot().leases.first)
+ #expect(lease.boundPlanSHA256 != nil)
+ }
+ }
+
@Test("two coordinator instances serialize one workspace transaction")
func concurrentInstances() async throws {
let fixture = try TransactionFixture()
@@ -399,21 +555,47 @@ private final class TransactionFixture: @unchecked Sendable {
deinit { try? FileManager.default.removeItem(atPath: root) }
func request(
+ definition requestedDefinition: DoryVirtualMachineDefinition? = nil,
+ workspacePublication: DoryDaemonVirtualMachineWorkspacePublication = .create,
+ planPublication: DoryDaemonVirtualMachinePlanPublication = .create,
leaseDuration: Int64 = 120_000
) -> DoryDaemonVirtualMachinePlanningTransactionRequest {
- DoryDaemonVirtualMachinePlanningTransactionRequest(
+ let requestedDefinition = requestedDefinition ?? definition
+ return DoryDaemonVirtualMachinePlanningTransactionRequest(
planning: DoryDaemonVirtualMachinePlanningRequest(
- definition: definition,
+ definition: requestedDefinition,
canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator
- .canonicalDefinitionData(definition),
+ .canonicalDefinitionData(requestedDefinition),
machine: machine,
- publication: .create
+ publication: planPublication
),
- workspacePublication: .create,
+ workspacePublication: workspacePublication,
startingLeaseDurationMilliseconds: leaseDuration
)
}
+ func replacementDefinition() -> DoryVirtualMachineDefinition {
+ var replacement = definition
+ replacement.lifecycle = DoryVMLifecycleMetadata(
+ revision: 2,
+ createdAtUnixMilliseconds: definition.lifecycle.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: definition.lifecycle.updatedAtUnixMilliseconds + 1
+ )
+ return replacement
+ }
+
+ func installLegacyProjectionAuthority() throws {
+ let legacy = Data("exact legacy machine bytes".utf8)
+ let facts = Data("exact canonical migration facts".utf8)
+ _ = try workspaces.reconcileLegacyProjection(
+ definition,
+ authoritativeLegacyData: legacy,
+ authoritativeMigrationFactsData: facts
+ )
+ mutationAuthority.authoritySHA256 = transactionSHA256(legacy)
+ mutationAuthority.migrationFactsSHA256 = transactionSHA256(facts)
+ }
+
func coordinator(
fault: TransactionFault? = nil
) -> DoryDaemonVirtualMachinePlanningTransactionCoordinator {
@@ -506,10 +688,24 @@ private final class TransactionMutationAuthority:
{
private let lock = NSLock()
private var storedAuthoritySHA256 = transactionDigest("e")
+ private var storedMigrationFactsSHA256 = transactionDigest("f")
+ private var storedCompletionCount = 0
+ private var storedRecoveryReleaseCount = 0
+ private var storedRejectValidation = false
var authoritySHA256: String {
get { lock.withLock { storedAuthoritySHA256 } }
set { lock.withLock { storedAuthoritySHA256 = newValue } }
}
+ var migrationFactsSHA256: String {
+ get { lock.withLock { storedMigrationFactsSHA256 } }
+ set { lock.withLock { storedMigrationFactsSHA256 = newValue } }
+ }
+ var completionCount: Int { lock.withLock { storedCompletionCount } }
+ var recoveryReleaseCount: Int { lock.withLock { storedRecoveryReleaseCount } }
+
+ func rejectFurtherValidation() {
+ lock.withLock { storedRejectValidation = true }
+ }
func acquirePlanningMutationFence(
machine: DoryMachineConfiguration,
@@ -520,10 +716,29 @@ private final class TransactionMutationAuthority:
throw TransactionInjectedFailure()
}
let digest = authoritySHA256
+ let definitionDigest = SHA256.hash(data: canonicalDefinitionData)
+ .map { String(format: "%02x", $0) }.joined()
return DoryDaemonVirtualMachinePlanningMutationFence(
- authoritySHA256: digest,
+ authority: DoryDaemonVirtualMachinePlanningMachineAuthority(
+ machineID: machine.id,
+ legacyConfigurationSHA256: digest,
+ migrationFactsSHA256: migrationFactsSHA256,
+ sourceDefinitionRevision: definition.lifecycle.revision,
+ sourceDefinitionSHA256: definitionDigest,
+ runtimeIdentitySHA256: transactionDigest("a")
+ ),
retainedAuthority: digest,
- validation: {}
+ validation: {
+ if self.lock.withLock({ self.storedRejectValidation }) {
+ throw TransactionInjectedFailure()
+ }
+ },
+ completion: {
+ self.lock.withLock { self.storedCompletionCount += 1 }
+ },
+ recoveryRelease: {
+ self.lock.withLock { self.storedRecoveryReleaseCount += 1 }
+ }
)
}
}
@@ -547,17 +762,30 @@ private struct TransactionPlanner: DoryDaemonVirtualMachineCapabilityPlanning {
private final class TransactionFault: @unchecked Sendable {
private let lock = NSLock()
private var stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage?
- init(_ stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage) {
+ private let throwsInjectedFailure: Bool
+ private let action: @Sendable () -> Void
+ init(
+ _ stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage,
+ throwsInjectedFailure: Bool = true,
+ action: @escaping @Sendable () -> Void = {}
+ ) {
self.stage = stage
+ self.throwsInjectedFailure = throwsInjectedFailure
+ self.action = action
}
func inject(
_ value: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage
) throws {
- try lock.withLock {
+ let triggered = lock.withLock {
if stage == value {
stage = nil
- throw TransactionInjectedFailure()
+ return true
}
+ return false
+ }
+ if triggered {
+ action()
+ if throwsInjectedFailure { throw TransactionInjectedFailure() }
}
}
}
@@ -649,3 +877,7 @@ private func transactionDummyAdmission(
private func transactionDigest(_ value: Character) -> String {
String(repeating: String(value), count: 64)
}
+
+private func transactionSHA256(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerPlanningMutationAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerPlanningMutationAuthorityTests.swift
new file mode 100644
index 00000000..9ebe90a1
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerPlanningMutationAuthorityTests.swift
@@ -0,0 +1,325 @@
+import CryptoKit
+import DoryOperations
+import Foundation
+import Testing
+@testable import DorydKit
+
+@Suite("MachineManager planning mutation authority")
+struct MachineManagerPlanningMutationAuthorityTests {
+ @Test("fence binds exact persisted machine and blocks every local mutation")
+ func exactAuthorityAndLocalMutationExclusion() throws {
+ try PlanningMutationFixture.withFixture("local") { fixture in
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+ _ = try manager.snapshot(id: fixture.machineID, snapshotID: "before")
+ let input = try fixture.input()
+
+ let fence = try manager.acquirePlanningMutationFence(
+ machine: input.machine,
+ definition: input.definition,
+ canonicalDefinitionData: input.canonicalDefinitionData
+ )
+ #expect(fence.authority.isValid)
+ #expect(fence.authority.machineID == fixture.machineID)
+ #expect(fence.authority.sourceDefinitionRevision
+ == input.definition.lifecycle.revision)
+ #expect(fence.authority.legacyConfigurationSHA256
+ == Self.sha256(input.legacyData))
+ #expect(fence.authority.sourceDefinitionSHA256
+ == Self.sha256(input.canonicalDefinitionData))
+ try fence.revalidate()
+
+ try Self.expectFailure(containing: "active planning mutation") {
+ _ = try manager.start(id: fixture.machineID)
+ }
+ try Self.expectFailure(containing: "active planning mutation") {
+ _ = try manager.stop(id: fixture.machineID)
+ }
+ try Self.expectFailure(containing: "active planning mutation") {
+ _ = try manager.update(id: fixture.machineID, memoryMB: 4_096)
+ }
+ try Self.expectFailure(containing: "active planning mutation") {
+ _ = try manager.snapshot(id: fixture.machineID, snapshotID: "blocked")
+ }
+ try Self.expectFailure(containing: "active planning mutation") {
+ _ = try manager.restoreSnapshot(
+ machineID: fixture.machineID,
+ snapshotID: "before"
+ )
+ }
+ try Self.expectFailure(containing: "active planning mutation") {
+ try manager.delete(id: fixture.machineID)
+ }
+ #expect(fixture.processStartCount == 0)
+
+ try fence.complete()
+ _ = try manager.update(id: fixture.machineID, memoryMB: 4_096)
+ #expect(manager.status(id: fixture.machineID)?.memoryMB == 4_096)
+ }
+ }
+
+ @Test("cross-process lifecycle and update mutations cannot bypass planning fence")
+ func crossProcessMutationExclusion() throws {
+ try PlanningMutationFixture.withFixture("cross-process") { fixture in
+ let owner = fixture.makeManager()
+ _ = try fixture.createMachine(owner)
+ _ = try owner.snapshot(id: fixture.machineID, snapshotID: "before")
+ let contender = fixture.makeManager(launchPolicy: .legacyCompatibility)
+ let input = try fixture.input()
+ let fence = try owner.acquirePlanningMutationFence(
+ machine: input.machine,
+ definition: input.definition,
+ canonicalDefinitionData: input.canonicalDefinitionData
+ )
+ defer { fence.releaseForRecovery() }
+
+ try Self.expectFailure(containing: "already in use") {
+ _ = try contender.start(id: fixture.machineID)
+ }
+ try Self.expectFailure(containing: "mutation authority is busy") {
+ _ = try contender.update(id: fixture.machineID, memoryMB: 4_096)
+ }
+ try Self.expectFailure(containing: "already in use") {
+ _ = try contender.snapshot(id: fixture.machineID, snapshotID: "blocked")
+ }
+ try Self.expectFailure(containing: "already in use") {
+ _ = try contender.restoreSnapshot(
+ machineID: fixture.machineID,
+ snapshotID: "before"
+ )
+ }
+ try Self.expectFailure(containing: "already in use") {
+ try contender.delete(id: fixture.machineID)
+ }
+ #expect(fixture.processStartCount == 0)
+ }
+ }
+
+ @Test("raw legacy byte drift invalidates retained authority")
+ func rawLegacyByteDrift() throws {
+ try PlanningMutationFixture.withFixture("raw-drift") { fixture in
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+ let input = try fixture.input()
+ let fence = try manager.acquirePlanningMutationFence(
+ machine: input.machine,
+ definition: input.definition,
+ canonicalDefinitionData: input.canonicalDefinitionData
+ )
+ defer { fence.releaseForRecovery() }
+
+ var changed = input.legacyData
+ changed.append(0x0a)
+ try fixture.writePrivate(changed, path: fixture.machineJSONPath)
+ try Self.expectFailure(containing: "authoritative machine state changed") {
+ try fence.revalidate()
+ }
+ #expect(fixture.processStartCount == 0)
+ }
+ }
+
+ @Test("migration-fact drift invalidates retained authority")
+ func migrationFactDrift() throws {
+ try PlanningMutationFixture.withFixture("facts-drift") { fixture in
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+ let input = try fixture.input()
+ let fence = try manager.acquirePlanningMutationFence(
+ machine: input.machine,
+ definition: input.definition,
+ canonicalDefinitionData: input.canonicalDefinitionData
+ )
+ defer { fence.releaseForRecovery() }
+
+ let rootfs = input.machine.rootfsPath
+ let original = try Data(contentsOf: URL(fileURLWithPath: rootfs))
+ var changed = original
+ changed.append(0x51)
+ try fixture.writePrivate(changed, path: rootfs)
+ try Self.expectFailure(containing: "migration facts changed") {
+ try fence.revalidate()
+ }
+ #expect(fixture.processStartCount == 0)
+ }
+ }
+
+ @Test("released interrupted authority is reacquired after daemon restart")
+ func restartReacquiresAuthority() throws {
+ try PlanningMutationFixture.withFixture("restart") { fixture in
+ var owner: MachineManager? = fixture.makeManager()
+ _ = try fixture.createMachine(try #require(owner))
+ let input = try fixture.input()
+ let first = try #require(owner).acquirePlanningMutationFence(
+ machine: input.machine,
+ definition: input.definition,
+ canonicalDefinitionData: input.canonicalDefinitionData
+ )
+ let firstAuthority = first.authority
+ first.releaseForRecovery()
+ owner = nil
+
+ let recovered = fixture.makeManager()
+ let recoveredInput = try fixture.input()
+ let second = try recovered.acquirePlanningMutationFence(
+ machine: recoveredInput.machine,
+ definition: recoveredInput.definition,
+ canonicalDefinitionData: recoveredInput.canonicalDefinitionData
+ )
+ #expect(second.authority == firstAuthority)
+ try second.revalidate()
+ try second.complete()
+ #expect(fixture.processStartCount == 0)
+ }
+ }
+
+ @Test("legacy policy and unrepresentable migration facts fail closed")
+ func unsupportedPromotionFailsClosed() throws {
+ try PlanningMutationFixture.withFixture("unsupported") { fixture in
+ let legacy = fixture.makeManager(launchPolicy: .legacyCompatibility)
+ _ = try fixture.createMachine(legacy)
+ let input = try fixture.input()
+ try Self.expectFailure(containing: "resolved-plan launch policy") {
+ _ = try legacy.acquirePlanningMutationFence(
+ machine: input.machine,
+ definition: input.definition,
+ canonicalDefinitionData: input.canonicalDefinitionData
+ )
+ }
+
+ let unsupported = fixture.makeManager(
+ launchPolicy: .requireResolvedPlan,
+ architecture: "mips64"
+ )
+ try Self.expectFailure(containing: "unsupported guest architecture") {
+ _ = try unsupported.acquirePlanningMutationFence(
+ machine: input.machine,
+ definition: input.definition,
+ canonicalDefinitionData: input.canonicalDefinitionData
+ )
+ }
+ #expect(fixture.processStartCount == 0)
+ }
+ }
+
+ private static func expectFailure(
+ containing fragment: String,
+ _ operation: () throws -> Void
+ ) throws {
+ do {
+ try operation()
+ Issue.record("Expected operation to fail with \(fragment)")
+ } catch {
+ #expect(String(describing: error).contains(fragment))
+ }
+ }
+
+ private static func sha256(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+private final class PlanningMutationFixture: @unchecked Sendable {
+ struct Input {
+ var machine: DoryMachineConfiguration
+ var legacyData: Data
+ var definition: DoryVirtualMachineDefinition
+ var canonicalDefinitionData: Data
+ }
+
+ let machineID = "planning-vm"
+ let base: String
+ let state: String
+ let runtime: String
+ let journal: String
+ private let countLock = NSLock()
+ private var _processStartCount = 0
+
+ var processStartCount: Int { countLock.withLock { _processStartCount } }
+ var machineJSONPath: String { state + "/\(machineID)/machine.json" }
+
+ init(label: String) throws {
+ base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-planning-authority-\(label)-\(UUID().uuidString)")
+ .path
+ state = base + "/machines"
+ runtime = base + "/runtime"
+ journal = base + "/journal"
+ try FileManager.default.createDirectory(
+ atPath: base,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ }
+
+ static func withFixture(
+ _ label: String,
+ _ body: (PlanningMutationFixture) throws -> Void
+ ) throws {
+ let fixture = try PlanningMutationFixture(label: label)
+ defer { try? FileManager.default.removeItem(atPath: fixture.base) }
+ try body(fixture)
+ }
+
+ func makeManager(
+ launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan,
+ architecture: String = "arm64"
+ ) -> MachineManager {
+ MachineManager(
+ configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ stateDirectory: state,
+ runtimeDirectory: runtime,
+ lifecycleJournalHome: journal,
+ baseArguments: ["30"],
+ passMachineArguments: false,
+ requiresReadyHandoff: false,
+ guestArchitecture: architecture
+ ),
+ launchPolicy: launchPolicy,
+ processStarter: { [weak self] _ in
+ self?.countLock.withLock { self?._processStartCount += 1 }
+ }
+ )
+ }
+
+ @discardableResult
+ func createMachine(_ manager: MachineManager) throws -> DoryMachineStatus {
+ try manager.create(DoryMachineConfiguration(
+ id: machineID,
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ memoryMB: 2_048,
+ cpuCount: 2
+ ))
+ }
+
+ func input() throws -> Input {
+ let legacyData = try Data(contentsOf: URL(fileURLWithPath: machineJSONPath))
+ let machine = try JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: legacyData
+ )
+ let recordData = try Data(contentsOf: URL(
+ fileURLWithPath: state + "/\(machineID)/" + DoryWorkspaceRepository.recordFileName
+ ))
+ let record = try JSONDecoder().decode(
+ DoryWorkspaceRepositoryRecord.self,
+ from: recordData
+ )
+ return Input(
+ machine: machine,
+ legacyData: legacyData,
+ definition: record.definition,
+ canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(record.definition)
+ )
+ }
+
+ func writePrivate(_ data: Data, path: String) throws {
+ try data.write(to: URL(fileURLWithPath: path), options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ }
+}
From 72b7f041afecc40674e569e82fa6b357823b9cc8 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 17:43:52 +0000
Subject: [PATCH 036/338] feat(vm): compose production planning authorities
---
...MachineProductionPlanningComposition.swift | 427 +++++++++++++
...neProductionPlanningCompositionTests.swift | 559 ++++++++++++++++++
2 files changed, 986 insertions(+)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift
new file mode 100644
index 00000000..54980ba1
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift
@@ -0,0 +1,427 @@
+import Darwin
+import DoryOperations
+import Foundation
+
+/// Supplies daemon-authoritative reconstruction inputs for a durable planning journal. The
+/// provider is expected to be backed by MachineManager's exact raw machine metadata, migration
+/// facts, and lifecycle fence; a journal is never recovered from its persisted plan bytes alone.
+public protocol DoryDaemonVirtualMachinePlanningRecoveryProviding: Sendable {
+ func recoveryRequest(
+ for machineID: String
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest?
+}
+
+public enum DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode:
+ String, Sendable, Equatable
+{
+ case mutationAuthorityUnavailable = "mutation-authority-unavailable"
+ case recoveryAuthorityUnavailable = "recovery-authority-unavailable"
+ case stateAuthorityUnavailable = "state-authority-unavailable"
+ case backendRegistryUnavailable = "backend-registry-unavailable"
+ case resourceAuthorityUnavailable = "resource-authority-unavailable"
+ case trustInventoryUnavailable = "trust-inventory-unavailable"
+ case recoveryRequestUnavailable = "recovery-request-unavailable"
+ case recoveryFailed = "recovery-failed"
+ case compositionFailed = "composition-failed"
+}
+
+public struct DoryDaemonVirtualMachineProductionPlanningCompositionFailure:
+ Error, Sendable, Equatable
+{
+ public var code: DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode
+ public var machineID: String?
+ public var message: String
+
+ public init(
+ code: DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode,
+ machineID: String? = nil,
+ message: String
+ ) {
+ self.code = code
+ self.machineID = machineID
+ self.message = message
+ }
+}
+
+/// Canonical durable roots shared by planning and start. These paths are diagnostic identities,
+/// not persisted caller intent; construction rejects aliases and derives every child from one
+/// exact state root.
+public struct DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity:
+ Sendable, Equatable, Hashable
+{
+ public var stateDirectory: String
+ public var workspaceRepositoryRoot: String
+ public var resolvedPlanRepositoryRoot: String
+ public var artifactAuthorityRoot: String
+ public var resourceAdmissionLedgerRoot: String
+
+ public init(stateDirectory: String) {
+ let canonical = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path
+ self.stateDirectory = canonical
+ workspaceRepositoryRoot = canonical
+ resolvedPlanRepositoryRoot = canonical
+ artifactAuthorityRoot = canonical + "/.artifact-authority"
+ resourceAdmissionLedgerRoot = canonical + "/.resource-admissions"
+ }
+}
+
+/// Non-Codable daemon composition. All handles below are the exact instances used by recovery,
+/// subsequent planning, and start-time resolution.
+public struct DoryDaemonVirtualMachineProductionPlanningContext: Sendable {
+ public let identity: DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity
+ public let artifactAuthority: DoryVirtualMachineArtifactAuthority
+ public let resourceLedger: DoryVirtualMachineResourceAdmissionLedger
+ public let workspaces: DoryWorkspaceRepository
+ public let plans: DoryResolvedMachinePlanRepository
+ public let registry: BackendRegistry
+ public let inventory: any DoryDaemonVirtualMachineTrustInventory
+ public let coordinator: DoryDaemonVirtualMachinePlanningTransactionCoordinator
+ public let launchResolver: DoryDaemonVirtualMachineLaunchPlanResolver
+ public let recoveredTransactionIDs: [String: String]
+
+ public var planningTransactionAvailable: Bool { true }
+
+ init(
+ identity: DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity,
+ artifactAuthority: DoryVirtualMachineArtifactAuthority,
+ resourceLedger: DoryVirtualMachineResourceAdmissionLedger,
+ workspaces: DoryWorkspaceRepository,
+ plans: DoryResolvedMachinePlanRepository,
+ registry: BackendRegistry,
+ inventory: any DoryDaemonVirtualMachineTrustInventory,
+ coordinator: DoryDaemonVirtualMachinePlanningTransactionCoordinator,
+ launchResolver: DoryDaemonVirtualMachineLaunchPlanResolver,
+ recoveredTransactionIDs: [String: String]
+ ) {
+ self.identity = identity
+ self.artifactAuthority = artifactAuthority
+ self.resourceLedger = resourceLedger
+ self.workspaces = workspaces
+ self.plans = plans
+ self.registry = registry
+ self.inventory = inventory
+ self.coordinator = coordinator
+ self.launchResolver = launchResolver
+ self.recoveredTransactionIDs = recoveredTransactionIDs
+ }
+}
+
+public enum DoryDaemonVirtualMachineProductionPlanningReadiness: Sendable {
+ case ready(DoryDaemonVirtualMachineProductionPlanningContext)
+ case unavailable(DoryDaemonVirtualMachineProductionPlanningCompositionFailure)
+
+ public var planningTransactionAvailable: Bool {
+ if case .ready = self { return true }
+ return false
+ }
+}
+
+/// Constructs the exact production planning/start authorities and recovers every discovered
+/// transaction before returning readiness. The public production trust factory will own this
+/// boundary; its dependency-injected initializer remains internal so callers cannot provide a
+/// fabricated trust inventory.
+public final class DoryDaemonVirtualMachineProductionPlanningCompositionFactory:
+ @unchecked Sendable
+{
+ typealias TrustedInventory = any DoryDaemonVirtualMachineTrustInventory
+ & DoryDaemonVirtualMachinePlanningTrustPreparing
+ typealias InventoryBuilder = @Sendable (
+ DoryVirtualMachineArtifactAuthority,
+ DoryVirtualMachineResourceAdmissionLedger
+ ) throws -> TrustedInventory
+
+ private static let journalFileName = "planning-transaction-v1.json"
+ private static let compositionLockFileName = ".planning-composition.lock"
+
+ private let identity: DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity
+ private let backends: [any MachineBackend]
+ private let mutationAuthority: (any DoryDaemonVirtualMachinePlanningMutationAuthorizing)?
+ private let recoveryProvider: (any DoryDaemonVirtualMachinePlanningRecoveryProviding)?
+ private let inventoryBuilder: InventoryBuilder
+ private let capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning
+ private let verifiedBackendDescriptors: [MachineBackendDescriptor]?
+
+ init(
+ stateDirectory: String,
+ backends: [any MachineBackend],
+ mutationAuthority: (any DoryDaemonVirtualMachinePlanningMutationAuthorizing)?,
+ recoveryProvider: (any DoryDaemonVirtualMachinePlanningRecoveryProviding)?,
+ capabilityPlanner: any DoryDaemonVirtualMachineCapabilityPlanning =
+ DoryAppleSiliconDaemonVirtualMachineCapabilityPlanner(),
+ verifiedBackendDescriptors: [MachineBackendDescriptor]? = nil,
+ inventoryBuilder: @escaping InventoryBuilder
+ ) {
+ identity = DoryDaemonVirtualMachineProductionPlanningAuthorityIdentity(
+ stateDirectory: stateDirectory
+ )
+ self.backends = backends
+ self.mutationAuthority = mutationAuthority
+ self.recoveryProvider = recoveryProvider
+ self.capabilityPlanner = capabilityPlanner
+ self.verifiedBackendDescriptors = verifiedBackendDescriptors?.sorted {
+ $0.identity.rawValue < $1.identity.rawValue
+ }
+ self.inventoryBuilder = inventoryBuilder
+ }
+
+ convenience init(
+ stateDirectory: String,
+ backends: [any MachineBackend],
+ qualificationAuthority: DoryVerifiedVirtualMachineQualificationAuthority,
+ runtimes: [DoryDaemonVerifiedBackendRuntime],
+ runtimeVerifier: @escaping DoryDaemonVirtualMachineProductionTrustFactory.RuntimeVerifier,
+ hostProbe: @escaping DoryDaemonVirtualMachineProductionTrustFactory.HostProbe,
+ mutationAuthority: (any DoryDaemonVirtualMachinePlanningMutationAuthorizing)?,
+ recoveryProvider: (any DoryDaemonVirtualMachinePlanningRecoveryProviding)?
+ ) {
+ let specifications = runtimes.compactMap { runtime
+ -> DoryDaemonBackendRuntimeSpecification? in
+ guard let component = runtime.components.first else { return nil }
+ return DoryDaemonBackendRuntimeSpecification(
+ descriptor: runtime.descriptor,
+ executablePath: runtime.executablePath,
+ componentIdentifier: component.componentIdentifier
+ )
+ }
+ self.init(
+ stateDirectory: stateDirectory,
+ backends: backends,
+ mutationAuthority: mutationAuthority,
+ recoveryProvider: recoveryProvider,
+ verifiedBackendDescriptors: runtimes.map(\.descriptor),
+ inventoryBuilder: { artifactAuthority, resourceLedger in
+ guard specifications.count == runtimes.count else {
+ throw DoryDaemonProductionTrustInventoryError.backendUnavailable
+ }
+ return DoryProductionDaemonVirtualMachineTrustInventory(
+ qualificationAuthority: qualificationAuthority,
+ artifactAuthority: artifactAuthority,
+ resourceLedger: resourceLedger,
+ stateDirectory: stateDirectory,
+ runtimeSpecifications: specifications,
+ runtimeVerifier: runtimeVerifier,
+ hostProbe: hostProbe
+ )
+ }
+ )
+ }
+
+ public func resolve() -> DoryDaemonVirtualMachineProductionPlanningReadiness {
+ guard let mutationAuthority else {
+ return unavailable(
+ .mutationAuthorityUnavailable,
+ "Production planning has no lifecycle-bound mutation authority."
+ )
+ }
+ guard let recoveryProvider else {
+ return unavailable(
+ .recoveryAuthorityUnavailable,
+ "Production planning has no authoritative journal recovery provider."
+ )
+ }
+ guard identity.stateDirectory
+ == URL(fileURLWithPath: identity.stateDirectory).standardizedFileURL.path else {
+ return unavailable(.stateAuthorityUnavailable, "VM state root is not canonical.")
+ }
+
+ do {
+ return try withCompositionLock {
+ let registry: BackendRegistry
+ do { registry = try BackendRegistry(backends: backends) }
+ catch {
+ throw failure(
+ .backendRegistryUnavailable,
+ "Production backend registry cannot be constructed."
+ )
+ }
+ if let verifiedBackendDescriptors,
+ registry.descriptors.sorted(by: {
+ $0.identity.rawValue < $1.identity.rawValue
+ }) != verifiedBackendDescriptors {
+ throw failure(
+ .backendRegistryUnavailable,
+ "Backend adapters do not exactly match verified runtime descriptors."
+ )
+ }
+ let artifactAuthority = DoryVirtualMachineArtifactAuthority(
+ root: identity.artifactAuthorityRoot
+ )
+ let resourceLedger = DoryVirtualMachineResourceAdmissionLedger(
+ root: identity.resourceAdmissionLedgerRoot
+ )
+ do { _ = try resourceLedger.snapshot() }
+ catch {
+ throw failure(
+ .resourceAuthorityUnavailable,
+ "Resource admission authority is corrupt or unavailable."
+ )
+ }
+ let workspaces = DoryWorkspaceRepository(
+ root: identity.workspaceRepositoryRoot
+ )
+ let plans = DoryResolvedMachinePlanRepository(
+ root: identity.resolvedPlanRepositoryRoot
+ )
+ let inventory: TrustedInventory
+ do { inventory = try inventoryBuilder(artifactAuthority, resourceLedger) }
+ catch {
+ throw failure(
+ .trustInventoryUnavailable,
+ "Verified production planning trust cannot be constructed."
+ )
+ }
+ let coordinator = DoryDaemonVirtualMachinePlanningTransactionCoordinator(
+ stateDirectory: identity.stateDirectory,
+ registry: registry,
+ trust: inventory,
+ mutationAuthority: mutationAuthority,
+ workspaces: workspaces,
+ plans: plans,
+ ledger: resourceLedger,
+ capabilityPlanner: capabilityPlanner
+ )
+
+ let pending = try pendingTransactionMachineIDs()
+ var recovered: [String: String] = [:]
+ for machineID in pending {
+ guard let request = try recoveryProvider.recoveryRequest(
+ for: machineID
+ ), request.planning.definition.identity.id == machineID,
+ request.planning.machine.id == machineID else {
+ throw failure(
+ .recoveryRequestUnavailable,
+ machineID: machineID,
+ "Exact authoritative recovery input is unavailable."
+ )
+ }
+ do {
+ let result = try coordinator.resolveReserveAndPublish(request)
+ recovered[machineID] = result.transactionID
+ } catch {
+ throw failure(
+ .recoveryFailed,
+ machineID: machineID,
+ "Durable planning recovery failed closed."
+ )
+ }
+ }
+
+ // A second discovery prevents a concurrent writer from being silently omitted
+ // during startup composition. Existing complete journals remain in the set.
+ guard try pendingTransactionMachineIDs() == pending else {
+ throw failure(
+ .recoveryFailed,
+ "Planning journal inventory changed during recovery."
+ )
+ }
+ let collector = DoryDaemonVirtualMachineStartEvidenceCollector(
+ registry: registry,
+ inventory: inventory
+ )
+ let resolver = DoryDaemonVirtualMachineLaunchPlanResolver(
+ registry: registry,
+ plans: plans,
+ evidenceCollector: collector
+ )
+ return .ready(DoryDaemonVirtualMachineProductionPlanningContext(
+ identity: identity,
+ artifactAuthority: artifactAuthority,
+ resourceLedger: resourceLedger,
+ workspaces: workspaces,
+ plans: plans,
+ registry: registry,
+ inventory: inventory,
+ coordinator: coordinator,
+ launchResolver: resolver,
+ recoveredTransactionIDs: recovered
+ ))
+ }
+ } catch let failure as DoryDaemonVirtualMachineProductionPlanningCompositionFailure {
+ return .unavailable(failure)
+ } catch {
+ return unavailable(.compositionFailed, "Production planning composition failed.")
+ }
+ }
+
+ private func pendingTransactionMachineIDs() throws -> [String] {
+ let names: [String]
+ do {
+ names = try FileManager.default.contentsOfDirectory(
+ atPath: identity.stateDirectory
+ )
+ } catch {
+ throw failure(.stateAuthorityUnavailable, "VM state root cannot be enumerated.")
+ }
+ var pending: [String] = []
+ for name in names {
+ let journal = identity.stateDirectory + "/" + name + "/" + Self.journalFileName
+ var info = stat()
+ if lstat(journal, &info) != 0 {
+ if errno == ENOENT || errno == ENOTDIR { continue }
+ throw failure(.stateAuthorityUnavailable, "Planning journal cannot be inspected.")
+ }
+ guard Self.isValidMachineID(name) else {
+ throw failure(.stateAuthorityUnavailable, "Planning journal has an unsafe workspace identity.")
+ }
+ pending.append(name)
+ }
+ return pending.sorted()
+ }
+
+ private func withCompositionLock(_ operation: () throws -> T) throws -> T {
+ try ensurePrivateStateDirectory()
+ let path = identity.stateDirectory + "/" + Self.compositionLockFileName
+ let descriptor = open(
+ path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600)
+ )
+ guard descriptor >= 0 else {
+ throw failure(.stateAuthorityUnavailable, "Planning composition lock cannot be opened.")
+ }
+ defer { close(descriptor) }
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ info.st_mode & S_IFMT == S_IFREG,
+ info.st_uid == geteuid(), info.st_nlink == 1,
+ info.st_mode & 0o077 == 0,
+ flock(descriptor, LOCK_EX) == 0 else {
+ throw failure(.stateAuthorityUnavailable, "Planning composition lock is insecure.")
+ }
+ defer { flock(descriptor, LOCK_UN) }
+ return try operation()
+ }
+
+ private func ensurePrivateStateDirectory() throws {
+ var info = stat()
+ guard lstat(identity.stateDirectory, &info) == 0,
+ info.st_mode & S_IFMT == S_IFDIR,
+ info.st_uid == geteuid(),
+ info.st_mode & 0o077 == 0 else {
+ throw failure(.stateAuthorityUnavailable, "VM state root is missing or insecure.")
+ }
+ }
+
+ private func unavailable(
+ _ code: DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode,
+ _ message: String
+ ) -> DoryDaemonVirtualMachineProductionPlanningReadiness {
+ .unavailable(failure(code, message))
+ }
+
+ private func failure(
+ _ code: DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode,
+ machineID: String? = nil,
+ _ message: String
+ ) -> DoryDaemonVirtualMachineProductionPlanningCompositionFailure {
+ DoryDaemonVirtualMachineProductionPlanningCompositionFailure(
+ code: code,
+ machineID: machineID,
+ message: message
+ )
+ }
+
+ private static func isValidMachineID(_ value: String) -> Bool {
+ value.utf8.count <= 63 && value.wholeMatch(
+ of: /[A-Za-z0-9](?:[A-Za-z0-9._-]{0,61}[A-Za-z0-9])?/
+ ) != nil
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
new file mode 100644
index 00000000..9523a0ce
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
@@ -0,0 +1,559 @@
+import DoryOperations
+@testable import DorydKit
+import Foundation
+import Testing
+
+@Suite("Production VM planning composition")
+struct DoryDaemonVirtualMachineProductionPlanningCompositionTests {
+ @Test("authorizer and recovery provider are both mandatory")
+ func dependenciesAreMandatory() throws {
+ let fixture = try CompositionFixture(ids: [])
+ for (hasAuthorizer, hasRecovery, expected) in [
+ (false, true,
+ DoryDaemonVirtualMachineProductionPlanningCompositionFailureCode
+ .mutationAuthorityUnavailable),
+ (true, false,
+ .recoveryAuthorityUnavailable),
+ ] {
+ let result = fixture.factory(
+ hasMutationAuthority: hasAuthorizer,
+ hasRecoveryProvider: hasRecovery
+ ).resolve()
+ guard case let .unavailable(failure) = result else {
+ Issue.record("Expected unavailable planning composition")
+ continue
+ }
+ #expect(failure.code == expected)
+ #expect(!result.planningTransactionAvailable)
+ }
+ }
+
+ @Test("durable recovery completes before readiness is exposed")
+ func recoveryPrecedesReadiness() throws {
+ let fixture = try CompositionFixture(ids: ["recover-one"])
+ try fixture.interrupt("recover-one", at: .planBindingCommitted)
+
+ let readiness = fixture.factory().resolve()
+ let context = try readyContext(readiness)
+ #expect(fixture.events.values.contains("recovery:recover-one"))
+ #expect(fixture.events.values.contains("mutation:recover-one"))
+ #expect(context.recoveredTransactionIDs.keys.sorted() == ["recover-one"])
+ #expect(try context.plans.read(id: "recover-one").machineID == "recover-one")
+ #expect(readiness.planningTransactionAvailable)
+ }
+
+ @Test("corrupt planning journal prevents readiness")
+ func corruptJournalFailsClosed() throws {
+ let fixture = try CompositionFixture(ids: ["corrupt-one"])
+ let directory = fixture.root + "/corrupt-one"
+ try FileManager.default.createDirectory(
+ atPath: directory,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ try Data("not-json\n".utf8).write(
+ to: URL(fileURLWithPath: directory + "/planning-transaction-v1.json")
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: directory + "/planning-transaction-v1.json"
+ )
+
+ guard case let .unavailable(failure) = fixture.factory().resolve() else {
+ Issue.record("Expected corrupt recovery to fail closed")
+ return
+ }
+ #expect(failure.code == .recoveryFailed)
+ #expect(failure.machineID == "corrupt-one")
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ _ = try fixture.plans.read(id: "corrupt-one")
+ }
+ }
+
+ @Test("recovery is isolated per workspace and uses exact repository roots")
+ func crossWorkspaceIsolationAndIdentity() throws {
+ let ids = ["recover-a", "recover-b"]
+ let fixture = try CompositionFixture(ids: ids)
+ try fixture.interrupt("recover-b", at: .workspacePublished)
+ try fixture.interrupt("recover-a", at: .candidateJournalPublished)
+
+ let context = try readyContext(fixture.factory().resolve())
+ #expect(context.recoveredTransactionIDs.keys.sorted() == ids)
+ #expect(fixture.recovery.requestedIDs == ids)
+ #expect(context.identity.stateDirectory == fixture.root)
+ #expect(context.workspaces.root == context.identity.workspaceRepositoryRoot)
+ #expect(context.plans.root == context.identity.resolvedPlanRepositoryRoot)
+ #expect(context.artifactAuthority.root == context.identity.artifactAuthorityRoot)
+ #expect(context.resourceLedger.root
+ == context.identity.resourceAdmissionLedgerRoot)
+ for id in ids {
+ #expect(try context.workspaces.read(id: id).identity.id == id)
+ #expect(try context.plans.read(id: id).machineID == id)
+ }
+ #expect(try context.resourceLedger.snapshot().leases.count == 2)
+ }
+
+ private func readyContext(
+ _ readiness: DoryDaemonVirtualMachineProductionPlanningReadiness
+ ) throws -> DoryDaemonVirtualMachineProductionPlanningContext {
+ guard case let .ready(context) = readiness else {
+ Issue.record("Expected ready production planning composition")
+ throw CompositionTestError.notReady
+ }
+ return context
+ }
+}
+
+private final class CompositionFixture: @unchecked Sendable {
+ let root: String
+ let events = CompositionEvents()
+ let registry: BackendRegistry
+ let backend: RawHVLinuxMachineBackend
+ let workspaces: DoryWorkspaceRepository
+ let plans: DoryResolvedMachinePlanRepository
+ let ledger: DoryVirtualMachineResourceAdmissionLedger
+ let trust: CompositionTrust
+ let authorizer: CompositionMutationAuthority
+ let recovery: CompositionRecovery
+ private let requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest]
+
+ init(ids: [String]) throws {
+ root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-production-planning-composition-\(UUID().uuidString)"
+ ).standardizedFileURL.path
+ try FileManager.default.createDirectory(
+ atPath: root,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ backend = RawHVLinuxMachineBackend(
+ executablePath: "/fixture/dory-hv",
+ operations: MachineBackendCompatibilityOperations(
+ start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) },
+ stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }
+ ),
+ executableIsAvailable: { _ in true }
+ )
+ registry = try BackendRegistry(backends: [backend])
+ workspaces = DoryWorkspaceRepository(root: root)
+ plans = DoryResolvedMachinePlanRepository(root: root)
+ ledger = DoryVirtualMachineResourceAdmissionLedger(
+ root: root + "/.resource-admissions"
+ )
+ var requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest] = [:]
+ var snapshots: [String: DoryDaemonVirtualMachineTrustedInventorySnapshot] = [:]
+ for id in ids {
+ let prepared = Self.requestAndSnapshot(id: id)
+ requests[id] = prepared.request
+ snapshots[id] = prepared.snapshot
+ }
+ self.requests = requests
+ trust = CompositionTrust(snapshots: snapshots)
+ authorizer = CompositionMutationAuthority(events: events)
+ recovery = CompositionRecovery(requests: requests, events: events)
+ }
+
+ deinit { try? FileManager.default.removeItem(atPath: root) }
+
+ func factory(
+ hasMutationAuthority: Bool = true,
+ hasRecoveryProvider: Bool = true
+ ) -> DoryDaemonVirtualMachineProductionPlanningCompositionFactory {
+ return DoryDaemonVirtualMachineProductionPlanningCompositionFactory(
+ stateDirectory: root,
+ backends: [backend],
+ mutationAuthority: hasMutationAuthority ? authorizer : nil,
+ recoveryProvider: hasRecoveryProvider ? recovery : nil,
+ capabilityPlanner: CompositionCapabilityPlanner(),
+ inventoryBuilder: { [trust] artifactAuthority, resourceLedger in
+ _ = artifactAuthority
+ _ = resourceLedger
+ return trust
+ }
+ )
+ }
+
+ func interrupt(
+ _ id: String,
+ at stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage
+ ) throws {
+ let fault = CompositionFault(stage)
+ let coordinator = DoryDaemonVirtualMachinePlanningTransactionCoordinator(
+ stateDirectory: root,
+ registry: registry,
+ trust: trust,
+ mutationAuthority: authorizer,
+ workspaces: workspaces,
+ plans: plans,
+ ledger: ledger,
+ capabilityPlanner: CompositionCapabilityPlanner(),
+ now: { 1_700_000_000_100 },
+ faultInjector: fault.inject
+ )
+ do {
+ _ = try coordinator.resolveReserveAndPublish(try #require(requests[id]))
+ Issue.record("Expected injected transaction interruption")
+ } catch is CompositionInjectedFailure {}
+ }
+
+ private static func requestAndSnapshot(
+ id: String
+ ) -> (
+ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ snapshot: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ ) {
+ let mediaReference = DoryVMResolverReference(
+ namespace: "artifact", identifier: "\(id)-runtime"
+ )
+ let diskReference = DoryVMResolverReference(
+ namespace: "artifact", identifier: "\(id)-disk"
+ )
+ let resources = DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 4 * 1_024 * 1_024 * 1_024,
+ diskBytes: 32 * 1_024 * 1_024 * 1_024
+ )
+ let definition = DoryVirtualMachineDefinition(
+ identity: DoryVirtualMachineIdentity(id: id, name: id),
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ workload: .desktop,
+ boot: DoryVMBootConfiguration(
+ phase: .normal,
+ devices: [DoryVMBootMediaReference(
+ id: "system", role: .system,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifact: mediaReference,
+ removable: false
+ )],
+ order: ["system"]
+ ),
+ backendPreference: DoryVMBackendPreference(
+ mode: .required, backend: .doryHypervisor
+ ),
+ graphics: DoryVMGraphicsPolicy(acceptableLevels: [.none]),
+ resources: resources,
+ storage: [DoryVMStorageAttachment(
+ id: "system-disk", role: .system, artifact: diskReference,
+ capacityBytes: resources.diskBytes
+ )],
+ audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false),
+ input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false),
+ lifecycle: DoryVMLifecycleMetadata(
+ revision: 1,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000
+ )
+ )
+ let machine = DoryMachineConfiguration(
+ id: id,
+ kernelPath: "/fixture/\(id)-kernel",
+ rootfsPath: "/fixture/\(id).raw",
+ bootMode: .linuxKernel,
+ displayMode: .desktop
+ )
+ let media = DoryBootMedia(
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifactSHA256: compositionDigest(id.last ?? "a")
+ )
+ let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let evidence = DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: compositionQualificationIdentity(media),
+ qualificationReportSHA256: compositionDigest("b"),
+ signingKeyID: "dory-test-key",
+ qualificationFormatVersion: 1,
+ guest: definition.guest,
+ bootMediaKind: media.kind,
+ immutableArtifactSHA256: media.artifactSHA256,
+ backend: .doryHypervisor,
+ backendRuntimeBuildID: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ graphics: .none,
+ devices: devices
+ )
+ let hostQualification = DoryResolvedHostQualificationEvidence(
+ qualificationIdentity: evidence.qualificationIdentity,
+ qualificationReportSHA256: evidence.qualificationReportSHA256,
+ hostHardwareModelIdentifier: "Mac16.1",
+ hostOperatingSystemBuild: "26A5406c",
+ backend: .doryHypervisor,
+ backendRuntimeBuildIdentifier: "raw-runtime-1",
+ virtualHardwareABIVersion: 1,
+ qualifierIdentifier: "dory-test-qualifier",
+ qualifierVersion: 1
+ )
+ let snapshot = DoryDaemonVirtualMachineTrustedInventorySnapshot(
+ hostFacts: compositionHostFacts(),
+ media: DoryDaemonVirtualMachineResolvedMedia(
+ reference: mediaReference, media: media
+ ),
+ backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory(
+ backend: .doryHypervisor,
+ runtimeBuildIdentifier: "raw-runtime-1",
+ components: [DoryResolvedBackendComponentEvidence(
+ componentIdentifier: "dory-hv",
+ buildIdentifier: "raw-runtime-1",
+ artifactSHA256: compositionDigest("c")
+ )],
+ hostQualification: hostQualification
+ )],
+ resourceAdmission: compositionAdmission(resources)
+ )
+ let planning = DoryDaemonVirtualMachinePlanningRequest(
+ definition: definition,
+ canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(definition),
+ machine: machine,
+ publication: .create
+ )
+ return (
+ DoryDaemonVirtualMachinePlanningTransactionRequest(
+ planning: planning,
+ workspacePublication: .create
+ ),
+ snapshot
+ )
+ }
+}
+
+private final class CompositionTrust:
+ DoryDaemonVirtualMachineTrustInventory,
+ DoryDaemonVirtualMachinePlanningTrustPreparing,
+ @unchecked Sendable
+{
+ let snapshots: [String: DoryDaemonVirtualMachineTrustedInventorySnapshot]
+ init(snapshots: [String: DoryDaemonVirtualMachineTrustedInventorySnapshot]) {
+ self.snapshots = snapshots
+ }
+
+ func preparePlanningTrust(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningTrustPreparation {
+ let base = try #require(snapshots[request.machineID])
+ return DoryDaemonVirtualMachinePlanningTrustPreparation(
+ hostResources: compositionHostResources(),
+ snapshot: { admission in
+ var snapshot = base
+ snapshot.resourceAdmission = admission
+ return snapshot
+ },
+ publicationAuthorization:
+ DoryDaemonVirtualMachinePlanningPublicationAuthorization {}
+ )
+ }
+
+ func planningInventory(
+ for request: DoryDaemonVirtualMachineInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot {
+ try #require(snapshots[request.machineID])
+ }
+
+ func startInventory(
+ for request: DoryDaemonVirtualMachineStartInventoryRequest
+ ) throws -> DoryDaemonVirtualMachineTrustedInventorySnapshot {
+ try #require(snapshots[request.machineID])
+ }
+}
+
+private final class CompositionRecovery:
+ DoryDaemonVirtualMachinePlanningRecoveryProviding, @unchecked Sendable
+{
+ private let lock = NSLock()
+ private let requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest]
+ private let events: CompositionEvents
+ private var storage: [String] = []
+
+ init(
+ requests: [String: DoryDaemonVirtualMachinePlanningTransactionRequest],
+ events: CompositionEvents
+ ) {
+ self.requests = requests
+ self.events = events
+ }
+
+ var requestedIDs: [String] { lock.withLock { storage } }
+
+ func recoveryRequest(
+ for machineID: String
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? {
+ lock.withLock { storage.append(machineID) }
+ events.append("recovery:\(machineID)")
+ return requests[machineID]
+ }
+}
+
+private final class CompositionMutationAuthority:
+ DoryDaemonVirtualMachinePlanningMutationAuthorizing, @unchecked Sendable
+{
+ private let events: CompositionEvents
+ init(events: CompositionEvents) { self.events = events }
+
+ func acquirePlanningMutationFence(
+ machine: DoryMachineConfiguration,
+ definition: DoryVirtualMachineDefinition,
+ canonicalDefinitionData: Data
+ ) throws -> DoryDaemonVirtualMachinePlanningMutationFence {
+ guard machine.id == definition.identity.id, !canonicalDefinitionData.isEmpty else {
+ throw CompositionTestError.invalidAuthority
+ }
+ events.append("mutation:\(machine.id)")
+ return DoryDaemonVirtualMachinePlanningMutationFence(
+ authority: DoryDaemonVirtualMachinePlanningMachineAuthority(
+ machineID: machine.id,
+ legacyConfigurationSHA256: compositionDigest("e"),
+ migrationFactsSHA256: compositionDigest("f"),
+ sourceDefinitionRevision: definition.lifecycle.revision,
+ sourceDefinitionSHA256:
+ DoryDaemonVirtualMachinePlanningCoordinator.sha256(
+ canonicalDefinitionData
+ ),
+ runtimeIdentitySHA256: compositionDigest("a")
+ ),
+ retainedAuthority: machine.id,
+ validation: {}
+ )
+ }
+}
+
+private struct CompositionCapabilityPlanner: DoryDaemonVirtualMachineCapabilityPlanning {
+ func plan(
+ _ request: DoryVirtualMachineBackendPlanRequest,
+ inventory: DoryDaemonVirtualMachineTrustedInventorySnapshot
+ ) -> DoryVirtualMachineBackendPlanResult {
+ let capabilityRequest = DoryVirtualMachineCapabilityRequest(
+ guest: request.guest,
+ bootMedia: request.bootMedia,
+ backend: .doryHypervisor,
+ graphics: request.acceptableGraphics.first ?? .none,
+ devices: request.devices,
+ virtualHardwareABIVersion: request.virtualHardwareABIVersion
+ )
+ let descriptor = DoryVirtualMachineCapabilityDescriptor(
+ evaluatorVersion:
+ DoryVirtualMachineCapabilityDescriptor.appleSiliconEvaluatorVersion,
+ request: capabilityRequest,
+ availability: DoryCapabilityAvailability(
+ supportTier: .supported, state: .available
+ ),
+ resolvedDevices: request.devices,
+ runtimeQualificationEvidence: DoryVirtualMachineRuntimeQualificationEvidence(
+ qualificationIdentity: compositionQualificationIdentity(request.bootMedia),
+ qualificationReportSHA256: compositionDigest("b"),
+ signingKeyID: "dory-test-key",
+ qualificationFormatVersion: 1,
+ guest: request.guest,
+ bootMediaKind: request.bootMedia.kind,
+ immutableArtifactSHA256: request.bootMedia.artifactSHA256,
+ backend: .doryHypervisor,
+ backendRuntimeBuildID: "raw-runtime-1",
+ virtualHardwareABIVersion: request.virtualHardwareABIVersion,
+ graphics: capabilityRequest.graphics,
+ devices: request.devices
+ )
+ )
+ _ = inventory
+ return DoryVirtualMachineBackendPlanResult(
+ selectedDescriptor: descriptor,
+ evaluatedDescriptors: [descriptor],
+ failure: nil
+ )
+ }
+}
+
+private final class CompositionEvents: @unchecked Sendable {
+ private let lock = NSLock()
+ private var storage: [String] = []
+ var values: [String] { lock.withLock { storage } }
+ func append(_ value: String) { lock.withLock { storage.append(value) } }
+}
+
+private final class CompositionFault: @unchecked Sendable {
+ private let lock = NSLock()
+ private var stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage?
+ init(_ stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage) {
+ self.stage = stage
+ }
+ func inject(
+ _ current: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage
+ ) throws {
+ try lock.withLock {
+ if stage == current {
+ stage = nil
+ throw CompositionInjectedFailure()
+ }
+ }
+ }
+}
+
+private enum CompositionTestError: Error { case notReady, invalidAuthority }
+private struct CompositionInjectedFailure: Error {}
+
+private func compositionHostResources() -> DoryVMHostResources {
+ DoryVMHostResources(
+ logicalCPUCount: 12,
+ physicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024,
+ freeStorageBytes: 512 * 1_024 * 1_024 * 1_024
+ )
+}
+
+private func compositionHostFacts() -> DoryAppleSiliconHostFacts {
+ DoryAppleSiliconHostFacts(
+ macOSMajorVersion: 26,
+ virtualizationFrameworkAvailable: true,
+ hypervisorFrameworkAvailable: true,
+ doryHypervisorAvailable: true,
+ qemuHypervisorFrameworkAvailable: false,
+ windowsUEFIFirmwareAvailable: false,
+ windowsSecureBootAvailable: false,
+ windowsSBSADeviceModelAvailable: false,
+ virtualTPM20Available: false,
+ windowsGuestDrivers: DoryWindowsGuestDriverFacts(
+ storageAvailable: false, networkAvailable: false,
+ displayAvailable: false, inputAvailable: false
+ ),
+ macOSGuestVirtualizationSupported: false,
+ macOSRestoreImageInstallationSupported: false,
+ doryMacOSBackendAvailable: false,
+ doryMacOSBackendQualified: false,
+ metalAvailable: true,
+ doryAcceleratedRendererAvailable: true,
+ runtimeQualificationContext: DoryVirtualMachineRuntimeQualificationHostContext(
+ virtualHardwareABIVersion: 1,
+ doryHypervisorRuntimeBuildID: "raw-runtime-1",
+ virtualizationFrameworkAdapterBuildID: "vz-runtime-1",
+ qemuRuntimeBuildID: ""
+ )
+ )
+}
+
+private func compositionAdmission(
+ _ resources: DoryVMResourceRequest
+) -> DoryResolvedMachineResourceAdmissionEvidence {
+ DoryResolvedMachineResourceAdmissionEvidence(
+ admittedVirtualCPUCount: resources.virtualCPUCount,
+ admittedMemoryBytes: resources.memoryBytes,
+ admittedStorageBytes: resources.diskBytes,
+ hostLogicalCPUCount: 12,
+ hostPhysicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024,
+ hostFreeStorageBytes: 512 * 1_024 * 1_024 * 1_024,
+ existingVirtualCPUCommitment: 0,
+ existingMemoryCommitmentBytes: 0,
+ existingStorageReservationBytes: 0,
+ hostReservedLogicalCPUCount: 2,
+ hostReservedMemoryBytes: 8 * 1_024 * 1_024 * 1_024,
+ hostReservedStorageBytes: 32 * 1_024 * 1_024 * 1_024,
+ admissionIdentity: "composition-dummy-admission",
+ admissionReportSHA256: compositionDigest("d"),
+ assessorIdentifier: DoryVirtualMachineResourceAdmissionLedger.assessorIdentifier,
+ assessorVersion: DoryVirtualMachineResourceAdmissionLedger.assessorVersion
+ )
+}
+
+private func compositionDigest(_ value: Character) -> String {
+ String(repeating: String(value), count: 64)
+}
+
+private func compositionQualificationIdentity(_ media: DoryBootMedia) -> String {
+ "qualification-\((media.artifactSHA256 ?? "missing").prefix(12))"
+}
From 29adae04ca95e90fe166810d27d5ba307140fc18 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 18:23:36 +0000
Subject: [PATCH 037/338] feat(vm): enforce per-workspace launch authority
---
.../DorydKit/DoryMachineRuntimeIdentity.swift | 1 +
.../DoryMachineRuntimeIdentityStore.swift | 388 ++++++++++
.../Sources/DorydKit/MachineManager.swift | 714 ++++++++++++++++--
...DoryMachineRuntimeIdentityStoreTests.swift | 151 ++++
...eManagerResolvedPlanIntegrationTests.swift | 542 +++++++++++++
5 files changed, 1723 insertions(+), 73 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentityStore.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityStoreTests.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift
index 8014b3e6..f5e2c365 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentity.swift
@@ -15,6 +15,7 @@ public enum DoryMachineRuntimeIdentityInvalidationReason: String, Codable, Senda
case restoredSnapshot = "restored-snapshot"
case planRecoveryFailed = "plan-recovery-failed"
case planNotInstalled = "plan-not-installed"
+ case importedSnapshot = "imported-snapshot"
}
public enum DoryMachineRuntimeIdentityValidationCode: String, Codable, Sendable, Hashable {
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentityStore.swift b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentityStore.swift
new file mode 100644
index 00000000..28326116
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineRuntimeIdentityStore.swift
@@ -0,0 +1,388 @@
+import CryptoKit
+import Darwin
+import Foundation
+
+enum DoryMachineRuntimeIdentityStoreError: Error, Equatable {
+ case invalidMachineID
+ case recordNotFound
+ case invalidRecord
+ case authorityMismatch
+ case filesystem(String)
+}
+
+struct DoryPersistedMachineRuntimeIdentity: Codable, Sendable, Equatable {
+ static let schemaVersion: UInt16 = 2
+
+ var schemaVersion: UInt16
+ var machineID: String
+ var authorityRevision: UInt64
+ var previousRecordSHA256: String?
+ var legacyConfigurationSHA256: String
+ var identity: DoryMachineRuntimeIdentity
+
+ init(
+ machineID: String,
+ authorityRevision: UInt64,
+ previousRecordSHA256: String?,
+ legacyConfigurationSHA256: String,
+ identity: DoryMachineRuntimeIdentity
+ ) {
+ schemaVersion = Self.schemaVersion
+ self.machineID = machineID
+ self.authorityRevision = authorityRevision
+ self.previousRecordSHA256 = previousRecordSHA256?.lowercased()
+ self.legacyConfigurationSHA256 = legacyConfigurationSHA256.lowercased()
+ self.identity = identity
+ }
+
+ var isValid: Bool {
+ schemaVersion == Self.schemaVersion
+ && Self.isValidMachineID(machineID)
+ && authorityRevision > 0
+ && ((authorityRevision == 1 && previousRecordSHA256 == nil)
+ || (authorityRevision > 1
+ && previousRecordSHA256.map(Self.isSHA256) == true))
+ && Self.isSHA256(legacyConfigurationSHA256)
+ && identity.validate().isEmpty
+ && (identity.resolvedPlan?.machineID == machineID
+ || identity.resolvedPlan == nil)
+ }
+
+ private static func isValidMachineID(_ value: String) -> Bool {
+ value.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil
+ && !value.hasPrefix(".")
+ }
+
+ private static func isSHA256(_ value: String) -> Bool {
+ value.utf8.count == 64 && value.utf8.allSatisfy {
+ (48...57).contains($0) || (97...102).contains($0)
+ }
+ }
+}
+
+private struct DoryMachineRuntimeIdentityHead: Codable, Sendable, Equatable {
+ static let schemaVersion: UInt16 = 1
+
+ var schemaVersion: UInt16 = Self.schemaVersion
+ var machineID: String
+ var authorityRevision: UInt64
+ var recordSHA256: String
+
+ var isValid: Bool {
+ schemaVersion == Self.schemaVersion
+ && authorityRevision > 0
+ && machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil
+ && !machineID.hasPrefix(".")
+ && recordSHA256.utf8.count == 64
+ && recordSHA256.utf8.allSatisfy {
+ (48...57).contains($0) || (97...102).contains($0)
+ }
+ }
+}
+
+/// Durable per-workspace launch authority. The record contains no paths or secrets and is bound
+/// to the exact authoritative legacy machine bytes, so a machine.json change cannot retain stale
+/// resolved or compatibility authority across daemon restart.
+final class DoryMachineRuntimeIdentityStore: @unchecked Sendable {
+ static let recordFileName = "runtime-identity-v1.json"
+ static let headFileName = "runtime-identity-head-v1.json"
+ static let recordTemporaryPrefix = ".runtime-identity-v1.tmp-"
+ static let headTemporaryPrefix = ".runtime-head.tmp-"
+ private static let maximumRecordBytes: Int64 = 16 * 1_024 * 1_024
+
+ let root: String
+ private let lock = NSLock()
+
+ init(root: String) {
+ self.root = URL(fileURLWithPath: root).standardizedFileURL.path
+ }
+
+ func readIfPresent(
+ machineID: String,
+ authoritativeLegacyData: Data
+ ) throws -> DoryMachineRuntimeIdentity? {
+ lock.lock()
+ defer { lock.unlock() }
+ let path = try recordPath(machineID: machineID)
+ let headPath = try headPath(machineID: machineID)
+ let hasRecord = Self.pathExists(path)
+ let hasHead = Self.pathExists(headPath)
+ guard hasRecord || hasHead else { return nil }
+ guard hasRecord, hasHead else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ let data = try Self.secureRead(path: path)
+ guard let record = try? JSONDecoder().decode(
+ DoryPersistedMachineRuntimeIdentity.self,
+ from: data
+ ), record.isValid, record.machineID == machineID else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ let head = try decodeHead(path: headPath, machineID: machineID)
+ let recordSHA256 = Self.sha256(data)
+ if head.recordSHA256 != recordSHA256
+ || head.authorityRevision != record.authorityRevision {
+ // Identity is published before its monotonic head. Complete that one permissible
+ // crash ordering; every rollback/substitution shape fails closed.
+ guard record.authorityRevision == head.authorityRevision + 1,
+ record.previousRecordSHA256 == head.recordSHA256 else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ try publishHead(
+ DoryMachineRuntimeIdentityHead(
+ machineID: machineID,
+ authorityRevision: record.authorityRevision,
+ recordSHA256: recordSHA256
+ ),
+ path: headPath
+ )
+ }
+ guard record.legacyConfigurationSHA256 == Self.sha256(authoritativeLegacyData) else {
+ throw DoryMachineRuntimeIdentityStoreError.authorityMismatch
+ }
+ return record.identity
+ }
+
+ func publish(
+ _ identity: DoryMachineRuntimeIdentity,
+ machineID: String,
+ authoritativeLegacyData: Data
+ ) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ let path = try recordPath(machineID: machineID)
+ let headPath = try headPath(machineID: machineID)
+ let previous: DoryMachineRuntimeIdentityHead?
+ if Self.pathExists(path) || Self.pathExists(headPath) {
+ guard Self.pathExists(path), Self.pathExists(headPath) else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ let oldData = try Self.secureRead(path: path)
+ let oldHead = try decodeHead(path: headPath, machineID: machineID)
+ guard oldHead.recordSHA256 == Self.sha256(oldData),
+ let oldRecord = try? JSONDecoder().decode(
+ DoryPersistedMachineRuntimeIdentity.self,
+ from: oldData
+ ), oldRecord.isValid,
+ oldRecord.authorityRevision == oldHead.authorityRevision else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ previous = oldHead
+ } else {
+ previous = nil
+ }
+ guard previous?.authorityRevision != UInt64.max else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ let authorityRevision = (previous?.authorityRevision ?? 0) + 1
+ let record = DoryPersistedMachineRuntimeIdentity(
+ machineID: machineID,
+ authorityRevision: authorityRevision,
+ previousRecordSHA256: previous?.recordSHA256,
+ legacyConfigurationSHA256: Self.sha256(authoritativeLegacyData),
+ identity: identity
+ )
+ guard record.isValid else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ let directory = URL(fileURLWithPath: path).deletingLastPathComponent().path
+ guard Self.isPrivateDirectory(directory) else {
+ throw DoryMachineRuntimeIdentityStoreError.filesystem(
+ "runtime identity owner is not a private directory"
+ )
+ }
+ let data = try Self.encoded(record)
+ guard !data.isEmpty, data.count <= Int(Self.maximumRecordBytes) else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ let temporary = directory + "/\(Self.recordTemporaryPrefix)\(UUID().uuidString.lowercased())"
+ let descriptor = open(
+ temporary,
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
+ mode_t(0o600)
+ )
+ guard descriptor >= 0 else {
+ throw Self.filesystem("create runtime identity temporary record")
+ }
+ var removeTemporary = true
+ defer {
+ _ = close(descriptor)
+ if removeTemporary { _ = unlink(temporary) }
+ }
+ try data.withUnsafeBytes { bytes in
+ var remaining = bytes.count
+ var pointer = bytes.baseAddress!
+ while remaining > 0 {
+ let count = Darwin.write(descriptor, pointer, remaining)
+ if count > 0 {
+ remaining -= count
+ pointer = pointer.advanced(by: count)
+ } else if count < 0, errno == EINTR {
+ continue
+ } else {
+ throw Self.filesystem("write runtime identity temporary record")
+ }
+ }
+ }
+ guard fsync(descriptor) == 0, fchmod(descriptor, mode_t(0o600)) == 0 else {
+ throw Self.filesystem("sync runtime identity temporary record")
+ }
+ guard rename(temporary, path) == 0 else {
+ throw Self.filesystem("publish runtime identity record")
+ }
+ removeTemporary = false
+ try Self.syncDirectory(directory)
+ try publishHead(
+ DoryMachineRuntimeIdentityHead(
+ machineID: machineID,
+ authorityRevision: authorityRevision,
+ recordSHA256: Self.sha256(data)
+ ),
+ path: headPath
+ )
+ }
+
+ private func recordPath(machineID: String) throws -> String {
+ guard machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil,
+ !machineID.hasPrefix(".") else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidMachineID
+ }
+ return root + "/" + machineID + "/" + Self.recordFileName
+ }
+
+ private func headPath(machineID: String) throws -> String {
+ _ = try recordPath(machineID: machineID)
+ return root + "/" + machineID + "/" + Self.headFileName
+ }
+
+ private func decodeHead(
+ path: String,
+ machineID: String
+ ) throws -> DoryMachineRuntimeIdentityHead {
+ let data = try Self.secureRead(path: path)
+ guard let head = try? JSONDecoder().decode(
+ DoryMachineRuntimeIdentityHead.self,
+ from: data
+ ), head.isValid, head.machineID == machineID else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ return head
+ }
+
+ private func publishHead(
+ _ head: DoryMachineRuntimeIdentityHead,
+ path: String
+ ) throws {
+ guard head.isValid else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ try Self.publishData(Self.encoded(head), path: path, prefix: Self.headTemporaryPrefix)
+ }
+
+ private static func encoded(_ value: T) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ return try encoder.encode(value)
+ }
+
+ private static func secureRead(path: String) throws -> Data {
+ let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)
+ guard descriptor >= 0 else { throw filesystem("open runtime identity record") }
+ defer { _ = close(descriptor) }
+ var info = stat()
+ guard fstat(descriptor, &info) == 0,
+ (info.st_mode & S_IFMT) == S_IFREG,
+ info.st_uid == getuid(),
+ info.st_nlink == 1,
+ (info.st_mode & 0o077) == 0,
+ info.st_size > 0,
+ info.st_size <= maximumRecordBytes else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false)
+ let data = try handle.readToEnd() ?? Data()
+ guard data.count == Int(info.st_size) else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ return data
+ }
+
+ private static func publishData(
+ _ data: Data,
+ path: String,
+ prefix: String
+ ) throws {
+ guard !data.isEmpty, data.count <= Int(maximumRecordBytes) else {
+ throw DoryMachineRuntimeIdentityStoreError.invalidRecord
+ }
+ let directory = URL(fileURLWithPath: path).deletingLastPathComponent().path
+ guard isPrivateDirectory(directory) else {
+ throw DoryMachineRuntimeIdentityStoreError.filesystem(
+ "runtime identity owner is not a private directory"
+ )
+ }
+ let temporary = directory + "/\(prefix)\(UUID().uuidString.lowercased())"
+ let descriptor = open(
+ temporary,
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
+ mode_t(0o600)
+ )
+ guard descriptor >= 0 else { throw filesystem("create runtime authority temporary") }
+ var removeTemporary = true
+ defer {
+ _ = close(descriptor)
+ if removeTemporary { _ = unlink(temporary) }
+ }
+ try data.withUnsafeBytes { bytes in
+ var remaining = bytes.count
+ var pointer = bytes.baseAddress!
+ while remaining > 0 {
+ let written = Darwin.write(descriptor, pointer, remaining)
+ if written > 0 {
+ remaining -= written
+ pointer = pointer.advanced(by: written)
+ } else if written < 0, errno == EINTR {
+ continue
+ } else {
+ throw filesystem("write runtime authority temporary")
+ }
+ }
+ }
+ guard fsync(descriptor) == 0, fchmod(descriptor, mode_t(0o600)) == 0 else {
+ throw filesystem("sync runtime authority temporary")
+ }
+ guard rename(temporary, path) == 0 else {
+ throw filesystem("publish runtime authority")
+ }
+ removeTemporary = false
+ try syncDirectory(directory)
+ }
+
+ private static func isPrivateDirectory(_ path: String) -> Bool {
+ var info = stat()
+ return lstat(path, &info) == 0
+ && (info.st_mode & S_IFMT) == S_IFDIR
+ && info.st_uid == getuid()
+ && (info.st_mode & 0o077) == 0
+ }
+
+ private static func pathExists(_ path: String) -> Bool {
+ var info = stat()
+ return lstat(path, &info) == 0
+ }
+
+ private static func syncDirectory(_ path: String) throws {
+ let descriptor = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC)
+ guard descriptor >= 0 else { throw filesystem("open runtime identity directory") }
+ defer { _ = close(descriptor) }
+ guard fsync(descriptor) == 0 else { throw filesystem("sync runtime identity directory") }
+ }
+
+ private static func sha256(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func filesystem(_ action: String) -> DoryMachineRuntimeIdentityStoreError {
+ .filesystem(action + ": " + String(cString: strerror(errno)))
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 9398d066..421b330b 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -694,6 +694,9 @@ public enum DoryMachineLaunchPolicy: String, Sendable, Equatable {
case legacyCompatibility
/// A start is rejected unless the complete persisted-plan trust path is installed.
case requireResolvedPlan
+ /// Each workspace is dispatched solely from its durable runtime identity. Existing machines
+ /// may retain explicit legacy compatibility while new and invalidated machines require a plan.
+ case perWorkspaceAuthority
}
public final class MachineManager: @unchecked Sendable {
@@ -707,6 +710,12 @@ public final class MachineManager: @unchecked Sendable {
private static let installedLinuxKernelName = "direct-kernel"
private static let installedLinuxInitrdName = "direct-initrd"
private static let machineMetadataTemporaryPrefix = ".dory-machine-metadata-"
+ private static let interruptedNativeCreationQuarantinePrefix =
+ ".dory-native-create-recovery-"
+ private static let nativeCreationPrecommitMarkerName =
+ ".dory-native-create-precommit-v1"
+ private static let nativeCreationCommittedMarkerName =
+ ".dory-native-create-committed-v1"
private static let machineRestoreBackupMarker = ".restore-"
private static let snapshotDeletionQuarantinePrefix = ".dory-snapshot-delete-"
private static let snapshotDiskTemporaryMarker = ".ext4.tmp-"
@@ -732,6 +741,7 @@ public final class MachineManager: @unchecked Sendable {
private let balloonController: any MachineBalloonControlling
private let processStarter: ProcessStarter
private let workspaceRepository: DoryWorkspaceRepository
+ private let runtimeIdentityStore: DoryMachineRuntimeIdentityStore
private let launchPolicy: DoryMachineLaunchPolicy
private let lifecycleJournalStore: DoryOperationJournalStore?
private let lifecycleJournalInitializationError: String?
@@ -770,6 +780,9 @@ public final class MachineManager: @unchecked Sendable {
self.agentConnector = agentConnector
self.processStarter = processStarter
self.workspaceRepository = DoryWorkspaceRepository(root: configuration.stateDirectory)
+ self.runtimeIdentityStore = DoryMachineRuntimeIdentityStore(
+ root: configuration.stateDirectory
+ )
do {
let store = try DoryOperationJournalStore(
home: configuration.lifecycleJournalHome
@@ -802,9 +815,17 @@ public final class MachineManager: @unchecked Sendable {
Self.removeStaleMachineMetadataArtifacts(stateDirectory: configuration.stateDirectory)
Self.removeStaleSnapshotArtifacts(stateDirectory: configuration.stateDirectory)
Self.restrictWorkspaceProjectionRootIfOwned(configuration.stateDirectory)
+ Self.recoverCompletedNativeCreationMarkers(
+ stateDirectory: configuration.stateDirectory,
+ runtimeIdentityStore: runtimeIdentityStore
+ )
+ Self.removeInterruptedNativeCreations(
+ stateDirectory: configuration.stateDirectory
+ )
self.machines = Self.loadPersistedMachines(
configuration: configuration,
- launchPolicy: launchPolicy
+ launchPolicy: launchPolicy,
+ runtimeIdentityStore: runtimeIdentityStore
)
for (machineID, diagnostic) in lifecycleRecoveryDiagnostics {
machines[machineID]?.lastError = diagnostic
@@ -813,6 +834,10 @@ public final class MachineManager: @unchecked Sendable {
recoverInterruptedDesktopUpdates()
}
+ public var configuredLaunchPolicy: DoryMachineLaunchPolicy { launchPolicy }
+
+ public var managedStateDirectory: String { configuration.stateDirectory }
+
/// Installs the daemon-owned active-component resolver. Desktop update writes fail closed until
/// this is installed; caller-supplied filesystem paths are never accepted as update authority.
public func installDesktopUpdateArtifactResolver(
@@ -834,9 +859,10 @@ public final class MachineManager: @unchecked Sendable {
) throws {
operationLock.lock()
defer { operationLock.unlock() }
- guard launchPolicy == .requireResolvedPlan else {
+ guard launchPolicy == .requireResolvedPlan
+ || launchPolicy == .perWorkspaceAuthority else {
throw MachineManagerError.persistence(
- "resolved launch infrastructure requires the requireResolvedPlan policy"
+ "resolved launch infrastructure requires a resolved-plan-capable policy"
)
}
guard resolvedLaunchRegistry == nil, resolvedLaunchPlanResolver == nil,
@@ -957,18 +983,48 @@ public final class MachineManager: @unchecked Sendable {
}
}
+ let nativeCreationMarker = statePath + "/"
+ + Self.nativeCreationPrecommitMarkerName
+ try Self.writeDurablePrivateData(
+ Self.nativeCreationPrecommitMarkerData(machineID: machine.id),
+ toPath: nativeCreationMarker
+ )
+ try Self.syncDirectory(path: statePath)
+
let preparedMachine = try prepareMachineArtifacts(machine)
try validateManagedMachineArtifacts(preparedMachine)
+ let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine()
+ let authoritativeLegacyData = try DoryMachineConfigurationMigrationBridge
+ .encodeLegacy(preparedMachine)
+ // The launch authority is durable before machine.json becomes discoverable. A crash in
+ // this window leaves an incomplete, non-loadable directory, never a newly created VM that
+ // startup can mistake for pre-companion legacy state.
+ try runtimeIdentityStore.publish(
+ initialRuntimeIdentity,
+ machineID: preparedMachine.id,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
try persist(preparedMachine)
+ // machine.json is now durable. Preserve this exact state on a marker-transition error so
+ // restart can complete it; never report success until the committed marker is directory-
+ // durable. Keeping the committed marker also distinguishes later metadata loss from an
+ // interrupted precommit and prevents destructive cleanup.
+ committed = true
+ let committedMarker = statePath + "/"
+ + Self.nativeCreationCommittedMarkerName
+ guard rename(nativeCreationMarker, committedMarker) == 0 else {
+ throw MachineManagerError.persistence(
+ "could not commit native creation authority marker"
+ )
+ }
+ try Self.syncDirectory(path: statePath)
lock.lock()
- let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine()
machines[machine.id] = MachineEntry(
configuration: preparedMachine,
state: .created,
runtimeIdentity: initialRuntimeIdentity
)
lock.unlock()
- committed = true
return DoryMachineStatus(
id: preparedMachine.id,
state: .created,
@@ -1001,29 +1057,11 @@ public final class MachineManager: @unchecked Sendable {
) throws -> DoryMachineStatus {
switch launchPolicy {
case .legacyCompatibility:
- let prepared = try prepareMachineStartWithLifecycle(
+ return try startLegacyMachine(
id: id,
- requiresAuthoritativeDefinition: false,
- journalLifecycle: journalLifecycle
+ journalLifecycle: journalLifecycle,
+ expectedDurableIdentity: nil
)
- let identity = try currentRuntimeIdentity(id: id)
- let lifecycle = try journalLifecycle
- ? beginLifecycleStart(machine: prepared.machine, targetIdentity: identity)
- : nil
- do {
- if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
- let status = try spawnPreparedMachine(prepared.machine, launchBinding: nil)
- if let lifecycle, status.state != .starting {
- _ = completeCommittedLifecycle(
- lifecycle,
- diagnostic: "running machine has an unfinished start journal"
- )
- }
- return status
- } catch {
- if let lifecycle { failLifecycle(lifecycle, stepID: "start.failed") }
- throw error
- }
case .requireResolvedPlan:
guard let registry = resolvedLaunchRegistry,
let resolver = resolvedLaunchPlanResolver,
@@ -1039,8 +1077,83 @@ public final class MachineManager: @unchecked Sendable {
resolver: resolver,
planStore: planStore,
revisionProvider: revisionProvider,
- journalLifecycle: journalLifecycle
+ journalLifecycle: journalLifecycle,
+ expectedRuntimeIdentity: nil
)
+ case .perWorkspaceAuthority:
+ let identity = try currentDurableRuntimeIdentity(id: id)
+ switch identity.mode {
+ case .legacyCompatibility:
+ return try startLegacyMachine(
+ id: id,
+ journalLifecycle: journalLifecycle,
+ expectedDurableIdentity: identity
+ )
+ case .requiresReplanning:
+ throw MachineManagerError.persistence(
+ "machine \(id) requires a resolved plan before launch"
+ )
+ case .resolvedPlan:
+ guard let registry = resolvedLaunchRegistry,
+ let resolver = resolvedLaunchPlanResolver,
+ let planStore = resolvedLaunchPlanStore,
+ let revisionProvider = resolvedPlanRevisionProvider else {
+ throw MachineManagerError.persistence(
+ "resolved launch infrastructure is not installed"
+ )
+ }
+ return try startResolvedMachine(
+ id: id,
+ registry: registry,
+ resolver: resolver,
+ planStore: planStore,
+ revisionProvider: revisionProvider,
+ journalLifecycle: journalLifecycle,
+ expectedRuntimeIdentity: identity
+ )
+ }
+ }
+ }
+
+ private func startLegacyMachine(
+ id: String,
+ journalLifecycle: Bool,
+ expectedDurableIdentity: DoryMachineRuntimeIdentity?
+ ) throws -> DoryMachineStatus {
+ let prepared = try prepareMachineStartWithLifecycle(
+ id: id,
+ requiresAuthoritativeDefinition: false,
+ journalLifecycle: journalLifecycle
+ )
+ let identity = try currentRuntimeIdentity(id: id)
+ guard identity.mode == .legacyCompatibility else {
+ throw MachineManagerError.persistence(
+ "legacy launch requires explicit compatibility authority"
+ )
+ }
+ if let expectedDurableIdentity {
+ try revalidateDurableRuntimeIdentity(
+ id: id,
+ expected: expectedDurableIdentity,
+ expectedMachine: prepared.machine
+ )
+ }
+ let lifecycle = try journalLifecycle
+ ? beginLifecycleStart(machine: prepared.machine, targetIdentity: identity)
+ : nil
+ do {
+ if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
+ let status = try spawnPreparedMachine(prepared.machine, launchBinding: nil)
+ if let lifecycle, status.state != .starting {
+ _ = completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "running machine has an unfinished start journal"
+ )
+ }
+ return status
+ } catch {
+ if let lifecycle { failLifecycle(lifecycle, stepID: "start.failed") }
+ throw error
}
}
@@ -1050,7 +1163,8 @@ public final class MachineManager: @unchecked Sendable {
resolver: any DoryDaemonVirtualMachineLaunchPlanResolving,
planStore: any DoryResolvedMachinePlanStoring,
revisionProvider: ResolvedPlanRevisionProvider,
- journalLifecycle: Bool
+ journalLifecycle: Bool,
+ expectedRuntimeIdentity: DoryMachineRuntimeIdentity?
) throws -> DoryMachineStatus {
let prepared = try prepareMachineStartWithLifecycle(
id: id,
@@ -1103,6 +1217,18 @@ public final class MachineManager: @unchecked Sendable {
resolvedPlan: resolved.resolvedPlan,
planSHA256: resolved.resolvedPlanSHA256
)
+ if let expectedRuntimeIdentity, runtimeIdentity != expectedRuntimeIdentity {
+ throw MachineManagerError.persistence(
+ "resolved launch does not match the workspace's durable runtime authority"
+ )
+ }
+ if let expectedRuntimeIdentity {
+ try revalidateDurableRuntimeIdentity(
+ id: id,
+ expected: expectedRuntimeIdentity,
+ expectedMachine: prepared.machine
+ )
+ }
let lifecycle = try journalLifecycle
? beginLifecycleStart(machine: prepared.machine, targetIdentity: runtimeIdentity)
: nil
@@ -1891,6 +2017,21 @@ public final class MachineManager: @unchecked Sendable {
guard updated != current else {
return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
}
+ if launchPolicy == .perWorkspaceAuthority {
+ if wasRunning {
+ _ = try stopImplementation(id: id, journalLifecycle: true)
+ }
+ if current.bootMode == .efi,
+ current.installerISOPath != nil,
+ updated.installerISOPath == nil {
+ try ensureInstalledLinuxBootBundleIfNeeded(updated)
+ }
+ try persist(updated)
+ try publishConfiguration(updated)
+ // Every desired-state mutation invalidates the former plan. The workspace remains
+ // stopped until planning publishes a replacement exact runtime identity.
+ return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
+ }
let requiresRestart = updated.memoryMB != current.memoryMB
|| updated.cpuCount != current.cpuCount
|| updated.shares != current.shares
@@ -1975,9 +2116,7 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.invalidID(snapshotID)
}
let (machine, wasRunning) = try configurationAndRunningState(id: id)
- guard let snapshotRuntimeIdentity = runtimeIdentity(id: id) else {
- throw MachineManagerError.unknownMachine(id)
- }
+ let snapshotRuntimeIdentity = try currentRuntimeIdentity(id: id)
try Self.validateLaunchConfiguration(machine)
try ensurePrivateSnapshotDirectory(machineID: id)
let rootfsPath = snapshotRootfsPath(machineID: id, snapshotID: snapshotID)
@@ -2399,7 +2538,7 @@ public final class MachineManager: @unchecked Sendable {
installedDesktopPayloadReceipt:
Self.configurationReceipt(restoring: snapshot)
)
- _ = try create(machine)
+ let created = try create(machine)
do {
if snapshot.bootMode == .efi {
guard let snapshotNVRAMPath = snapshot.nvramPath else {
@@ -2412,6 +2551,11 @@ public final class MachineManager: @unchecked Sendable {
destination: machineFirmwareNVRAMPath(id: newID)
)
}
+ if launchPolicy == .perWorkspaceAuthority {
+ // A clone has new mutable storage and a new machine identity. Its source runtime
+ // evidence remains useful history, but can never authorize the clone's launch.
+ return status(id: newID) ?? created
+ }
return try start(id: newID)
} catch {
do {
@@ -2456,7 +2600,10 @@ public final class MachineManager: @unchecked Sendable {
restoredMachine.installedDesktopPayloadReceipt =
Self.configurationReceipt(restoring: snapshot)
let sourceIdentity = try currentRuntimeIdentity(id: machineID)
- let targetIdentity = runtimeIdentityAfterSnapshotRestore(snapshot.runtimeIdentity)
+ let targetIdentity = runtimeIdentityAfterSnapshotRestore(
+ snapshot.runtimeIdentity,
+ sourceIdentity: sourceIdentity
+ )
let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot)
let lifecycle = try beginLifecycleRestore(
sourceMachine: machine,
@@ -2464,7 +2611,8 @@ public final class MachineManager: @unchecked Sendable {
sourceIdentity: sourceIdentity,
targetIdentity: targetIdentity,
sourceState: wasRunning ? .running : .stopped,
- targetState: wasRunning && launchPolicy == .legacyCompatibility ? .running : .stopped,
+ targetState: wasRunning && shouldRestartAfterSnapshotRestore(targetIdentity)
+ ? .running : .stopped,
snapshotID: snapshotID,
snapshotAuthority: snapshotAuthority
)
@@ -2479,6 +2627,10 @@ public final class MachineManager: @unchecked Sendable {
operationID: lifecycle.operation.operationID
) {
try persist(restoredMachine)
+ try persistRuntimeIdentity(
+ targetIdentity,
+ configuration: restoredMachine
+ )
lock.lock()
if var entry = machines[machineID] {
entry.configuration = restoredMachine
@@ -2489,7 +2641,7 @@ public final class MachineManager: @unchecked Sendable {
lock.unlock()
}
let status: DoryMachineStatus
- if wasRunning, launchPolicy == .legacyCompatibility {
+ if wasRunning, shouldRestartAfterSnapshotRestore(targetIdentity) {
status = try startAndWaitUntilReady(id: machineID, journalLifecycle: false)
} else {
status = self.status(id: machineID)
@@ -2616,6 +2768,13 @@ public final class MachineManager: @unchecked Sendable {
}
try Self.validateResources(memoryMB: snapshot.memoryMB, cpuCount: snapshot.cpuCount)
try Self.validateSnapshotRuntimeIdentity(snapshot)
+ if launchPolicy == .perWorkspaceAuthority {
+ snapshot.runtimeIdentity = .requiresReplanning(
+ virtualHardwareABIVersion:
+ snapshot.runtimeIdentity.virtualHardwareABIVersion,
+ reason: .importedSnapshot
+ )
+ }
snapshot.installedDesktopPayloadReceipt =
snapshot.installedDesktopPayloadReceipt?.portableSnapshotReceipt
snapshot.address = nil
@@ -3654,6 +3813,16 @@ public final class MachineManager: @unchecked Sendable {
}
private func publishConfiguration(_ configuration: DoryMachineConfiguration) throws {
+ let identity = runtimeIdentityForUnplannedMachine(reason: .definitionChanged)
+ var identityPersistenceError: String?
+ do {
+ try persistRuntimeIdentity(identity, configuration: configuration)
+ } catch {
+ // machine.json is already the durable authority at every call site. Never leave the
+ // in-memory configuration behind that commit; a missing/stale companion fails closed
+ // to requires-replanning on the next restart.
+ identityPersistenceError = "runtime identity publication requires recovery: \(error)"
+ }
lock.lock()
defer { lock.unlock() }
guard var entry = machines[configuration.id] else {
@@ -3661,9 +3830,8 @@ public final class MachineManager: @unchecked Sendable {
}
entry.configuration = configuration
entry.currentBalloonTargetMB = nil
- entry.runtimeIdentity = runtimeIdentityForUnplannedMachine(
- reason: .definitionChanged
- )
+ entry.runtimeIdentity = identity
+ if let identityPersistenceError { entry.lastError = identityPersistenceError }
machines[configuration.id] = entry
}
@@ -3676,7 +3844,7 @@ public final class MachineManager: @unchecked Sendable {
virtualHardwareABIVersion:
DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
)
- case .requireResolvedPlan:
+ case .requireResolvedPlan, .perWorkspaceAuthority:
return .requiresReplanning(
virtualHardwareABIVersion:
DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
@@ -3686,7 +3854,8 @@ public final class MachineManager: @unchecked Sendable {
}
private func runtimeIdentityAfterSnapshotRestore(
- _ snapshotIdentity: DoryMachineRuntimeIdentity
+ _ snapshotIdentity: DoryMachineRuntimeIdentity,
+ sourceIdentity: DoryMachineRuntimeIdentity
) -> DoryMachineRuntimeIdentity {
switch launchPolicy {
case .legacyCompatibility:
@@ -3698,6 +3867,30 @@ public final class MachineManager: @unchecked Sendable {
virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion,
reason: .restoredSnapshot
)
+ case .perWorkspaceAuthority:
+ if sourceIdentity.mode == .legacyCompatibility,
+ snapshotIdentity.mode == .legacyCompatibility {
+ return .legacyCompatibility(
+ virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion
+ )
+ }
+ return .requiresReplanning(
+ virtualHardwareABIVersion: snapshotIdentity.virtualHardwareABIVersion,
+ reason: .restoredSnapshot
+ )
+ }
+ }
+
+ private func shouldRestartAfterSnapshotRestore(
+ _ targetIdentity: DoryMachineRuntimeIdentity
+ ) -> Bool {
+ switch launchPolicy {
+ case .legacyCompatibility:
+ return true
+ case .requireResolvedPlan:
+ return false
+ case .perWorkspaceAuthority:
+ return targetIdentity.mode == .legacyCompatibility
}
}
@@ -3708,45 +3901,130 @@ public final class MachineManager: @unchecked Sendable {
plans: any DoryResolvedMachinePlanStoring,
expectedPlanRevision: ResolvedPlanRevisionProvider
) {
- let entries: [(String, DoryMachineConfiguration)] = {
+ let entries: [(String, DoryMachineConfiguration, DoryMachineRuntimeIdentity)] = {
lock.lock()
defer { lock.unlock() }
- return machines.map { ($0.key, $0.value.configuration) }
+ return machines.map {
+ ($0.key, $0.value.configuration, $0.value.runtimeIdentity)
+ }
}()
- for (id, machine) in entries {
- var recovered = DoryMachineRuntimeIdentity.requiresReplanning(
+ for (id, machine, existingIdentity) in entries {
+ if launchPolicy == .perWorkspaceAuthority {
+ switch existingIdentity.mode {
+ case .legacyCompatibility, .requiresReplanning:
+ // Only an acquired planning mutation fence may cross either authority
+ // boundary. An old definition-exact plan cannot cover a restored/imported or
+ // otherwise changed mutable disk.
+ continue
+ case .resolvedPlan:
+ break
+ }
+ }
+ var recovered = exactResolvedRuntimeIdentity(
+ machine: machine,
+ plans: plans,
+ expectedPlanRevision: expectedPlanRevision
+ ) ?? .requiresReplanning(
virtualHardwareABIVersion:
- DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
+ existingIdentity.virtualHardwareABIVersion,
reason: .planRecoveryFailed
)
- if let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: id)),
- let definition = reconcileWorkspaceProjection(
- machine: machine,
- authoritativeLegacyData: legacyData
- ),
- let canonicalData = try? Self.canonicalDefinitionData(definition),
- let plan = try? plans.read(id: id),
- expectedPlanRevision(id) == plan.planRevision,
- plan.machineID == id,
- plan.definitionRevision == definition.lifecycle.revision,
- plan.definitionSHA256 == Self.sha256(data: canonicalData),
- plan.migrationDisposition == .current,
- plan.validate().isEmpty,
- let identity = try? DoryMachineRuntimeIdentity(
- resolvedPlan: plan,
- planSHA256: DoryMachineRuntimeIdentity.planSHA256(plan)
- ) {
- recovered = identity
+ if launchPolicy == .perWorkspaceAuthority,
+ recovered != existingIdentity {
+ recovered = .requiresReplanning(
+ virtualHardwareABIVersion: existingIdentity.virtualHardwareABIVersion,
+ reason: .planRecoveryFailed
+ )
+ }
+ var persistenceError: String?
+ var effectiveIdentity = recovered
+ if launchPolicy == .perWorkspaceAuthority {
+ do {
+ if recovered != existingIdentity {
+ try persistRuntimeIdentity(recovered, configuration: machine)
+ }
+ }
+ catch {
+ persistenceError = "runtime identity recovery could not be persisted: \(error)"
+ effectiveIdentity = .requiresReplanning(
+ virtualHardwareABIVersion: recovered.virtualHardwareABIVersion,
+ reason: .planRecoveryFailed
+ )
+ }
}
lock.lock()
if var entry = machines[id] {
- entry.runtimeIdentity = recovered
+ entry.runtimeIdentity = effectiveIdentity
+ if let persistenceError { entry.lastError = persistenceError }
machines[id] = entry
}
lock.unlock()
}
}
+ private func exactResolvedRuntimeIdentity(
+ machine: DoryMachineConfiguration,
+ plans: any DoryResolvedMachinePlanStoring,
+ expectedPlanRevision: ResolvedPlanRevisionProvider
+ ) -> DoryMachineRuntimeIdentity? {
+ guard let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: machine.id)),
+ let definition = reconcileWorkspaceProjection(
+ machine: machine,
+ authoritativeLegacyData: legacyData
+ ),
+ let canonicalData = try? Self.canonicalDefinitionData(definition),
+ let plan = try? plans.read(id: machine.id),
+ expectedPlanRevision(machine.id) == plan.planRevision,
+ plan.machineID == machine.id,
+ plan.definitionRevision == definition.lifecycle.revision,
+ plan.definitionSHA256 == Self.sha256(data: canonicalData),
+ plan.migrationDisposition == .current,
+ plan.validate().isEmpty else {
+ return nil
+ }
+ return try? DoryMachineRuntimeIdentity(
+ resolvedPlan: plan,
+ planSHA256: DoryMachineRuntimeIdentity.planSHA256(plan)
+ )
+ }
+
+ private func completePlanningRuntimeIdentity(machineID: String) throws {
+ // Recovery completes planning before production activation installs the launch graph.
+ // Both planning and start deliberately use the canonical manager state root, so reading
+ // the exact durable plan here does not invent evidence or select a backend.
+ let plans: any DoryResolvedMachinePlanStoring = resolvedLaunchPlanStore
+ ?? DoryResolvedMachinePlanRepository(root: configuration.stateDirectory)
+ let expectedPlanRevision: ResolvedPlanRevisionProvider =
+ resolvedPlanRevisionProvider ?? { machineID in
+ try? plans.read(id: machineID).planRevision
+ }
+ lock.lock()
+ let machine = machines[machineID]?.configuration
+ lock.unlock()
+ guard let machine,
+ let identity = exactResolvedRuntimeIdentity(
+ machine: machine,
+ plans: plans,
+ expectedPlanRevision: expectedPlanRevision
+ ) else {
+ throw MachineManagerError.persistence(
+ "published plan does not match current workspace authority"
+ )
+ }
+ try persistRuntimeIdentity(identity, configuration: machine)
+ lock.lock()
+ guard var entry = machines[machineID], entry.configuration == machine else {
+ lock.unlock()
+ throw MachineManagerError.persistence(
+ "machine authority changed while completing planning"
+ )
+ }
+ entry.runtimeIdentity = identity
+ entry.lastError = nil
+ machines[machineID] = entry
+ lock.unlock()
+ }
+
private func prepareMachineArtifacts(_ machine: DoryMachineConfiguration) throws -> DoryMachineConfiguration {
let rootfsDestination = machineRootfsPath(id: machine.id)
let kernelDestination = machineKernelPath(id: machine.id)
@@ -4061,6 +4339,36 @@ public final class MachineManager: @unchecked Sendable {
}
}
+ private func persistRuntimeIdentity(
+ _ identity: DoryMachineRuntimeIdentity,
+ configuration machine: DoryMachineConfiguration
+ ) throws {
+ guard identity.validate().isEmpty,
+ let authoritativeLegacyData = Self.readPrivateMetadata(
+ path: machineConfigPath(id: machine.id)
+ ),
+ let decoded = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: authoritativeLegacyData
+ ),
+ decoded == machine else {
+ throw MachineManagerError.persistence(
+ "runtime identity does not match authoritative machine metadata"
+ )
+ }
+ do {
+ try runtimeIdentityStore.publish(
+ identity,
+ machineID: machine.id,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "could not publish runtime identity for \(machine.id): \(error)"
+ )
+ }
+ }
+
private func reconcileLoadedWorkspaceProjections() {
let configurations = machines.values.map(\.configuration)
.sorted { $0.id < $1.id }
@@ -4450,18 +4758,21 @@ public final class MachineManager: @unchecked Sendable {
try Self.validateSnapshotRuntimeIdentity(snapshot)
switch snapshot.runtimeIdentity.mode {
case .legacyCompatibility:
- guard launchPolicy == .legacyCompatibility else {
+ guard launchPolicy == .legacyCompatibility
+ || launchPolicy == .perWorkspaceAuthority else {
throw MachineManagerError.persistence(
"legacy snapshot requires explicit legacy compatibility launch policy"
)
}
case .resolvedPlan:
- guard !cloning else {
+ guard !cloning || launchPolicy == .perWorkspaceAuthority else {
throw MachineManagerError.persistence(
"resolved snapshot identity is machine-bound and must be replanned before cloning"
)
}
- guard launchPolicy == .requireResolvedPlan,
+ if cloning, launchPolicy == .perWorkspaceAuthority { return }
+ guard launchPolicy == .requireResolvedPlan
+ || launchPolicy == .perWorkspaceAuthority,
let plan = snapshot.runtimeIdentity.resolvedPlan,
let registry = resolvedLaunchRegistry,
let planStore = resolvedLaunchPlanStore,
@@ -4490,7 +4801,8 @@ public final class MachineManager: @unchecked Sendable {
)
}
case .requiresReplanning:
- guard launchPolicy == .requireResolvedPlan, !cloning else {
+ guard (launchPolicy == .requireResolvedPlan && !cloning)
+ || launchPolicy == .perWorkspaceAuthority else {
throw MachineManagerError.persistence(
"snapshot requires a new resolved plan before it can launch"
)
@@ -5080,7 +5392,8 @@ public final class MachineManager: @unchecked Sendable {
private static func loadPersistedMachines(
configuration: MachineManagerConfiguration,
- launchPolicy: DoryMachineLaunchPolicy
+ launchPolicy: DoryMachineLaunchPolicy,
+ runtimeIdentityStore: DoryMachineRuntimeIdentityStore
) -> [String: MachineEntry] {
let root = configuration.stateDirectory
guard let ids = try? FileManager.default.contentsOfDirectory(atPath: root) else {
@@ -5124,6 +5437,12 @@ public final class MachineManager: @unchecked Sendable {
DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
reason: .planRecoveryFailed
)
+ case .perWorkspaceAuthority:
+ loadOrMigratePerWorkspaceRuntimeIdentity(
+ machine: machine,
+ authoritativeLegacyData: data,
+ store: runtimeIdentityStore
+ )
}
loaded[id] = MachineEntry(
configuration: machine,
@@ -5134,18 +5453,258 @@ public final class MachineManager: @unchecked Sendable {
return loaded
}
+ private static func removeInterruptedNativeCreations(
+ stateDirectory: String
+ ) {
+ guard let ids = try? FileManager.default.contentsOfDirectory(
+ atPath: stateDirectory
+ ) else { return }
+ let allowed = Set([
+ "rootfs.ext4",
+ "kernel",
+ "installer.iso",
+ Self.nativeCreationPrecommitMarkerName,
+ DoryMachineRuntimeIdentityStore.recordFileName,
+ DoryMachineRuntimeIdentityStore.headFileName,
+ ])
+ for id in ids where Self.isValidID(id) {
+ let directory = stateDirectory + "/" + id
+ let markerPath = directory + "/" + Self.nativeCreationPrecommitMarkerName
+ guard Self.isPrivateDirectory(path: directory),
+ !Self.pathEntryExists(directory + "/machine.json"),
+ let entries = try? FileManager.default.contentsOfDirectory(
+ atPath: directory
+ ) else {
+ continue
+ }
+ let expectedMarker = Self.nativeCreationPrecommitMarkerData(machineID: id)
+ let isMarkerInitializationCrash = entries.isEmpty
+ || (entries.count == 1
+ && entries[0] == Self.nativeCreationPrecommitMarkerName
+ && Self.isPrivatePartialNativeCreationMarker(
+ path: markerPath,
+ expectedByteCount: expectedMarker.count
+ ))
+ let containsOnlyCreationEntries = entries.allSatisfy { entry in
+ allowed.contains(entry)
+ || entry.hasPrefix(
+ DoryMachineRuntimeIdentityStore.recordTemporaryPrefix
+ )
+ || entry.hasPrefix(
+ DoryMachineRuntimeIdentityStore.headTemporaryPrefix
+ )
+ }
+ let containsOnlyPrivateRuntimeTemporaries = entries.filter { entry in
+ entry.hasPrefix(
+ DoryMachineRuntimeIdentityStore.recordTemporaryPrefix
+ ) || entry.hasPrefix(
+ DoryMachineRuntimeIdentityStore.headTemporaryPrefix
+ )
+ }.allSatisfy { entry in
+ Self.isPrivateRegularFile(path: directory + "/" + entry)
+ }
+ let hasValidManagedArtifacts =
+ (!entries.contains("rootfs.ext4")
+ || Self.isPrivateRegularFile(path: directory + "/rootfs.ext4"))
+ && (!entries.contains("kernel")
+ || Self.isPrivateRegularFile(path: directory + "/kernel"))
+ && (!entries.contains("installer.iso")
+ || Self.isPrivateRegularFile(path: directory + "/installer.iso"))
+ let isAuthenticatedInterruptedCreation =
+ Self.readPrivateMetadata(path: markerPath) == expectedMarker
+ && containsOnlyCreationEntries
+ && containsOnlyPrivateRuntimeTemporaries
+ && hasValidManagedArtifacts
+ guard isMarkerInitializationCrash || isAuthenticatedInterruptedCreation else {
+ continue
+ }
+ let quarantine = stateDirectory + "/"
+ + Self.interruptedNativeCreationQuarantinePrefix
+ + id + "-" + UUID().uuidString.lowercased()
+ guard rename(directory, quarantine) == 0 else { continue }
+ try? FileManager.default.removeItem(atPath: quarantine)
+ }
+ }
+
+ private static func isPrivatePartialNativeCreationMarker(
+ path: String,
+ expectedByteCount: Int
+ ) -> Bool {
+ var info = stat()
+ return lstat(path, &info) == 0
+ && (info.st_mode & S_IFMT) == S_IFREG
+ && info.st_uid == getuid()
+ && info.st_nlink == 1
+ && (info.st_mode & 0o077) == 0
+ && info.st_size >= 0
+ && info.st_size < Int64(expectedByteCount)
+ }
+
+ private static func recoverCompletedNativeCreationMarkers(
+ stateDirectory: String,
+ runtimeIdentityStore: DoryMachineRuntimeIdentityStore
+ ) {
+ guard let ids = try? FileManager.default.contentsOfDirectory(
+ atPath: stateDirectory
+ ) else { return }
+ for id in ids where Self.isValidID(id) {
+ let directory = stateDirectory + "/" + id
+ let preparing = directory + "/" + Self.nativeCreationPrecommitMarkerName
+ let committed = directory + "/" + Self.nativeCreationCommittedMarkerName
+ guard Self.isPrivateDirectory(path: directory),
+ !Self.pathEntryExists(committed),
+ Self.readPrivateMetadata(path: preparing)
+ == Self.nativeCreationPrecommitMarkerData(machineID: id),
+ let legacyData = Self.readPrivateMetadata(
+ path: directory + "/machine.json"
+ ),
+ let machine = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: legacyData
+ ), machine.id == id,
+ (try? runtimeIdentityStore.readIfPresent(
+ machineID: id,
+ authoritativeLegacyData: legacyData
+ )) != nil else {
+ continue
+ }
+ guard rename(preparing, committed) == 0 else { continue }
+ try? Self.syncDirectory(path: directory)
+ }
+ }
+
+ private static func nativeCreationPrecommitMarkerData(machineID: String) -> Data {
+ Data("DORY-NATIVE-CREATE-PRECOMMIT-V1:\(machineID)\n".utf8)
+ }
+
+ private static func loadOrMigratePerWorkspaceRuntimeIdentity(
+ machine: DoryMachineConfiguration,
+ authoritativeLegacyData: Data,
+ store: DoryMachineRuntimeIdentityStore
+ ) -> DoryMachineRuntimeIdentity {
+ do {
+ if let persisted = try store.readIfPresent(
+ machineID: machine.id,
+ authoritativeLegacyData: authoritativeLegacyData
+ ) {
+ return persisted
+ }
+ let machineDirectory = store.root + "/" + machine.id
+ if Self.readPrivateMetadata(
+ path: machineDirectory + "/" + Self.nativeCreationCommittedMarkerName
+ ) == Self.nativeCreationPrecommitMarkerData(machineID: machine.id) {
+ let unresolved = DoryMachineRuntimeIdentity.requiresReplanning(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
+ reason: .planRecoveryFailed
+ )
+ try store.publish(
+ unresolved,
+ machineID: machine.id,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ return unresolved
+ }
+ if Self.pathEntryExists(
+ machineDirectory + "/" + DoryResolvedMachinePlanRepository.recordFileName
+ ) || Self.pathEntryExists(
+ machineDirectory + "/planning-transaction-v1.json"
+ ) {
+ let unresolved = DoryMachineRuntimeIdentity.requiresReplanning(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
+ reason: .planRecoveryFailed
+ )
+ try store.publish(
+ unresolved,
+ machineID: machine.id,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ return unresolved
+ }
+ // Absence is the one compatibility migration case: this exact machine predates the
+ // per-workspace authority record. Persist the migration decision before allowing a
+ // compatibility launch; malformed or stale records never receive this fallback.
+ let migrated = DoryMachineRuntimeIdentity.legacyCompatibility(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
+ )
+ try store.publish(
+ migrated,
+ machineID: machine.id,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ return migrated
+ } catch {
+ return .requiresReplanning(
+ virtualHardwareABIVersion:
+ DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion,
+ reason: .planRecoveryFailed
+ )
+ }
+ }
+
private func currentRuntimeIdentity(id: String) throws -> DoryMachineRuntimeIdentity {
lock.lock()
- defer { lock.unlock() }
- guard let identity = machines[id]?.runtimeIdentity else {
+ guard let entry = machines[id] else {
+ lock.unlock()
throw MachineManagerError.unknownMachine(id)
}
+ let identity = entry.runtimeIdentity
+ let machine = entry.configuration
+ lock.unlock()
guard identity.validate().isEmpty else {
throw MachineManagerError.persistence("machine runtime identity is invalid")
}
+ if launchPolicy == .perWorkspaceAuthority {
+ try revalidateDurableRuntimeIdentity(
+ id: id,
+ expected: identity,
+ expectedMachine: machine
+ )
+ }
return identity
}
+ private func currentDurableRuntimeIdentity(id: String) throws -> DoryMachineRuntimeIdentity {
+ try currentRuntimeIdentity(id: id)
+ }
+
+ private func revalidateDurableRuntimeIdentity(
+ id: String,
+ expected: DoryMachineRuntimeIdentity,
+ expectedMachine: DoryMachineConfiguration
+ ) throws {
+ guard expected.validate().isEmpty,
+ let authoritativeLegacyData = Self.readPrivateMetadata(
+ path: machineConfigPath(id: id)
+ ),
+ let decoded = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: authoritativeLegacyData
+ ), decoded == expectedMachine else {
+ throw MachineManagerError.persistence(
+ "durable machine authority changed before launch"
+ )
+ }
+ let durable: DoryMachineRuntimeIdentity?
+ do {
+ durable = try runtimeIdentityStore.readIfPresent(
+ machineID: id,
+ authoritativeLegacyData: authoritativeLegacyData
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "durable runtime identity is unavailable before launch: \(error)"
+ )
+ }
+ guard durable == expected else {
+ throw MachineManagerError.persistence(
+ "durable runtime identity changed before launch"
+ )
+ }
+ }
+
/// Acquires the same per-workspace cross-process mutation lock used by lifecycle operations,
/// then derives planning authority only from the exact persisted legacy bytes and migration
/// facts observed under that lock. This path never starts a helper or mutates guest state.
@@ -5157,7 +5716,8 @@ public final class MachineManager: @unchecked Sendable {
operationLock.lock()
defer { operationLock.unlock() }
- guard launchPolicy == .requireResolvedPlan else {
+ guard launchPolicy == .requireResolvedPlan
+ || launchPolicy == .perWorkspaceAuthority else {
throw MachineManagerError.persistence(
"planning promotion requires the resolved-plan launch policy"
)
@@ -5300,7 +5860,15 @@ public final class MachineManager: @unchecked Sendable {
runtimeIdentitySHA256: authority.runtimeIdentitySHA256
)
},
- completion: { retention.release() },
+ completion: { [weak self] in
+ guard let self else {
+ throw MachineManagerError.persistence("machine manager is unavailable")
+ }
+ if self.launchPolicy == .perWorkspaceAuthority {
+ try self.completePlanningRuntimeIdentity(machineID: machine.id)
+ }
+ retention.release()
+ },
recoveryRelease: { retention.release() }
)
}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityStoreTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityStoreTests.swift
new file mode 100644
index 00000000..f76ea052
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineRuntimeIdentityStoreTests.swift
@@ -0,0 +1,151 @@
+import Foundation
+import Testing
+@testable import DorydKit
+
+@Suite("Per-workspace runtime identity store", .serialized)
+struct DoryMachineRuntimeIdentityStoreTests {
+ @Test("identity authority is monotonic across A to B to A configuration history")
+ func rejectsRolledBackIdentityRecord() throws {
+ try withStore("rollback") { store, directory in
+ let dataA = Data("configuration-a".utf8)
+ let dataB = Data("configuration-b".utf8)
+ try store.publish(
+ .legacyCompatibility(virtualHardwareABIVersion: 1),
+ machineID: "dev",
+ authoritativeLegacyData: dataA
+ )
+ let recordPath = directory + "/" + DoryMachineRuntimeIdentityStore.recordFileName
+ let oldRecord = try Data(contentsOf: URL(fileURLWithPath: recordPath))
+
+ try store.publish(
+ .requiresReplanning(
+ virtualHardwareABIVersion: 1,
+ reason: .definitionChanged
+ ),
+ machineID: "dev",
+ authoritativeLegacyData: dataB
+ )
+ try store.publish(
+ .requiresReplanning(
+ virtualHardwareABIVersion: 1,
+ reason: .definitionChanged
+ ),
+ machineID: "dev",
+ authoritativeLegacyData: dataA
+ )
+
+ try oldRecord.write(to: URL(fileURLWithPath: recordPath), options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: recordPath
+ )
+ #expect(throws: DoryMachineRuntimeIdentityStoreError.invalidRecord) {
+ _ = try store.readIfPresent(
+ machineID: "dev",
+ authoritativeLegacyData: dataA
+ )
+ }
+ }
+ }
+
+ @Test("missing or corrupt identity beside a durable head never becomes absent legacy state")
+ func partialAuthorityFailsClosed() throws {
+ try withStore("missing") { store, directory in
+ let legacyData = Data("configuration".utf8)
+ try store.publish(
+ .requiresReplanning(
+ virtualHardwareABIVersion: 1,
+ reason: .planNotInstalled
+ ),
+ machineID: "dev",
+ authoritativeLegacyData: legacyData
+ )
+ let recordPath = directory + "/" + DoryMachineRuntimeIdentityStore.recordFileName
+ try FileManager.default.removeItem(atPath: recordPath)
+ #expect(throws: DoryMachineRuntimeIdentityStoreError.invalidRecord) {
+ _ = try store.readIfPresent(
+ machineID: "dev",
+ authoritativeLegacyData: legacyData
+ )
+ }
+ }
+ try withStore("corrupt") { store, directory in
+ let legacyData = Data("configuration".utf8)
+ try store.publish(
+ .legacyCompatibility(virtualHardwareABIVersion: 1),
+ machineID: "dev",
+ authoritativeLegacyData: legacyData
+ )
+ let recordPath = directory + "/" + DoryMachineRuntimeIdentityStore.recordFileName
+ try Data("not-json".utf8).write(
+ to: URL(fileURLWithPath: recordPath),
+ options: .atomic
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: recordPath
+ )
+ #expect(throws: DoryMachineRuntimeIdentityStoreError.invalidRecord) {
+ _ = try store.readIfPresent(
+ machineID: "dev",
+ authoritativeLegacyData: legacyData
+ )
+ }
+ }
+ }
+
+ @Test("restart completes the one valid identity-before-head crash ordering")
+ func healsForwardPublication() throws {
+ try withStore("forward") { store, directory in
+ let dataA = Data("configuration-a".utf8)
+ let dataB = Data("configuration-b".utf8)
+ try store.publish(
+ .legacyCompatibility(virtualHardwareABIVersion: 1),
+ machineID: "dev",
+ authoritativeLegacyData: dataA
+ )
+ let headPath = directory + "/" + DoryMachineRuntimeIdentityStore.headFileName
+ let oldHead = try Data(contentsOf: URL(fileURLWithPath: headPath))
+ let expected = DoryMachineRuntimeIdentity.requiresReplanning(
+ virtualHardwareABIVersion: 1,
+ reason: .definitionChanged
+ )
+ try store.publish(
+ expected,
+ machineID: "dev",
+ authoritativeLegacyData: dataB
+ )
+ try oldHead.write(to: URL(fileURLWithPath: headPath), options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: headPath
+ )
+
+ #expect(try store.readIfPresent(
+ machineID: "dev",
+ authoritativeLegacyData: dataB
+ ) == expected)
+ #expect(try store.readIfPresent(
+ machineID: "dev",
+ authoritativeLegacyData: dataB
+ ) == expected)
+ }
+ }
+
+ private func withStore(
+ _ label: String,
+ _ body: (DoryMachineRuntimeIdentityStore, String) throws -> Void
+ ) throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-runtime-identity-\(label)-\(UUID().uuidString)")
+ .path
+ let directory = root + "/dev"
+ try FileManager.default.createDirectory(
+ atPath: directory,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ defer { try? FileManager.default.removeItem(atPath: root) }
+ try body(DoryMachineRuntimeIdentityStore(root: root), directory)
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
index 564dd2eb..cc0ebf62 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -509,6 +509,548 @@ struct MachineManagerResolvedPlanIntegrationTests {
}
}
+ @Test("per-workspace policy blocks new machines until planning")
+ func perWorkspaceNewMachineRequiresPlanning() throws {
+ try withHarness("per-workspace-new", launchPolicy: .perWorkspaceAuthority) {
+ manager, starter, _ in
+ let status = try #require(manager.status(id: "dev"))
+ #expect(status.runtimeIdentity.mode == .requiresReplanning)
+ #expect(status.runtimeIdentity.invalidationReason == .planNotInstalled)
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ }
+ }
+
+ @Test("native creation authority is durable before machine metadata becomes discoverable")
+ func nativeCreationCrashOrderingNeverMintsLegacy() throws {
+ let state = try makeState("native-create-order")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ let directory = state + "/native"
+ try FileManager.default.createDirectory(
+ atPath: directory,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let rootfs = directory + "/rootfs.ext4"
+ let kernel = directory + "/kernel"
+ try FileManager.default.copyItem(atPath: doryTestRootfsPath, toPath: rootfs)
+ try FileManager.default.copyItem(atPath: doryTestKernelPath, toPath: kernel)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: rootfs
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: kernel
+ )
+ let machine = DoryMachineConfiguration(
+ id: "native",
+ kernelPath: kernel,
+ rootfsPath: rootfs,
+ memoryMB: 2_048,
+ cpuCount: 2,
+ displayMode: .desktop
+ )
+ let legacyData = try DoryMachineConfigurationMigrationBridge.encodeLegacy(machine)
+ let expected = DoryMachineRuntimeIdentity.requiresReplanning(
+ virtualHardwareABIVersion: 1,
+ reason: .planNotInstalled
+ )
+ let markerPath = directory + "/.dory-native-create-precommit-v1"
+ try Data("DORY-NATIVE-CREATE-PRECOMMIT-V1:native\n".utf8).write(
+ to: URL(fileURLWithPath: markerPath),
+ options: .atomic
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: markerPath
+ )
+ try DoryMachineRuntimeIdentityStore(root: state).publish(
+ expected,
+ machineID: "native",
+ authoritativeLegacyData: legacyData
+ )
+ let interruptedStoreTemporary = directory
+ + "/.runtime-identity-v1.tmp-interrupted"
+ try Data("partial".utf8).write(
+ to: URL(fileURLWithPath: interruptedStoreTemporary),
+ options: .atomic
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: interruptedStoreTemporary
+ )
+
+ // Crash before machine.json publication: startup does not discover a machine at all.
+ let recovered = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority
+ )
+ #expect(recovered.status(id: "native") == nil)
+ #expect(!FileManager.default.fileExists(atPath: directory))
+ let retried = try createMachine(id: "native", manager: recovered)
+ #expect(retried.runtimeIdentity == expected)
+
+ let completedRootfs = state + "/native/rootfs.ext4"
+ let completedKernel = state + "/native/kernel"
+ let committedMarker = state
+ + "/native/.dory-native-create-committed-v1"
+ let preparingMarker = state
+ + "/native/.dory-native-create-precommit-v1"
+ #expect(FileManager.default.fileExists(atPath: committedMarker))
+ // Simulate a crash after durable machine.json but before marker transition. Restart
+ // completes the exact authority rather than deleting or widening it.
+ try FileManager.default.moveItem(
+ atPath: committedMarker,
+ toPath: preparingMarker
+ )
+ let markerRecovered = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority
+ )
+ #expect(markerRecovered.status(id: "native")?.runtimeIdentity == expected)
+ #expect(FileManager.default.fileExists(atPath: committedMarker))
+ #expect(!FileManager.default.fileExists(atPath: preparingMarker))
+
+ try FileManager.default.removeItem(atPath: state + "/native/machine.json")
+ let metadataLost = makeManager(state: state, policy: .perWorkspaceAuthority)
+ #expect(metadataLost.status(id: "native") == nil)
+ #expect(FileManager.default.fileExists(atPath: completedRootfs))
+ #expect(FileManager.default.fileExists(atPath: completedKernel))
+ #expect(FileManager.default.fileExists(atPath: state + "/native"))
+ }
+
+ @Test("native creation recovers crashes before its durable marker is complete")
+ func nativeCreationInitialMarkerCrashCanRetry() throws {
+ for shape in ["empty-directory", "partial-marker"] {
+ let state = try makeState("native-create-initial-\(shape)")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ let directory = state + "/native"
+ try FileManager.default.createDirectory(
+ atPath: directory,
+ withIntermediateDirectories: false,
+ attributes: [.posixPermissions: 0o700]
+ )
+ if shape == "partial-marker" {
+ let marker = directory + "/.dory-native-create-precommit-v1"
+ try Data("DORY-NATIVE-CREATE".utf8).write(
+ to: URL(fileURLWithPath: marker)
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: marker
+ )
+ }
+
+ let recovered = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority
+ )
+ #expect(recovered.status(id: "native") == nil)
+ #expect(!FileManager.default.fileExists(atPath: directory))
+ let retried = try createMachine(id: "native", manager: recovered)
+ #expect(retried.runtimeIdentity.mode == .requiresReplanning)
+ #expect(retried.runtimeIdentity.invalidationReason == .planNotInstalled)
+ }
+ }
+
+ @Test("legacy compatibility migration is exact-byte bound and cannot be widened")
+ func perWorkspaceLegacyMigrationRejectsRawByteDrift() throws {
+ let state = try makeState("legacy-byte-authority")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ let legacy = makeManager(state: state, policy: .legacyCompatibility)
+ _ = try createMachine(id: "legacy", manager: legacy)
+
+ let migrated = makeManager(state: state, policy: .perWorkspaceAuthority)
+ #expect(migrated.status(id: "legacy")?.runtimeIdentity.mode == .legacyCompatibility)
+ let path = state + "/legacy/machine.json"
+ var bytes = try Data(contentsOf: URL(fileURLWithPath: path))
+ bytes.append(0x0A)
+ try bytes.write(to: URL(fileURLWithPath: path), options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+
+ let changed = makeManager(state: state, policy: .perWorkspaceAuthority)
+ #expect(changed.status(id: "legacy")?.runtimeIdentity.mode == .requiresReplanning)
+ #expect(changed.status(id: "legacy")?.runtimeIdentity.invalidationReason == .planRecoveryFailed)
+ }
+
+ @Test("per-workspace policy dispatches legacy and resolved identities independently")
+ func perWorkspaceMixedAuthorityAndRestart() throws {
+ let state = try makeState("per-workspace-mixed")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ let legacyManager = makeManager(state: state, policy: .legacyCompatibility)
+ _ = try createMachine(id: "legacy", manager: legacyManager)
+
+ let firstPerWorkspace = makeManager(state: state, policy: .perWorkspaceAuthority)
+ #expect(firstPerWorkspace.status(id: "legacy")?.runtimeIdentity.mode == .legacyCompatibility)
+ _ = try createMachine(id: "planned", manager: firstPerWorkspace)
+ #expect(firstPerWorkspace.status(id: "planned")?.runtimeIdentity.mode == .requiresReplanning)
+
+ let plans = MutablePlanStore()
+ let plannedIdentity = try persistResolvedIdentity(
+ machineID: "planned",
+ state: state,
+ plans: plans
+ )
+ #expect(plannedIdentity.mode == .resolvedPlan)
+
+ let starter = CountingProcessStarter()
+ let restarted = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority,
+ starter: starter
+ )
+ let operations = restarted.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let resolver = ClosureLaunchResolver { request in
+ try exactResolution(request: request)
+ }
+ try restarted.installResolvedLaunchInfrastructure(
+ registry: rawRegistry(operations: operations),
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { $0 == "planned" ? 1 : nil }
+ )
+ #expect(restarted.status(id: "legacy")?.runtimeIdentity.mode == .legacyCompatibility)
+ #expect(restarted.status(id: "planned")?.runtimeIdentity == plannedIdentity)
+
+ #expect(try restarted.start(id: "legacy").state == .running)
+ _ = try restarted.stop(id: "legacy")
+ #expect(try restarted.start(id: "planned").state == .running)
+ #expect(resolver.callCount == 1)
+ #expect(starter.count == 2)
+ _ = try restarted.stop(id: "planned")
+
+ let secondRestart = makeManager(state: state, policy: .perWorkspaceAuthority)
+ #expect(secondRestart.status(id: "legacy")?.runtimeIdentity.mode == .legacyCompatibility)
+ #expect(secondRestart.status(id: "planned")?.runtimeIdentity == plannedIdentity)
+ }
+
+ @Test("missing or changed resolved authority never falls back to legacy")
+ func perWorkspaceResolvedAuthorityFailsClosed() throws {
+ let state = try makeState("per-workspace-no-fallback")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ let bootstrap = makeManager(state: state, policy: .perWorkspaceAuthority)
+ _ = try createMachine(id: "planned", manager: bootstrap)
+ let plans = MutablePlanStore()
+ _ = try persistResolvedIdentity(machineID: "planned", state: state, plans: plans)
+
+ let withoutInfrastructureStarter = CountingProcessStarter()
+ let withoutInfrastructure = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority,
+ starter: withoutInfrastructureStarter
+ )
+ #expect(withoutInfrastructure.status(id: "planned")?.runtimeIdentity.mode == .resolvedPlan)
+ #expect(throws: MachineManagerError.self) {
+ _ = try withoutInfrastructure.start(id: "planned")
+ }
+ #expect(withoutInfrastructureStarter.count == 0)
+
+ plans.set(nil)
+ let missingPlanStarter = CountingProcessStarter()
+ let missingPlan = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority,
+ starter: missingPlanStarter
+ )
+ let resolver = ClosureLaunchResolver { request in
+ try exactResolution(request: request)
+ }
+ try missingPlan.installResolvedLaunchInfrastructure(
+ registry: rawRegistry(
+ operations: missingPlan.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ ),
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+ #expect(missingPlan.status(id: "planned")?.runtimeIdentity.mode == .requiresReplanning)
+ #expect(throws: MachineManagerError.self) {
+ _ = try missingPlan.start(id: "planned")
+ }
+ #expect(resolver.callCount == 0)
+ #expect(missingPlanStarter.count == 0)
+
+ let recordPath = state + "/planned/"
+ + DoryMachineRuntimeIdentityStore.recordFileName
+ try FileManager.default.removeItem(atPath: recordPath)
+ let missingCompanionStarter = CountingProcessStarter()
+ let missingCompanion = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority,
+ starter: missingCompanionStarter
+ )
+ #expect(missingCompanion.status(id: "planned")?.runtimeIdentity.mode == .requiresReplanning)
+ #expect(throws: MachineManagerError.self) {
+ _ = try missingCompanion.start(id: "planned")
+ }
+ #expect(missingCompanionStarter.count == 0)
+ }
+
+ @Test("live durable identity deletion or substitution rejects launch and snapshot")
+ func perWorkspaceLiveIdentityTamperFailsClosed() throws {
+ let legacyState = try makeState("live-legacy-tamper")
+ defer { try? FileManager.default.removeItem(atPath: legacyState) }
+ let legacyBootstrap = makeManager(
+ state: legacyState,
+ policy: .legacyCompatibility
+ )
+ _ = try createMachine(id: "legacy", manager: legacyBootstrap)
+ let legacyStarter = CountingProcessStarter()
+ let legacy = makeManager(
+ state: legacyState,
+ policy: .perWorkspaceAuthority,
+ starter: legacyStarter
+ )
+ try FileManager.default.removeItem(
+ atPath: legacyState + "/legacy/"
+ + DoryMachineRuntimeIdentityStore.recordFileName
+ )
+ #expect(throws: MachineManagerError.self) {
+ _ = try legacy.start(id: "legacy")
+ }
+ #expect(throws: MachineManagerError.self) {
+ _ = try legacy.snapshot(id: "legacy", snapshotID: "must-not-publish")
+ }
+ #expect(legacyStarter.count == 0)
+ #expect(!FileManager.default.fileExists(
+ atPath: legacyState + "/legacy/snapshots/must-not-publish.json"
+ ))
+
+ let resolvedState = try makeState("live-resolved-tamper")
+ defer { try? FileManager.default.removeItem(atPath: resolvedState) }
+ let bootstrap = makeManager(
+ state: resolvedState,
+ policy: .perWorkspaceAuthority
+ )
+ _ = try createMachine(id: "planned", manager: bootstrap)
+ let plans = MutablePlanStore()
+ _ = try persistResolvedIdentity(
+ machineID: "planned",
+ state: resolvedState,
+ plans: plans
+ )
+ let resolvedStarter = CountingProcessStarter()
+ let resolved = makeManager(
+ state: resolvedState,
+ policy: .perWorkspaceAuthority,
+ starter: resolvedStarter
+ )
+ let resolver = ClosureLaunchResolver { request in
+ try exactResolution(request: request)
+ }
+ try resolved.installResolvedLaunchInfrastructure(
+ registry: rawRegistry(
+ operations: resolved.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ ),
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+ let machineData = try Data(contentsOf: URL(
+ fileURLWithPath: resolvedState + "/planned/machine.json"
+ ))
+ try DoryMachineRuntimeIdentityStore(root: resolvedState).publish(
+ .legacyCompatibility(virtualHardwareABIVersion: 1),
+ machineID: "planned",
+ authoritativeLegacyData: machineData
+ )
+ #expect(throws: MachineManagerError.self) {
+ _ = try resolved.start(id: "planned")
+ }
+ #expect(resolver.callCount == 0)
+ #expect(resolvedStarter.count == 0)
+ }
+
+ @Test("restoring historical legacy snapshot cannot revive compatibility after planning")
+ func resolvedWorkspaceRestoreOfLegacySnapshotRequiresReplanning() throws {
+ let state = try makeState("resolved-restore-legacy")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ let legacy = makeManager(state: state, policy: .legacyCompatibility)
+ _ = try createMachine(id: "dev", manager: legacy)
+ _ = try legacy.snapshot(id: "dev", snapshotID: "historical")
+
+ let migrated = makeManager(state: state, policy: .perWorkspaceAuthority)
+ #expect(migrated.status(id: "dev")?.runtimeIdentity.mode == .legacyCompatibility)
+ let plans = MutablePlanStore()
+ _ = try persistResolvedIdentity(machineID: "dev", state: state, plans: plans)
+ let starter = CountingProcessStarter()
+ let resolved = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority,
+ starter: starter
+ )
+ let resolver = ClosureLaunchResolver { request in
+ try exactResolution(request: request)
+ }
+ try resolved.installResolvedLaunchInfrastructure(
+ registry: rawRegistry(
+ operations: resolved.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ ),
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+ #expect(resolved.status(id: "dev")?.runtimeIdentity.mode == .resolvedPlan)
+
+ let restored = try resolved.restoreSnapshot(
+ machineID: "dev",
+ snapshotID: "historical"
+ )
+ #expect(restored.state == .stopped)
+ #expect(restored.runtimeIdentity.mode == .requiresReplanning)
+ #expect(restored.runtimeIdentity.invalidationReason == .restoredSnapshot)
+ #expect(throws: MachineManagerError.self) {
+ _ = try resolved.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+
+ let restartedStarter = CountingProcessStarter()
+ let restarted = makeManager(
+ state: state,
+ policy: .perWorkspaceAuthority,
+ starter: restartedStarter
+ )
+ let restartedResolver = ClosureLaunchResolver { request in
+ try exactResolution(request: request)
+ }
+ try restarted.installResolvedLaunchInfrastructure(
+ registry: rawRegistry(
+ operations: restarted.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ ),
+ resolver: restartedResolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+ #expect(restarted.status(id: "dev")?.runtimeIdentity.mode == .requiresReplanning)
+ #expect(throws: MachineManagerError.self) {
+ _ = try restarted.start(id: "dev")
+ }
+ #expect(restartedResolver.callCount == 0)
+ #expect(restartedStarter.count == 0)
+ }
+
+ @Test("per-workspace clone and portable import require a fresh plan")
+ func perWorkspaceCloneAndImportInvalidateAuthority() throws {
+ let state = try makeState("per-workspace-copy")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ let legacy = makeManager(state: state, policy: .legacyCompatibility)
+ _ = try createMachine(id: "source", manager: legacy)
+ let sourceSnapshot = try legacy.snapshot(id: "source", snapshotID: "base")
+ let bundle = state + "/source.dorymachine"
+ try legacy.exportSnapshot(
+ machineID: "source",
+ snapshotID: sourceSnapshot.id,
+ toPath: bundle
+ )
+
+ let manager = makeManager(state: state, policy: .perWorkspaceAuthority)
+ let clone = try manager.cloneSnapshot(
+ machineID: "source",
+ snapshotID: "base",
+ newID: "clone"
+ )
+ #expect(clone.state == .created)
+ #expect(clone.runtimeIdentity.mode == .requiresReplanning)
+ #expect(clone.runtimeIdentity.invalidationReason == .planNotInstalled)
+
+ let imported = try manager.importSnapshot(fromPath: bundle)
+ #expect(imported.runtimeIdentity.mode == .requiresReplanning)
+ #expect(imported.runtimeIdentity.invalidationReason == .importedSnapshot)
+ }
+
+ private func makeState(_ label: String) throws -> String {
+ let state = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dory-authority-\(label)-\(UUID().uuidString)")
+ .path
+ try FileManager.default.createDirectory(
+ atPath: state,
+ withIntermediateDirectories: true
+ )
+ return state
+ }
+
+ private func makeManager(
+ state: String,
+ policy: DoryMachineLaunchPolicy,
+ starter: CountingProcessStarter = CountingProcessStarter()
+ ) -> MachineManager {
+ MachineManager(
+ configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ acceleratedDesktopExecutablePath: "/bin/sleep",
+ stateDirectory: state,
+ baseArguments: ["30"],
+ acceleratedDesktopBaseArguments: ["30"],
+ passMachineArguments: false,
+ requiresReadyHandoff: false
+ ),
+ launchPolicy: policy,
+ processStarter: { process in try starter.start(process) }
+ )
+ }
+
+ @discardableResult
+ private func createMachine(id: String, manager: MachineManager) throws -> DoryMachineStatus {
+ try manager.create(DoryMachineConfiguration(
+ id: id,
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ memoryMB: 2_048,
+ cpuCount: 2,
+ displayMode: .desktop
+ ))
+ }
+
+ private func persistResolvedIdentity(
+ machineID: String,
+ state: String,
+ plans: MutablePlanStore
+ ) throws -> DoryMachineRuntimeIdentity {
+ let definition = try DoryWorkspaceRepository(root: state)
+ .readPersistedRecord(id: machineID).definition
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let definitionData = try encoder.encode(definition)
+ let legacyData = try Data(
+ contentsOf: URL(fileURLWithPath: state + "/\(machineID)/machine.json")
+ )
+ let machine = try JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: legacyData
+ )
+ let resolution = try exactResolution(request: .init(
+ definition: definition,
+ canonicalDefinitionData: definitionData,
+ machine: machine,
+ expectedPlanRevision: 1
+ ))
+ plans.set(resolution.resolvedPlan)
+ let identity = try DoryMachineRuntimeIdentity(
+ resolvedPlan: resolution.resolvedPlan,
+ planSHA256: resolution.resolvedPlanSHA256
+ )
+ try DoryMachineRuntimeIdentityStore(root: state).publish(
+ identity,
+ machineID: machineID,
+ authoritativeLegacyData: legacyData
+ )
+ return identity
+ }
+
private func withHarness(
_ label: String,
launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan,
From ae26a430e1ff8a8844e8c80e403255ad62b282e8 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 18:23:43 +0000
Subject: [PATCH 038/338] feat(vm): activate production planning safely
---
...onVirtualMachineProductionActivation.swift | 245 +++++++++++++++
...yDaemonVirtualMachineProductionTrust.swift | 294 +++++++++++-------
...onVirtualMachineProductionTrustTests.swift | 123 +++++++-
3 files changed, 555 insertions(+), 107 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
new file mode 100644
index 00000000..4c47eb5d
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
@@ -0,0 +1,245 @@
+import DoryOperations
+import Foundation
+
+public enum DoryDaemonVirtualMachineProductionActivationFailureCode:
+ String, Sendable, Equatable
+{
+ case trustUnavailable = "trust-unavailable"
+ case stateAuthorityUnavailable = "state-authority-unavailable"
+ case backendCompositionUnavailable = "backend-composition-unavailable"
+ case planningUnavailable = "planning-unavailable"
+ case installationRejected = "installation-rejected"
+ case trustFloorActivationRejected = "trust-floor-activation-rejected"
+}
+
+public struct DoryDaemonVirtualMachineProductionActivationFailure:
+ Error, Sendable, Equatable
+{
+ public var code: DoryDaemonVirtualMachineProductionActivationFailureCode
+ public var message: String
+ public var trustFailure: DoryDaemonVirtualMachineProductionTrustUnavailable?
+ public var planningFailure:
+ DoryDaemonVirtualMachineProductionPlanningCompositionFailure?
+
+ public init(
+ code: DoryDaemonVirtualMachineProductionActivationFailureCode,
+ message: String,
+ trustFailure: DoryDaemonVirtualMachineProductionTrustUnavailable? = nil,
+ planningFailure:
+ DoryDaemonVirtualMachineProductionPlanningCompositionFailure? = nil
+ ) {
+ self.code = code
+ self.message = message
+ self.trustFailure = trustFailure
+ self.planningFailure = planningFailure
+ }
+}
+
+/// The successfully installed production graph. This is deliberately non-Codable: every member
+/// is live daemon authority, not caller intent or a persisted assertion of trust.
+public struct DoryDaemonVirtualMachineProductionActivationContext: Sendable {
+ public let machineManager: MachineManager
+ public let planning: DoryDaemonVirtualMachineProductionPlanningContext
+ public let backendRuntimeBuildIdentifiers: [
+ DoryVirtualizationBackendIdentity: String
+ ]
+
+ public var inventory: any DoryDaemonVirtualMachineTrustInventory {
+ planning.inventory
+ }
+
+ init(
+ machineManager: MachineManager,
+ planning: DoryDaemonVirtualMachineProductionPlanningContext,
+ backendRuntimeBuildIdentifiers: [
+ DoryVirtualizationBackendIdentity: String
+ ]
+ ) {
+ self.machineManager = machineManager
+ self.planning = planning
+ self.backendRuntimeBuildIdentifiers = backendRuntimeBuildIdentifiers
+ }
+}
+
+public enum DoryDaemonVirtualMachineProductionActivationResult: Sendable {
+ case activated(DoryDaemonVirtualMachineProductionActivationContext)
+ case unavailable(DoryDaemonVirtualMachineProductionActivationFailure)
+
+ public var isActivated: Bool {
+ if case .activated = self { return true }
+ return false
+ }
+}
+
+extension DoryDaemonVirtualMachineProductionTrustFactory {
+ /// Verifies production trust, recovers durable planning transactions, installs the exact
+ /// recovered launch graph into a production-owned manager, and only then advances the trust
+ /// floor. No manager is returned on failure, so partially installed in-memory authority cannot
+ /// escape this boundary or poison a retry.
+ public func activate(
+ store: DoryComponentStore,
+ machineConfiguration: MachineManagerConfiguration,
+ recoveryProvider: any DoryDaemonVirtualMachinePlanningRecoveryProviding
+ ) -> DoryDaemonVirtualMachineProductionActivationResult {
+ activate(
+ store: store,
+ machineConfiguration: machineConfiguration,
+ recoveryProvider: recoveryProvider,
+ appVersion: Self.compiledDaemonVersion,
+ publicKey: DoryComponentDefaults.publicKey,
+ expectedArchitecture: DoryComponentDefaults.architecture
+ )
+ }
+
+ /// Internal deterministic seam for signed-catalog and failure-order tests. Production callers
+ /// cannot replace the compiled version, pinned catalog key, or architecture.
+ func activate(
+ store: DoryComponentStore,
+ machineConfiguration: MachineManagerConfiguration,
+ recoveryProvider: any DoryDaemonVirtualMachinePlanningRecoveryProviding,
+ appVersion: String,
+ publicKey: String,
+ expectedArchitecture: String
+ ) -> DoryDaemonVirtualMachineProductionActivationResult {
+ let canonicalStateDirectory = URL(
+ fileURLWithPath: machineConfiguration.stateDirectory
+ ).standardizedFileURL.path
+ guard canonicalStateDirectory == machineConfiguration.stateDirectory else {
+ return unavailableActivation(
+ .stateAuthorityUnavailable,
+ "Production planning requires one canonical state authority."
+ )
+ }
+
+ let material: DoryDaemonVirtualMachineVerifiedTrustMaterial
+ switch verifiedTrustMaterial(
+ store: store,
+ machineConfiguration: machineConfiguration,
+ appVersion: appVersion,
+ publicKey: publicKey,
+ expectedArchitecture: expectedArchitecture
+ ) {
+ case let .success(verified):
+ material = verified
+ case let .failure(reason):
+ return .unavailable(DoryDaemonVirtualMachineProductionActivationFailure(
+ code: .trustUnavailable,
+ message: "Production VM trust preparation failed.",
+ trustFailure: reason
+ ))
+ }
+
+ // This is the only manager admitted to the activated context. Public activation has no
+ // manager/dependency injection point: executable paths, runtime/log roots, lifecycle
+ // services, process launching, and guest architecture all come from the verified
+ // production configuration and MachineManager's production defaults.
+ let machineManager = MachineManager(
+ configuration: machineConfiguration,
+ launchPolicy: .perWorkspaceAuthority
+ )
+
+ let backends: [any MachineBackend]
+ do {
+ backends = try material.runtimes.map { runtime -> any MachineBackend in
+ switch runtime.descriptor.identity {
+ case .appleVirtualizationFramework:
+ return VirtualizationFrameworkLinuxMachineBackend(
+ executablePath: runtime.executablePath,
+ operations: machineManager.resolvedLaunchCompatibilityOperations(
+ for: runtime.descriptor.identity
+ )
+ )
+ case .doryHypervisor:
+ return RawHVLinuxMachineBackend(
+ executablePath: runtime.executablePath,
+ operations: machineManager.resolvedLaunchCompatibilityOperations(
+ for: runtime.descriptor.identity
+ )
+ )
+ case .qemuHypervisorFramework:
+ throw DoryDaemonVirtualMachineProductionActivationFailure(
+ code: .backendCompositionUnavailable,
+ message: "No verified production adapter exists for the runtime."
+ )
+ }
+ }
+ } catch let failure as DoryDaemonVirtualMachineProductionActivationFailure {
+ return .unavailable(failure)
+ } catch {
+ return unavailableActivation(
+ .backendCompositionUnavailable,
+ "Verified backend adapters could not be composed."
+ )
+ }
+
+ let composition = DoryDaemonVirtualMachineProductionPlanningCompositionFactory(
+ stateDirectory: canonicalStateDirectory,
+ backends: backends,
+ qualificationAuthority: material.authority,
+ runtimes: material.runtimes,
+ runtimeVerifier: material.runtimeVerifier,
+ hostProbe: material.hostProbe,
+ mutationAuthority: machineManager,
+ recoveryProvider: recoveryProvider
+ )
+ let planning: DoryDaemonVirtualMachineProductionPlanningContext
+ switch composition.resolve() {
+ case let .ready(context):
+ planning = context
+ case let .unavailable(reason):
+ return .unavailable(DoryDaemonVirtualMachineProductionActivationFailure(
+ code: .planningUnavailable,
+ message: "Production planning recovery or composition failed.",
+ planningFailure: reason
+ ))
+ }
+
+ do {
+ try machineManager.installResolvedLaunchInfrastructure(
+ registry: planning.registry,
+ resolver: planning.launchResolver,
+ plans: planning.plans,
+ expectedPlanRevision: { machineID in
+ try? planning.plans.read(id: machineID).planRevision
+ }
+ )
+ } catch {
+ return unavailableActivation(
+ .installationRejected,
+ "MachineManager rejected the exact recovered launch infrastructure."
+ )
+ }
+
+ do {
+ try activateVerifiedTrustFloor(
+ stateDirectory: canonicalStateDirectory,
+ material: material
+ )
+ } catch {
+ return unavailableActivation(
+ .trustFloorActivationRejected,
+ "Recovered launch infrastructure was installed, but the trust floor could not be durably advanced."
+ )
+ }
+
+ return .activated(DoryDaemonVirtualMachineProductionActivationContext(
+ machineManager: machineManager,
+ planning: planning,
+ backendRuntimeBuildIdentifiers: Dictionary(
+ uniqueKeysWithValues: material.runtimes.map {
+ ($0.descriptor.identity, $0.runtimeBuildIdentifier)
+ }
+ )
+ ))
+ }
+
+ private func unavailableActivation(
+ _ code: DoryDaemonVirtualMachineProductionActivationFailureCode,
+ _ message: String
+ ) -> DoryDaemonVirtualMachineProductionActivationResult {
+ .unavailable(DoryDaemonVirtualMachineProductionActivationFailure(
+ code: code,
+ message: message
+ ))
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
index 1a92adc9..40bb098a 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
@@ -382,6 +382,19 @@ struct DoryDaemonVerifiedBackendRuntime: Sendable, Equatable {
}
}
+/// Opaque daemon-only output from signature, catalog, host, and installed-runtime verification.
+/// It deliberately carries no MachineManager or repository objects: production activation must
+/// compose those once around the already-constructed manager and the planning composition seam.
+struct DoryDaemonVirtualMachineVerifiedTrustMaterial: Sendable {
+ var authority: DoryVerifiedVirtualMachineQualificationAuthority
+ var runtimes: [DoryDaemonVerifiedBackendRuntime]
+ var permitsLegacyCompatibilityMigration: Bool
+ var runtimeVerifier: @Sendable (
+ String, MachineBackendDescriptor, String
+ ) throws -> DoryDaemonVerifiedBackendRuntime
+ var hostProbe: @Sendable (String) throws -> DoryDaemonProductionHostObservation
+}
+
struct DoryDaemonBackendRuntimeSpecification: Sendable, Equatable {
var descriptor: MachineBackendDescriptor
var executablePath: String
@@ -954,6 +967,11 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
) throws -> DoryDaemonVerifiedBackendRuntime
typealias HostProbe = @Sendable (String) throws -> DoryDaemonProductionHostObservation
typealias DirectorySynchronizer = @Sendable (Int32) -> Bool
+ typealias TrustFloorActivator = @Sendable (
+ String,
+ DoryVerifiedVirtualMachineQualificationAuthority,
+ @escaping DirectorySynchronizer
+ ) throws -> Void
private let authorityResolver: AuthorityResolver
private let runtimeVerifier: RuntimeVerifier
@@ -961,6 +979,7 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
private let daemonIdentityVerifier: @Sendable () -> Bool
private let planningTransactionAvailable: @Sendable () -> Bool
private let synchronizeTrustFloorDirectory: DirectorySynchronizer
+ private let trustFloorActivator: TrustFloorActivator
/// Compiled into and covered by the daemon's code signature. Production catalog compatibility
/// must not trust an environment override or a mutable outer Info.plist.
@@ -981,6 +1000,13 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
.currentProcessSatisfiesProductionDaemonRequirement
planningTransactionAvailable = { false }
synchronizeTrustFloorDirectory = { fsync($0) == 0 }
+ trustFloorActivator = { stateDirectory, authority, synchronizeDirectory in
+ try DoryDaemonVirtualMachineProductionTrustFloor.activate(
+ stateDirectory: stateDirectory,
+ authority: authority,
+ synchronizeDirectory: synchronizeDirectory
+ )
+ }
}
init(
@@ -989,7 +1015,8 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
hostProbe: @escaping HostProbe,
daemonIdentityVerifier: @escaping @Sendable () -> Bool,
planningTransactionAvailable: @escaping @Sendable () -> Bool = { false },
- synchronizeTrustFloorDirectory: @escaping DirectorySynchronizer = { fsync($0) == 0 }
+ synchronizeTrustFloorDirectory: @escaping DirectorySynchronizer = { fsync($0) == 0 },
+ trustFloorActivator: TrustFloorActivator? = nil
) {
self.authorityResolver = authorityResolver
self.runtimeVerifier = runtimeVerifier
@@ -997,6 +1024,14 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
self.daemonIdentityVerifier = daemonIdentityVerifier
self.planningTransactionAvailable = planningTransactionAvailable
self.synchronizeTrustFloorDirectory = synchronizeTrustFloorDirectory
+ self.trustFloorActivator = trustFloorActivator ?? {
+ stateDirectory, authority, synchronizeDirectory in
+ try DoryDaemonVirtualMachineProductionTrustFloor.activate(
+ stateDirectory: stateDirectory,
+ authority: authority,
+ synchronizeDirectory: synchronizeDirectory
+ )
+ }
}
public func resolve(
@@ -1021,108 +1056,22 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
publicKey: String,
expectedArchitecture: String
) -> DoryDaemonVirtualMachineProductionTrustReadiness {
- let trustFloor: DoryDaemonVirtualMachineProductionTrustFloor.Record?
- let hasResolvedPlanState: Bool
- do {
- trustFloor = try DoryDaemonVirtualMachineProductionTrustFloor
- .read(stateDirectory: machineConfiguration.stateDirectory)
- hasResolvedPlanState = try DoryDaemonVirtualMachineProductionTrustFloor
- .hasResolvedPlanState(stateDirectory: machineConfiguration.stateDirectory)
- } catch {
- return unavailable(
- .trustFloorViolated,
- "The durable VM production trust floor is invalid or unavailable."
- )
- }
- let mayUseLegacyMigration = trustFloor == nil && !hasResolvedPlanState
- let authority: DoryVerifiedVirtualMachineQualificationAuthority
- do {
- authority = try authorityResolver(
- store, publicKey, expectedArchitecture, appVersion
- )
- } catch let error as DoryVirtualMachineQualificationAuthorityError {
- let code: DoryDaemonVirtualMachineProductionTrustReadinessCode
- switch error {
- case .catalogUnavailable:
- code = .catalogUnavailable
- case let .catalogSchemaUnsupported(version):
- code = version == DoryComponentCatalog.oldestSupportedSchemaVersion
- ? .catalogSchemaV1Migration : .catalogSchemaUnsupported
- default:
- code = .qualificationAuthorityUnavailable
- }
- if code == .catalogUnavailable || code == .catalogSchemaV1Migration {
- guard mayUseLegacyMigration else {
- return unavailable(
- .trustFloorViolated,
- "VM production trust was previously activated; catalog authority cannot be removed or downgraded."
- )
- }
- return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable(
- code: code,
- message: error.description,
- permitsLegacyCompatibilityMigration: true
- ))
- }
- return .unavailable(DoryDaemonVirtualMachineProductionTrustUnavailable(
- code: code,
- message: error.description
- ))
- } catch {
- return unavailable(
- .qualificationAuthorityUnavailable,
- "VM qualification authority could not be verified."
- )
- }
-
- do {
- try DoryDaemonVirtualMachineProductionTrustFloor.validate(
- authority: authority,
- against: trustFloor
- )
- } catch {
- return unavailable(
- .trustFloorViolated,
- "The signed component catalog violates the accepted VM trust floor."
- )
- }
-
- guard daemonIdentityVerifier() else {
- return unavailable(
- .daemonSignatureUnavailable,
- "Resolved-plan launch requires a production-signed Dory daemon."
- )
- }
- do { _ = try hostProbe(machineConfiguration.stateDirectory) }
- catch {
- return unavailable(
- .hostFactsUnavailable,
- "Exact host model, OS build, framework, or resource facts are unavailable."
- )
- }
-
- var runtimes: [DoryDaemonVerifiedBackendRuntime] = []
- do {
- runtimes.append(try runtimeVerifier(
- machineConfiguration.vmmExecutablePath,
- VirtualizationFrameworkLinuxMachineBackend.backendDescriptor,
- "dory-vmm"
- ))
- } catch {
- return unavailable(
- .backendRuntimeUnavailable,
- "The signed Virtualization.framework helper failed exact verification."
- )
- }
- if let rawPath = machineConfiguration.acceleratedDesktopExecutablePath {
- if let runtime = try? runtimeVerifier(
- rawPath,
- RawHVLinuxMachineBackend.backendDescriptor,
- "dory-hv"
- ) {
- runtimes.append(runtime)
- }
+ let material: DoryDaemonVirtualMachineVerifiedTrustMaterial
+ switch verifiedTrustMaterial(
+ store: store,
+ machineConfiguration: machineConfiguration,
+ appVersion: appVersion,
+ publicKey: publicKey,
+ expectedArchitecture: expectedArchitecture
+ ) {
+ case let .success(verified):
+ material = verified
+ case let .failure(reason):
+ return .unavailable(reason)
}
+ let authority = material.authority
+ let runtimes = material.runtimes
+ let mayUseLegacyMigration = material.permitsLegacyCompatibilityMigration
let artifactAuthority = DoryVirtualMachineArtifactAuthority(
root: machineConfiguration.stateDirectory + "/.artifact-authority"
@@ -1161,10 +1110,9 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
)
do {
- try DoryDaemonVirtualMachineProductionTrustFloor.activate(
+ try activateVerifiedTrustFloor(
stateDirectory: machineConfiguration.stateDirectory,
- authority: authority,
- synchronizeDirectory: synchronizeTrustFloorDirectory
+ material: material
)
let manager = MachineManager(
configuration: machineConfiguration,
@@ -1229,6 +1177,142 @@ public struct DoryDaemonVirtualMachineProductionTrustFactory: Sendable {
}
}
+ /// Shared preparation boundary for the legacy composition bridge and the per-workspace
+ /// activation path. Verification is performed once and yields only opaque daemon authority;
+ /// neither path may independently rebuild or reinterpret the catalog/runtime trust graph.
+ func verifiedTrustMaterial(
+ store: DoryComponentStore,
+ machineConfiguration: MachineManagerConfiguration,
+ appVersion: String,
+ publicKey: String,
+ expectedArchitecture: String
+ ) -> Result<
+ DoryDaemonVirtualMachineVerifiedTrustMaterial,
+ DoryDaemonVirtualMachineProductionTrustUnavailable
+ > {
+ let trustFloor: DoryDaemonVirtualMachineProductionTrustFloor.Record?
+ let hasResolvedPlanState: Bool
+ do {
+ trustFloor = try DoryDaemonVirtualMachineProductionTrustFloor
+ .read(stateDirectory: machineConfiguration.stateDirectory)
+ hasResolvedPlanState = try DoryDaemonVirtualMachineProductionTrustFloor
+ .hasResolvedPlanState(stateDirectory: machineConfiguration.stateDirectory)
+ } catch {
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "The durable VM production trust floor is invalid or unavailable."
+ ))
+ }
+ let mayUseLegacyMigration = trustFloor == nil && !hasResolvedPlanState
+ let authority: DoryVerifiedVirtualMachineQualificationAuthority
+ do {
+ authority = try authorityResolver(
+ store, publicKey, expectedArchitecture, appVersion
+ )
+ } catch let error as DoryVirtualMachineQualificationAuthorityError {
+ let code: DoryDaemonVirtualMachineProductionTrustReadinessCode
+ switch error {
+ case .catalogUnavailable:
+ code = .catalogUnavailable
+ case let .catalogSchemaUnsupported(version):
+ code = version == DoryComponentCatalog.oldestSupportedSchemaVersion
+ ? .catalogSchemaV1Migration : .catalogSchemaUnsupported
+ default:
+ code = .qualificationAuthorityUnavailable
+ }
+ if code == .catalogUnavailable || code == .catalogSchemaV1Migration {
+ guard mayUseLegacyMigration else {
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "VM production trust was previously activated; catalog authority cannot be removed or downgraded."
+ ))
+ }
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: code,
+ message: error.description,
+ permitsLegacyCompatibilityMigration: true
+ ))
+ }
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: code,
+ message: error.description
+ ))
+ } catch {
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .qualificationAuthorityUnavailable,
+ message: "VM qualification authority could not be verified."
+ ))
+ }
+
+ do {
+ try DoryDaemonVirtualMachineProductionTrustFloor.validate(
+ authority: authority,
+ against: trustFloor
+ )
+ } catch {
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .trustFloorViolated,
+ message: "The signed component catalog violates the accepted VM trust floor."
+ ))
+ }
+
+ guard daemonIdentityVerifier() else {
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .daemonSignatureUnavailable,
+ message: "Resolved-plan launch requires a production-signed Dory daemon."
+ ))
+ }
+ do { _ = try hostProbe(machineConfiguration.stateDirectory) }
+ catch {
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .hostFactsUnavailable,
+ message: "Exact host model, OS build, framework, or resource facts are unavailable."
+ ))
+ }
+
+ var runtimes: [DoryDaemonVerifiedBackendRuntime] = []
+ do {
+ runtimes.append(try runtimeVerifier(
+ machineConfiguration.vmmExecutablePath,
+ VirtualizationFrameworkLinuxMachineBackend.backendDescriptor,
+ "dory-vmm"
+ ))
+ } catch {
+ return .failure(DoryDaemonVirtualMachineProductionTrustUnavailable(
+ code: .backendRuntimeUnavailable,
+ message: "The signed Virtualization.framework helper failed exact verification."
+ ))
+ }
+ if let rawPath = machineConfiguration.acceleratedDesktopExecutablePath,
+ let runtime = try? runtimeVerifier(
+ rawPath,
+ RawHVLinuxMachineBackend.backendDescriptor,
+ "dory-hv"
+ ) {
+ runtimes.append(runtime)
+ }
+ return .success(DoryDaemonVirtualMachineVerifiedTrustMaterial(
+ authority: authority,
+ runtimes: runtimes,
+ permitsLegacyCompatibilityMigration: mayUseLegacyMigration,
+ runtimeVerifier: runtimeVerifier,
+ hostProbe: hostProbe
+ ))
+ }
+
+ /// Advances the monotonic catalog floor only after the caller has recovered planning state
+ /// and installed the exact resulting launch graph into its MachineManager.
+ func activateVerifiedTrustFloor(
+ stateDirectory: String,
+ material: DoryDaemonVirtualMachineVerifiedTrustMaterial
+ ) throws {
+ try trustFloorActivator(
+ stateDirectory,
+ material.authority,
+ synchronizeTrustFloorDirectory
+ )
+ }
+
private func unavailable(
_ code: DoryDaemonVirtualMachineProductionTrustReadinessCode,
_ message: String
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
index b8aa7ea8..e027d1d2 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
@@ -309,6 +309,77 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
== fixture.runtimeBuildIdentifier)
}
+ @Test("activation owns the exact production manager and graph")
+ func activationOrderAndExactGraph() throws {
+ let trustFloor = ProductionTrustFloorActivationState()
+ let fixture = try ProductionTrustFixture(
+ trustFloorActivationState: trustFloor
+ )
+ defer { fixture.cleanup() }
+
+ let result = fixture.factory.activate(
+ store: fixture.store,
+ machineConfiguration: fixture.machineConfiguration,
+ recoveryProvider: ProductionEmptyPlanningRecovery(),
+ appVersion: fixture.appVersion,
+ publicKey: fixture.publicKey,
+ expectedArchitecture: "arm64"
+ )
+ guard case let .activated(context) = result else {
+ Issue.record("Expected production activation")
+ return
+ }
+ #expect(context.machineManager.configuredLaunchPolicy == .perWorkspaceAuthority)
+ #expect(context.machineManager.managedStateDirectory
+ == fixture.machineConfiguration.stateDirectory)
+ #expect(context.planning.identity.stateDirectory
+ == context.machineManager.managedStateDirectory)
+ #expect(context.planning.plans.root
+ == context.machineManager.managedStateDirectory)
+ #expect(context.planning.workspaces.root
+ == context.machineManager.managedStateDirectory)
+ #expect(context.inventory is DoryProductionDaemonVirtualMachineTrustInventory)
+ #expect(trustFloor.activationCount == 1)
+ }
+
+ @Test("trust-floor failure exposes no manager and a fresh activation can retry")
+ func activationFloorFailureIsTyped() throws {
+ let trustFloor = ProductionTrustFloorActivationState(remainingFailures: 1)
+ let fixture = try ProductionTrustFixture(
+ trustFloorActivationState: trustFloor
+ )
+ defer { fixture.cleanup() }
+ let recovery = ProductionEmptyPlanningRecovery()
+
+ guard case let .unavailable(failure) = fixture.factory.activate(
+ store: fixture.store,
+ machineConfiguration: fixture.machineConfiguration,
+ recoveryProvider: recovery,
+ appVersion: fixture.appVersion,
+ publicKey: fixture.publicKey,
+ expectedArchitecture: "arm64"
+ ) else {
+ Issue.record("Expected trust-floor activation failure")
+ return
+ }
+ #expect(failure.code == .trustFloorActivationRejected)
+ #expect(trustFloor.activationCount == 1)
+
+ guard case let .activated(context) = fixture.factory.activate(
+ store: fixture.store,
+ machineConfiguration: fixture.machineConfiguration,
+ recoveryProvider: recovery,
+ appVersion: fixture.appVersion,
+ publicKey: fixture.publicKey,
+ expectedArchitecture: "arm64"
+ ) else {
+ Issue.record("Expected a fresh production-owned manager to activate")
+ return
+ }
+ #expect(context.machineManager.configuredLaunchPolicy == .perWorkspaceAuthority)
+ #expect(trustFloor.activationCount == 2)
+ }
+
@Test("production inventory never fabricates an admission during planning")
func planningFailsUntilAtomicAdmissionBindingExists() throws {
let fixture = try ProductionTrustFixture()
@@ -453,6 +524,17 @@ private enum ProductionTrustFixtureError: Error {
case runtimeRejected
}
+private struct ProductionEmptyPlanningRecovery:
+ DoryDaemonVirtualMachinePlanningRecoveryProviding
+{
+ func recoveryRequest(
+ for machineID: String
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? {
+ _ = machineID
+ return nil
+ }
+}
+
private final class ProductionCallCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
@@ -470,6 +552,32 @@ private final class ProductionCallCounter: @unchecked Sendable {
}
}
+private final class ProductionTrustFloorActivationState: @unchecked Sendable {
+ private let lock = NSLock()
+ private var remainingFailures: Int
+ private var count = 0
+
+ init(remainingFailures: Int = 0) {
+ self.remainingFailures = remainingFailures
+ }
+
+ var activationCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return count
+ }
+
+ func activate() throws {
+ lock.lock()
+ defer { lock.unlock() }
+ count += 1
+ if remainingFailures > 0 {
+ remainingFailures -= 1
+ throw ProductionTrustFixtureError.runtimeRejected
+ }
+ }
+}
+
private final class ProductionHostState: @unchecked Sendable {
private let lock = NSLock()
private var observation: DoryDaemonProductionHostObservation
@@ -560,7 +668,8 @@ private final class ProductionTrustFixture: @unchecked Sendable {
daemonTeamIdentifier: String? = DorydXPCSecurity.productionTeamID,
runtimeVerificationFails: Bool = false,
planningTransactionAvailable: Bool = true,
- trustFloorDirectorySyncFails: Bool = false
+ trustFloorDirectorySyncFails: Bool = false,
+ trustFloorActivationState: ProductionTrustFloorActivationState? = nil
) throws {
helperDigest = Self.digest(Data("verified-helper".utf8))
hostState = ProductionHostState(host)
@@ -621,6 +730,15 @@ private final class ProductionTrustFixture: @unchecked Sendable {
let digest = helperDigest
let build = runtimeBuildIdentifier
let observedHostState = hostState
+ let floorActivator:
+ DoryDaemonVirtualMachineProductionTrustFactory.TrustFloorActivator?
+ if let trustFloorActivationState {
+ floorActivator = { _, _, _ in
+ try trustFloorActivationState.activate()
+ }
+ } else {
+ floorActivator = nil
+ }
factory = DoryDaemonVirtualMachineProductionTrustFactory(
authorityResolver: { store, key, architecture, appVersion in
try DoryVirtualMachineQualificationAuthorityResolver.resolve(
@@ -650,7 +768,8 @@ private final class ProductionTrustFixture: @unchecked Sendable {
planningTransactionAvailable: { planningTransactionAvailable },
synchronizeTrustFloorDirectory: { descriptor in
!trustFloorDirectorySyncFails && fsync(descriptor) == 0
- }
+ },
+ trustFloorActivator: floorActivator
)
}
From fd2f8818e8e83edb33bc8f1cf3eedc284cec4ba7 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 19:28:36 +0000
Subject: [PATCH 039/338] feat(vm): bind exact launch artifacts and devices
---
.../Sources/dory-hv/DesktopMode.swift | 63 ++-
.../Sources/dory-hv/main.swift | 22 +-
.../DoryVirtualMachineArtifactAuthority.swift | 2 +-
.../DoryVirtualMachineCapabilities.swift | 6 +
.../DoryVirtualMachineDefinition.swift | 39 +-
.../Sources/DoryVMMKit/DoryVMM.swift | 141 +++++--
.../DoryVMMDesktopApplication.swift | 57 +--
...emonVirtualMachineLaunchPlanResolver.swift | 1 +
...achinePlanningTransactionCoordinator.swift | 106 ++++-
...onVirtualMachineProductionActivation.swift | 16 +-
...MachineProductionPlanningComposition.swift | 47 ++-
...lMachineProductionPlanningController.swift | 362 ++++++++++++++++++
...yDaemonVirtualMachineProductionTrust.swift | 68 +++-
...yDaemonVirtualMachineRuntimePlanning.swift | 118 ++++++
.../DorydKit/DoryResolvedMachinePlan.swift | 185 ++++++++-
.../LinuxMachineBackendAdapters.swift | 4 +-
.../Sources/DorydKit/MachineBackend.swift | 10 +-
.../Sources/DorydKit/MachineManager.swift | 129 ++++++-
...VirtualMachineArtifactAuthorityTests.swift | 27 ++
.../DoryVirtualMachineDefinitionTests.swift | 17 +-
...ePlanningTransactionCoordinatorTests.swift | 12 +
...neProductionPlanningCompositionTests.swift | 97 ++++-
...ineProductionPlanningControllerTests.swift | 237 ++++++++++++
...onVirtualMachineProductionTrustTests.swift | 98 ++++-
...onVirtualMachineRuntimePlanningTests.swift | 42 ++
...esolvedMachineLaunchArtifactFixtures.swift | 55 +++
.../DoryResolvedMachinePlanTests.swift | 43 +++
.../Tests/DorydKitTests/DoryVMMKitTests.swift | 85 ++++
...lMachineResourceAdmissionLedgerTests.swift | 14 +
.../DorydKitTests/HealthReporterTests.swift | 6 +
.../DorydKitTests/MachineBackendTests.swift | 20 +-
...eManagerResolvedPlanIntegrationTests.swift | 201 +++++++++-
.../DorydKitTests/MachineManagerTests.swift | 2 +-
33 files changed, 2202 insertions(+), 130 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningControllerTests.swift
create mode 100644 dory-core-swift/Tests/DorydKitTests/DoryResolvedMachineLaunchArtifactFixtures.swift
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
index d3cedac5..cc0739ca 100644
--- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
@@ -25,6 +25,8 @@ enum DesktopMode {
var cpuCount: Int
var shares: [DoryMachineShareConfiguration]
var environment: [String: String]
+ var resolvedGraphics: DoryGraphicsAccelerationLevel?
+ var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?
}
private struct ResolvedGraphics {
@@ -73,9 +75,34 @@ enum DesktopMode {
self.serialLog = try Self.openAppendLog("\(configuration.stateDirectory)/serial.log")
let resolvedGraphics = try Self.resolveGraphics(
environment: configuration.environment,
- requireVulkan: configuration.genericGuest
+ requireVulkan: configuration.genericGuest,
+ exactLevel: configuration.resolvedGraphics
)
self.graphicsBackend = resolvedGraphics.backend
+ if let devices = configuration.resolvedDevices {
+ guard devices.networkAttachment == .sharedNAT,
+ !devices.clockSynchronization,
+ !devices.gracefulShutdown else {
+ throw VMError.bootFailure(
+ "resolved device contract contains a device not implemented by raw-HV"
+ )
+ }
+ guard devices.keyboard == devices.pointer else {
+ throw VMError.bootFailure(
+ "raw-HV input is a combined keyboard/pointer device"
+ )
+ }
+ guard devices.audioInput == devices.audioOutput else {
+ throw VMError.bootFailure(
+ "raw-HV audio is a combined input/output device"
+ )
+ }
+ guard devices.directorySharing == !configuration.shares.isEmpty else {
+ throw VMError.bootFailure(
+ "resolved directory-sharing contract does not match the launch shares"
+ )
+ }
+ }
self.machine = try Machine(configuration: MachineConfiguration(
kernelPath: configuration.kernelPath,
initrdPath: configuration.initrdPath,
@@ -153,13 +180,19 @@ enum DesktopMode {
var backends: [VirtioDeviceBackend] = [
try VirtioBlk(path: configuration.rootfsPath, identity: "dory-rootfs"),
gpu,
- input,
VirtioRng(),
balloon,
vsock,
- sound,
]
- for share in configuration.shares {
+ if configuration.resolvedDevices?.keyboard != false {
+ backends.append(input)
+ }
+ if configuration.resolvedDevices?.audioInput != false {
+ backends.append(sound)
+ }
+ let attachedShares = configuration.resolvedDevices?.directorySharing == false
+ ? [] : configuration.shares
+ for share in attachedShares {
let rawShare = try VirtioFSShareConfiguration(
tag: share.tag,
path: share.hostPath,
@@ -173,7 +206,7 @@ enum DesktopMode {
backends.append(network)
for (slot, backend) in backends.enumerated() {
let transport = Self.attachBackend(backend, to: machine, slot: slot)
- if backend === gpu {
+ if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false {
display.onDrawableSizeChange = { [weak gpu, weak transport] width, height in
guard let gpu, let transport else { return }
gpu.updateScanoutSize(width: width, height: height, transport: transport)
@@ -209,7 +242,7 @@ enum DesktopMode {
} else {
self.sshAgentBridge = nil
}
- if configuration.genericGuest {
+ if configuration.genericGuest || configuration.resolvedDevices?.clipboard == false {
self.clipboard = nil
} else {
let clipboardControl = DorydKit.AgentControl(configuration: .init(
@@ -535,7 +568,8 @@ enum DesktopMode {
private static func resolveGraphics(
environment: [String: String],
- requireVulkan: Bool
+ requireVulkan: Bool,
+ exactLevel: DoryGraphicsAccelerationLevel?
) throws -> ResolvedGraphics {
let preference = try DoryDesktopGraphicsPreference(environment: environment)
var rendererEnvironment = ProcessInfo.processInfo.environment
@@ -560,6 +594,21 @@ enum DesktopMode {
)
}
+ if let exactLevel {
+ switch exactLevel {
+ case .hardwareAccelerated3D:
+ return try accelerated(classicOnly: false)
+ case .hostAcceleratedDisplay:
+ return try accelerated(classicOnly: true)
+ case .software:
+ return ResolvedGraphics(backend: .software, renderer: nil)
+ case .none:
+ throw VMError.bootFailure(
+ "raw-HV desktop cannot satisfy a no-graphics resolved plan"
+ )
+ }
+ }
+
switch preference {
case .automatic:
if requireVulkan {
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift
index 3c7a1abe..e2b9a492 100644
--- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift
@@ -436,6 +436,8 @@ case "desktop":
var cpus = 6
var shares = [DoryMachineShareConfiguration]()
var environment = [String: String]()
+ var resolvedGraphics: DoryGraphicsAccelerationLevel?
+ var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?
var iterator = arguments.dropFirst().makeIterator()
while let argument = iterator.next() {
switch argument {
@@ -463,6 +465,22 @@ case "desktop":
fail("desktop --env requires KEY=VALUE")
}
environment[String(value[.. 0 else {
+ guard Self.isSHA256(sha256), byteCount > 0 else {
throw DoryVirtualMachineArtifactAuthorityError.invalidRecord
}
case let .mutable(provenance, sha256, byteCount, _, _, _, _, _, _):
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
index 8f7b18a7..dc2f6d10 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
@@ -406,6 +406,12 @@ public struct DoryMutableBootMediaProvenanceAuditEvidence: Codable, Sendable, Eq
public struct DoryTrustedMutableBootMediaProvenance: Sendable, Equatable, Hashable {
let auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence
+ /// Non-secret durable receipt reference. The opaque trusted wrapper remains daemon-owned;
+ /// persisted plans carry only this audit evidence and must resolve it again before launch.
+ public var persistedAuditEvidence: DoryMutableBootMediaProvenanceAuditEvidence {
+ auditEvidence
+ }
+
init(auditEvidence: DoryMutableBootMediaProvenanceAuditEvidence) {
self.auditEvidence = auditEvidence
}
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
index 505bf86d..1354ac71 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
@@ -129,6 +129,9 @@ public struct DoryVMStorageAttachment: Codable, Sendable, Equatable {
public var id: String
public var role: DoryVMStorageRole
public var artifact: DoryVMResolverReference
+ /// Provenance intent for the virtual disk. Storage kind is always `.virtualDisk`; writable
+ /// attachments require mutable daemon provenance and read-only attachments are immutable.
+ public var source: DoryBootMediaSource
public var capacityBytes: UInt64
public var readOnly: Bool
@@ -136,15 +139,38 @@ public struct DoryVMStorageAttachment: Codable, Sendable, Equatable {
id: String,
role: DoryVMStorageRole,
artifact: DoryVMResolverReference,
+ source: DoryBootMediaSource = .userProvided,
capacityBytes: UInt64,
readOnly: Bool = false
) {
self.id = id
self.role = role
self.artifact = artifact
+ self.source = source
self.capacityBytes = capacityBytes
self.readOnly = readOnly
}
+
+ private enum CodingKeys: String, CodingKey {
+ case id, role, artifact, source, capacityBytes, readOnly
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.init(
+ id: try container.decode(String.self, forKey: .id),
+ role: try container.decode(DoryVMStorageRole.self, forKey: .role),
+ artifact: try container.decode(DoryVMResolverReference.self, forKey: .artifact),
+ // Schema 2 did not bind storage provenance. Conservatively migrate it as imported;
+ // never manufacture Dory distribution/qualification authority.
+ source: try container.decodeIfPresent(
+ DoryBootMediaSource.self,
+ forKey: .source
+ ) ?? .userProvided,
+ capacityBytes: try container.decode(UInt64.self, forKey: .capacityBytes),
+ readOnly: try container.decode(Bool.self, forKey: .readOnly)
+ )
+ }
}
public enum DoryVMNetworkMode: String, Codable, Sendable, CaseIterable {
@@ -323,7 +349,7 @@ public struct DoryVMDefinitionValidationIssue: Codable, Sendable, Equatable {
/// passwords, tokens, host filesystem paths, or volatile process state.
public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
public static let oldestSupportedSchemaVersion: UInt16 = 1
- public static let currentSchemaVersion: UInt16 = 2
+ public static let currentSchemaVersion: UInt16 = 3
public static let currentVirtualHardwareABIVersion: UInt16 = 1
public var schemaVersion: UInt16
@@ -535,7 +561,16 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
return
}
- schemaVersion = persistedSchema
+ guard persistedSchema == 2 || persistedSchema == Self.currentSchemaVersion else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .schemaVersion,
+ in: container,
+ debugDescription: "Unsupported VM definition schema \(persistedSchema)."
+ )
+ }
+ // Schema 2 is structurally migrated by the attachment decoder above. Its missing storage
+ // source becomes `.userProvided`; encoding always publishes current schema 3.
+ schemaVersion = Self.currentSchemaVersion
virtualHardwareABIVersion = try container.decode(
UInt16.self,
forKey: .virtualHardwareABIVersion
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
index d091c62e..4460c37a 100644
--- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
@@ -45,6 +45,8 @@ public struct DoryVMMArguments: Sendable, Equatable {
public var holdSeconds: UInt32?
public var shares: [DoryMachineShareConfiguration] = []
public var environment: [String: String] = [:]
+ public var resolvedGraphics: DoryGraphicsAccelerationLevel?
+ public var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?
public init() {}
@@ -69,6 +71,8 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver
case invalidDisplayMode(String)
case invalidMachineBootMode(String)
case invalidEnvironment(String)
+ case invalidResolvedGraphics(String)
+ case invalidResolvedDevices(String)
public var description: String {
switch self {
@@ -96,6 +100,10 @@ public enum DoryVMMArgumentError: Error, Sendable, Equatable, CustomStringConver
return "invalid --boot-mode (expected linux-kernel or efi): \(mode)"
case let .invalidEnvironment(value):
return "invalid --env value: \(value)"
+ case let .invalidResolvedGraphics(value):
+ return "invalid --resolved-graphics value: \(value)"
+ case let .invalidResolvedDevices(value):
+ return "invalid --resolved-devices value: \(value)"
}
}
}
@@ -175,6 +183,22 @@ public func parseDoryVMMArguments(_ raw: [String]) throws -> DoryVMMArguments {
throw DoryVMMArgumentError.invalidEnvironment(key)
}
parsed.environment[key] = String(rawValue[rawValue.index(after: equals)...])
+ case "--resolved-graphics":
+ let rawValue = try value(after: argument, from: raw, index: &index)
+ guard let graphics = DoryGraphicsAccelerationLevel(rawValue: rawValue) else {
+ throw DoryVMMArgumentError.invalidResolvedGraphics(rawValue)
+ }
+ parsed.resolvedGraphics = graphics
+ case "--resolved-devices":
+ let rawValue = try value(after: argument, from: raw, index: &index)
+ do {
+ parsed.resolvedDevices = try JSONDecoder().decode(
+ DoryVirtualMachineDeviceCapabilityRequest.self,
+ from: Data(rawValue.utf8)
+ )
+ } catch {
+ throw DoryVMMArgumentError.invalidResolvedDevices(rawValue)
+ }
case "--exit-after-handoff":
parsed.exitAfterHandoff = true
case "--handoff-only":
@@ -224,6 +248,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable {
public var kernelCommandLine: String?
public var shares: [DoryMachineShareConfiguration]
public var environment: [String: String]
+ public var resolvedGraphics: DoryGraphicsAccelerationLevel?
+ public var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?
public var dockerDataDiskPath: String?
public var nativeIPv6: Bool
public var sourcePreservingLAN: Bool
@@ -242,6 +268,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable {
kernelCommandLine: String? = nil,
shares: [DoryMachineShareConfiguration] = [],
environment: [String: String] = [:],
+ resolvedGraphics: DoryGraphicsAccelerationLevel? = nil,
+ resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? = nil,
dockerDataDiskPath: String? = nil,
nativeIPv6: Bool = false,
sourcePreservingLAN: Bool = false,
@@ -259,6 +287,8 @@ public struct DoryVZMachineSpec: Sendable, Equatable {
self.kernelCommandLine = kernelCommandLine
self.shares = shares
self.environment = environment
+ self.resolvedGraphics = resolvedGraphics
+ self.resolvedDevices = resolvedDevices
self.dockerDataDiskPath = dockerDataDiskPath
self.nativeIPv6 = nativeIPv6
self.sourcePreservingLAN = sourcePreservingLAN
@@ -333,6 +363,38 @@ public enum DoryVZConfigurationBuilder {
throw DoryVZMachineError.missingFile(spec.rootfsPath)
}
+ if let graphics = spec.resolvedGraphics {
+ let expected: DoryGraphicsAccelerationLevel = spec.displayMode == .desktop
+ ? .hostAcceleratedDisplay : .none
+ guard graphics == expected else {
+ throw DoryVZMachineError.validation(
+ "resolved graphics \(graphics.rawValue) cannot be represented by this VZ launch"
+ )
+ }
+ }
+ if let devices = spec.resolvedDevices {
+ guard devices.networkAttachment == .sharedNAT,
+ !devices.clockSynchronization,
+ !devices.dynamicDisplay,
+ !devices.gracefulShutdown else {
+ throw DoryVZMachineError.validation(
+ "resolved device contract contains a device not implemented by this VZ launch"
+ )
+ }
+ guard devices.directorySharing == !spec.shares.isEmpty else {
+ throw DoryVZMachineError.validation(
+ "resolved directory-sharing contract does not match the launch shares"
+ )
+ }
+ if spec.displayMode != .desktop,
+ devices.audioInput || devices.audioOutput || devices.keyboard
+ || devices.pointer || devices.clipboard {
+ throw DoryVZMachineError.validation(
+ "resolved desktop devices cannot be attached to a headless VZ launch"
+ )
+ }
+ }
+
let configuration = VZVirtualMachineConfiguration()
switch spec.bootMode {
case .linuxKernel:
@@ -376,38 +438,51 @@ public enum DoryVZConfigurationBuilder {
configuration.networkDevices = [network]
if spec.displayMode == .desktop {
+ let devices = spec.resolvedDevices
let graphics = VZVirtioGraphicsDeviceConfiguration()
graphics.scanouts = [VZVirtioGraphicsScanoutConfiguration(
widthInPixels: DoryVMMDisplayDefaults.widthInPixels,
heightInPixels: DoryVMMDisplayDefaults.heightInPixels
)]
configuration.graphicsDevices = [graphics]
- configuration.keyboards = [VZUSBKeyboardConfiguration()]
- configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
-
- let output = VZVirtioSoundDeviceOutputStreamConfiguration()
- output.sink = VZHostAudioOutputStreamSink()
- let outputSound = VZVirtioSoundDeviceConfiguration()
- outputSound.streams = [output]
-
- let input = VZVirtioSoundDeviceInputStreamConfiguration()
- input.source = VZHostAudioInputStreamSource()
- let inputSound = VZVirtioSoundDeviceConfiguration()
- inputSound.streams = [input]
- configuration.audioDevices = [outputSound, inputSound]
-
- let console = VZVirtioConsoleDeviceConfiguration()
- let spiceAgent = VZVirtioConsolePortConfiguration()
- spiceAgent.name = VZSpiceAgentPortAttachment.spiceAgentPortName
- let spiceAttachment = VZSpiceAgentPortAttachment()
- // The native SPICE bridge has only an all-or-nothing switch. Keep it for the efficient
- // bidirectional default; directional policies use Dory's agent-backed bridge instead.
- spiceAttachment.sharesClipboard = DoryDesktopClipboardPolicy(
- environment: spec.environment
- ) == .bidirectional
- spiceAgent.attachment = spiceAttachment
- console.ports[0] = spiceAgent
- configuration.consoleDevices = [console]
+ if devices?.keyboard != false {
+ configuration.keyboards = [VZUSBKeyboardConfiguration()]
+ }
+ if devices?.pointer != false {
+ configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
+ }
+
+ var audioDevices = [VZVirtioSoundDeviceConfiguration]()
+ if devices?.audioOutput != false {
+ let output = VZVirtioSoundDeviceOutputStreamConfiguration()
+ output.sink = VZHostAudioOutputStreamSink()
+ let outputSound = VZVirtioSoundDeviceConfiguration()
+ outputSound.streams = [output]
+ audioDevices.append(outputSound)
+ }
+ if devices?.audioInput != false {
+ let input = VZVirtioSoundDeviceInputStreamConfiguration()
+ input.source = VZHostAudioInputStreamSource()
+ let inputSound = VZVirtioSoundDeviceConfiguration()
+ inputSound.streams = [input]
+ audioDevices.append(inputSound)
+ }
+ configuration.audioDevices = audioDevices
+
+ if devices?.clipboard != false {
+ let console = VZVirtioConsoleDeviceConfiguration()
+ let spiceAgent = VZVirtioConsolePortConfiguration()
+ spiceAgent.name = VZSpiceAgentPortAttachment.spiceAgentPortName
+ let spiceAttachment = VZSpiceAgentPortAttachment()
+ // The native SPICE bridge has only an all-or-nothing switch. Keep it for the efficient
+ // bidirectional default; directional policies use Dory's agent-backed bridge instead.
+ spiceAttachment.sharesClipboard = DoryDesktopClipboardPolicy(
+ environment: spec.environment
+ ) == .bidirectional
+ spiceAgent.attachment = spiceAttachment
+ console.ports[0] = spiceAgent
+ configuration.consoleDevices = [console]
+ }
}
var dockerDataDiskPath: String?
@@ -442,7 +517,10 @@ public enum DoryVZConfigurationBuilder {
)
directoryShares = [bootConfigShare] + spec.shares
}
- configuration.directorySharingDevices = try directoryShares.map { share in
+ let attachedDirectoryShares = spec.resolvedDevices?.directorySharing == false
+ ? directoryShares.filter { $0.tag == bootConfigTag }
+ : directoryShares
+ configuration.directorySharingDevices = try attachedDirectoryShares.map { share in
try share.validate()
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: share.hostPath, isDirectory: &isDirectory),
@@ -836,6 +914,8 @@ public enum DoryVMMMain {
readyTimeoutSeconds: arguments.readyTimeoutSeconds,
shares: arguments.shares,
environment: arguments.environment,
+ resolvedGraphics: arguments.resolvedGraphics,
+ resolvedDevices: arguments.resolvedDevices,
gvproxyPath: arguments.gvproxyPath,
sshAgentSocketPath: arguments.sshAgentSocketPath,
publishHost: arguments.publishHost,
@@ -866,7 +946,8 @@ public enum DoryVMMMain {
try DoryVMMDesktopApplication.run(
runtime: runtime,
machineID: machineID,
- environment: arguments.environment
+ environment: arguments.environment,
+ resolvedDevices: arguments.resolvedDevices
)
}
} else {
@@ -923,6 +1004,8 @@ public enum DoryVMMMain {
readyTimeoutSeconds: TimeInterval,
shares: [DoryMachineShareConfiguration],
environment: [String: String],
+ resolvedGraphics: DoryGraphicsAccelerationLevel?,
+ resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?,
gvproxyPath: String?,
sshAgentSocketPath: String?,
publishHost: String,
@@ -1014,6 +1097,8 @@ public enum DoryVMMMain {
kernelCommandLine: kernelCommandLine,
shares: shares,
environment: environment,
+ resolvedGraphics: resolvedGraphics,
+ resolvedDevices: resolvedDevices,
dockerDataDiskPath: dataDrive?.engineDataDiskPath,
nativeIPv6: gvproxyNetwork != nil,
sourcePreservingLAN: sourcePreservingLANClient != nil,
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift
index d4b53c5d..9e7291f3 100644
--- a/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMMDesktopApplication.swift
@@ -9,14 +9,21 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
private let runtime: DoryVMMRuntime
private let machineView: VZVirtualMachineView
private let window: NSWindow
- private let clipboard: DoryDesktopClipboardCoordinator
+ private let clipboard: DoryDesktopClipboardCoordinator?
+ private let dynamicDisplayEnabled: Bool
private var pendingDisplayResize: DispatchWorkItem?
private var requestedPixelSize: CGSize?
private var stopError: String?
- private init(runtime: DoryVMMRuntime, machineID: String, environment: [String: String]) {
+ private init(
+ runtime: DoryVMMRuntime,
+ machineID: String,
+ environment: [String: String],
+ resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?
+ ) {
self.application = NSApplication.shared
self.runtime = runtime
+ dynamicDisplayEnabled = resolvedDevices?.dynamicDisplay ?? true
let windowSize = NSSize(width: 1_280, height: 800)
let machineView = DoryVirtualMachineView(frame: NSRect(origin: .zero, size: windowSize))
@@ -35,21 +42,21 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
let coordinatorPolicy: DoryDesktopClipboardPolicy = requestedPolicy == .bidirectional
? .off
: requestedPolicy
- self.clipboard = DoryDesktopClipboardCoordinator(
- policy: coordinatorPolicy,
- execute: { argv, stdin, timeoutMs, outputLimitBytes in
- try runtime.executeDesktopIntegration(
- argv: argv,
- stdin: stdin,
- timeoutMs: timeoutMs,
- outputLimitBytes: outputLimitBytes
- )
- },
- sendShortcut: { keyCode in machineView.sendControlShortcut(linuxKeyCode: keyCode) },
- log: { message in
- FileHandle.standardError.write(Data("dory-vmm clipboard: \(message)\n".utf8))
- }
- )
+ clipboard = resolvedDevices?.clipboard == false ? nil : DoryDesktopClipboardCoordinator(
+ policy: coordinatorPolicy,
+ execute: { argv, stdin, timeoutMs, outputLimitBytes in
+ try runtime.executeDesktopIntegration(
+ argv: argv,
+ stdin: stdin,
+ timeoutMs: timeoutMs,
+ outputLimitBytes: outputLimitBytes
+ )
+ },
+ sendShortcut: { keyCode in machineView.sendControlShortcut(linuxKeyCode: keyCode) },
+ log: { message in
+ FileHandle.standardError.write(Data("dory-vmm clipboard: \(message)\n".utf8))
+ }
+ )
self.window = NSWindow(
contentRect: NSRect(origin: .zero, size: windowSize),
@@ -73,12 +80,14 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
static func run(
runtime: DoryVMMRuntime,
machineID: String,
- environment: [String: String]
+ environment: [String: String],
+ resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? = nil
) throws {
let controller = DoryVMMDesktopApplication(
runtime: runtime,
machineID: machineID,
- environment: environment
+ environment: environment,
+ resolvedDevices: resolvedDevices
)
try controller.runUntilStopped()
}
@@ -86,11 +95,11 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
private func runUntilStopped() throws {
application.setActivationPolicy(.regular)
application.delegate = self
- clipboard.start()
- clipboard.markGuestReady()
+ clipboard?.start()
+ clipboard?.markGuestReady()
window.makeKeyAndOrderFront(nil)
application.activate()
- reconfigureDisplayNow()
+ if dynamicDisplayEnabled { reconfigureDisplayNow() }
let runtime = self.runtime
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
@@ -107,7 +116,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
}
application.run()
- clipboard.stop()
+ clipboard?.stop()
if let stopError {
throw DoryVZMachineError.stoppedWithError(stopError)
}
@@ -177,6 +186,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
}
private func scheduleDisplayReconfiguration() {
+ guard dynamicDisplayEnabled else { return }
pendingDisplayResize?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.reconfigureDisplayNow()
@@ -186,6 +196,7 @@ final class DoryVMMDesktopApplication: NSObject, NSApplicationDelegate, NSWindow
}
private func reconfigureDisplayNow() {
+ guard dynamicDisplayEnabled else { return }
pendingDisplayResize?.cancel()
pendingDisplayResize = nil
let size = Self.targetPixelSize(
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
index a174d606..af50ff08 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineLaunchPlanResolver.swift
@@ -211,6 +211,7 @@ public final class DoryDaemonVirtualMachineStartEvidenceCollector:
inspectionEvidence: capability.bootMediaInspectionEvidence,
mutableProvenanceEvidence: capability.mutableBootMediaProvenanceEvidence
),
+ launchArtifacts: snapshot.launchArtifacts,
components: runtime.components.sorted {
$0.componentIdentifier < $1.componentIdentifier
},
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
index 928db557..ffa50fb4 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
@@ -30,6 +30,63 @@ public struct DoryDaemonVirtualMachinePlanningTransactionRequest: Sendable {
}
}
+/// Validated, daemon-local reconstruction authority decoded from the durable transaction journal.
+/// It carries no raw machine configuration, filesystem path, or environment value. A recovery
+/// provider must re-read those values from its own MachineManager authority and prove that the
+/// resulting request matches every digest and publication decision below.
+public struct DoryDaemonVirtualMachinePlanningRecoveryDescriptor: Sendable, Equatable {
+ public var machineID: String
+ public var definition: DoryVirtualMachineDefinition
+ public var definitionSHA256: String
+ public var workspacePublication: DoryDaemonVirtualMachineWorkspacePublication
+ public var planPublication: DoryDaemonVirtualMachinePlanPublication
+ public var machineSHA256: String
+ public var resourceRequirements: [DoryVMResourceRequirement]
+ public var startingLeaseDurationMilliseconds: Int64
+ public var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization?
+ public var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization?
+ public var requestSHA256: String
+ public var machineAuthoritySHA256: String
+
+ public func matches(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest
+ ) -> Bool {
+ let definitionData = DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(request.planning.definition)
+ return request.planning.definition == definition
+ && request.planning.canonicalDefinitionData == definitionData
+ && DoryDaemonVirtualMachinePlanningCoordinator.sha256(definitionData)
+ == definitionSHA256
+ && Self.sha256(request.planning.machine) == machineSHA256
+ && request.workspacePublication == workspacePublication
+ && request.planning.publication == planPublication
+ && request.resourceRequirements.sorted(by: Self.requirementOrder)
+ == resourceRequirements
+ && request.startingLeaseDurationMilliseconds
+ == startingLeaseDurationMilliseconds
+ && request.planning.fallbackAuthorization == fallbackAuthorization
+ && request.planning.experimentalAuthorization == experimentalAuthorization
+ }
+
+ private static func sha256(_ value: T) -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ guard let data = try? encoder.encode(value) else { return "" }
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func requirementOrder(
+ _ lhs: DoryVMResourceRequirement,
+ _ rhs: DoryVMResourceRequirement
+ ) -> Bool {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ let left = (try? encoder.encode(lhs)) ?? Data()
+ let right = (try? encoder.encode(rhs)) ?? Data()
+ return left.lexicographicallyPrecedes(right)
+ }
+}
+
/// One-use daemon authority. It re-resolves media, helper, host identity, and signed
/// qualification immediately before desired-state publication. Start obtains a separate token.
public final class DoryDaemonVirtualMachinePlanningPublicationAuthorization:
@@ -337,6 +394,11 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
var phase: Phase
var requestSHA256: String
var machineAuthoritySHA256: String
+ var machineSHA256: String
+ var resourceRequirements: [DoryVMResourceRequirement]
+ var startingLeaseDurationMilliseconds: Int64
+ var fallbackAuthorization: DoryResolvedMachineFallbackAuthorization?
+ var experimentalAuthorization: DoryResolvedExperimentalSupportAuthorization?
var definition: DoryVirtualMachineDefinition
var definitionSHA256: String
var sourceDefinitionSHA256: String?
@@ -483,6 +545,33 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
}
}
+ /// Reads and integrity-validates a durable journal before any external recovery authority is
+ /// consulted. Callers cannot select publication modes or authorization policy from live state.
+ public func recoveryDescriptor(
+ for machineID: String
+ ) throws -> DoryDaemonVirtualMachinePlanningRecoveryDescriptor? {
+ try instanceLock.withLock {
+ try withWorkspaceLock(machineID: machineID) {
+ guard let journal = try readJournal(machineID: machineID) else { return nil }
+ return DoryDaemonVirtualMachinePlanningRecoveryDescriptor(
+ machineID: journal.definition.identity.id,
+ definition: journal.definition,
+ definitionSHA256: journal.definitionSHA256,
+ workspacePublication: journal.workspacePublication,
+ planPublication: Self.planPublication(from: journal.planPublication),
+ machineSHA256: journal.machineSHA256,
+ resourceRequirements: journal.resourceRequirements,
+ startingLeaseDurationMilliseconds:
+ journal.startingLeaseDurationMilliseconds,
+ fallbackAuthorization: journal.fallbackAuthorization,
+ experimentalAuthorization: journal.experimentalAuthorization,
+ requestSHA256: journal.requestSHA256,
+ machineAuthoritySHA256: journal.machineAuthoritySHA256
+ )
+ }
+ }
+ }
+
private func execute(
_ request: DoryDaemonVirtualMachinePlanningTransactionRequest
) throws -> DoryDaemonVirtualMachinePlanningTransactionResult {
@@ -707,6 +796,14 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
phase: .prepared,
requestSHA256: requestDigest,
machineAuthoritySHA256: machineAuthoritySHA256,
+ machineSHA256: Self.sha256(Self.canonicalData(request.planning.machine)),
+ resourceRequirements: request.resourceRequirements.sorted(
+ by: Self.requirementOrder
+ ),
+ startingLeaseDurationMilliseconds:
+ request.startingLeaseDurationMilliseconds,
+ fallbackAuthorization: request.planning.fallbackAuthorization,
+ experimentalAuthorization: request.planning.experimentalAuthorization,
definition: request.planning.definition,
definitionSHA256: Self.sha256(canonicalDefinition),
sourceDefinitionSHA256: existingDefinition.map {
@@ -1218,7 +1315,9 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
_ definition: DoryVirtualMachineDefinition
) throws -> DoryDaemonVirtualMachineInventoryRequest {
guard let boot = DoryDaemonVirtualMachinePlanningCoordinator
- .primaryBootMedia(in: definition) else {
+ .primaryBootMedia(in: definition),
+ let launchArtifacts = DoryDaemonVirtualMachinePlanningCoordinator
+ .launchArtifactRequirements(for: definition) else {
throw failure(.invalidRequest, "Workspace has no primary boot media.")
}
return DoryDaemonVirtualMachineInventoryRequest(
@@ -1226,6 +1325,7 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
definitionRevision: definition.lifecycle.revision,
guest: definition.guest,
bootMedia: boot,
+ launchArtifacts: launchArtifacts,
resources: definition.resources,
devices: DoryDaemonVirtualMachinePlanningCoordinator.devices(for: definition),
acceptableGraphics: definition.graphics.acceptableLevels,
@@ -1410,6 +1510,10 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
) != nil,
Self.isSHA256(journal.requestSHA256),
Self.isSHA256(journal.machineAuthoritySHA256),
+ Self.isSHA256(journal.machineSHA256),
+ journal.startingLeaseDurationMilliseconds > 0,
+ journal.resourceRequirements
+ == journal.resourceRequirements.sorted(by: Self.requirementOrder),
Self.isSHA256(journal.definitionSHA256),
journal.definitionSHA256 == Self.sha256(
DoryDaemonVirtualMachinePlanningCoordinator
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
index 4c47eb5d..eabf5cdf 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
@@ -40,6 +40,8 @@ public struct DoryDaemonVirtualMachineProductionActivationFailure:
public struct DoryDaemonVirtualMachineProductionActivationContext: Sendable {
public let machineManager: MachineManager
public let planning: DoryDaemonVirtualMachineProductionPlanningContext
+ public let planningController:
+ DoryDaemonVirtualMachineProductionPlanningController
public let backendRuntimeBuildIdentifiers: [
DoryVirtualizationBackendIdentity: String
]
@@ -51,12 +53,15 @@ public struct DoryDaemonVirtualMachineProductionActivationContext: Sendable {
init(
machineManager: MachineManager,
planning: DoryDaemonVirtualMachineProductionPlanningContext,
+ planningController:
+ DoryDaemonVirtualMachineProductionPlanningController,
backendRuntimeBuildIdentifiers: [
DoryVirtualizationBackendIdentity: String
]
) {
self.machineManager = machineManager
self.planning = planning
+ self.planningController = planningController
self.backendRuntimeBuildIdentifiers = backendRuntimeBuildIdentifiers
}
}
@@ -78,13 +83,11 @@ extension DoryDaemonVirtualMachineProductionTrustFactory {
/// escape this boundary or poison a retry.
public func activate(
store: DoryComponentStore,
- machineConfiguration: MachineManagerConfiguration,
- recoveryProvider: any DoryDaemonVirtualMachinePlanningRecoveryProviding
+ machineConfiguration: MachineManagerConfiguration
) -> DoryDaemonVirtualMachineProductionActivationResult {
activate(
store: store,
machineConfiguration: machineConfiguration,
- recoveryProvider: recoveryProvider,
appVersion: Self.compiledDaemonVersion,
publicKey: DoryComponentDefaults.publicKey,
expectedArchitecture: DoryComponentDefaults.architecture
@@ -96,7 +99,6 @@ extension DoryDaemonVirtualMachineProductionTrustFactory {
func activate(
store: DoryComponentStore,
machineConfiguration: MachineManagerConfiguration,
- recoveryProvider: any DoryDaemonVirtualMachinePlanningRecoveryProviding,
appVersion: String,
publicKey: String,
expectedArchitecture: String
@@ -180,7 +182,9 @@ extension DoryDaemonVirtualMachineProductionTrustFactory {
runtimeVerifier: material.runtimeVerifier,
hostProbe: material.hostProbe,
mutationAuthority: machineManager,
- recoveryProvider: recoveryProvider
+ recoveryProvider: DoryDaemonVirtualMachineProductionRecoveryProvider(
+ stateDirectory: canonicalStateDirectory
+ )
)
let planning: DoryDaemonVirtualMachineProductionPlanningContext
switch composition.resolve() {
@@ -225,6 +229,8 @@ extension DoryDaemonVirtualMachineProductionTrustFactory {
return .activated(DoryDaemonVirtualMachineProductionActivationContext(
machineManager: machineManager,
planning: planning,
+ planningController:
+ DoryDaemonVirtualMachineProductionPlanningController(planning: planning),
backendRuntimeBuildIdentifiers: Dictionary(
uniqueKeysWithValues: material.runtimes.map {
($0.descriptor.identity, $0.runtimeBuildIdentifier)
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift
index 54980ba1..d7ace0e8 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningComposition.swift
@@ -7,7 +7,7 @@ import Foundation
/// facts, and lifecycle fence; a journal is never recovered from its persisted plan bytes alone.
public protocol DoryDaemonVirtualMachinePlanningRecoveryProviding: Sendable {
func recoveryRequest(
- for machineID: String
+ for descriptor: DoryDaemonVirtualMachinePlanningRecoveryDescriptor
) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest?
}
@@ -284,10 +284,47 @@ public final class DoryDaemonVirtualMachineProductionPlanningCompositionFactory:
let pending = try pendingTransactionMachineIDs()
var recovered: [String: String] = [:]
for machineID in pending {
- guard let request = try recoveryProvider.recoveryRequest(
- for: machineID
- ), request.planning.definition.identity.id == machineID,
- request.planning.machine.id == machineID else {
+ let descriptor: DoryDaemonVirtualMachinePlanningRecoveryDescriptor
+ do {
+ guard let recoveredDescriptor = try coordinator.recoveryDescriptor(
+ for: machineID
+ ), recoveredDescriptor.machineID == machineID else {
+ throw failure(
+ .recoveryRequestUnavailable,
+ machineID: machineID,
+ "The durable planning recovery descriptor is unavailable."
+ )
+ }
+ descriptor = recoveredDescriptor
+ } catch {
+ throw failure(
+ .recoveryFailed,
+ machineID: machineID,
+ "The durable planning recovery descriptor failed validation."
+ )
+ }
+ let request: DoryDaemonVirtualMachinePlanningTransactionRequest
+ do {
+ guard let recoveredRequest = try recoveryProvider.recoveryRequest(
+ for: descriptor
+ ) else {
+ throw failure(
+ .recoveryRequestUnavailable,
+ machineID: machineID,
+ "Exact authoritative recovery input is unavailable."
+ )
+ }
+ request = recoveredRequest
+ } catch {
+ throw failure(
+ .recoveryFailed,
+ machineID: machineID,
+ "Authoritative recovery input failed validation."
+ )
+ }
+ guard request.planning.definition.identity.id == machineID,
+ request.planning.machine.id == machineID,
+ descriptor.matches(request) else {
throw failure(
.recoveryRequestUnavailable,
machineID: machineID,
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
new file mode 100644
index 00000000..5b78662d
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
@@ -0,0 +1,362 @@
+import Darwin
+import DoryOperations
+import Foundation
+
+public enum DoryDaemonVirtualMachineProductionPlanningControllerFailureCode:
+ String, Sendable, Equatable
+{
+ case invalidRequest = "invalid-request"
+ case mediaAuthorityMissing = "media-authority-missing"
+ case mediaAuthorityConflict = "media-authority-conflict"
+ case transactionRejected = "transaction-rejected"
+ case publicationMismatch = "publication-mismatch"
+}
+
+public struct DoryDaemonVirtualMachineProductionPlanningControllerFailure:
+ Error, Sendable, Equatable
+{
+ public var code: DoryDaemonVirtualMachineProductionPlanningControllerFailureCode
+ public var message: String
+
+ public init(
+ code: DoryDaemonVirtualMachineProductionPlanningControllerFailureCode,
+ message: String
+ ) {
+ self.code = code
+ self.message = message
+ }
+}
+
+/// Daemon-local path binding supplied only after MachineManager has staged the artifact into its
+/// private workspace. This value is intentionally non-Codable: host paths never become API or
+/// desired-state authority.
+public struct DoryDaemonVirtualMachinePlanningArtifactPublication: Sendable, Equatable {
+ public enum Mutability: Sendable, Equatable {
+ case immutable
+ case mutable
+ }
+
+ public var reference: DoryVMResolverReference
+ public var path: String
+ public var kind: DoryBootMediaKind
+ public var source: DoryBootMediaSource
+ public var mutability: Mutability
+ public var expectedAuthorityRevision: UInt64?
+
+ public init(
+ reference: DoryVMResolverReference,
+ path: String,
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ mutability: Mutability,
+ expectedAuthorityRevision: UInt64? = nil
+ ) {
+ self.reference = reference
+ self.path = path
+ self.kind = kind
+ self.source = source
+ self.mutability = mutability
+ self.expectedAuthorityRevision = expectedAuthorityRevision
+ }
+}
+
+public protocol DoryDaemonVirtualMachinePlanningTransactionCoordinating: Sendable {
+ func resolveReserveAndPublish(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult
+}
+
+extension DoryDaemonVirtualMachinePlanningTransactionCoordinator:
+ DoryDaemonVirtualMachinePlanningTransactionCoordinating {}
+
+/// The sole production entry for a manager-owned native create/update/replan transaction. It
+/// first establishes exact artifact authority, then delegates reserve -> bind -> publication to
+/// the crash-safe coordinator, and finally proves the published plan and workspace are the exact
+/// result. It never derives intent from compatibility environment variables or calls MachineManager
+/// create/update recursively.
+public final class DoryDaemonVirtualMachineProductionPlanningController:
+ @unchecked Sendable
+{
+ private let artifactAuthority: DoryVirtualMachineArtifactAuthority
+ private let coordinator: any DoryDaemonVirtualMachinePlanningTransactionCoordinating
+ private let workspaces: DoryWorkspaceRepository
+ private let plans: DoryResolvedMachinePlanRepository
+
+ init(
+ planning: DoryDaemonVirtualMachineProductionPlanningContext
+ ) {
+ artifactAuthority = planning.artifactAuthority
+ coordinator = planning.coordinator
+ workspaces = planning.workspaces
+ plans = planning.plans
+ }
+
+ init(
+ artifactAuthority: DoryVirtualMachineArtifactAuthority,
+ coordinator: any DoryDaemonVirtualMachinePlanningTransactionCoordinating,
+ workspaces: DoryWorkspaceRepository,
+ plans: DoryResolvedMachinePlanRepository
+ ) {
+ self.artifactAuthority = artifactAuthority
+ self.coordinator = coordinator
+ self.workspaces = workspaces
+ self.plans = plans
+ }
+
+ public func resolveReserveAndPublish(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ artifacts publications: [DoryDaemonVirtualMachinePlanningArtifactPublication]
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult {
+ guard request.planning.machine.id == request.planning.definition.identity.id,
+ let requirements = DoryDaemonVirtualMachinePlanningCoordinator
+ .launchArtifactRequirements(for: request.planning.definition),
+ requirements.count == publications.count,
+ Set(publications.map(\.reference)).count == publications.count else {
+ throw failure(.invalidRequest, "Planning artifacts do not match desired-state authority.")
+ }
+ let publicationsByReference = Dictionary(
+ uniqueKeysWithValues: publications.map { ($0.reference, $0) }
+ )
+ for requirement in requirements {
+ guard let publication = publicationsByReference[requirement.reference],
+ publication.kind == requirement.kind,
+ publication.source == requirement.source,
+ (publication.mutability == .mutable) == requirement.mutable,
+ let launchPath = exactLaunchPath(
+ for: requirement,
+ definition: request.planning.definition,
+ machine: request.planning.machine
+ ),
+ publication.path == launchPath else {
+ throw failure(
+ .invalidRequest,
+ "Planning artifact path, kind, provenance, or mutability is not exact."
+ )
+ }
+ try establishArtifactAuthority(publication)
+ }
+
+ let result: DoryDaemonVirtualMachinePlanningTransactionResult
+ do { result = try coordinator.resolveReserveAndPublish(request) }
+ catch {
+ throw failure(.transactionRejected, "Production planning transaction failed closed.")
+ }
+ let workspace: DoryVirtualMachineDefinition
+ let plan: DoryResolvedMachinePlan
+ do {
+ workspace = try workspaces.readPersistedRecord(
+ id: request.planning.definition.identity.id
+ ).definition
+ plan = try plans.read(id: request.planning.definition.identity.id)
+ } catch {
+ throw failure(.publicationMismatch, "Published planning authority is unavailable.")
+ }
+ guard workspace == request.planning.definition,
+ plan == result.planning.resolvedPlan,
+ DoryMachineRuntimeIdentity.planSHA256(plan)
+ == result.planning.resolvedPlanSHA256 else {
+ throw failure(.publicationMismatch, "Published planning authority is not exact.")
+ }
+ return result
+ }
+
+ /// Compatibility launch paths remain daemon-local, but they are still launch authority. A
+ /// resolver publication must describe the exact path the selected adapter will consume; a
+ /// definition reference cannot authorize different bytes at an unrelated machine path.
+ private func exactLaunchPath(
+ for requirement: DoryDaemonVirtualMachineLaunchArtifactRequirement,
+ definition: DoryVirtualMachineDefinition,
+ machine: DoryMachineConfiguration
+ ) -> String? {
+ var paths = Set()
+ for usage in requirement.usages {
+ switch usage.kind {
+ case .boot:
+ guard let boot = definition.boot.devices.first(where: {
+ $0.id == usage.identifier && $0.artifact == requirement.reference
+ }) else { return nil }
+ switch boot.kind {
+ case .installedLinuxBootBundle:
+ paths.insert(machine.kernelPath)
+ case .installerISO:
+ guard let installerISOPath = machine.installerISOPath else { return nil }
+ paths.insert(installerISOPath)
+ case .virtualDisk:
+ paths.insert(machine.rootfsPath)
+ case .macOSRestoreImage:
+ // MachineManager has no macOS restore-media launch contract yet.
+ return nil
+ }
+ case .storage:
+ guard let storage = definition.storage.first(where: {
+ $0.id == usage.identifier && $0.artifact == requirement.reference
+ }), storage.role == .system else {
+ // Compatibility MachineManager cannot launch auxiliary typed disks yet.
+ return nil
+ }
+ paths.insert(machine.rootfsPath)
+ case .firmware:
+ // Firmware resolver paths are not represented by DoryMachineConfiguration yet.
+ return nil
+ }
+ }
+ guard paths.count == 1, let path = paths.first, !path.isEmpty,
+ path.hasPrefix("/"),
+ URL(fileURLWithPath: path).standardizedFileURL.path == path else {
+ return nil
+ }
+ return path
+ }
+
+ private func establishArtifactAuthority(
+ _ publication: DoryDaemonVirtualMachinePlanningArtifactPublication
+ ) throws {
+ let current: DoryVirtualMachineArtifactAuthorityRecord?
+ do { current = try artifactAuthority.authorityRecord(reference: publication.reference) }
+ catch { throw failure(.mediaAuthorityConflict, "Media authority cannot be inspected.") }
+
+ if let current {
+ if current.path == URL(fileURLWithPath: publication.path).standardizedFileURL.path,
+ current.kind == publication.kind, current.source == publication.source,
+ (try? artifactAuthority.resolve(
+ reference: publication.reference,
+ kind: publication.kind,
+ source: publication.source
+ )) != nil {
+ return
+ }
+ guard publication.mutability == .mutable,
+ publication.expectedAuthorityRevision == current.authorityRevision else {
+ throw failure(.mediaAuthorityConflict, "Existing media authority is not exact.")
+ }
+ } else if publication.expectedAuthorityRevision != nil {
+ throw failure(.mediaAuthorityMissing, "Expected media authority does not exist.")
+ }
+
+ do {
+ switch publication.mutability {
+ case .immutable:
+ _ = try artifactAuthority.publishImmutable(
+ reference: publication.reference,
+ path: publication.path,
+ kind: publication.kind,
+ source: publication.source,
+ expectedAuthorityRevision: publication.expectedAuthorityRevision
+ )
+ case .mutable:
+ _ = try artifactAuthority.publishMutable(
+ reference: publication.reference,
+ path: publication.path,
+ kind: publication.kind,
+ source: publication.source,
+ expectedAuthorityRevision: publication.expectedAuthorityRevision
+ )
+ }
+ } catch {
+ throw failure(.mediaAuthorityConflict, "Exact media authority could not be published.")
+ }
+ }
+
+ private func failure(
+ _ code: DoryDaemonVirtualMachineProductionPlanningControllerFailureCode,
+ _ message: String
+ ) -> DoryDaemonVirtualMachineProductionPlanningControllerFailure {
+ DoryDaemonVirtualMachineProductionPlanningControllerFailure(
+ code: code,
+ message: message
+ )
+ }
+}
+
+enum DoryDaemonVirtualMachineProductionRecoveryError: Error, Sendable, Equatable {
+ case invalidDescriptor
+ case machineUnavailable
+ case insecureMachineAuthority
+ case machineAuthorityChanged
+ case requestMismatch
+}
+
+/// Production-owned recovery bridge. It reconstructs raw compatibility state only from the
+/// manager's canonical private root and only after the coordinator has validated the journal
+/// descriptor. No public activation argument can substitute a recovery request.
+final class DoryDaemonVirtualMachineProductionRecoveryProvider:
+ DoryDaemonVirtualMachinePlanningRecoveryProviding, @unchecked Sendable
+{
+ private static let maximumMachineBytes = 16 * 1_024 * 1_024
+ private let stateDirectory: String
+
+ init(stateDirectory: String) {
+ self.stateDirectory = URL(fileURLWithPath: stateDirectory).standardizedFileURL.path
+ }
+
+ func recoveryRequest(
+ for descriptor: DoryDaemonVirtualMachinePlanningRecoveryDescriptor
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? {
+ guard descriptor.machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil,
+ !descriptor.machineID.hasPrefix("."),
+ descriptor.definition.identity.id == descriptor.machineID else {
+ throw DoryDaemonVirtualMachineProductionRecoveryError.invalidDescriptor
+ }
+ let machine = try readMachine(id: descriptor.machineID)
+ let request = DoryDaemonVirtualMachinePlanningTransactionRequest(
+ planning: DoryDaemonVirtualMachinePlanningRequest(
+ definition: descriptor.definition,
+ canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(descriptor.definition),
+ machine: machine,
+ publication: descriptor.planPublication,
+ fallbackAuthorization: descriptor.fallbackAuthorization,
+ experimentalAuthorization: descriptor.experimentalAuthorization
+ ),
+ workspacePublication: descriptor.workspacePublication,
+ resourceRequirements: descriptor.resourceRequirements,
+ startingLeaseDurationMilliseconds:
+ descriptor.startingLeaseDurationMilliseconds
+ )
+ guard descriptor.matches(request) else {
+ throw DoryDaemonVirtualMachineProductionRecoveryError.requestMismatch
+ }
+ return request
+ }
+
+ private func readMachine(id: String) throws -> DoryMachineConfiguration {
+ let path = stateDirectory + "/" + id + "/machine.json"
+ let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
+ guard descriptor >= 0 else {
+ throw DoryDaemonVirtualMachineProductionRecoveryError.machineUnavailable
+ }
+ defer { close(descriptor) }
+ var before = stat()
+ guard fstat(descriptor, &before) == 0,
+ before.st_mode & S_IFMT == S_IFREG,
+ before.st_uid == geteuid(), before.st_nlink == 1,
+ before.st_mode & 0o077 == 0,
+ before.st_size > 0,
+ before.st_size <= Self.maximumMachineBytes else {
+ throw DoryDaemonVirtualMachineProductionRecoveryError.insecureMachineAuthority
+ }
+ let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false)
+ let data = try handle.readToEnd() ?? Data()
+ var after = stat()
+ guard data.count == before.st_size, fstat(descriptor, &after) == 0,
+ Self.sameSnapshot(before, after) else {
+ throw DoryDaemonVirtualMachineProductionRecoveryError.machineAuthorityChanged
+ }
+ guard let machine = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: data
+ ), machine.id == id else {
+ throw DoryDaemonVirtualMachineProductionRecoveryError.machineUnavailable
+ }
+ return machine
+ }
+
+ private static func sameSnapshot(_ lhs: stat, _ rhs: stat) -> Bool {
+ lhs.st_dev == rhs.st_dev && lhs.st_ino == rhs.st_ino
+ && lhs.st_size == rhs.st_size
+ && lhs.st_mtimespec.tv_sec == rhs.st_mtimespec.tv_sec
+ && lhs.st_mtimespec.tv_nsec == rhs.st_mtimespec.tv_nsec
+ && lhs.st_ctimespec.tv_sec == rhs.st_ctimespec.tv_sec
+ && lhs.st_ctimespec.tv_nsec == rhs.st_ctimespec.tv_nsec
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
index 40bb098a..508e2a2e 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
@@ -437,6 +437,7 @@ private struct DoryDaemonProductionPlanningHostIdentity: Sendable, Equatable {
private struct DoryDaemonProductionPlanningMaterial: Sendable {
var host: DoryDaemonProductionHostObservation
var artifact: DoryVerifiedVirtualMachineArtifact
+ var launchArtifacts: [DoryResolvedMachineLaunchArtifact]
var runtimes: [DoryDaemonVerifiedBackendRuntime]
var qualifications: [DoryResolvedTrustedVirtualMachineQualification]
var media: DoryDaemonVirtualMachineResolvedMedia
@@ -521,6 +522,7 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
DoryDaemonVirtualMachineTrustedInventorySnapshot(
hostFacts: initial.hostFacts,
media: initial.media,
+ launchArtifacts: initial.launchArtifacts,
backendRuntimes: initial.backendInventories,
resourceAdmission: admission,
runtimeQualifications: initial.qualifications.map(\.runtime),
@@ -540,6 +542,7 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
current.artifact.media == initial.artifact.media,
current.artifact.authorityRevision
== initial.artifact.authorityRevision,
+ current.launchArtifacts == initial.launchArtifacts,
current.runtimes == initial.runtimes,
current.qualificationRecords == initial.qualificationRecords else {
throw DoryDaemonProductionTrustInventoryError.invalidRequest
@@ -560,11 +563,20 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
do { host = try hostProbe(stateDirectory) }
catch { throw DoryDaemonProductionTrustInventoryError.invalidRequest }
let artifact: DoryVerifiedVirtualMachineArtifact
+ let launchArtifacts: [DoryResolvedMachineLaunchArtifact]
do {
+ launchArtifacts = try resolveLaunchArtifacts(request.launchArtifacts)
+ guard let primary = launchArtifacts.first(where: {
+ $0.resolverReference == request.bootMedia.artifact
+ && $0.media.kind == request.bootMedia.kind
+ && $0.media.source == request.bootMedia.source
+ }) else {
+ throw DoryDaemonProductionTrustInventoryError.mediaUnavailable
+ }
artifact = try artifactAuthority.resolve(
- reference: request.bootMedia.artifact,
- kind: request.bootMedia.kind,
- source: request.bootMedia.source
+ reference: primary.resolverReference,
+ kind: primary.media.kind,
+ source: primary.media.source
)
} catch {
throw DoryDaemonProductionTrustInventoryError.mediaUnavailable
@@ -673,6 +685,7 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
return DoryDaemonProductionPlanningMaterial(
host: host,
artifact: artifact,
+ launchArtifacts: launchArtifacts,
runtimes: runtimes,
qualifications: qualifications,
media: DoryDaemonVirtualMachineResolvedMedia(
@@ -733,7 +746,12 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
}
let artifact: DoryVerifiedVirtualMachineArtifact
+ let launchArtifacts: [DoryResolvedMachineLaunchArtifact]
do {
+ launchArtifacts = try resolveLaunchArtifacts(plan.launchArtifacts)
+ guard launchArtifacts == plan.launchArtifacts else {
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
artifact = try artifactAuthority.resolve(
reference: request.bootMediaReference,
kind: plan.bootMedia.media.kind,
@@ -839,6 +857,7 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
bootInspection: inspection,
mutableProvenance: artifact.mutableProvenance
),
+ launchArtifacts: launchArtifacts,
backendRuntimes: [runtimeInventory],
resourceAdmission: admission,
exactStartRuntimeQualification: qualification.runtime
@@ -858,6 +877,49 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
}
}
+ private func resolveLaunchArtifacts(
+ _ requirements: [DoryDaemonVirtualMachineLaunchArtifactRequirement]
+ ) throws -> [DoryResolvedMachineLaunchArtifact] {
+ try requirements.map { requirement in
+ let artifact = try artifactAuthority.resolve(
+ reference: requirement.reference,
+ kind: requirement.kind,
+ source: requirement.source
+ )
+ guard (artifact.media.mutableProvenance != nil) == requirement.mutable else {
+ throw DoryDaemonProductionTrustInventoryError.mediaInvalid
+ }
+ return DoryResolvedMachineLaunchArtifact(
+ resolverReference: artifact.reference,
+ media: artifact.media,
+ authorityRevision: artifact.authorityRevision,
+ usages: requirement.usages,
+ mutableProvenanceEvidence:
+ artifact.mutableProvenance?.persistedAuditEvidence
+ )
+ }
+ }
+
+ private func resolveLaunchArtifacts(
+ _ planned: [DoryResolvedMachineLaunchArtifact]
+ ) throws -> [DoryResolvedMachineLaunchArtifact] {
+ try planned.map { expected in
+ let artifact = try artifactAuthority.resolve(
+ reference: expected.resolverReference,
+ kind: expected.media.kind,
+ source: expected.media.source
+ )
+ return DoryResolvedMachineLaunchArtifact(
+ resolverReference: artifact.reference,
+ media: artifact.media,
+ authorityRevision: artifact.authorityRevision,
+ usages: expected.usages,
+ mutableProvenanceEvidence:
+ artifact.mutableProvenance?.persistedAuditEvidence
+ )
+ }
+ }
+
private func hostFacts(
host: DoryDaemonProductionHostObservation,
runtime: DoryDaemonVerifiedBackendRuntime
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
index 2b6344ec..92308cc1 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
@@ -26,6 +26,28 @@ public struct DoryDaemonVirtualMachineResolvedMedia: Sendable {
}
}
+public struct DoryDaemonVirtualMachineLaunchArtifactRequirement: Sendable, Equatable {
+ public var reference: DoryVMResolverReference
+ public var kind: DoryBootMediaKind
+ public var source: DoryBootMediaSource
+ public var mutable: Bool
+ public var usages: [DoryResolvedMachineLaunchArtifactUsage]
+
+ public init(
+ reference: DoryVMResolverReference,
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ mutable: Bool,
+ usages: [DoryResolvedMachineLaunchArtifactUsage]
+ ) {
+ self.reference = reference
+ self.kind = kind
+ self.source = source
+ self.mutable = mutable
+ self.usages = usages
+ }
+}
+
public struct DoryDaemonVirtualMachineBackendRuntimeInventory: Sendable, Equatable {
public var backend: DoryVirtualizationBackendIdentity
public var runtimeBuildIdentifier: String
@@ -48,6 +70,7 @@ public struct DoryDaemonVirtualMachineBackendRuntimeInventory: Sendable, Equatab
public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable {
public var hostFacts: DoryAppleSiliconHostFacts
public var media: DoryDaemonVirtualMachineResolvedMedia
+ public var launchArtifacts: [DoryResolvedMachineLaunchArtifact]
public var backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory]
public var resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence
public var runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification]
@@ -60,6 +83,7 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable {
public init(
hostFacts: DoryAppleSiliconHostFacts,
media: DoryDaemonVirtualMachineResolvedMedia,
+ launchArtifacts: [DoryResolvedMachineLaunchArtifact] = [],
backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory],
resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence,
runtimeQualifications: [DoryTrustedVirtualMachineRuntimeQualification] = [],
@@ -69,6 +93,7 @@ public struct DoryDaemonVirtualMachineTrustedInventorySnapshot: Sendable {
) {
self.hostFacts = hostFacts
self.media = media
+ self.launchArtifacts = launchArtifacts
self.backendRuntimes = backendRuntimes
self.resourceAdmission = resourceAdmission
self.runtimeQualifications = runtimeQualifications
@@ -106,6 +131,7 @@ public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable {
public var definitionRevision: UInt64
public var guest: DoryGuestPlatform
public var bootMedia: DoryVMBootMediaReference
+ public var launchArtifacts: [DoryDaemonVirtualMachineLaunchArtifactRequirement]
public var resources: DoryVMResourceRequest
public var devices: DoryVirtualMachineDeviceCapabilityRequest
public var acceptableGraphics: [DoryGraphicsAccelerationLevel]
@@ -116,6 +142,7 @@ public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable {
definitionRevision: UInt64,
guest: DoryGuestPlatform,
bootMedia: DoryVMBootMediaReference,
+ launchArtifacts: [DoryDaemonVirtualMachineLaunchArtifactRequirement],
resources: DoryVMResourceRequest,
devices: DoryVirtualMachineDeviceCapabilityRequest,
acceptableGraphics: [DoryGraphicsAccelerationLevel],
@@ -125,6 +152,7 @@ public struct DoryDaemonVirtualMachineInventoryRequest: Sendable, Equatable {
self.definitionRevision = definitionRevision
self.guest = guest
self.bootMedia = bootMedia
+ self.launchArtifacts = launchArtifacts
self.resources = resources
self.devices = devices
self.acceptableGraphics = acceptableGraphics
@@ -335,11 +363,17 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda
throw failure(.bootMediaUnavailable, "The workspace has no primary boot device.")
}
let devices = Self.devices(for: definition)
+ guard let launchArtifactRequirements = Self.launchArtifactRequirements(
+ for: definition
+ ) else {
+ throw failure(.invalidDefinition, "Launch artifact requirements conflict.")
+ }
let inventoryRequest = DoryDaemonVirtualMachineInventoryRequest(
machineID: definition.identity.id,
definitionRevision: definition.lifecycle.revision,
guest: definition.guest,
bootMedia: bootReference,
+ launchArtifacts: launchArtifactRequirements,
resources: definition.resources,
devices: devices,
acceptableGraphics: definition.graphics.acceptableLevels,
@@ -356,6 +390,19 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda
snapshot.media.media.source == bootReference.source else {
throw failure(.mediaInventoryMismatch, "Resolved media does not match the boot reference.")
}
+ guard snapshot.launchArtifacts.map(\.resolverReference)
+ == launchArtifactRequirements.map(\.reference),
+ zip(snapshot.launchArtifacts, launchArtifactRequirements).allSatisfy({ artifact, requirement in
+ artifact.media.kind == requirement.kind
+ && artifact.media.source == requirement.source
+ && artifact.usages == requirement.usages
+ && (artifact.media.mutableProvenance != nil) == requirement.mutable
+ }) else {
+ throw failure(
+ .mediaInventoryMismatch,
+ "Resolved launch artifacts do not match desired-state references."
+ )
+ }
guard snapshot.resourceAdmission.admittedVirtualCPUCount
== definition.resources.virtualCPUCount,
snapshot.resourceAdmission.admittedMemoryBytes == definition.resources.memoryBytes,
@@ -418,6 +465,7 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda
backendDescriptor: backend.descriptor,
backendRuntimeBuildIdentifier: runtime.runtimeBuildIdentifier,
resolverReference: snapshot.media.reference,
+ launchArtifacts: snapshot.launchArtifacts,
components: runtime.components.sorted {
$0.componentIdentifier < $1.componentIdentifier
},
@@ -479,6 +527,76 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda
)
}
+ static func launchArtifactRequirements(
+ for definition: DoryVirtualMachineDefinition
+ ) -> [DoryDaemonVirtualMachineLaunchArtifactRequirement]? {
+ var requirements: [DoryVMResolverReference:
+ DoryDaemonVirtualMachineLaunchArtifactRequirement] = [:]
+ func insert(
+ reference: DoryVMResolverReference,
+ kind: DoryBootMediaKind,
+ source: DoryBootMediaSource,
+ mutable: Bool,
+ usage: DoryResolvedMachineLaunchArtifactUsage
+ ) -> Bool {
+ if var existing = requirements[reference] {
+ guard existing.kind == kind, existing.source == source,
+ existing.mutable == mutable else { return false }
+ existing.usages.append(usage)
+ requirements[reference] = existing
+ } else {
+ requirements[reference] = DoryDaemonVirtualMachineLaunchArtifactRequirement(
+ reference: reference,
+ kind: kind,
+ source: source,
+ mutable: mutable,
+ usages: [usage]
+ )
+ }
+ return true
+ }
+ for boot in definition.boot.devices {
+ let mutable = boot.kind == .virtualDisk
+ guard insert(
+ reference: boot.artifact,
+ kind: boot.kind,
+ source: boot.source,
+ mutable: mutable,
+ usage: DoryResolvedMachineLaunchArtifactUsage(
+ kind: .boot,
+ identifier: boot.id,
+ readOnly: !mutable
+ )
+ ) else { return nil }
+ }
+ for storage in definition.storage {
+ guard insert(
+ reference: storage.artifact,
+ kind: .virtualDisk,
+ source: storage.source,
+ mutable: !storage.readOnly,
+ usage: DoryResolvedMachineLaunchArtifactUsage(
+ kind: .storage,
+ identifier: storage.id,
+ readOnly: storage.readOnly
+ )
+ ) else { return nil }
+ }
+ return requirements.values.map { requirement in
+ var requirement = requirement
+ requirement.usages.sort {
+ let left = $0.kind.rawValue + "\0" + $0.identifier
+ let right = $1.kind.rawValue + "\0" + $1.identifier
+ return left < right
+ }
+ return requirement
+ }.sorted {
+ let left = $0.reference.namespace + "\0" + $0.reference.identifier
+ let right = $1.reference.namespace + "\0" + $1.reference.identifier
+ return left < right
+ }
+ }
+
private static func plannerRequest(
definition: DoryVirtualMachineDefinition,
media: DoryBootMedia,
diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
index 0cc83325..6f958e56 100644
--- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
@@ -12,7 +12,7 @@ public struct DoryResolvedBackendComponentEvidence: Codable, Sendable, Equatable
public var buildIdentifier: String
public var artifactSHA256: String
- public init(
+ init(
componentIdentifier: String,
buildIdentifier: String,
artifactSHA256: String
@@ -31,7 +31,7 @@ public struct DoryResolvedMachineBootMedia: Codable, Sendable, Equatable, Hashab
public var inspectionEvidence: DoryBootMediaInspectionAuditEvidence?
public var mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence?
- public init(
+ init(
resolverReference: DoryVMResolverReference?,
media: DoryBootMedia,
inspectionEvidence: DoryBootMediaInspectionAuditEvidence? = nil,
@@ -44,6 +44,61 @@ public struct DoryResolvedMachineBootMedia: Codable, Sendable, Equatable, Hashab
}
}
+public enum DoryResolvedMachineLaunchArtifactUsageKind:
+ String, Codable, Sendable, Equatable, Hashable
+{
+ case boot
+ case storage
+ case firmware
+}
+
+/// One launch-time use of an artifact. Keeping the stable desired-state identifier makes a plan
+/// auditable without persisting any host path.
+public struct DoryResolvedMachineLaunchArtifactUsage:
+ Codable, Sendable, Equatable, Hashable
+{
+ public var kind: DoryResolvedMachineLaunchArtifactUsageKind
+ public var identifier: String
+ public var readOnly: Bool
+
+ public init(
+ kind: DoryResolvedMachineLaunchArtifactUsageKind,
+ identifier: String,
+ readOnly: Bool
+ ) {
+ self.kind = kind
+ self.identifier = identifier
+ self.readOnly = readOnly
+ }
+}
+
+/// Exact, path-free authority for every artifact whose bytes can influence launch. Immutable
+/// artifacts carry a content digest in `media`; mutable disks carry a daemon-issued provenance
+/// revision and receipt. `authorityRevision` binds the exact private resolver publication.
+public struct DoryResolvedMachineLaunchArtifact:
+ Codable, Sendable, Equatable, Hashable
+{
+ public var resolverReference: DoryVMResolverReference
+ public var media: DoryBootMedia
+ public var authorityRevision: UInt64
+ public var usages: [DoryResolvedMachineLaunchArtifactUsage]
+ public var mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence?
+
+ public init(
+ resolverReference: DoryVMResolverReference,
+ media: DoryBootMedia,
+ authorityRevision: UInt64,
+ usages: [DoryResolvedMachineLaunchArtifactUsage],
+ mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence? = nil
+ ) {
+ self.resolverReference = resolverReference
+ self.media = media
+ self.authorityRevision = authorityRevision
+ self.usages = usages
+ self.mutableProvenanceEvidence = mutableProvenanceEvidence
+ }
+}
+
/// Persisted audit references only. Signature verification and qualification decisions remain
/// daemon-owned; these references let the daemon resolve and verify the exact evidence again.
public struct DoryResolvedMachineQualificationEvidence: Codable, Sendable, Equatable, Hashable {
@@ -401,6 +456,7 @@ public enum DoryResolvedMachinePlanValidationCode: String, Codable, Sendable, Ha
case invalidResolverReference = "invalid-resolver-reference"
case invalidMediaBinding = "invalid-media-binding"
case invalidMediaEvidence = "invalid-media-evidence"
+ case invalidLaunchArtifactEvidence = "invalid-launch-artifact-evidence"
case unsupportedRuntimeCombination = "unsupported-runtime-combination"
case duplicateComponent = "duplicate-component"
case unorderedComponents = "unordered-components"
@@ -435,7 +491,7 @@ public struct DoryResolvedMachinePlanValidationIssue: Codable, Sendable, Equatab
/// decision or non-secret audit reference and is replaced whenever any bound evidence changes.
public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
public static let oldestSupportedSchemaVersion: UInt16 = 1
- public static let currentSchemaVersion: UInt16 = 2
+ public static let currentSchemaVersion: UInt16 = 3
public var schemaVersion: UInt16
public var sourceSchemaVersion: UInt16
@@ -452,6 +508,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
public var backendRuntimeBuildIdentifier: String
public var virtualHardwareABIVersion: UInt16
public var bootMedia: DoryResolvedMachineBootMedia
+ public var launchArtifacts: [DoryResolvedMachineLaunchArtifact]
public var components: [DoryResolvedBackendComponentEvidence]
public var devices: DoryVirtualMachineDeviceCapabilityRequest
public var graphics: DoryGraphicsAccelerationLevel
@@ -475,6 +532,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
backendRuntimeBuildIdentifier: String,
virtualHardwareABIVersion: UInt16,
bootMedia: DoryResolvedMachineBootMedia,
+ launchArtifacts: [DoryResolvedMachineLaunchArtifact],
components: [DoryResolvedBackendComponentEvidence],
devices: DoryVirtualMachineDeviceCapabilityRequest,
graphics: DoryGraphicsAccelerationLevel,
@@ -500,6 +558,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier
self.virtualHardwareABIVersion = virtualHardwareABIVersion
self.bootMedia = bootMedia
+ self.launchArtifacts = launchArtifacts
self.components = components
self.devices = devices
self.graphics = graphics
@@ -523,6 +582,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
backendDescriptor: MachineBackendDescriptor,
backendRuntimeBuildIdentifier: String,
resolverReference: DoryVMResolverReference?,
+ launchArtifacts: [DoryResolvedMachineLaunchArtifact],
components: [DoryResolvedBackendComponentEvidence],
resourceAdmission: DoryResolvedMachineResourceAdmissionEvidence,
hostQualification: DoryResolvedHostQualificationEvidence,
@@ -570,6 +630,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
inspectionEvidence: selectedCapability.bootMediaInspectionEvidence,
mutableProvenanceEvidence: selectedCapability.mutableBootMediaProvenanceEvidence
),
+ launchArtifacts: launchArtifacts,
components: components,
devices: selectedCapability.request.devices,
graphics: selectedCapability.request.graphics,
@@ -605,6 +666,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
case backendRuntimeBuildID
case virtualHardwareABIVersion
case bootMedia
+ case launchArtifacts
case components
case componentDigests
case devices
@@ -652,6 +714,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
resolverReference: nil,
media: try container.decode(DoryBootMedia.self, forKey: .bootMedia)
)
+ launchArtifacts = []
let legacyDigests = try container.decode(
[String: String].self,
forKey: .componentDigests
@@ -674,16 +737,14 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
resourceAdmission = nil
hostQualification = nil
experimentalAuthorization = nil
- case Self.currentSchemaVersion:
- schemaVersion = persistedSchema
- sourceSchemaVersion = try container.decodeIfPresent(
- UInt16.self,
- forKey: .sourceSchemaVersion
- ) ?? persistedSchema
- migrationDisposition = try container.decode(
- DoryResolvedMachinePlanMigrationDisposition.self,
- forKey: .migrationDisposition
- )
+ case 2, Self.currentSchemaVersion:
+ schemaVersion = Self.currentSchemaVersion
+ sourceSchemaVersion = persistedSchema
+ migrationDisposition = persistedSchema == Self.currentSchemaVersion
+ ? try container.decode(
+ DoryResolvedMachinePlanMigrationDisposition.self,
+ forKey: .migrationDisposition
+ ) : .requiresReplanning
machineID = try container.decode(String.self, forKey: .machineID)
definitionRevision = try container.decode(UInt64.self, forKey: .definitionRevision)
definitionSHA256 = try container.decodeIfPresent(String.self, forKey: .definitionSHA256)
@@ -711,6 +772,11 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
forKey: .virtualHardwareABIVersion
)
bootMedia = try container.decode(DoryResolvedMachineBootMedia.self, forKey: .bootMedia)
+ launchArtifacts = persistedSchema == Self.currentSchemaVersion
+ ? try container.decode(
+ [DoryResolvedMachineLaunchArtifact].self,
+ forKey: .launchArtifacts
+ ) : []
components = try container.decode(
[DoryResolvedBackendComponentEvidence].self,
forKey: .components
@@ -767,6 +833,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
try container.encode(backendRuntimeBuildIdentifier, forKey: .backendRuntimeBuildIdentifier)
try container.encode(virtualHardwareABIVersion, forKey: .virtualHardwareABIVersion)
try container.encode(bootMedia, forKey: .bootMedia)
+ try container.encode(launchArtifacts, forKey: .launchArtifacts)
try container.encode(components, forKey: .components)
try container.encode(devices, forKey: .devices)
try container.encode(graphics, forKey: .graphics)
@@ -812,6 +879,7 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
}
validateBootMedia(into: &issues)
+ validateLaunchArtifacts(into: &issues)
validateComponents(into: &issues)
validateQualifications(into: &issues)
validateResourceAdmission(into: &issues)
@@ -889,6 +957,85 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
}
}
+ private func validateLaunchArtifacts(
+ into issues: inout [DoryResolvedMachinePlanValidationIssue]
+ ) {
+ func add(_ field: String) {
+ issues.append(DoryResolvedMachinePlanValidationIssue(
+ code: .invalidLaunchArtifactEvidence,
+ field: field
+ ))
+ }
+ guard !launchArtifacts.isEmpty else {
+ add("launchArtifacts")
+ return
+ }
+ let ordered = launchArtifacts.sorted { lhs, rhs in
+ let left = lhs.resolverReference.namespace + "\0"
+ + lhs.resolverReference.identifier
+ let right = rhs.resolverReference.namespace + "\0"
+ + rhs.resolverReference.identifier
+ return left < right
+ }
+ if ordered != launchArtifacts { add("launchArtifacts") }
+
+ var references: Set = []
+ var bootBindingIsPresent = false
+ for (index, artifact) in launchArtifacts.enumerated() {
+ let field = "launchArtifacts[\(index)]"
+ guard Self.isSafeResolverReference(artifact.resolverReference),
+ references.insert(artifact.resolverReference).inserted,
+ artifact.authorityRevision > 0,
+ !artifact.usages.isEmpty else {
+ add(field)
+ continue
+ }
+ let usages = artifact.usages.sorted {
+ ($0.kind.rawValue, $0.identifier) < ($1.kind.rawValue, $1.identifier)
+ }
+ if usages != artifact.usages
+ || Set(usages.map { $0.kind.rawValue + "\0" + $0.identifier }).count
+ != usages.count
+ || usages.contains(where: { !Self.isSafeIdentifier($0.identifier) }) {
+ add("\(field).usages")
+ }
+
+ let immutable = artifact.media.artifactSHA256
+ let mutable = artifact.media.mutableProvenance
+ let identityIsValid = (immutable.map(Self.isSHA256) ?? false)
+ != (mutable != nil)
+ if !identityIsValid { add("\(field).media") }
+ if let evidence = artifact.mutableProvenanceEvidence {
+ if mutable == nil
+ || evidence.provenance != mutable
+ || !Self.isSafeEvidenceIdentifier(evidence.receiptIdentity)
+ || !Self.isSHA256(evidence.receiptSHA256)
+ || !Self.isSafeEvidenceIdentifier(evidence.resolverID)
+ || evidence.resolverVersion == 0 {
+ add("\(field).mutableProvenanceEvidence")
+ }
+ } else if mutable != nil {
+ add("\(field).mutableProvenanceEvidence")
+ }
+ if mutable != nil && artifact.media.kind != .virtualDisk {
+ add("\(field).media.kind")
+ }
+ for usage in artifact.usages where usage.kind == .storage {
+ if artifact.media.kind != .virtualDisk
+ || (usage.readOnly && immutable == nil)
+ || (!usage.readOnly && mutable == nil) {
+ add("\(field).usages")
+ }
+ }
+ if artifact.usages.contains(where: { $0.kind == .boot }),
+ artifact.resolverReference == bootMedia.resolverReference,
+ artifact.media == bootMedia.media {
+ bootBindingIsPresent = true
+ }
+ }
+ if !bootBindingIsPresent { add("bootMedia.resolverReference") }
+ }
+
private func validateComponents(
into issues: inout [DoryResolvedMachinePlanValidationIssue]
) {
@@ -1276,6 +1423,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable,
public var backendRuntimeBuildIdentifier: String
public var virtualHardwareABIVersion: UInt16
public var bootMedia: DoryResolvedMachineBootMedia
+ public var launchArtifacts: [DoryResolvedMachineLaunchArtifact]
public var components: [DoryResolvedBackendComponentEvidence]
public var devices: DoryVirtualMachineDeviceCapabilityRequest
public var graphics: DoryGraphicsAccelerationLevel
@@ -1293,6 +1441,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable,
backendRuntimeBuildIdentifier: String,
virtualHardwareABIVersion: UInt16,
bootMedia: DoryResolvedMachineBootMedia,
+ launchArtifacts: [DoryResolvedMachineLaunchArtifact],
components: [DoryResolvedBackendComponentEvidence],
devices: DoryVirtualMachineDeviceCapabilityRequest,
graphics: DoryGraphicsAccelerationLevel,
@@ -1309,6 +1458,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable,
self.backendRuntimeBuildIdentifier = backendRuntimeBuildIdentifier
self.virtualHardwareABIVersion = virtualHardwareABIVersion
self.bootMedia = bootMedia
+ self.launchArtifacts = launchArtifacts
self.components = components
self.devices = devices
self.graphics = graphics
@@ -1327,6 +1477,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable,
backendRuntimeBuildIdentifier = plan.backendRuntimeBuildIdentifier
virtualHardwareABIVersion = plan.virtualHardwareABIVersion
bootMedia = plan.bootMedia
+ launchArtifacts = plan.launchArtifacts
components = plan.components
devices = plan.devices
graphics = plan.graphics
@@ -1337,6 +1488,7 @@ public struct DoryResolvedMachineRuntimeEvidence: Codable, Sendable, Equatable,
hostQualification = plan.hostQualification
experimentalAuthorization = plan.experimentalAuthorization
}
+
}
public struct DoryResolvedMachinePlanStartRevalidationInput: Codable, Sendable, Equatable, Hashable {
@@ -1373,6 +1525,7 @@ public enum DoryResolvedMachinePlanRevalidationCode: String, Codable, Sendable,
case backendRuntimeBuildMismatch = "backend-runtime-build-mismatch"
case virtualHardwareABIMismatch = "virtual-hardware-abi-mismatch"
case bootMediaEvidenceMismatch = "boot-media-evidence-mismatch"
+ case launchArtifactEvidenceMismatch = "launch-artifact-evidence-mismatch"
case componentEvidenceMismatch = "component-evidence-mismatch"
case deviceContractMismatch = "device-contract-mismatch"
case graphicsMismatch = "graphics-mismatch"
@@ -1478,6 +1631,12 @@ public enum DoryResolvedMachinePlanStartValidator {
field: "virtualHardwareABIVersion"
)
compare(runtime.bootMedia, plan.bootMedia, code: .bootMediaEvidenceMismatch, field: "bootMedia")
+ compare(
+ runtime.launchArtifacts,
+ plan.launchArtifacts,
+ code: .launchArtifactEvidenceMismatch,
+ field: "launchArtifacts"
+ )
compare(runtime.components, plan.components, code: .componentEvidenceMismatch, field: "components")
compare(runtime.devices, plan.devices, code: .deviceContractMismatch, field: "devices")
compare(runtime.graphics, plan.graphics, code: .graphicsMismatch, field: "graphics")
diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
index f7876170..9835c5a1 100644
--- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
+++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
@@ -213,7 +213,9 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable {
machineID: plan.machine.id,
backend: descriptor,
componentIdentifier: componentIdentifier,
- executablePath: executablePath
+ executablePath: executablePath,
+ graphics: plan.capability.request.graphics,
+ devices: plan.capability.request.devices
))
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift
index 0d2ff36e..603365a3 100644
--- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift
@@ -210,17 +210,25 @@ public struct MachineBackendLaunchBinding: Sendable, Equatable {
public var backend: MachineBackendDescriptor
public var componentIdentifier: String
public var executablePath: String
+ /// Exact capability contract selected and revalidated by this adapter. These values must be
+ /// carried into process construction; a launcher may not reinterpret them as "automatic".
+ public var graphics: DoryGraphicsAccelerationLevel
+ public var devices: DoryVirtualMachineDeviceCapabilityRequest
public init(
machineID: String,
backend: MachineBackendDescriptor,
componentIdentifier: String,
- executablePath: String
+ executablePath: String,
+ graphics: DoryGraphicsAccelerationLevel,
+ devices: DoryVirtualMachineDeviceCapabilityRequest
) {
self.machineID = machineID
self.backend = backend
self.componentIdentifier = componentIdentifier
self.executablePath = executablePath
+ self.graphics = graphics
+ self.devices = devices
}
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 421b330b..d0f630ed 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -1240,6 +1240,8 @@ public final class MachineManager: @unchecked Sendable {
backend: resolved.backendPlan.backend,
runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier,
runtimeComponents: resolved.resolvedPlan.components,
+ graphics: resolved.resolvedPlan.graphics,
+ devices: resolved.resolvedPlan.devices,
planRevision: resolved.resolvedPlan.planRevision,
planSHA256: resolved.resolvedPlanSHA256,
preSpawnAuthorization: preSpawnAuthorization
@@ -1340,9 +1342,12 @@ public final class MachineManager: @unchecked Sendable {
authorization.machine.id == binding.machineID,
authorization.backend == binding.backend,
binding.backend.identity == expectedBackend,
+ authorization.graphics == binding.graphics,
+ authorization.devices == binding.devices,
!binding.executablePath.isEmpty else {
+ pendingResolvedStart = nil
throw MachineManagerError.persistence(
- "backend start lacks a validated resolved-plan authorization"
+ "backend start does not match the exact resolved-plan launch contract"
)
}
let executableSHA256: String
@@ -1585,13 +1590,17 @@ public final class MachineManager: @unchecked Sendable {
// This single-use daemon-owned check deliberately sits after all persisted
// machine/plan validation and immediately before any path-derived launch arguments
// are read. Failure consumes the token and cannot reach processStarter.
- if launchBinding != nil {
+ if let launchBinding {
guard let preSpawnAuthorization else {
throw MachineManagerError.persistence(
"resolved launch is missing final pre-spawn authorization"
)
}
try preSpawnAuthorization.authorize()
+ try revalidateMaterializedInstalledLinuxBootRuntimeImmediatelyBeforeSpawn(
+ entry.configuration,
+ launchBinding: launchBinding
+ )
}
processConfiguration = try self.processConfiguration(
for: entry.configuration,
@@ -2078,6 +2087,10 @@ public final class MachineManager: @unchecked Sendable {
return try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch {
let updateError = error
+ // The handoff callback publishes `.failed` before it can acquire operationLock to
+ // terminalize the readiness journal. update() already owns operationLock here, so it
+ // must settle that failed start before opening the rollback stop/start transaction.
+ failActiveStartLifecycle(id: id, stepID: "start.readiness-failed")
_ = try? stopImplementation(id: id, journalLifecycle: true)
do {
try persist(current)
@@ -2954,7 +2967,8 @@ public final class MachineManager: @unchecked Sendable {
for: machine,
handoffPath: handoffPath,
baseArguments: target.baseArguments,
- acceleratedDesktop: target.acceleratedDesktop
+ acceleratedDesktop: target.acceleratedDesktop,
+ resolvedLaunchBinding: resolvedLaunchBinding
),
logPath: "\(configuration.logDirectory)/\(machine.id).log",
restartPolicy: configuration.requiresReadyHandoff
@@ -3054,7 +3068,8 @@ public final class MachineManager: @unchecked Sendable {
for machine: DoryMachineConfiguration,
handoffPath: String?,
baseArguments: [String],
- acceleratedDesktop: Bool
+ acceleratedDesktop: Bool,
+ resolvedLaunchBinding: MachineBackendLaunchBinding?
) throws -> [String] {
guard configuration.passMachineArguments else {
return baseArguments
@@ -3094,10 +3109,64 @@ public final class MachineManager: @unchecked Sendable {
if let handoffPath {
arguments.append(contentsOf: ["--handoff-sock", handoffPath])
}
+ if let resolvedLaunchBinding {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let deviceContract = String(
+ decoding: try encoder.encode(resolvedLaunchBinding.devices),
+ as: UTF8.self
+ )
+ arguments.append(contentsOf: [
+ "--resolved-graphics", resolvedLaunchBinding.graphics.rawValue,
+ "--resolved-devices", deviceContract,
+ ])
+ }
for share in machine.shares {
arguments.append(contentsOf: ["--share", share.argumentValue])
}
- for (key, value) in machine.environment.sorted(by: { $0.key < $1.key }) {
+ var launchEnvironment = machine.environment
+ if let resolvedLaunchBinding {
+ launchEnvironment.removeValue(forKey: DoryDesktopVMMPreference.environmentKey)
+ launchEnvironment.removeValue(forKey: DoryDesktopGraphicsPreference.environmentKey)
+ launchEnvironment.removeValue(
+ forKey: DoryDesktopGraphicsPreference.legacyClassicOnlyEnvironmentKey
+ )
+ switch resolvedLaunchBinding.backend.identity {
+ case .doryHypervisor:
+ let preference: DoryDesktopGraphicsPreference
+ switch resolvedLaunchBinding.graphics {
+ case .hardwareAccelerated3D:
+ preference = .virglVenus
+ case .hostAcceleratedDisplay:
+ preference = .virgl
+ case .software:
+ preference = .software
+ case .none:
+ throw MachineManagerError.persistence(
+ "raw-Hypervisor desktop cannot satisfy a no-graphics resolved plan"
+ )
+ }
+ launchEnvironment[DoryDesktopGraphicsPreference.environmentKey]
+ = preference.rawValue
+ launchEnvironment[DoryDesktopVMMPreference.environmentKey]
+ = DoryDesktopVMMPreference.accelerated.rawValue
+ case .appleVirtualizationFramework:
+ guard resolvedLaunchBinding.graphics == .hostAcceleratedDisplay
+ || (machine.displayMode == .headless
+ && resolvedLaunchBinding.graphics == .none) else {
+ throw MachineManagerError.persistence(
+ "Virtualization.framework cannot satisfy the exact resolved graphics contract"
+ )
+ }
+ launchEnvironment[DoryDesktopVMMPreference.environmentKey]
+ = DoryDesktopVMMPreference.compatible.rawValue
+ default:
+ throw MachineManagerError.persistence(
+ "resolved backend has no exact graphics argument contract"
+ )
+ }
+ }
+ for (key, value) in launchEnvironment.sorted(by: { $0.key < $1.key }) {
arguments.append(contentsOf: ["--env", "\(key)=\(value)"])
}
let isSandbox = machine.environment["DORY_SANDBOX"] == "1"
@@ -3745,6 +3814,44 @@ public final class MachineManager: @unchecked Sendable {
}
}
+ /// Installed-Linux acceleration launches materialized kernel/initrd paths rather than the
+ /// authority-bound bundle itself. Re-materialize from the fully verified bundle only after
+ /// the single-use pre-spawn authorization has succeeded so stale or replaced derived files
+ /// can never reach process construction.
+ private func revalidateMaterializedInstalledLinuxBootRuntimeImmediatelyBeforeSpawn(
+ _ machine: DoryMachineConfiguration,
+ launchBinding: MachineBackendLaunchBinding
+ ) throws {
+ guard launchBinding.backend.identity == .doryHypervisor,
+ machine.bootMode == .efi,
+ machine.installerISOPath == nil,
+ DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else {
+ return
+ }
+ do {
+ try DoryInstalledLinuxBootBundle.materialize(
+ fromPath: machine.kernelPath,
+ kernelPath: machineInstalledLinuxKernelPath(id: machine.id),
+ initrdPath: machineInstalledLinuxInitrdPath(id: machine.id)
+ )
+ guard Self.isPrivateRegularFile(
+ path: machineInstalledLinuxKernelPath(id: machine.id)
+ ), Self.isPrivateRegularFile(
+ path: machineInstalledLinuxInitrdPath(id: machine.id)
+ ) else {
+ throw MachineManagerError.persistence(
+ "materialized installed-Linux launch artifacts are not private regular files"
+ )
+ }
+ } catch let error as MachineManagerError {
+ throw error
+ } catch {
+ throw MachineManagerError.persistence(
+ "installed-Linux launch artifacts failed final verification: \(error)"
+ )
+ }
+ }
+
private func machineFirmwareIdentifierPath(id: String) -> String {
"\(machineStateDirectory(id: id))/MachineIdentifier"
}
@@ -5991,6 +6098,16 @@ public final class MachineManager: @unchecked Sendable {
existing.depth += 1
return existing
}
+ // A handoff publishes the runtime state before its callback can acquire operationLock to
+ // settle the readiness journal. If the very next user mutation wins that race, it owns
+ // operationLock already and can deterministically finish the committed start itself
+ // instead of rejecting a healthy running machine as an active lifecycle conflict.
+ if activeLifecycleOperations[id]?.operation.kind == .starting {
+ lock.lock()
+ let readinessCommitted = machines[id]?.state == .running
+ lock.unlock()
+ if readinessCommitted { completeActiveStartLifecycle(id: id) }
+ }
guard activeLifecycleOperations[id] == nil else {
throw MachineManagerError.persistence(
"machine \(id) already has an active lifecycle mutation"
@@ -7604,6 +7721,8 @@ private struct PendingResolvedMachineStart {
var backend: MachineBackendDescriptor
var runtimeBuildIdentifier: String
var runtimeComponents: [DoryResolvedBackendComponentEvidence]
+ var graphics: DoryGraphicsAccelerationLevel
+ var devices: DoryVirtualMachineDeviceCapabilityRequest
var planRevision: UInt64
var planSHA256: String
var preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift
index b3c05d01..b7cd4061 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineArtifactAuthorityTests.swift
@@ -42,6 +42,33 @@ final class DoryVirtualMachineArtifactAuthorityTests: XCTestCase {
}
}
+ func testReadOnlyVirtualDiskCanBePublishedAsImmutable() throws {
+ try withFixture("immutable-read-only-disk") { fixture in
+ let disk = try fixture.file(
+ "tools.raw",
+ data: Data(repeating: 0x5a, count: 4_096)
+ )
+ let published = try fixture.authority.publishImmutable(
+ reference: fixture.reference,
+ path: disk,
+ kind: .virtualDisk,
+ source: .bundledByDory
+ )
+
+ XCTAssertEqual(published.media.kind, .virtualDisk)
+ XCTAssertNotNil(published.media.artifactSHA256)
+ XCTAssertNil(published.media.mutableProvenance)
+ XCTAssertEqual(
+ try fixture.authority.resolve(
+ reference: fixture.reference,
+ kind: .virtualDisk,
+ source: .bundledByDory
+ ).media,
+ published.media
+ )
+ }
+ }
+
func testMutableArtifactRequiresExplicitRevisionPublication() throws {
try withFixture("mutable") { fixture in
let disk = try fixture.file("system.raw", data: Data(repeating: 0, count: 4_096))
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
index 5fcfea08..b3fc58b9 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineDefinitionTests.swift
@@ -7,7 +7,7 @@ struct DoryVirtualMachineDefinitionTests {
private let gibibyte: UInt64 = 1_073_741_824
private let nowMilliseconds: Int64 = 1_787_200_000_000
- @Test("schema 2 round trips with stable resolver and timestamp representations")
+ @Test("schema 3 round trips with stable resolver and timestamp representations")
func currentRoundTrip() throws {
let original = linuxDefinition()
#expect(original.isValid)
@@ -19,7 +19,7 @@ struct DoryVirtualMachineDefinitionTests {
#expect(decoded == original)
let json = try #require(String(data: data, encoding: .utf8))
- #expect(json.contains("\"schemaVersion\":2"))
+ #expect(json.contains("\"schemaVersion\":3"))
#expect(json.contains("\"virtualHardwareABIVersion\":1"))
#expect(json.contains("\"createdAtUnixMilliseconds\":1787200000000"))
#expect(json.contains("\"namespace\":\"boot\""))
@@ -30,7 +30,7 @@ struct DoryVirtualMachineDefinitionTests {
#expect(!json.contains("hostLocationID"))
}
- @Test("schema 2 records written before typed guest intent retain compatibility defaults")
+ @Test("schema 2 records migrate storage provenance and typed-intent defaults")
func additiveSchemaTwoMigration() throws {
let encoder = JSONEncoder()
let data = try encoder.encode(linuxDefinition())
@@ -39,6 +39,10 @@ struct DoryVirtualMachineDefinitionTests {
)
object.removeValue(forKey: "guestIdentityIntent")
object.removeValue(forKey: "clipboardPolicy")
+ object["schemaVersion"] = 2
+ var storage = try #require(object["storage"] as? [[String: Any]])
+ for index in storage.indices { storage[index].removeValue(forKey: "source") }
+ object["storage"] = storage
let oldSchemaTwo = try JSONSerialization.data(withJSONObject: object)
let decoded = try JSONDecoder().decode(
@@ -47,6 +51,8 @@ struct DoryVirtualMachineDefinitionTests {
)
#expect(decoded.guestIdentityIntent == .unspecified)
#expect(decoded.clipboardPolicy == .legacyDesktop(.bidirectional))
+ #expect(decoded.schemaVersion == 3)
+ #expect(decoded.storage.allSatisfy { $0.source == .userProvided })
#expect(decoded.isValid)
}
@@ -176,19 +182,20 @@ struct DoryVirtualMachineDefinitionTests {
"""#.utf8)
let migrated = try JSONDecoder().decode(DoryVirtualMachineDefinition.self, from: golden)
- #expect(migrated.schemaVersion == 2)
+ #expect(migrated.schemaVersion == 3)
#expect(migrated.virtualHardwareABIVersion == 1)
#expect(migrated.workload == .desktop)
#expect(migrated.boot.phase == .install)
#expect(migrated.boot.order == ["installer"])
#expect(migrated.boot.devices[0].artifact == reference("install", "ubuntu-24.04"))
#expect(migrated.graphics.acceptableLevels == [.hostAcceleratedDisplay, .software])
+ #expect(migrated.storage[0].source == .userProvided)
#expect(migrated.lifecycle.createdAtUnixMilliseconds == 1_700_000_000_000)
#expect(migrated.isValid)
let upgraded = try JSONEncoder().encode(migrated)
let upgradedJSON = try #require(String(data: upgraded, encoding: .utf8))
- #expect(upgradedJSON.contains("\"schemaVersion\":2"))
+ #expect(upgradedJSON.contains("\"schemaVersion\":3"))
#expect(upgradedJSON.contains("createdAtUnixMilliseconds"))
#expect(!upgradedJSON.contains("\"bootMedia\""))
}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
index d5571bc2..dddf11dd 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
@@ -513,6 +513,18 @@ private final class TransactionFixture: @unchecked Sendable {
media: DoryDaemonVirtualMachineResolvedMedia(
reference: bootArtifact, media: media
),
+ launchArtifacts: [
+ resolvedMutableStorageLaunchArtifact(
+ reference: diskArtifact,
+ source: .userProvided,
+ identifier: "system-disk"
+ ),
+ resolvedBootLaunchArtifacts(
+ reference: bootArtifact,
+ media: media,
+ identifier: "system"
+ )[0],
+ ],
backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory(
backend: .doryHypervisor,
runtimeBuildIdentifier: "raw-runtime-1",
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
index 9523a0ce..3bfb8d6c 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
@@ -93,6 +93,49 @@ struct DoryDaemonVirtualMachineProductionPlanningCompositionTests {
#expect(try context.resourceLedger.snapshot().leases.count == 2)
}
+ @Test("production recovery replays only the exact private machine authority")
+ func productionRecoveryReplaysExactMachineAuthority() throws {
+ let machineID = "production-recovery-a"
+ let fixture = try CompositionFixture(ids: [machineID])
+ try fixture.interrupt(machineID, at: .planBindingCommitted)
+ try fixture.writeMachineAuthority(machineID)
+
+ let provider = DoryDaemonVirtualMachineProductionRecoveryProvider(
+ stateDirectory: fixture.root
+ )
+ let context = try readyContext(fixture.factory(
+ recoveryProvider: provider
+ ).resolve())
+
+ #expect(context.recoveredTransactionIDs.keys.sorted() == [machineID])
+ #expect(try context.plans.read(id: machineID).machineID == machineID)
+ }
+
+ @Test("stale private machine authority cannot replay a durable journal")
+ func staleProductionMachineAuthorityFailsClosed() throws {
+ let machineID = "stale-production-recovery-b"
+ let fixture = try CompositionFixture(ids: [machineID])
+ try fixture.interrupt(machineID, at: .planBindingCommitted)
+ try fixture.writeMachineAuthority(machineID) { machine in
+ machine.cpuCount += 1
+ }
+
+ let provider = DoryDaemonVirtualMachineProductionRecoveryProvider(
+ stateDirectory: fixture.root
+ )
+ guard case let .unavailable(failure) = fixture.factory(
+ recoveryProvider: provider
+ ).resolve() else {
+ Issue.record("Expected stale private authority to fail closed")
+ return
+ }
+ #expect(failure.code == .recoveryFailed)
+ #expect(failure.machineID == machineID)
+ #expect(throws: DoryResolvedMachinePlanRepositoryError.self) {
+ _ = try fixture.plans.read(id: machineID)
+ }
+ }
+
private func readyContext(
_ readiness: DoryDaemonVirtualMachineProductionPlanningReadiness
) throws -> DoryDaemonVirtualMachineProductionPlanningContext {
@@ -157,13 +200,21 @@ private final class CompositionFixture: @unchecked Sendable {
func factory(
hasMutationAuthority: Bool = true,
- hasRecoveryProvider: Bool = true
+ hasRecoveryProvider: Bool = true,
+ recoveryProvider:
+ (any DoryDaemonVirtualMachinePlanningRecoveryProviding)? = nil
) -> DoryDaemonVirtualMachineProductionPlanningCompositionFactory {
+ let selectedRecovery: (any DoryDaemonVirtualMachinePlanningRecoveryProviding)?
+ if hasRecoveryProvider {
+ selectedRecovery = recoveryProvider ?? recovery
+ } else {
+ selectedRecovery = nil
+ }
return DoryDaemonVirtualMachineProductionPlanningCompositionFactory(
stateDirectory: root,
backends: [backend],
mutationAuthority: hasMutationAuthority ? authorizer : nil,
- recoveryProvider: hasRecoveryProvider ? recovery : nil,
+ recoveryProvider: selectedRecovery,
capabilityPlanner: CompositionCapabilityPlanner(),
inventoryBuilder: { [trust] artifactAuthority, resourceLedger in
_ = artifactAuthority
@@ -173,6 +224,28 @@ private final class CompositionFixture: @unchecked Sendable {
)
}
+ func writeMachineAuthority(
+ _ id: String,
+ mutate: (inout DoryMachineConfiguration) -> Void = { _ in }
+ ) throws {
+ var machine = try #require(requests[id]).planning.machine
+ mutate(&machine)
+ let directory = root + "/" + id
+ try FileManager.default.createDirectory(
+ atPath: directory,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ let path = directory + "/machine.json"
+ try encoder.encode(machine).write(to: URL(fileURLWithPath: path))
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: path
+ )
+ }
+
func interrupt(
_ id: String,
at stage: DoryDaemonVirtualMachinePlanningTransactionCoordinator.PublicationStage
@@ -288,6 +361,18 @@ private final class CompositionFixture: @unchecked Sendable {
media: DoryDaemonVirtualMachineResolvedMedia(
reference: mediaReference, media: media
),
+ launchArtifacts: [
+ resolvedMutableStorageLaunchArtifact(
+ reference: diskReference,
+ source: .userProvided,
+ identifier: "system-disk"
+ ),
+ resolvedBootLaunchArtifacts(
+ reference: mediaReference,
+ media: media,
+ identifier: "system"
+ )[0],
+ ],
backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory(
backend: .doryHypervisor,
runtimeBuildIdentifier: "raw-runtime-1",
@@ -375,11 +460,15 @@ private final class CompositionRecovery:
var requestedIDs: [String] { lock.withLock { storage } }
func recoveryRequest(
- for machineID: String
+ for descriptor: DoryDaemonVirtualMachinePlanningRecoveryDescriptor
) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? {
+ let machineID = descriptor.machineID
lock.withLock { storage.append(machineID) }
events.append("recovery:\(machineID)")
- return requests[machineID]
+ guard let request = requests[machineID], descriptor.matches(request) else {
+ return nil
+ }
+ return request
}
}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningControllerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningControllerTests.swift
new file mode 100644
index 00000000..3d33c750
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningControllerTests.swift
@@ -0,0 +1,237 @@
+import DoryOperations
+@testable import DorydKit
+import Foundation
+import Testing
+
+@Suite("Production VM planning controller")
+struct DoryDaemonVirtualMachineProductionPlanningControllerTests {
+ @Test("controller publishes every exact boot and storage authority before planning")
+ func publishesClosedArtifactSet() throws {
+ let fixture = try ControllerFixture()
+ defer { fixture.cleanup() }
+
+ do {
+ _ = try fixture.controller.resolveReserveAndPublish(
+ fixture.request,
+ artifacts: fixture.publications
+ )
+ Issue.record("Expected the fixture transaction to stop after artifact publication")
+ } catch let failure
+ as DoryDaemonVirtualMachineProductionPlanningControllerFailure {
+ #expect(failure.code == .transactionRejected)
+ }
+ #expect(fixture.coordinator.callCount == 1)
+ #expect(try fixture.authority.resolve(
+ reference: fixture.bootReference,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory
+ ).media.artifactSHA256 != nil)
+ #expect(try fixture.authority.resolve(
+ reference: fixture.diskReference,
+ kind: .virtualDisk,
+ source: .userProvided
+ ).media.mutableProvenance != nil)
+ }
+
+ @Test("missing extra duplicate and mismatched artifact publications fail before planning")
+ func rejectsNonExactPublicationSets() throws {
+ let fixture = try ControllerFixture()
+ defer { fixture.cleanup() }
+ var cases: [[DoryDaemonVirtualMachinePlanningArtifactPublication]] = [
+ [fixture.publications[0]],
+ fixture.publications + [fixture.publications[0]],
+ ]
+ var wrongSource = fixture.publications
+ wrongSource[1].source = .bundledByDory
+ cases.append(wrongSource)
+ var wrongMutability = fixture.publications
+ wrongMutability[1].mutability = .immutable
+ cases.append(wrongMutability)
+ var swappedPaths = fixture.publications
+ swappedPaths[0].path = fixture.diskPath
+ swappedPaths[1].path = fixture.bootPath
+ cases.append(swappedPaths)
+
+ for publications in cases {
+ do {
+ _ = try fixture.controller.resolveReserveAndPublish(
+ fixture.request,
+ artifacts: publications
+ )
+ Issue.record("Expected non-exact publication rejection")
+ } catch let failure
+ as DoryDaemonVirtualMachineProductionPlanningControllerFailure {
+ #expect(failure.code == .invalidRequest)
+ }
+ }
+ #expect(fixture.coordinator.callCount == 0)
+ }
+
+ @Test("changed immutable boot bytes cannot reuse a published authority")
+ func changedPublishedArtifactFailsClosed() throws {
+ let fixture = try ControllerFixture()
+ defer { fixture.cleanup() }
+ _ = try? fixture.controller.resolveReserveAndPublish(
+ fixture.request,
+ artifacts: fixture.publications
+ )
+ try Data("changed-boot".utf8).write(
+ to: URL(fileURLWithPath: fixture.bootPath)
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600], ofItemAtPath: fixture.bootPath
+ )
+
+ do {
+ _ = try fixture.controller.resolveReserveAndPublish(
+ fixture.request,
+ artifacts: fixture.publications
+ )
+ Issue.record("Expected changed immutable authority rejection")
+ } catch let failure
+ as DoryDaemonVirtualMachineProductionPlanningControllerFailure {
+ #expect(failure.code == .mediaAuthorityConflict)
+ }
+ #expect(fixture.coordinator.callCount == 1)
+ }
+}
+
+private final class ControllerFixture: @unchecked Sendable {
+ let root: String
+ let authority: DoryVirtualMachineArtifactAuthority
+ let coordinator = RejectingPlanningCoordinator()
+ let controller: DoryDaemonVirtualMachineProductionPlanningController
+ let request: DoryDaemonVirtualMachinePlanningTransactionRequest
+ let bootReference = DoryVMResolverReference(
+ namespace: "artifact", identifier: "controller-boot"
+ )
+ let diskReference = DoryVMResolverReference(
+ namespace: "artifact", identifier: "controller-disk"
+ )
+ let bootPath: String
+ let diskPath: String
+ let publications: [DoryDaemonVirtualMachinePlanningArtifactPublication]
+
+ init() throws {
+ root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-production-planning-controller-\(UUID().uuidString)"
+ ).standardizedFileURL.path
+ try FileManager.default.createDirectory(
+ atPath: root,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ bootPath = root + "/boot.bundle"
+ diskPath = root + "/system.raw"
+ try Data("boot-bundle".utf8).write(to: URL(fileURLWithPath: bootPath))
+ try Data(repeating: 0x31, count: 4_096).write(to: URL(fileURLWithPath: diskPath))
+ for path in [bootPath, diskPath] {
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600], ofItemAtPath: path
+ )
+ }
+ let resources = DoryVMResourceRequest(
+ virtualCPUCount: 2,
+ memoryBytes: 2 * 1_024 * 1_024 * 1_024,
+ diskBytes: 32 * 1_024 * 1_024 * 1_024
+ )
+ let definition = DoryVirtualMachineDefinition(
+ identity: DoryVirtualMachineIdentity(
+ id: "controller-vm", name: "Controller VM"
+ ),
+ guest: DoryGuestPlatform(family: .linux, architecture: .arm64),
+ workload: .desktop,
+ boot: DoryVMBootConfiguration(
+ phase: .normal,
+ devices: [DoryVMBootMediaReference(
+ id: "system",
+ role: .system,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ artifact: bootReference,
+ removable: false
+ )],
+ order: ["system"]
+ ),
+ backendPreference: DoryVMBackendPreference(
+ mode: .required, backend: .doryHypervisor
+ ),
+ graphics: DoryVMGraphicsPolicy(acceptableLevels: [.none]),
+ resources: resources,
+ storage: [DoryVMStorageAttachment(
+ id: "system-disk",
+ role: .system,
+ artifact: diskReference,
+ source: .userProvided,
+ capacityBytes: resources.diskBytes
+ )],
+ audio: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false),
+ input: DoryVMInputConfiguration(keyboardEnabled: false, pointerEnabled: false),
+ lifecycle: DoryVMLifecycleMetadata(
+ revision: 1,
+ createdAtUnixMilliseconds: 1_700_000_000_000,
+ updatedAtUnixMilliseconds: 1_700_000_000_000
+ )
+ )
+ let machine = DoryMachineConfiguration(
+ id: definition.identity.id,
+ kernelPath: bootPath,
+ rootfsPath: diskPath,
+ bootMode: .linuxKernel,
+ displayMode: .desktop
+ )
+ request = DoryDaemonVirtualMachinePlanningTransactionRequest(
+ planning: DoryDaemonVirtualMachinePlanningRequest(
+ definition: definition,
+ canonicalDefinitionData: DoryDaemonVirtualMachinePlanningCoordinator
+ .canonicalDefinitionData(definition),
+ machine: machine,
+ publication: .create
+ ),
+ workspacePublication: .create
+ )
+ authority = DoryVirtualMachineArtifactAuthority(root: root + "/authority")
+ controller = DoryDaemonVirtualMachineProductionPlanningController(
+ artifactAuthority: authority,
+ coordinator: coordinator,
+ workspaces: DoryWorkspaceRepository(root: root),
+ plans: DoryResolvedMachinePlanRepository(root: root)
+ )
+ publications = [
+ DoryDaemonVirtualMachinePlanningArtifactPublication(
+ reference: bootReference,
+ path: bootPath,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ mutability: .immutable
+ ),
+ DoryDaemonVirtualMachinePlanningArtifactPublication(
+ reference: diskReference,
+ path: diskPath,
+ kind: .virtualDisk,
+ source: .userProvided,
+ mutability: .mutable
+ ),
+ ]
+ }
+
+ func cleanup() { try? FileManager.default.removeItem(atPath: root) }
+}
+
+private final class RejectingPlanningCoordinator:
+ DoryDaemonVirtualMachinePlanningTransactionCoordinating, @unchecked Sendable
+{
+ private let lock = NSLock()
+ private var calls = 0
+ var callCount: Int { lock.withLock { calls } }
+
+ func resolveReserveAndPublish(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest
+ ) throws -> DoryDaemonVirtualMachinePlanningTransactionResult {
+ _ = request
+ lock.withLock { calls += 1 }
+ throw ControllerFixtureError.expectedRejection
+ }
+}
+
+private enum ControllerFixtureError: Error { case expectedRejection }
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
index e027d1d2..cc9c312e 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
@@ -262,6 +262,28 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
}
}
+ @Test("mutable storage changed after planning is rejected before spawn")
+ func changedStorageFailsPreSpawnAuthorization() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ guard case let .ready(context) = fixture.resolve(),
+ let provider = context.inventory
+ as? any DoryDaemonVirtualMachinePreSpawnAuthorizationProviding else {
+ Issue.record("Expected production pre-spawn authority")
+ return
+ }
+ let request = try fixture.makeBoundStartRequest()
+ let authorization = try provider.preSpawnAuthorization(for: request)
+ let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: fixture.storagePath))
+ try handle.write(contentsOf: Data("changed-storage".utf8))
+ try handle.synchronize()
+ try handle.close()
+
+ #expect(throws: DoryDaemonVirtualMachinePreSpawnAuthorizationError.self) {
+ try authorization.authorize()
+ }
+ }
+
@Test("start inventory refreshes volatile host resources")
func startInventoryRefreshesHostResources() throws {
let fixture = try ProductionTrustFixture()
@@ -320,7 +342,6 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
let result = fixture.factory.activate(
store: fixture.store,
machineConfiguration: fixture.machineConfiguration,
- recoveryProvider: ProductionEmptyPlanningRecovery(),
appVersion: fixture.appVersion,
publicKey: fixture.publicKey,
expectedArchitecture: "arm64"
@@ -349,12 +370,9 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
trustFloorActivationState: trustFloor
)
defer { fixture.cleanup() }
- let recovery = ProductionEmptyPlanningRecovery()
-
guard case let .unavailable(failure) = fixture.factory.activate(
store: fixture.store,
machineConfiguration: fixture.machineConfiguration,
- recoveryProvider: recovery,
appVersion: fixture.appVersion,
publicKey: fixture.publicKey,
expectedArchitecture: "arm64"
@@ -368,7 +386,6 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
guard case let .activated(context) = fixture.factory.activate(
store: fixture.store,
machineConfiguration: fixture.machineConfiguration,
- recoveryProvider: recovery,
appVersion: fixture.appVersion,
publicKey: fixture.publicKey,
expectedArchitecture: "arm64"
@@ -406,6 +423,15 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
artifact: reference,
removable: false
),
+ launchArtifacts: [DoryDaemonVirtualMachineLaunchArtifactRequirement(
+ reference: reference,
+ kind: .installedLinuxBootBundle,
+ source: .bundledByDory,
+ mutable: false,
+ usages: [DoryResolvedMachineLaunchArtifactUsage(
+ kind: .boot, identifier: "system", readOnly: true
+ )]
+ )],
resources: DoryVMResourceRequest(
virtualCPUCount: 2,
memoryBytes: 2 * 1_024 * 1_024 * 1_024,
@@ -524,17 +550,6 @@ private enum ProductionTrustFixtureError: Error {
case runtimeRejected
}
-private struct ProductionEmptyPlanningRecovery:
- DoryDaemonVirtualMachinePlanningRecoveryProviding
-{
- func recoveryRequest(
- for machineID: String
- ) throws -> DoryDaemonVirtualMachinePlanningTransactionRequest? {
- _ = machineID
- return nil
- }
-}
-
private final class ProductionCallCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
@@ -615,6 +630,15 @@ private func productionPlanningRequest(
artifact: plan.bootMedia.resolverReference!,
removable: false
),
+ launchArtifacts: plan.launchArtifacts.map { artifact in
+ DoryDaemonVirtualMachineLaunchArtifactRequirement(
+ reference: artifact.resolverReference,
+ kind: artifact.media.kind,
+ source: artifact.media.source,
+ mutable: artifact.media.mutableProvenance != nil,
+ usages: artifact.usages
+ )
+ },
resources: DoryVMResourceRequest(
virtualCPUCount: plan.resourceAdmission!.admittedVirtualCPUCount,
memoryBytes: plan.resourceAdmission!.admittedMemoryBytes,
@@ -642,6 +666,11 @@ private final class ProductionTrustFixture: @unchecked Sendable {
namespace: "artifact",
identifier: "qualified-linux-boot"
)
+ let storagePath: String
+ let storageReference = DoryVMResolverReference(
+ namespace: "artifact",
+ identifier: "qualified-linux-disk"
+ )
let guest = DoryGuestPlatform(family: .linux, architecture: .arm64)
let host = DoryDaemonProductionHostObservation(
hardwareModelIdentifier: "Mac16,1",
@@ -711,14 +740,28 @@ private final class ProductionTrustFixture: @unchecked Sendable {
toPath: mediaPath
)
mediaDigest = try DoryComponentCatalogVerifier.fileDigest(mediaPath)
- _ = try DoryVirtualMachineArtifactAuthority(
+ storagePath = root.appendingPathComponent("qualified-linux.raw").path
+ try Data(repeating: 0x5a, count: 4_096).write(
+ to: URL(fileURLWithPath: storagePath)
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: storagePath
+ )
+ let artifactAuthority = DoryVirtualMachineArtifactAuthority(
root: state.path + "/.artifact-authority"
- ).publishImmutable(
+ )
+ _ = try artifactAuthority.publishImmutable(
reference: mediaReference,
path: mediaPath,
kind: .installedLinuxBootBundle,
source: .bundledByDory
)
+ _ = try artifactAuthority.publishMutable(
+ reference: storageReference,
+ path: storagePath,
+ source: .userProvided
+ )
if installCatalog {
try installCatalogFixture(
@@ -876,6 +919,13 @@ private final class ProductionTrustFixture: @unchecked Sendable {
trustedRuntimeQualification: qualification.runtime
)
#expect(descriptor.availability.isUsable)
+ let storageArtifact = try DoryVirtualMachineArtifactAuthority(
+ root: machineConfiguration.stateDirectory + "/.artifact-authority"
+ ).resolve(
+ reference: storageReference,
+ kind: .virtualDisk,
+ source: .userProvided
+ )
let plan = DoryResolvedMachinePlan(
machineID: binding.machineID,
definitionRevision: binding.definitionRevision,
@@ -893,6 +943,18 @@ private final class ProductionTrustFixture: @unchecked Sendable {
resolverReference: mediaReference,
media: media
),
+ launchArtifacts: resolvedBootLaunchArtifacts(
+ reference: mediaReference, media: media, identifier: "system"
+ ) + [DoryResolvedMachineLaunchArtifact(
+ resolverReference: storageArtifact.reference,
+ media: storageArtifact.media,
+ authorityRevision: storageArtifact.authorityRevision,
+ usages: [DoryResolvedMachineLaunchArtifactUsage(
+ kind: .storage, identifier: "system-disk", readOnly: false
+ )],
+ mutableProvenanceEvidence:
+ storageArtifact.mutableProvenance?.persistedAuditEvidence
+ )],
components: [DoryResolvedBackendComponentEvidence(
componentIdentifier: "dory-hv",
buildIdentifier: runtimeBuildIdentifier,
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
index c4aab26e..4e735789 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
@@ -14,6 +14,9 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests {
#expect(result.resolvedPlan.definitionSHA256
== DoryDaemonVirtualMachinePlanningCoordinator.sha256(fixture.canonicalDefinition))
#expect(result.resolvedPlanSHA256.count == 64)
+ #expect(result.resolvedPlan.launchArtifacts.count == 2)
+ #expect(result.resolvedPlan.launchArtifacts.flatMap(\.usages).map(\.kind)
+ == [.storage, .boot])
#expect(result.backendPlan.backend.identity == .doryHypervisor)
#expect(try fixture.store.read(id: fixture.definition.identity.id) == result.resolvedPlan)
}
@@ -124,6 +127,33 @@ struct DoryDaemonVirtualMachineRuntimePlanningTests {
}
}
+ @Test("changed storage authority rejects start evidence")
+ func storageEvidenceMismatch() throws {
+ let fixture = try Fixture()
+ let planned = try fixture.coordinator.resolveAndPersist(fixture.planningRequest())
+ var evidence = DoryResolvedMachineRuntimeEvidence(plan: planned.resolvedPlan)
+ evidence.launchArtifacts[0].authorityRevision += 1
+ let collector = FixtureEvidenceCollector(collection:
+ DoryDaemonVirtualMachineStartEvidenceCollection(
+ capability: planned.plannerResult.selectedDescriptor!,
+ runtimeEvidence: evidence
+ ))
+ let resolver = DoryDaemonVirtualMachineLaunchPlanResolver(
+ registry: fixture.registry,
+ plans: fixture.store,
+ evidenceCollector: collector
+ )
+
+ #expect(throws: DoryDaemonVirtualMachineLaunchPlanFailure.self) {
+ _ = try resolver.resolve(DoryDaemonVirtualMachineLaunchPlanRequest(
+ definition: fixture.definition,
+ canonicalDefinitionData: fixture.canonicalDefinition,
+ machine: fixture.machine,
+ expectedPlanRevision: planned.resolvedPlan.planRevision
+ ))
+ }
+ }
+
@Test("missing durable plan has a typed launch failure")
func missingPlan() throws {
let fixture = try Fixture()
@@ -262,6 +292,18 @@ private struct Fixture {
let snapshot = DoryDaemonVirtualMachineTrustedInventorySnapshot(
hostFacts: hostFacts(),
media: DoryDaemonVirtualMachineResolvedMedia(reference: bootArtifact, media: media),
+ launchArtifacts: [
+ resolvedMutableStorageLaunchArtifact(
+ reference: diskArtifact,
+ source: .userProvided,
+ identifier: "system-disk"
+ ),
+ resolvedBootLaunchArtifacts(
+ reference: bootArtifact,
+ media: media,
+ identifier: "system"
+ )[0],
+ ],
backendRuntimes: [DoryDaemonVirtualMachineBackendRuntimeInventory(
backend: .doryHypervisor,
runtimeBuildIdentifier: "raw-runtime-1",
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachineLaunchArtifactFixtures.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachineLaunchArtifactFixtures.swift
new file mode 100644
index 00000000..d95aa8cb
--- /dev/null
+++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachineLaunchArtifactFixtures.swift
@@ -0,0 +1,55 @@
+import DoryOperations
+@testable import DorydKit
+
+func resolvedBootLaunchArtifacts(
+ reference: DoryVMResolverReference,
+ media: DoryBootMedia,
+ mutableEvidence: DoryMutableBootMediaProvenanceAuditEvidence? = nil,
+ identifier: String = "primary"
+) -> [DoryResolvedMachineLaunchArtifact] {
+ [DoryResolvedMachineLaunchArtifact(
+ resolverReference: reference,
+ media: media,
+ authorityRevision: 1,
+ usages: [DoryResolvedMachineLaunchArtifactUsage(
+ kind: .boot,
+ identifier: identifier,
+ readOnly: media.mutableProvenance == nil
+ )],
+ mutableProvenanceEvidence: mutableEvidence
+ )]
+}
+
+func resolvedMutableStorageLaunchArtifact(
+ reference: DoryVMResolverReference,
+ source: DoryBootMediaSource,
+ identifier: String,
+ digestCharacter: Character = "9"
+) -> DoryResolvedMachineLaunchArtifact {
+ let provenance = DoryMutableBootMediaProvenanceReference(
+ repositoryIdentity: "fixture-artifact-authority",
+ mediaIdentity: reference.namespace + "-" + reference.identifier,
+ revision: 1
+ )
+ return DoryResolvedMachineLaunchArtifact(
+ resolverReference: reference,
+ media: DoryBootMedia(
+ kind: .virtualDisk,
+ source: source,
+ mutableProvenance: provenance
+ ),
+ authorityRevision: 1,
+ usages: [DoryResolvedMachineLaunchArtifactUsage(
+ kind: .storage,
+ identifier: identifier,
+ readOnly: false
+ )],
+ mutableProvenanceEvidence: DoryMutableBootMediaProvenanceAuditEvidence(
+ receiptIdentity: "fixture-storage-receipt-1",
+ provenance: provenance,
+ receiptSHA256: String(repeating: String(digestCharacter), count: 64),
+ resolverID: "fixture-artifact-authority",
+ resolverVersion: 1
+ )
+ )
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift
index be75d5cc..a67e02ed 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryResolvedMachinePlanTests.swift
@@ -39,6 +39,28 @@ struct DoryResolvedMachinePlanTests {
#expect(result.issues.contains { $0.code == .storedPlanInvalid })
}
+ @Test("schema v2 without launch-artifact evidence requires replanning")
+ func schemaV2LaunchArtifactMigration() throws {
+ let encoded = try JSONEncoder().encode(supportedPlan())
+ var object = try #require(
+ JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+ )
+ object["schemaVersion"] = 2
+ object["sourceSchemaVersion"] = 2
+ object.removeValue(forKey: "launchArtifacts")
+
+ let migrated = try JSONDecoder().decode(
+ DoryResolvedMachinePlan.self,
+ from: JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
+ )
+ #expect(migrated.schemaVersion == DoryResolvedMachinePlan.currentSchemaVersion)
+ #expect(migrated.sourceSchemaVersion == 2)
+ #expect(migrated.migrationDisposition == .requiresReplanning)
+ #expect(migrated.launchArtifacts.isEmpty)
+ #expect(migrated.validate().contains { $0.code == .legacyPlanRequiresReplanning })
+ #expect(migrated.validate().contains { $0.code == .invalidLaunchArtifactEvidence })
+ }
+
@Test("exact fresh evidence authorizes start")
func exactStartEvidence() {
let plan = supportedPlan()
@@ -180,6 +202,7 @@ struct DoryResolvedMachinePlanTests {
backendDescriptor: VirtualizationFrameworkLinuxMachineBackend.backendDescriptor,
backendRuntimeBuildIdentifier: plan.backendRuntimeBuildIdentifier,
resolverReference: plan.bootMedia.resolverReference,
+ launchArtifacts: plan.launchArtifacts,
components: plan.components,
resourceAdmission: plan.resourceAdmission!,
hostQualification: plan.hostQualification!,
@@ -208,6 +231,7 @@ struct DoryResolvedMachinePlanTests {
backendDescriptor: RawHVLinuxMachineBackend.backendDescriptor,
backendRuntimeBuildIdentifier: plan.backendRuntimeBuildIdentifier,
resolverReference: plan.bootMedia.resolverReference,
+ launchArtifacts: plan.launchArtifacts,
components: plan.components,
resourceAdmission: plan.resourceAdmission!,
hostQualification: plan.hostQualification!,
@@ -708,6 +732,12 @@ private func supportedPlan() -> DoryResolvedMachinePlan {
),
media: media
),
+ launchArtifacts: resolvedBootLaunchArtifacts(
+ reference: DoryVMResolverReference(
+ namespace: "artifact", identifier: "ubuntu-desktop-1"
+ ),
+ media: media
+ ),
components: [
DoryResolvedBackendComponentEvidence(
componentIdentifier: "dory-hv",
@@ -792,6 +822,19 @@ private func mutableVZPlan() -> DoryResolvedMachinePlan {
resolverVersion: 1
)
),
+ launchArtifacts: resolvedBootLaunchArtifacts(
+ reference: DoryVMResolverReference(
+ namespace: "machine", identifier: "workspace-one-disk"
+ ),
+ media: media,
+ mutableEvidence: DoryMutableBootMediaProvenanceAuditEvidence(
+ receiptIdentity: "disk-receipt-7",
+ provenance: provenance,
+ receiptSHA256: digest("7"),
+ resolverID: "machine-store",
+ resolverVersion: 1
+ )
+ ),
components: [DoryResolvedBackendComponentEvidence(
componentIdentifier: "dory-vmm",
buildIdentifier: "vz-runtime-1",
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
index d2ba6144..856244a5 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
@@ -240,6 +240,30 @@ final class DoryVMMKitTests: XCTestCase {
}
}
+ func testParsesExactResolvedLaunchContract() throws {
+ let devices = DoryVirtualMachineDeviceCapabilityRequest(
+ audioOutput: true,
+ keyboard: true,
+ pointer: true
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let encodedDevices = String(decoding: try encoder.encode(devices), as: UTF8.self)
+ let arguments = try parseDoryVMMArguments([
+ "--resolved-graphics", "host-accelerated-display",
+ "--resolved-devices", encodedDevices,
+ ])
+
+ XCTAssertEqual(arguments.resolvedGraphics, .hostAcceleratedDisplay)
+ XCTAssertEqual(arguments.resolvedDevices, devices)
+ XCTAssertThrowsError(try parseDoryVMMArguments([
+ "--resolved-graphics", "auto",
+ ]))
+ XCTAssertThrowsError(try parseDoryVMMArguments([
+ "--resolved-devices", "{}",
+ ]))
+ }
+
func testRejectsInvalidDisplayModeArgument() throws {
XCTAssertThrowsError(try parseDoryVMMArguments([
"--display-mode", "gui",
@@ -577,6 +601,67 @@ final class DoryVMMKitTests: XCTestCase {
XCTAssertFalse(FileManager.default.fileExists(atPath: "\(base)/dorycfg"))
}
+ func testResolvedVZContractControlsAttachedDesktopDevices() throws {
+ let base = "/tmp/dory-vmm-resolved-devices-\(getpid())-\(UInt32.random(in: 0.. DoryResolvedMachinePlan {
),
media: media
),
+ launchArtifacts: resolvedBootLaunchArtifacts(
+ reference: DoryVMResolverReference(
+ namespace: "artifact", identifier: "qualified-linux"
+ ),
+ media: media
+ ),
components: [DoryResolvedBackendComponentEvidence(
componentIdentifier: "dory-hv",
buildIdentifier: "raw-runtime-1",
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
index 4b46ca3e..44c4f420 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
@@ -96,6 +96,8 @@ final class MachineBackendTests: XCTestCase {
let started = registry.start(plan)
XCTAssertTrue(started.isSuccess)
XCTAssertEqual(started.observation?.state, .running)
+ XCTAssertEqual(recorder.launchBindings.first?.graphics, .hostAcceleratedDisplay)
+ XCTAssertEqual(recorder.launchBindings.first?.devices, .minimumBootable)
let stopped = registry.stop(MachineBackendRuntimeRequest(
machineID: plan.machine.id,
@@ -317,16 +319,21 @@ final class MachineBackendTests: XCTestCase {
private final class OperationRecorder: @unchecked Sendable {
private let lock = NSLock()
private var recordedEvents: [String] = []
+ private var recordedLaunchBindings: [MachineBackendLaunchBinding] = []
var events: [String] {
lock.withLock { recordedEvents }
}
+ var launchBindings: [MachineBackendLaunchBinding] {
+ lock.withLock { recordedLaunchBindings }
+ }
+
lazy var operations = MachineBackendCompatibilityOperations(
- start: { [weak self] id in
- self?.append("start:\(id)")
+ authorizedStart: { [weak self] binding in
+ self?.append(binding)
return MachineBackendRuntimeObservation(
- machineID: id,
+ machineID: binding.machineID,
state: .running,
processIdentifier: 42
)
@@ -340,4 +347,11 @@ private final class OperationRecorder: @unchecked Sendable {
private func append(_ event: String) {
lock.withLock { recordedEvents.append(event) }
}
+
+ private func append(_ binding: MachineBackendLaunchBinding) {
+ lock.withLock {
+ recordedLaunchBindings.append(binding)
+ recordedEvents.append("start:\(binding.machineID)")
+ }
+ }
}
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
index cc0ebf62..8a7ac144 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -87,6 +87,194 @@ struct MachineManagerResolvedPlanIntegrationTests {
}
}
+ @Test("adapter cannot substitute resolved graphics or devices")
+ func adapterCannotSubstituteLaunchContract() throws {
+ enum Mutation: CaseIterable, Sendable { case graphics, devices }
+
+ for mutation in Mutation.allCases {
+ try withHarness("binding-substitution-\(mutation)") { manager, starter, _ in
+ let plans = MutablePlanStore()
+ let managerOperations = manager.resolvedLaunchCompatibilityOperations(
+ for: .doryHypervisor
+ )
+ let mutatingOperations = MachineBackendCompatibilityOperations(
+ authorizedStart: { binding in
+ var changed = binding
+ switch mutation {
+ case .graphics:
+ changed.graphics = .software
+ case .devices:
+ changed.devices.keyboard.toggle()
+ }
+ return try managerOperations.authorizedStart(changed)
+ },
+ stop: managerOperations.stop
+ )
+ let registry = try rawRegistry(operations: mutatingOperations)
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(request: request)
+ plans.set(resolution.resolvedPlan)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ #expect(throws: MachineManagerError.self) {
+ _ = try manager.start(id: "dev")
+ }
+ #expect(starter.count == 0)
+ }
+ }
+ }
+
+ @Test("resolved raw-HV launch carries explicit graphics and device arguments")
+ func resolvedLaunchUsesExactHelperArguments() throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-resolved-arguments-\(UUID().uuidString)"
+ ).path
+ let helper = root + "/helper.sh"
+ let capture = root + "/arguments.txt"
+ try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: root) }
+ try writeExecutable(
+ "#!/bin/sh\nprintf '%s\\n' \"$@\" > '\(capture)'\nsleep 30\n",
+ path: helper
+ )
+
+ try withHarness(
+ "exact-arguments",
+ acceleratedExecutablePath: helper,
+ passMachineArguments: true
+ ) { manager, starter, _ in
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(operations: operations, executablePath: helper)
+ let helperSHA256 = try fileSHA256(path: helper)
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(
+ request: request,
+ componentSHA256: helperSHA256
+ )
+ plans.set(resolution.resolvedPlan)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ _ = try manager.start(id: "dev")
+ #expect(starter.count == 1)
+ let deadline = Date().addingTimeInterval(2)
+ while !FileManager.default.fileExists(atPath: capture), Date() < deadline {
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ let arguments = try String(contentsOfFile: capture, encoding: .utf8)
+ .split(separator: "\n").map(String.init)
+ func value(after flag: String) throws -> String {
+ let index = try #require(arguments.firstIndex(of: flag))
+ return arguments[index + 1]
+ }
+ #expect(try value(after: "--resolved-graphics") == "host-accelerated-display")
+ let deviceData = Data(try value(after: "--resolved-devices").utf8)
+ #expect(try JSONDecoder().decode(
+ DoryVirtualMachineDeviceCapabilityRequest.self,
+ from: deviceData
+ ) == .minimumBootable)
+ #expect(arguments.contains("DORY_DESKTOP_GRAPHICS=virgl"))
+ #expect(arguments.contains("DORY_DESKTOP_VMM=accelerated"))
+ #expect(!arguments.contains("DORY_DESKTOP_GRAPHICS=auto"))
+ }
+ }
+
+ @Test("resolved installed-Linux launch rematerializes verified boot outputs after authorization")
+ func resolvedInstalledLinuxRevalidatesMaterializedOutputs() throws {
+ let root = try makeState("resolved-installed-linux")
+ defer { try? FileManager.default.removeItem(atPath: root) }
+ let sourceBundle = root + "/source.boot"
+ let sourceDisk = root + "/source.raw"
+ let kernel = Data(repeating: 0x41, count: 8_192)
+ let initrd = Data(repeating: 0x42, count: 16_384)
+ try DoryInstalledLinuxBootBundle.write(
+ assets: DoryLinuxInstallerBootAssets(
+ kernel: kernel,
+ initrd: initrd,
+ kernelISOPath: "casper/vmlinuz",
+ initrdISOPath: "casper/initrd"
+ ),
+ rootDevice: "/dev/vda2",
+ toPath: sourceBundle
+ )
+ try Data(repeating: 0x31, count: 4_096).write(
+ to: URL(fileURLWithPath: sourceDisk)
+ )
+ let starter = CountingProcessStarter()
+ let manager = MachineManager(
+ configuration: MachineManagerConfiguration(
+ vmmExecutablePath: "/bin/sleep",
+ acceleratedDesktopExecutablePath: "/bin/sleep",
+ stateDirectory: root + "/machines",
+ baseArguments: ["30"],
+ acceleratedDesktopBaseArguments: ["30"],
+ passMachineArguments: false,
+ requiresReadyHandoff: false
+ ),
+ launchPolicy: .requireResolvedPlan,
+ processStarter: { process in try starter.start(process) }
+ )
+ defer {
+ _ = try? manager.stop(id: "installed")
+ _ = try? manager.delete(id: "installed")
+ }
+ _ = try manager.create(DoryMachineConfiguration(
+ id: "installed",
+ kernelPath: sourceBundle,
+ rootfsPath: sourceDisk,
+ bootMode: .efi,
+ memoryMB: 4_096,
+ cpuCount: 4,
+ displayMode: .desktop
+ ))
+ let state = root + "/machines/installed"
+ let directKernel = state + "/direct-kernel"
+ let directInitrd = state + "/direct-initrd"
+ let plans = MutablePlanStore()
+ let operations = manager.resolvedLaunchCompatibilityOperations(for: .doryHypervisor)
+ let registry = try rawRegistry(operations: operations)
+ let resolver = ClosureLaunchResolver { request in
+ let resolution = try exactResolution(
+ request: request,
+ preSpawnRevalidation: {
+ try Data("stale-kernel".utf8).write(
+ to: URL(fileURLWithPath: directKernel)
+ )
+ try Data("stale-initrd".utf8).write(
+ to: URL(fileURLWithPath: directInitrd)
+ )
+ }
+ )
+ plans.set(resolution.resolvedPlan)
+ return resolution
+ }
+ try manager.installResolvedLaunchInfrastructure(
+ registry: registry,
+ resolver: resolver,
+ plans: plans,
+ expectedPlanRevision: { _ in 1 }
+ )
+
+ _ = try manager.start(id: "installed")
+ #expect(starter.count == 1)
+ #expect(try Data(contentsOf: URL(fileURLWithPath: directKernel)) == kernel)
+ #expect(try Data(contentsOf: URL(fileURLWithPath: directInitrd)) == initrd)
+ }
+
@Test("resolved snapshot disk capacity is bound to resource admission")
func resolvedSnapshotRejectsSelfConsistentDifferentCapacityDisk() throws {
try withHarness("snapshot-storage-evidence") { manager, _, state in
@@ -1055,6 +1243,7 @@ struct MachineManagerResolvedPlanIntegrationTests {
_ label: String,
launchPolicy: DoryMachineLaunchPolicy = .requireResolvedPlan,
acceleratedExecutablePath: String? = "/bin/sleep",
+ passMachineArguments: Bool = false,
_ body: (MachineManager, CountingProcessStarter, String) throws -> Void
) throws {
let state = FileManager.default.temporaryDirectory
@@ -1068,7 +1257,7 @@ struct MachineManagerResolvedPlanIntegrationTests {
stateDirectory: state,
baseArguments: ["30"],
acceleratedDesktopBaseArguments: ["30"],
- passMachineArguments: false,
+ passMachineArguments: passMachineArguments,
requiresReadyHandoff: false
),
launchPolicy: launchPolicy,
@@ -1134,13 +1323,19 @@ struct MachineManagerResolvedPlanIntegrationTests {
RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier,
backendRuntimeBuildIdentifier: runtime,
virtualHardwareABIVersion: request.definition.virtualHardwareABIVersion,
- bootMedia: DoryResolvedMachineBootMedia(
+ bootMedia: DoryResolvedMachineBootMedia(
resolverReference: DoryVMResolverReference(
namespace: "machine",
identifier: "dev-kernel"
),
- media: media
+ media: media
+ ),
+ launchArtifacts: resolvedBootLaunchArtifacts(
+ reference: DoryVMResolverReference(
+ namespace: "machine", identifier: "dev-kernel"
),
+ media: media
+ ),
components: [DoryResolvedBackendComponentEvidence(
componentIdentifier: "dory-hv",
buildIdentifier: runtime,
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index 1a91c79c..9db4c23e 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -4333,7 +4333,7 @@ private func waitForMachineStatus(
}
return try XCTUnwrap(
manager.status(id: id).flatMap { predicate($0) ? $0 : nil },
- "timed out waiting for matching machine status for \(id)"
+ "timed out waiting for matching machine status for \(id); current=\(String(describing: manager.status(id: id)))"
)
}
From baeafd35f1ee8d9d71f3e8a75f3bfff0b108d387 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 19:55:31 +0000
Subject: [PATCH 040/338] feat(vm): activate production planning in doryd
---
.../Sources/dory-hv/DesktopMode.swift | 4 +-
.../DoryVirtualMachineCapabilities.swift | 36 ++--
.../Sources/DoryVMMKit/DoryVMM.swift | 4 +-
...achinePlanningTransactionCoordinator.swift | 8 +-
...lMachineProductionPlanningController.swift | 38 +++-
...yDaemonVirtualMachineRuntimePlanning.swift | 8 +-
.../DorydKit/DoryResolvedMachinePlan.swift | 2 +
.../Sources/DorydKit/DorydService.swift | 31 +++-
.../LinuxMachineBackendAdapters.swift | 17 +-
.../Sources/DorydKit/MachineManager.swift | 167 ++++++++++++++++++
dory-core-swift/Sources/doryd/main.swift | 20 ++-
.../DoryVirtualMachineCapabilitiesTests.swift | 20 +++
...onVirtualMachineProductionTrustTests.swift | 118 +++++++++++--
.../DorydKitTests/DorydServiceTests.swift | 99 +++++++++++
.../DorydKitTests/MachineBackendTests.swift | 43 ++++-
15 files changed, 559 insertions(+), 56 deletions(-)
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
index cc0739ca..74db508a 100644
--- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
@@ -80,9 +80,7 @@ enum DesktopMode {
)
self.graphicsBackend = resolvedGraphics.backend
if let devices = configuration.resolvedDevices {
- guard devices.networkAttachment == .sharedNAT,
- !devices.clockSynchronization,
- !devices.gracefulShutdown else {
+ guard devices.networkAttachment == .sharedNAT else {
throw VMError.bootFailure(
"resolved device contract contains a device not implemented by raw-HV"
)
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
index dc2f6d10..b9ea570f 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
@@ -928,8 +928,11 @@ public enum DoryAppleSiliconCapabilityEvaluator {
switch (request.guest.family, request.backend) {
case (.linux, .doryHypervisor):
return request.bootMedia.kind == .installedLinuxBootBundle
- case (.linux, .appleVirtualizationFramework),
- (.linux, .qemuHypervisorFramework),
+ case (.linux, .appleVirtualizationFramework):
+ return request.bootMedia.kind == .installerISO
+ || request.bootMedia.kind == .virtualDisk
+ || request.bootMedia.kind == .installedLinuxBootBundle
+ case (.linux, .qemuHypervisorFramework),
(.windows, .qemuHypervisorFramework):
return request.bootMedia.kind == .installerISO || request.bootMedia.kind == .virtualDisk
case (.macOS, .appleVirtualizationFramework):
@@ -1385,11 +1388,15 @@ public enum DoryAppleSiliconCapabilityEvaluator {
)
}
if devices.clockSynchronization {
- return unavailable(
- tier: tier,
- code: .clockSynchronizationUnsupported,
- message: "Clock synchronization is not yet part of a qualified backend contract."
- )
+ guard request.guest.family == .linux,
+ request.backend == .doryHypervisor
+ || request.backend == .appleVirtualizationFramework else {
+ return unavailable(
+ tier: tier,
+ code: .clockSynchronizationUnsupported,
+ message: "The selected guest/backend contract does not implement clock synchronization."
+ )
+ }
}
if devices.dynamicDisplay {
return unavailable(
@@ -1399,12 +1406,15 @@ public enum DoryAppleSiliconCapabilityEvaluator {
)
}
if devices.gracefulShutdown {
- return unavailable(
- tier: tier,
- code: .gracefulShutdownUnsupported,
- message: "Graceful shutdown requires a digest-bound guest-integration "
- + "qualification that is not yet modeled."
- )
+ guard request.guest.family == .linux,
+ request.backend == .doryHypervisor
+ || request.backend == .appleVirtualizationFramework else {
+ return unavailable(
+ tier: tier,
+ code: .gracefulShutdownUnsupported,
+ message: "The selected guest/backend contract does not implement graceful shutdown."
+ )
+ }
}
return nil
}
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
index 4460c37a..68473565 100644
--- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
@@ -374,9 +374,7 @@ public enum DoryVZConfigurationBuilder {
}
if let devices = spec.resolvedDevices {
guard devices.networkAttachment == .sharedNAT,
- !devices.clockSynchronization,
- !devices.dynamicDisplay,
- !devices.gracefulShutdown else {
+ !devices.dynamicDisplay else {
throw DoryVZMachineError.validation(
"resolved device contract contains a device not implemented by this VZ launch"
)
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
index ffa50fb4..1a5e5e2b 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachinePlanningTransactionCoordinator.swift
@@ -964,7 +964,13 @@ public final class DoryDaemonVirtualMachinePlanningTransactionCoordinator:
)
let result: DoryDaemonVirtualMachinePlanningResult
do { result = try coordinator.resolveAndPersist(request.planning) }
- catch {
+ catch let planning as DoryDaemonVirtualMachinePlanningFailure {
+ throw failure(
+ .planConstructionRejected,
+ "Exact trusted facts could not construct a plan "
+ + "(\(planning.code.rawValue): \(planning.message))"
+ )
+ } catch {
throw failure(.planConstructionRejected, "Exact trusted facts could not construct a plan.")
}
let candidateData = Self.canonicalData(result.resolvedPlan)
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
index 5b78662d..f30a9c28 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
@@ -69,6 +69,17 @@ public protocol DoryDaemonVirtualMachinePlanningTransactionCoordinating: Sendabl
extension DoryDaemonVirtualMachinePlanningTransactionCoordinator:
DoryDaemonVirtualMachinePlanningTransactionCoordinating {}
+/// Narrow product boundary used by MachineManager and XPC. The concrete controller retains the
+/// trusted artifact/repository graph; tests can inject only an observer/rejector and therefore
+/// cannot manufacture a resolved runtime identity.
+public protocol DoryDaemonVirtualMachineProductionPlanningControlling: Sendable {
+ func authorityRevision(for reference: DoryVMResolverReference) throws -> UInt64?
+ func publishResolvedPlan(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ artifacts: [DoryDaemonVirtualMachinePlanningArtifactPublication]
+ ) throws
+}
+
/// The sole production entry for a manager-owned native create/update/replan transaction. It
/// first establishes exact artifact authority, then delegates reserve -> bind -> publication to
/// the crash-safe coordinator, and finally proves the published plan and workspace are the exact
@@ -138,7 +149,13 @@ public final class DoryDaemonVirtualMachineProductionPlanningController:
let result: DoryDaemonVirtualMachinePlanningTransactionResult
do { result = try coordinator.resolveReserveAndPublish(request) }
- catch {
+ catch let transaction as DoryDaemonVirtualMachinePlanningTransactionFailure {
+ throw failure(
+ .transactionRejected,
+ "Production planning transaction failed closed "
+ + "(\(transaction.code.rawValue): \(transaction.message))"
+ )
+ } catch {
throw failure(.transactionRejected, "Production planning transaction failed closed.")
}
let workspace: DoryVirtualMachineDefinition
@@ -160,6 +177,22 @@ public final class DoryDaemonVirtualMachineProductionPlanningController:
return result
}
+ /// Returns the exact current artifact-authority revision so a manager-owned replan can make
+ /// mutable-media replacement an explicit compare-and-swap. Corrupt authority is never
+ /// collapsed into a missing record.
+ public func authorityRevision(
+ for reference: DoryVMResolverReference
+ ) throws -> UInt64? {
+ try artifactAuthority.authorityRecord(reference: reference)?.authorityRevision
+ }
+
+ public func publishResolvedPlan(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ artifacts: [DoryDaemonVirtualMachinePlanningArtifactPublication]
+ ) throws {
+ _ = try resolveReserveAndPublish(request, artifacts: artifacts)
+ }
+
/// Compatibility launch paths remain daemon-local, but they are still launch authority. A
/// resolver publication must describe the exact path the selected adapter will consume; a
/// definition reference cannot authorize different bytes at an unrelated machine path.
@@ -268,6 +301,9 @@ public final class DoryDaemonVirtualMachineProductionPlanningController:
}
}
+extension DoryDaemonVirtualMachineProductionPlanningController:
+ DoryDaemonVirtualMachineProductionPlanningControlling {}
+
enum DoryDaemonVirtualMachineProductionRecoveryError: Error, Sendable, Equatable {
case invalidDescriptor
case machineUnavailable
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
index 92308cc1..e42e53ce 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineRuntimePlanning.swift
@@ -434,7 +434,13 @@ public final class DoryDaemonVirtualMachinePlanningCoordinator: @unchecked Senda
backendPlan.backend == backend.descriptor,
backendPlan.machine == input.machine,
backendPlan.capability == selected else {
- throw failure(.backendPlanRejected, "The selected adapter rejected the machine plan.")
+ let detail = backendResult.failure.map {
+ " (\($0.code.rawValue): \($0.message))"
+ } ?? ""
+ throw failure(
+ .backendPlanRejected,
+ "The selected adapter rejected the machine plan.\(detail)"
+ )
}
let timing: (revision: UInt64, created: Int64, updated: Int64)
diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
index 6f958e56..6b718233 100644
--- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
@@ -920,6 +920,8 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
(.windows, .qemuHypervisorFramework):
runtimeCombinationIsImplemented = bootMedia.media.kind == .installerISO
|| bootMedia.media.kind == .virtualDisk
+ || (backend == .appleVirtualizationFramework
+ && bootMedia.media.kind == .installedLinuxBootBundle)
case (.macOS, .appleVirtualizationFramework):
runtimeCombinationIsImplemented = bootMedia.media.kind == .macOSRestoreImage
|| bootMedia.media.kind == .virtualDisk
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index a44d42b9..8481d791 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -8,6 +8,8 @@ public final class DorydService: NSObject, DorydControl {
private let home: String
private let dockerTier: DockerTier?
private let machineManager: MachineManager?
+ private let productionPlanningController:
+ (any DoryDaemonVirtualMachineProductionPlanningControlling)?
private let machineBackupScheduler: MachineBackupScheduler?
private let remoteManager: RemoteMachineManager?
private let networkingController: NetworkingController?
@@ -26,6 +28,8 @@ public final class DorydService: NSObject, DorydControl {
home: String = NSHomeDirectory(),
dockerTier: DockerTier? = nil,
machineManager: MachineManager? = nil,
+ productionPlanningController:
+ (any DoryDaemonVirtualMachineProductionPlanningControlling)? = nil,
machineBackupScheduler: MachineBackupScheduler? = nil,
remoteManager: RemoteMachineManager? = nil,
networkingController: NetworkingController? = nil,
@@ -42,6 +46,7 @@ public final class DorydService: NSObject, DorydControl {
self.home = home
self.dockerTier = dockerTier
self.machineManager = machineManager
+ self.productionPlanningController = productionPlanningController
self.machineBackupScheduler = machineBackupScheduler
self.remoteManager = remoteManager
self.networkingController = networkingController
@@ -241,7 +246,18 @@ public final class DorydService: NSObject, DorydControl {
to: [:],
displayMode: machine.displayMode
)
- let status = try machineManager.create(machine)
+ var status = try machineManager.create(machine)
+ if machineManager.configuredLaunchPolicy == .perWorkspaceAuthority {
+ guard let productionPlanningController else {
+ throw MachineManagerError.persistence(
+ "production planning controller is not configured"
+ )
+ }
+ status = try machineManager.resolveAndPublishProductionPlan(
+ id: machine.id,
+ controller: productionPlanningController
+ )
+ }
incidentWriter?.record(type: "machine.create", detail: machine.id)
reply(true, status.xpcDictionary, "")
} catch {
@@ -279,7 +295,7 @@ public final class DorydService: NSObject, DorydControl {
}
do {
let update = try MachineUpdateRequest(xpcDictionary: config)
- let status = try machineManager.update(
+ var status = try machineManager.update(
id: machineID,
memoryMB: update.memoryMB,
cpuCount: update.cpuCount,
@@ -290,6 +306,17 @@ public final class DorydService: NSObject, DorydControl {
typedSettingsPatch: update.typedSettings.isEmpty ? nil : update.typedSettings,
installerMediaAttached: update.installerMediaAttached
)
+ if machineManager.configuredLaunchPolicy == .perWorkspaceAuthority {
+ guard let productionPlanningController else {
+ throw MachineManagerError.persistence(
+ "production planning controller is not configured"
+ )
+ }
+ status = try machineManager.resolveAndPublishProductionPlan(
+ id: machineID,
+ controller: productionPlanningController
+ )
+ }
incidentWriter?.record(type: "machine.update", detail: machineID)
reply(true, status.xpcDictionary, "")
} catch {
diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
index 9835c5a1..c5feed06 100644
--- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
+++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
@@ -423,7 +423,7 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @
implementationIdentifier: "dory.vz-linux.compatibility.v1",
guestFamilies: [.linux],
guestArchitectures: [.arm64],
- bootMediaKinds: [.installerISO, .virtualDisk],
+ bootMediaKinds: [.installedLinuxBootBundle, .installerISO, .virtualDisk],
lifecycle: .currentMachineManager
)
@@ -445,16 +445,21 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @
executableIsAvailable: executableIsAvailable,
operations: operations,
validateMachine: { machine, capability in
- guard machine.bootMode == .efi, machine.displayMode == .desktop else {
- return "The current Virtualization.framework media path requires an EFI desktop machine."
- }
switch capability.request.bootMedia.kind {
+ case .installedLinuxBootBundle:
+ guard machine.bootMode == .linuxKernel,
+ machine.installerISOPath == nil,
+ DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else {
+ return "A direct Linux VZ plan requires an installed-Linux boot bundle without installer media."
+ }
case .installerISO:
- guard machine.installerISOPath?.isEmpty == false else {
+ guard machine.bootMode == .efi, machine.displayMode == .desktop,
+ machine.installerISOPath?.isEmpty == false else {
return "An installer capability requires attached installer media."
}
case .virtualDisk:
- guard machine.installerISOPath == nil else {
+ guard machine.bootMode == .efi, machine.displayMode == .desktop,
+ machine.installerISOPath == nil else {
return "A virtual-disk capability cannot retain attached installer media."
}
if DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) {
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index d0f630ed..fa089da2 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -1041,6 +1041,173 @@ public final class MachineManager: @unchecked Sendable {
)
}
+ /// Resolves and publishes the exact current compatibility projection through the production
+ /// planning transaction. This is invoked by the product create/update boundary only after
+ /// machine metadata and managed artifacts are durable. Starts remain fail-closed throughout:
+ /// the workspace is `requires-replanning` until the coordinator completes its mutation fence
+ /// and publishes the matching runtime identity.
+ @discardableResult
+ public func resolveAndPublishProductionPlan(
+ id: String,
+ controller: any DoryDaemonVirtualMachineProductionPlanningControlling
+ ) throws -> DoryMachineStatus {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ guard launchPolicy == .perWorkspaceAuthority else {
+ guard let current = status(id: id) else {
+ throw MachineManagerError.unknownMachine(id)
+ }
+ return current
+ }
+ try requireNoActivePlanningMutation(id: id)
+
+ let entry: MachineEntry
+ lock.lock()
+ guard let current = machines[id], !deletingMachineIDs.contains(id) else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ entry = current
+ lock.unlock()
+ guard entry.process == nil, entry.handoffServer == nil,
+ entry.state == .created || entry.state == .stopped else {
+ throw MachineManagerError.persistence(
+ "machine \(id) must be stopped before production planning"
+ )
+ }
+ switch entry.runtimeIdentity.mode {
+ case .legacyCompatibility, .resolvedPlan:
+ guard let current = status(id: id) else {
+ throw MachineManagerError.unknownMachine(id)
+ }
+ return current
+ case .requiresReplanning:
+ break
+ }
+
+ guard let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: id)),
+ let decoded = try? JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: legacyData
+ ), decoded == entry.configuration else {
+ throw MachineManagerError.persistence(
+ "authoritative machine metadata is unavailable for production planning"
+ )
+ }
+ let facts = try workspaceMigrationFacts(for: entry.configuration)
+ let factsData = try Self.workspaceMigrationAuthorityData(facts)
+ let migration = try DoryMachineConfigurationMigrationBridge.migrate(
+ entry.configuration,
+ facts: facts
+ )
+ let definition: DoryVirtualMachineDefinition
+ do {
+ definition = try workspaceRepository.reconcileLegacyProjection(
+ migration.definition,
+ authoritativeLegacyData: legacyData,
+ authoritativeMigrationFactsData: factsData
+ ).definition
+ let persisted = try workspaceRepository.readLegacyProjection(
+ id: id,
+ authoritativeLegacyData: legacyData,
+ authoritativeMigrationFactsData: factsData
+ )
+ guard persisted == definition else {
+ throw MachineManagerError.persistence(
+ "workspace projection changed during production planning"
+ )
+ }
+ } catch let error as MachineManagerError {
+ throw error
+ } catch {
+ throw MachineManagerError.persistence(
+ "workspace projection is unavailable for production planning: \(error)"
+ )
+ }
+ let canonicalDefinitionData = try Self.canonicalDefinitionData(definition)
+
+ let plans: any DoryResolvedMachinePlanStoring = resolvedLaunchPlanStore
+ ?? DoryResolvedMachinePlanRepository(root: configuration.stateDirectory)
+ let planPublication: DoryDaemonVirtualMachinePlanPublication
+ do {
+ let existing = try plans.read(id: id)
+ planPublication = .replace(expectedPlanRevision: existing.planRevision)
+ } catch let error as DoryResolvedMachinePlanRepositoryError {
+ switch error {
+ case .planNotFound:
+ planPublication = .create
+ default:
+ throw MachineManagerError.persistence(
+ "resolved-plan authority cannot be inspected: \(error)"
+ )
+ }
+ } catch {
+ throw MachineManagerError.persistence(
+ "resolved-plan authority cannot be inspected: \(error)"
+ )
+ }
+
+ guard let requirements = DoryDaemonVirtualMachinePlanningCoordinator
+ .launchArtifactRequirements(for: definition) else {
+ throw MachineManagerError.persistence(
+ "workspace launch artifacts are not representable"
+ )
+ }
+ let bindings = Dictionary(grouping: migration.artifactBindings, by: \.reference)
+ var publications: [DoryDaemonVirtualMachinePlanningArtifactPublication] = []
+ publications.reserveCapacity(requirements.count)
+ for requirement in requirements {
+ guard let matches = bindings[requirement.reference], matches.count == 1,
+ let binding = matches.first else {
+ throw MachineManagerError.persistence(
+ "workspace launch artifact has no exact managed path"
+ )
+ }
+ let revision: UInt64?
+ do { revision = try controller.authorityRevision(for: requirement.reference) }
+ catch {
+ throw MachineManagerError.persistence(
+ "workspace artifact authority cannot be inspected: \(error)"
+ )
+ }
+ publications.append(DoryDaemonVirtualMachinePlanningArtifactPublication(
+ reference: requirement.reference,
+ path: binding.path,
+ kind: requirement.kind,
+ source: requirement.source,
+ mutability: requirement.mutable ? .mutable : .immutable,
+ expectedAuthorityRevision: revision
+ ))
+ }
+
+ let request = DoryDaemonVirtualMachinePlanningTransactionRequest(
+ planning: DoryDaemonVirtualMachinePlanningRequest(
+ definition: definition,
+ canonicalDefinitionData: canonicalDefinitionData,
+ machine: entry.configuration,
+ publication: planPublication
+ ),
+ workspacePublication: .retainExistingExact
+ )
+ do {
+ try controller.publishResolvedPlan(
+ request,
+ artifacts: publications
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "production planning failed closed: \(error)"
+ )
+ }
+ guard let current = status(id: id),
+ current.runtimeIdentity.mode == .resolvedPlan else {
+ throw MachineManagerError.persistence(
+ "production planning did not publish resolved runtime authority"
+ )
+ }
+ return current
+ }
+
@discardableResult
public func start(id: String) throws -> DoryMachineStatus {
operationLock.lock()
diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift
index d28603f9..ef044da2 100644
--- a/dory-core-swift/Sources/doryd/main.swift
+++ b/dory-core-swift/Sources/doryd/main.swift
@@ -67,25 +67,30 @@ let dockerTier = dorydEnvironment.dockerTierConfiguration().map {
let desktopUpdateArtifactResolver = DoryComponentStoreDesktopUpdateArtifactResolver(
store: DoryComponentStore(drive: dataDrive)
)
+var productionPlanningController:
+ (any DoryDaemonVirtualMachineProductionPlanningControlling)?
let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { configuration -> MachineManager? in
- let readiness = DoryDaemonVirtualMachineProductionTrustFactory().resolve(
+ let readiness = DoryDaemonVirtualMachineProductionTrustFactory().activate(
store: DoryComponentStore(drive: dataDrive),
machineConfiguration: configuration
)
switch readiness {
- case let .ready(context):
+ case let .activated(context):
FileHandle.standardError.write(Data(
- "doryd: VM launch policy requireResolvedPlan (production trust inventory ready)\n".utf8
+ "doryd: VM launch policy perWorkspaceAuthority "
+ .appending("(production planning recovered and activated)\n").utf8
))
+ productionPlanningController = context.planningController
context.machineManager.installDesktopUpdateArtifactResolver(desktopUpdateArtifactResolver)
return context.machineManager
- case let .unavailable(reason):
- guard reason.permitsLegacyCompatibilityMigration else {
+ case let .unavailable(failure):
+ guard let trustFailure = failure.trustFailure,
+ trustFailure.permitsLegacyCompatibilityMigration else {
// Integrity and runtime verification failures disable VM launch. Falling through to
// legacy here would execute the same helper that production trust just rejected.
FileHandle.standardError.write(Data(
"doryd: VM launch unavailable "
- .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8
+ .appending("(\(failure.code.rawValue): \(failure.message))\n").utf8
))
return nil
}
@@ -93,7 +98,7 @@ let machineManager = dorydEnvironment.machineManagerConfiguration().flatMap { co
// qualification authority yet, so the existing compatibility path stays labeled.
FileHandle.standardError.write(Data(
"doryd: VM launch policy legacyCompatibility "
- .appending("(\(reason.code.rawValue): \(reason.message))\n").utf8
+ .appending("(\(trustFailure.code.rawValue): \(trustFailure.message))\n").utf8
))
let manager = MachineManager(
configuration: configuration,
@@ -246,6 +251,7 @@ let service = DorydService(
home: dorydEnvironment.home,
dockerTier: dockerTier,
machineManager: machineManager,
+ productionPlanningController: productionPlanningController,
machineBackupScheduler: machineBackupScheduler,
remoteManager: remoteManager,
networkingController: networkingController,
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
index c8d9c0d7..7c37a918 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
@@ -287,6 +287,26 @@ struct VirtualMachineCapabilitiesTests {
#expect(linuxRestore.availability.reason?.code == .bootMediaDoesNotSupportGuest)
}
+ @Test("VZ direct-kernel lifecycle devices remain exact pending qualification")
+ func vzDirectKernelLifecycleDevices() {
+ let devices = DoryVirtualMachineDeviceCapabilityRequest(
+ clockSynchronization: true,
+ gracefulShutdown: true
+ )
+ let descriptor = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .appleVirtualizationFramework,
+ graphics: .none,
+ devices: devices
+ )
+
+ #expect(descriptor.availability.isUsable)
+ #expect(descriptor.resolvedDevices == devices)
+ #expect(descriptor.availability.reason?.code == .runtimeQualificationUnavailable)
+ }
+
@Test("missing renderer blocks accelerated native graphics but not software graphics")
func rendererAvailabilityIsExact() {
var host = Self.provisionedHost
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
index cc9c312e..c4ab10d5 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
@@ -363,6 +363,58 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
#expect(trustFloor.activationCount == 1)
}
+ @Test("activated production graph publishes a headless create plan through XPC authority")
+ func activatedGraphPlansHeadlessCreate() throws {
+ let fixture = try ProductionTrustFixture()
+ defer { fixture.cleanup() }
+ guard case let .activated(context) = fixture.factory.activate(
+ store: fixture.store,
+ machineConfiguration: fixture.machineConfiguration,
+ appVersion: fixture.appVersion,
+ publicKey: fixture.publicKey,
+ expectedArchitecture: "arm64"
+ ) else {
+ Issue.record("Expected production activation")
+ return
+ }
+ let service = DorydService(
+ socketPath: fixture.root.appendingPathComponent("doryd.sock").path,
+ machineManager: context.machineManager,
+ productionPlanningController: context.planningController
+ )
+ let qualifiedDisk = fixture.root.appendingPathComponent("qualified-headless.raw").path
+ FileManager.default.createFile(
+ atPath: qualifiedDisk,
+ contents: nil,
+ attributes: [.posixPermissions: 0o600]
+ )
+ let diskHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: qualifiedDisk))
+ try diskHandle.truncate(atOffset: 16 * 1_024 * 1_024 * 1_024)
+ try diskHandle.synchronize()
+ try diskHandle.close()
+ let completed = LockedPlanningCreateReply()
+ service.machineCreate([
+ "id": "qualified-headless",
+ "kernelPath": fixture.mediaPath,
+ "rootfsPath": qualifiedDisk,
+ "displayMode": "headless",
+ "memoryMB": UInt64(2_048),
+ "cpuCount": 2,
+ ]) { ok, body, message in
+ completed.set(ok: ok, body: body, message: message)
+ }
+ let reply = completed.value
+ #expect(reply.ok, Comment(rawValue: reply.message))
+ #expect(reply.body["runtimeIdentity"] != nil)
+ #expect(context.machineManager.status(id: "qualified-headless")?
+ .runtimeIdentity.mode == .resolvedPlan)
+ let plan = try context.planning.plans.read(id: "qualified-headless")
+ #expect(plan.machineID == "qualified-headless")
+ #expect(plan.launchArtifacts.count == 2)
+ #expect(plan.devices.clockSynchronization)
+ #expect(plan.devices.gracefulShutdown)
+ }
+
@Test("trust-floor failure exposes no manager and a fresh activation can retry")
func activationFloorFailureIsTyped() throws {
let trustFloor = ProductionTrustFloorActivationState(remainingFailures: 1)
@@ -550,6 +602,29 @@ private enum ProductionTrustFixtureError: Error {
case runtimeRejected
}
+private final class LockedPlanningCreateReply: @unchecked Sendable {
+ struct Value {
+ var ok = false
+ var body: NSDictionary = [:]
+ var message = "callback was not invoked"
+ }
+
+ private let lock = NSLock()
+ private var stored = Value()
+
+ var value: Value {
+ lock.lock()
+ defer { lock.unlock() }
+ return stored
+ }
+
+ func set(ok: Bool, body: NSDictionary, message: String) {
+ lock.lock()
+ stored = Value(ok: ok, body: body, message: message)
+ lock.unlock()
+ }
+}
+
private final class ProductionCallCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
@@ -1018,6 +1093,10 @@ private final class ProductionTrustFixture: @unchecked Sendable {
) throws {
let signingKeyID = Self.digest(privateKey.publicKey.rawRepresentation)
let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
+ let headlessDevices = DoryVirtualMachineDeviceCapabilityRequest(
+ clockSynchronization: true,
+ gracefulShutdown: true
+ )
let components = ["dory-hv", "dory-vmm"].map {
DoryVirtualMachineQualifiedComponent(
componentIdentifier: $0,
@@ -1025,30 +1104,33 @@ private final class ProductionTrustFixture: @unchecked Sendable {
artifactSHA256: helperDigest
)
}
- let records = [
+ let backends = [
(DoryVirtualizationBackendIdentity.doryHypervisor,
RawHVLinuxMachineBackend.backendDescriptor.implementationIdentifier,
"dory-hv"),
(DoryVirtualizationBackendIdentity.appleVirtualizationFramework,
VirtualizationFrameworkLinuxMachineBackend.backendDescriptor.implementationIdentifier,
"dory-vmm"),
- ].map { backend, implementation, component in
- DoryVirtualMachineQualificationRecord(
- qualificationIdentity: "\(component)-qualification",
- guest: guest,
- bootMediaKind: .installedLinuxBootBundle,
- bootMediaSource: .bundledByDory,
- immutableArtifactSHA256: mediaDigest,
- backend: backend,
- backendImplementationIdentifier: implementation,
- backendRuntimeBuildIdentifier: runtimeBuildIdentifier,
- virtualHardwareABIVersion: 1,
- graphics: .none,
- devices: devices,
- hostHardwareModelIdentifier: host.hardwareModelIdentifier,
- hostOperatingSystemBuild: host.operatingSystemBuild,
- components: [components.first { $0.componentIdentifier == component }!]
- )
+ ]
+ let records = backends.flatMap { backend, implementation, component in
+ [("minimum", devices), ("headless", headlessDevices)].map { suffix, devices in
+ DoryVirtualMachineQualificationRecord(
+ qualificationIdentity: "\(component)-\(suffix)-qualification",
+ guest: guest,
+ bootMediaKind: .installedLinuxBootBundle,
+ bootMediaSource: .bundledByDory,
+ immutableArtifactSHA256: mediaDigest,
+ backend: backend,
+ backendImplementationIdentifier: implementation,
+ backendRuntimeBuildIdentifier: runtimeBuildIdentifier,
+ virtualHardwareABIVersion: 1,
+ graphics: .none,
+ devices: devices,
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ components: [components.first { $0.componentIdentifier == component }!]
+ )
+ }
}
let manifest = DoryVirtualMachineQualificationManifest(
manifestIdentity: "production-vm-qualification-1",
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index d8fb79ea..8fb3258a 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -1,6 +1,7 @@
import DoryCore
@testable import DorydKit
import CryptoKit
+import DoryOperations
import XCTest
final class DorydServiceTests: XCTestCase {
@@ -1440,6 +1441,63 @@ final class DorydServiceTests: XCTestCase {
XCTAssertEqual(manager.status(id: "legacy")?.environment["PRIVATE_TOKEN"], "opaque-legacy-value")
}
+ func testPerWorkspaceCreateInvokesProductionPlanningAndFailsClosed() throws {
+ let base = "/tmp/doryd-service-production-plan-\(getpid())-\(UInt32.random(in: 0.. UInt64? {
+ _ = reference
+ return nil
+ }
+
+ func publishResolvedPlan(
+ _ request: DoryDaemonVirtualMachinePlanningTransactionRequest,
+ artifacts: [DoryDaemonVirtualMachinePlanningArtifactPublication]
+ ) throws {
+ lock.lock()
+ stored.append(Capture(request: request, artifacts: artifacts))
+ lock.unlock()
+ throw ServiceProductionPlanningTestError.rejected
+ }
+}
+
+private enum ServiceProductionPlanningTestError: Error { case rejected }
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
index 44c4f420..93e09ed2 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
@@ -15,7 +15,10 @@ final class MachineBackendTests: XCTestCase {
XCTAssertEqual(vz.identity, .appleVirtualizationFramework)
XCTAssertEqual(vz.guestFamilies, [.linux])
XCTAssertEqual(vz.guestArchitectures, [.arm64])
- XCTAssertEqual(vz.bootMediaKinds, [.installerISO, .virtualDisk])
+ XCTAssertEqual(
+ vz.bootMediaKinds,
+ [.installedLinuxBootBundle, .installerISO, .virtualDisk]
+ )
XCTAssertFalse(vz.lifecycle.pause)
XCTAssertFalse(vz.lifecycle.resume)
}
@@ -125,6 +128,44 @@ final class MachineBackendTests: XCTestCase {
XCTAssertEqual(result.plan?.capability.request.bootMedia.kind, .installerISO)
}
+ func testRegistryPlansVZInstalledLinuxBundleWithPlannerSelection() throws {
+ let bundle = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "dory-vz-direct-\(UUID().uuidString).boot"
+ )
+ defer { try? FileManager.default.removeItem(at: bundle) }
+ try DoryInstalledLinuxBootBundle.write(
+ assets: DoryLinuxInstallerBootAssets(
+ kernel: Data("kernel".utf8),
+ initrd: Data("initrd".utf8),
+ kernelISOPath: "/boot/kernel",
+ initrdISOPath: "/boot/initrd"
+ ),
+ rootDevice: "/dev/vda2",
+ toPath: bundle.path
+ )
+ let machine = DoryMachineConfiguration(
+ id: "vz-direct-linux",
+ kernelPath: bundle.path,
+ rootfsPath: "/fixture/linux.raw",
+ bootMode: .linuxKernel,
+ displayMode: .headless
+ )
+ let registry = try BackendRegistry(backends: [
+ availableVZBackend(operations: recordingOperations().operations),
+ ])
+ let result = registry.plan(MachineBackendPlanRequest(
+ machine: machine,
+ capabilityPlan: capabilityPlan(
+ backend: .appleVirtualizationFramework,
+ media: .installedLinuxBootBundle
+ )
+ ))
+
+ XCTAssertTrue(result.isSuccess)
+ XCTAssertEqual(result.plan?.backend.identity, .appleVirtualizationFramework)
+ XCTAssertEqual(result.plan?.capability.request.bootMedia.kind, .installedLinuxBootBundle)
+ }
+
func testPlanningFailsClosedWhenProductPlannerHasNoSelection() throws {
let registry = try BackendRegistry(backends: [
availableRawBackend(operations: recordingOperations().operations),
From dcf69b13a28be9aee88fb9fe4aabc8652dfbe194 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 20:29:10 +0000
Subject: [PATCH 041/338] feat(vm): bind resource admission to lifecycle
---
...onVirtualMachineProductionActivation.swift | 10 +-
...irtualMachineResourceAdmissionLedger.swift | 40 ++
.../Sources/DorydKit/MachineManager.swift | 508 +++++++++++++++++-
...onVirtualMachineProductionTrustTests.swift | 44 +-
...lMachineResourceAdmissionLedgerTests.swift | 57 ++
5 files changed, 638 insertions(+), 21 deletions(-)
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
index eabf5cdf..76132f02 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionActivation.swift
@@ -198,6 +198,9 @@ extension DoryDaemonVirtualMachineProductionTrustFactory {
))
}
+ let planningController = DoryDaemonVirtualMachineProductionPlanningController(
+ planning: planning
+ )
do {
try machineManager.installResolvedLaunchInfrastructure(
registry: planning.registry,
@@ -205,7 +208,9 @@ extension DoryDaemonVirtualMachineProductionTrustFactory {
plans: planning.plans,
expectedPlanRevision: { machineID in
try? planning.plans.read(id: machineID).planRevision
- }
+ },
+ productionPlanningController: planningController,
+ resourceAdmissionLedger: planning.resourceLedger
)
} catch {
return unavailableActivation(
@@ -229,8 +234,7 @@ extension DoryDaemonVirtualMachineProductionTrustFactory {
return .activated(DoryDaemonVirtualMachineProductionActivationContext(
machineManager: machineManager,
planning: planning,
- planningController:
- DoryDaemonVirtualMachineProductionPlanningController(planning: planning),
+ planningController: planningController,
backendRuntimeBuildIdentifiers: Dictionary(
uniqueKeysWithValues: material.runtimes.map {
($0.descriptor.identity, $0.runtimeBuildIdentifier)
diff --git a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
index 47a4f8fa..c0370b14 100644
--- a/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryVirtualMachineResourceAdmissionLedger.swift
@@ -463,6 +463,46 @@ public final class DoryVirtualMachineResourceAdmissionLedger: @unchecked Sendabl
}
}
+ /// Reopens the same exact running admission for a daemon-controlled restart that did not
+ /// change guest or plan authority (for example, quiescing a VM to take a snapshot). Runtime
+ /// capacity remains reserved throughout the stop/restart window; this does not re-admit a
+ /// stopped VM or authorize a changed plan.
+ func prepareRetainedRunningForRestart(
+ leaseID: String,
+ plan: DoryResolvedMachinePlan,
+ startingLeaseDurationMilliseconds: Int64 = 120_000,
+ expectedLeaseRevision: UInt64
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ try withExclusiveAccess {
+ let timestamp = now()
+ guard startingLeaseDurationMilliseconds > 0,
+ startingLeaseDurationMilliseconds
+ <= Self.maximumStartingLeaseDurationMilliseconds,
+ timestamp > 0,
+ timestamp <= Int64.max - startingLeaseDurationMilliseconds else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseDuration
+ }
+ var record = try readRecord()
+ _ = try recoverExpired(in: &record, at: timestamp)
+ let index = try leaseIndex(leaseID, in: record)
+ var lease = record.leases[index]
+ try Self.validateLeaseRevision(expectedLeaseRevision, lease)
+ guard lease.state == .running else {
+ throw DoryVirtualMachineResourceAdmissionLedgerError.invalidLeaseState(lease.state)
+ }
+ try Self.validate(plan, against: lease, requireBoundDigest: true)
+ try Self.validateCapacity(record.leases, hostFacts: lease.hostFacts)
+ lease.state = .starting
+ lease.startingExpiresAtUnixMilliseconds = timestamp
+ + startingLeaseDurationMilliseconds
+ lease.leaseRevision = try Self.incrementing(lease.leaseRevision)
+ lease.updatedAtUnixMilliseconds = timestamp
+ record.leases[index] = lease
+ try advanceAndPublish(&record)
+ return lease
+ }
+ }
+
/// Releases CPU and RAM after stop while intentionally retaining the disk reservation.
public func markStopped(
leaseID: String,
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index fa089da2..9ce542f1 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -754,6 +754,10 @@ public final class MachineManager: @unchecked Sendable {
private var resolvedLaunchPlanResolver: (any DoryDaemonVirtualMachineLaunchPlanResolving)?
private var resolvedLaunchPlanStore: (any DoryResolvedMachinePlanStoring)?
private var resolvedPlanRevisionProvider: ResolvedPlanRevisionProvider?
+ private var productionPlanningController:
+ (any DoryDaemonVirtualMachineProductionPlanningControlling)?
+ private var productionResourceAdmissionLedger:
+ DoryVirtualMachineResourceAdmissionLedger?
private var pendingResolvedStart: PendingResolvedMachineStart?
private var resolvedLaunchIdentities: [String: DoryMachineResolvedLaunchIdentity] = [:]
private var activeLifecycleOperations: [String: MachineLifecycleJournalContext] = [:]
@@ -855,7 +859,10 @@ public final class MachineManager: @unchecked Sendable {
registry: BackendRegistry,
resolver: any DoryDaemonVirtualMachineLaunchPlanResolving,
plans: any DoryResolvedMachinePlanStoring,
- expectedPlanRevision: @escaping ResolvedPlanRevisionProvider
+ expectedPlanRevision: @escaping ResolvedPlanRevisionProvider,
+ productionPlanningController:
+ (any DoryDaemonVirtualMachineProductionPlanningControlling)? = nil,
+ resourceAdmissionLedger: DoryVirtualMachineResourceAdmissionLedger? = nil
) throws {
operationLock.lock()
defer { operationLock.unlock() }
@@ -865,9 +872,16 @@ public final class MachineManager: @unchecked Sendable {
"resolved launch infrastructure requires a resolved-plan-capable policy"
)
}
+ guard (productionPlanningController == nil) == (resourceAdmissionLedger == nil) else {
+ throw MachineManagerError.persistence(
+ "production planning and resource-admission lifecycle must be installed together"
+ )
+ }
guard resolvedLaunchRegistry == nil, resolvedLaunchPlanResolver == nil,
resolvedLaunchPlanStore == nil,
resolvedPlanRevisionProvider == nil,
+ self.productionPlanningController == nil,
+ productionResourceAdmissionLedger == nil,
pendingResolvedStart == nil else {
throw MachineManagerError.persistence(
"resolved launch infrastructure is already installed"
@@ -877,10 +891,13 @@ public final class MachineManager: @unchecked Sendable {
resolvedLaunchPlanResolver = resolver
resolvedLaunchPlanStore = plans
resolvedPlanRevisionProvider = expectedPlanRevision
+ self.productionPlanningController = productionPlanningController
+ productionResourceAdmissionLedger = resourceAdmissionLedger
recoverResolvedRuntimeIdentities(
plans: plans,
expectedPlanRevision: expectedPlanRevision
)
+ try reconcileResourceAdmissionsAfterDaemonRestart()
}
/// Operations for the current Linux compatibility adapters. Start is protected by a
@@ -1076,13 +1093,38 @@ public final class MachineManager: @unchecked Sendable {
)
}
switch entry.runtimeIdentity.mode {
- case .legacyCompatibility, .resolvedPlan:
+ case .legacyCompatibility:
guard let current = status(id: id) else {
throw MachineManagerError.unknownMachine(id)
}
return current
case .requiresReplanning:
break
+ case .resolvedPlan:
+ guard let plan = entry.runtimeIdentity.resolvedPlan else {
+ throw MachineManagerError.persistence(
+ "resolved runtime identity has no exact plan"
+ )
+ }
+ guard let ledger = productionResourceAdmissionLedger else {
+ guard let current = status(id: id) else {
+ throw MachineManagerError.unknownMachine(id)
+ }
+ return current
+ }
+ switch try exactResourceAdmissionLease(for: plan, ledger: ledger).state {
+ case .starting, .running:
+ guard let current = status(id: id) else {
+ throw MachineManagerError.unknownMachine(id)
+ }
+ return current
+ case .stopped:
+ break
+ case .recoveryRequired:
+ throw MachineManagerError.persistence(
+ "machine \(id) resource admission requires restart reconciliation"
+ )
+ }
}
guard let legacyData = Self.readPrivateMetadata(path: machineConfigPath(id: id)),
@@ -1213,6 +1255,7 @@ public final class MachineManager: @unchecked Sendable {
operationLock.lock()
defer { operationLock.unlock() }
try requireNoActivePlanningMutation(id: id)
+ try refreshResolvedAdmissionForStartIfNeeded(id: id)
let directMutation = try retainDirectWorkspaceMutationLock(id: id)
defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
return try startImplementation(id: id, journalLifecycle: true)
@@ -1404,6 +1447,7 @@ public final class MachineManager: @unchecked Sendable {
if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
pendingResolvedStart = PendingResolvedMachineStart(
machine: prepared.machine,
+ plan: resolved.resolvedPlan,
backend: resolved.backendPlan.backend,
runtimeBuildIdentifier: resolved.resolvedPlan.backendRuntimeBuildIdentifier,
runtimeComponents: resolved.resolvedPlan.components,
@@ -1543,7 +1587,8 @@ public final class MachineManager: @unchecked Sendable {
return MachineBackendRuntimeObservation(try spawnPreparedMachine(
authorization.machine,
launchBinding: binding,
- preSpawnAuthorization: authorization.preSpawnAuthorization
+ preSpawnAuthorization: authorization.preSpawnAuthorization,
+ resolvedPlan: authorization.plan
))
}
@@ -1597,6 +1642,255 @@ public final class MachineManager: @unchecked Sendable {
return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
+ private func exactResourceAdmissionLease(
+ for plan: DoryResolvedMachinePlan,
+ ledger: DoryVirtualMachineResourceAdmissionLedger
+ ) throws -> DoryVirtualMachineResourceAdmissionLease {
+ guard let admission = plan.resourceAdmission,
+ let definitionSHA256 = plan.definitionSHA256 else {
+ throw MachineManagerError.persistence(
+ "resolved plan has no exact resource-admission authority"
+ )
+ }
+ let snapshot: DoryVirtualMachineResourceAdmissionLedgerSnapshot
+ do { snapshot = try ledger.snapshot() }
+ catch {
+ throw MachineManagerError.persistence(
+ "resource-admission ledger cannot be inspected: \(error)"
+ )
+ }
+ let matches = snapshot.leases.filter { $0.leaseID == admission.admissionIdentity }
+ guard matches.count == 1, let lease = matches.first,
+ lease.binding.machineID == plan.machineID,
+ lease.binding.definitionRevision == plan.definitionRevision,
+ lease.binding.definitionSHA256.lowercased() == definitionSHA256.lowercased(),
+ lease.binding.plannedPlanRevision == plan.planRevision,
+ lease.boundPlanSHA256?.lowercased()
+ == (try Self.canonicalResolvedPlanSHA256(plan)),
+ lease.evidence == admission else {
+ throw MachineManagerError.persistence(
+ "resolved plan does not match its durable resource-admission lease"
+ )
+ }
+ return lease
+ }
+
+ private func refreshResolvedAdmissionForStartIfNeeded(id: String) throws {
+ guard launchPolicy == .perWorkspaceAuthority,
+ let controller = productionPlanningController,
+ let ledger = productionResourceAdmissionLedger else { return }
+ let identity = try currentDurableRuntimeIdentity(id: id)
+ guard identity.mode == .resolvedPlan, let plan = identity.resolvedPlan else { return }
+ var lease = try exactResourceAdmissionLease(for: plan, ledger: ledger)
+ switch lease.state {
+ case .starting:
+ return
+ case .running:
+ guard status(id: id)?.state != .running else { return }
+ do {
+ lease = try ledger.markStopped(
+ leaseID: lease.leaseID,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "stale running resource admission could not be reconciled: \(error)"
+ )
+ }
+ case .recoveryRequired:
+ guard status(id: id)?.state != .running else {
+ throw MachineManagerError.persistence(
+ "running machine has an ambiguous expired resource admission"
+ )
+ }
+ do {
+ lease = try ledger.reconcileExpiredStart(
+ leaseID: lease.leaseID,
+ observedRuntimeState: .stopped,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "expired resource admission could not be reconciled: \(error)"
+ )
+ }
+ case .stopped:
+ break
+ }
+ guard lease.state == .stopped else {
+ throw MachineManagerError.persistence(
+ "resource admission did not settle to stopped before replanning"
+ )
+ }
+ _ = try resolveAndPublishProductionPlan(id: id, controller: controller)
+ }
+
+ private func reconcileResourceAdmissionsAfterDaemonRestart() throws {
+ guard let ledger = productionResourceAdmissionLedger else { return }
+ let snapshot: DoryVirtualMachineResourceAdmissionLedgerSnapshot
+ do { snapshot = try ledger.snapshot() }
+ catch {
+ throw MachineManagerError.persistence(
+ "resource-admission restart reconciliation failed: \(error)"
+ )
+ }
+ let loadedMachineIDs: Set
+ lock.lock()
+ loadedMachineIDs = Set(machines.keys)
+ lock.unlock()
+ for lease in snapshot.leases {
+ let machineIsLoaded = loadedMachineIDs.contains(lease.binding.machineID)
+ let machinePathExists = Self.pathEntryExists(
+ machineStateDirectory(id: lease.binding.machineID)
+ )
+ do {
+ if !machineIsLoaded, !machinePathExists, lease.state == .stopped {
+ try ledger.releaseStorageReservation(
+ leaseID: lease.leaseID,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ continue
+ }
+ guard machineIsLoaded else { continue }
+ switch lease.state {
+ case .running:
+ _ = try ledger.markStopped(
+ leaseID: lease.leaseID,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ case .recoveryRequired:
+ _ = try ledger.reconcileExpiredStart(
+ leaseID: lease.leaseID,
+ observedRuntimeState: .stopped,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ case .starting, .stopped:
+ break
+ }
+ } catch {
+ throw MachineManagerError.persistence(
+ "resource admission for \(lease.binding.machineID) could not be reconciled: \(error)"
+ )
+ }
+ }
+ }
+
+ private func markResolvedAdmissionRunning(
+ plan: DoryResolvedMachinePlan
+ ) throws {
+ guard let ledger = productionResourceAdmissionLedger else { return }
+ let lease = try exactResourceAdmissionLease(for: plan, ledger: ledger)
+ guard lease.state == .starting else {
+ throw MachineManagerError.persistence(
+ "resolved start requires a starting resource-admission lease"
+ )
+ }
+ do {
+ _ = try ledger.markRunning(
+ leaseID: lease.leaseID,
+ plan: plan,
+ hostFacts: lease.hostFacts,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "resource admission could not commit running state: \(error)"
+ )
+ }
+ }
+
+ private func markResolvedAdmissionStopped(
+ plan: DoryResolvedMachinePlan?
+ ) throws {
+ guard let plan, let ledger = productionResourceAdmissionLedger else { return }
+ let lease = try exactResourceAdmissionLease(for: plan, ledger: ledger)
+ do {
+ switch lease.state {
+ case .starting, .running:
+ _ = try ledger.markStopped(
+ leaseID: lease.leaseID,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ case .recoveryRequired:
+ _ = try ledger.reconcileExpiredStart(
+ leaseID: lease.leaseID,
+ observedRuntimeState: .stopped,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ case .stopped:
+ break
+ }
+ } catch {
+ throw MachineManagerError.persistence(
+ "resource admission could not commit stopped state: \(error)"
+ )
+ }
+ }
+
+ private func prepareRetainedResolvedAdmissionForRestart(
+ plan: DoryResolvedMachinePlan?
+ ) throws {
+ guard let plan, let ledger = productionResourceAdmissionLedger else { return }
+ let lease = try exactResourceAdmissionLease(for: plan, ledger: ledger)
+ switch lease.state {
+ case .running:
+ do {
+ _ = try ledger.prepareRetainedRunningForRestart(
+ leaseID: lease.leaseID,
+ plan: plan,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "retained resource admission could not authorize restart: \(error)"
+ )
+ }
+ case .starting:
+ return
+ case .stopped, .recoveryRequired:
+ throw MachineManagerError.persistence(
+ "retained resource admission is unavailable for restart"
+ )
+ }
+ }
+
+ private func releaseResolvedAdmissionStorage(
+ machineID: String,
+ plan: DoryResolvedMachinePlan?
+ ) throws {
+ guard let ledger = productionResourceAdmissionLedger else { return }
+ let lease: DoryVirtualMachineResourceAdmissionLease
+ if let plan {
+ lease = try exactResourceAdmissionLease(for: plan, ledger: ledger)
+ } else {
+ let matches = try ledger.snapshot().leases.filter {
+ $0.binding.machineID == machineID
+ }
+ guard matches.count <= 1 else {
+ throw MachineManagerError.persistence(
+ "machine has ambiguous resource storage reservations"
+ )
+ }
+ guard let retained = matches.first else { return }
+ lease = retained
+ }
+ guard lease.state == .stopped else {
+ throw MachineManagerError.persistence(
+ "resource storage can be released only after the machine is stopped"
+ )
+ }
+ do {
+ try ledger.releaseStorageReservation(
+ leaseID: lease.leaseID,
+ expectedLeaseRevision: lease.leaseRevision
+ )
+ } catch {
+ throw MachineManagerError.persistence(
+ "resource storage reservation could not be released: \(error)"
+ )
+ }
+ }
+
private static func fileSHA256(path: String) throws -> String {
guard let handle = FileHandle(forReadingAtPath: path) else {
throw MachineManagerError.persistence("cannot open runtime executable at \(path)")
@@ -1719,7 +2013,8 @@ public final class MachineManager: @unchecked Sendable {
private func spawnPreparedMachine(
_ preparedMachine: DoryMachineConfiguration,
launchBinding: MachineBackendLaunchBinding?,
- preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil
+ preSpawnAuthorization: DoryDaemonVirtualMachinePreSpawnAuthorization? = nil,
+ resolvedPlan: DoryResolvedMachinePlan? = nil
) throws -> DoryMachineStatus {
lock.lock()
guard var entry = machines[preparedMachine.id] else {
@@ -1777,6 +2072,13 @@ public final class MachineManager: @unchecked Sendable {
} catch {
handoffServer?.stop()
lock.unlock()
+ do { try markResolvedAdmissionStopped(plan: resolvedPlan) }
+ catch let settlementError {
+ throw MachineManagerError.persistence(
+ "resolved launch failed before spawn: \(error); "
+ + "resource settlement failed: \(settlementError)"
+ )
+ }
throw error
}
let process = HvProcess(
@@ -1796,7 +2098,11 @@ public final class MachineManager: @unchecked Sendable {
entry.launchID = launchID
entry.runtimeAddress = nil
entry.currentBalloonTargetMB = nil
- entry.state = configuration.requiresReadyHandoff ? .starting : .running
+ entry.activeResolvedPlan = resolvedPlan
+ let requiresAdmissionCommit = resolvedPlan != nil
+ && productionResourceAdmissionLedger != nil
+ entry.state = configuration.requiresReadyHandoff || requiresAdmissionCommit
+ ? .starting : .running
entry.lastError = nil
machines[id] = entry
lock.unlock()
@@ -1809,13 +2115,42 @@ public final class MachineManager: @unchecked Sendable {
machines[id]?.handoffServer = nil
machines[id]?.launchID = nil
machines[id]?.runtimeAddress = nil
+ machines[id]?.activeResolvedPlan = nil
machines[id]?.state = .failed
machines[id]?.lastError = "\(error)"
lock.unlock()
+ process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
+ do { try markResolvedAdmissionStopped(plan: resolvedPlan) }
+ catch let settlementError {
+ throw MachineManagerError.persistence(
+ "resolved process start failed: \(error); "
+ + "resource settlement failed: \(settlementError)"
+ )
+ }
throw error
}
if configuration.requiresReadyHandoff {
scheduleHandoffTimeout(id: id, process: process, timeout: handoffReadyTimeout)
+ } else if requiresAdmissionCommit, let resolvedPlan {
+ do {
+ try markResolvedAdmissionRunning(plan: resolvedPlan)
+ lock.lock()
+ if machines[id]?.launchID == launchID {
+ machines[id]?.state = .running
+ }
+ lock.unlock()
+ } catch {
+ process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
+ lock.lock()
+ if machines[id]?.launchID == launchID {
+ machines[id]?.state = .failed
+ machines[id]?.lastError = "\(error)"
+ machines[id]?.activeResolvedPlan = nil
+ }
+ lock.unlock()
+ try? markResolvedAdmissionStopped(plan: resolvedPlan)
+ throw error
+ }
}
return status(id: id) ?? DoryMachineStatus(id: id, state: .running)
}
@@ -1893,12 +2228,15 @@ public final class MachineManager: @unchecked Sendable {
entry.currentBalloonTargetMB = nil
entry.state = .failed
entry.lastError = "vmm ready handoff timed out after \(Int(timeout))s"
+ let admissionPlan = entry.activeResolvedPlan
+ entry.activeResolvedPlan = nil
self.machines[id] = entry
self.lock.unlock()
+ process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
self.operationLock.lock()
+ try? self.markResolvedAdmissionStopped(plan: admissionPlan)
self.failActiveStartLifecycle(id: id, stepID: "start.readiness-timeout")
self.operationLock.unlock()
- process.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
}
}
@@ -1908,6 +2246,7 @@ public final class MachineManager: @unchecked Sendable {
termination: HvProcessTermination
) {
var handoffServer: VmmHandoffServer?
+ var admissionPlan: DoryResolvedMachinePlan?
lock.lock()
guard var entry = machines[machineID],
entry.launchID == launchID,
@@ -1917,17 +2256,20 @@ public final class MachineManager: @unchecked Sendable {
return
}
handoffServer = entry.handoffServer
+ admissionPlan = entry.activeResolvedPlan
entry.handoffServer = nil
entry.handoff = nil
entry.launchID = nil
entry.runtimeAddress = nil
entry.currentBalloonTargetMB = nil
+ entry.activeResolvedPlan = nil
entry.state = .failed
entry.lastError = "dory-vmm \(termination.description)"
machines[machineID] = entry
lock.unlock()
operationLock.lock()
+ try? markResolvedAdmissionStopped(plan: admissionPlan)
failActiveStartLifecycle(id: machineID, stepID: "start.helper-exited")
operationLock.unlock()
handoffServer?.stop()
@@ -1945,7 +2287,8 @@ public final class MachineManager: @unchecked Sendable {
private func stopImplementation(
id: String,
- journalLifecycle: Bool
+ journalLifecycle: Bool,
+ preserveResolvedAdmissionForRestart: Bool = false
) throws -> DoryMachineStatus {
lock.lock()
guard var entry = machines[id] else {
@@ -1955,10 +2298,12 @@ public final class MachineManager: @unchecked Sendable {
let wasActive = [.starting, .running].contains(entry.state) && entry.process != nil
let machine = entry.configuration
let runtimeIdentity = entry.runtimeIdentity
+ let admissionPlan = entry.activeResolvedPlan ?? runtimeIdentity.resolvedPlan
lock.unlock()
let lifecycle = try journalLifecycle && wasActive
? beginLifecycleStop(machine: machine, runtimeIdentity: runtimeIdentity)
: nil
+ var stopCommitted = false
do {
if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
lock.lock()
@@ -1975,12 +2320,17 @@ public final class MachineManager: @unchecked Sendable {
entry.launchID = nil
entry.runtimeAddress = nil
entry.currentBalloonTargetMB = nil
+ entry.activeResolvedPlan = nil
entry.state = .stopped
machines[id] = entry
lock.unlock()
handoffServer?.stop()
process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
+ stopCommitted = true
+ if !preserveResolvedAdmissionForRestart {
+ try markResolvedAdmissionStopped(plan: admissionPlan)
+ }
#if DEBUG
try injectLifecycleFault(.stopAfterProcessStop)
#endif
@@ -1995,6 +2345,16 @@ public final class MachineManager: @unchecked Sendable {
#if DEBUG
if error is MachineLifecycleInjectedCrash { throw error }
#endif
+ if stopCommitted {
+ if let lifecycle {
+ activeLifecycleOperations.removeValue(forKey: id)
+ lifecycle.releaseLease()
+ }
+ lock.lock()
+ machines[id]?.lastError = "machine stopped but resource settlement requires recovery: \(error)"
+ lock.unlock()
+ throw error
+ }
if let lifecycle { failLifecycle(lifecycle, stepID: "stop.failed") }
throw error
}
@@ -2005,7 +2365,12 @@ public final class MachineManager: @unchecked Sendable {
defer { operationLock.unlock() }
lock.lock()
let runningEntries = machines.map { id, entry in
- (id: id, process: entry.process, handoffServer: entry.handoffServer)
+ (
+ id: id,
+ process: entry.process,
+ handoffServer: entry.handoffServer,
+ admissionPlan: entry.activeResolvedPlan ?? entry.runtimeIdentity.resolvedPlan
+ )
}
for id in machines.keys {
machines[id]?.process = nil
@@ -2014,6 +2379,7 @@ public final class MachineManager: @unchecked Sendable {
machines[id]?.launchID = nil
machines[id]?.runtimeAddress = nil
machines[id]?.currentBalloonTargetMB = nil
+ machines[id]?.activeResolvedPlan = nil
machines[id]?.state = .stopped
}
lock.unlock()
@@ -2021,6 +2387,7 @@ public final class MachineManager: @unchecked Sendable {
for entry in runningEntries {
entry.handoffServer?.stop()
entry.process?.stop(timeout: DoryEngineShutdownTiming.hostTerminationSeconds)
+ try? markResolvedAdmissionStopped(plan: entry.admissionPlan)
}
}
@@ -2090,6 +2457,10 @@ public final class MachineManager: @unchecked Sendable {
// in-memory entry whose storage no longer exists. Recovery can deterministically
// finish the still-durable deleting operation on the next daemon start.
deletionCommitted = true
+ try releaseResolvedAdmissionStorage(
+ machineID: id,
+ plan: sourceEntry.runtimeIdentity.resolvedPlan
+ )
do {
try completeLifecycle(lifecycle)
} catch {
@@ -2102,6 +2473,7 @@ public final class MachineManager: @unchecked Sendable {
#endif
if deletionCommitted {
activeLifecycleOperations.removeValue(forKey: id)
+ lifecycle.releaseLease()
return
}
let quarantine = lifecycleDeletionQuarantinePath(
@@ -2313,7 +2685,20 @@ public final class MachineManager: @unchecked Sendable {
// Quiesce under its own durable stop journal. Once stopped, the exact bytes selected for
// the snapshot can be hashed and bound before any snapshot artifact is published.
if wasRunning {
- _ = try stopImplementation(id: id, journalLifecycle: true)
+ _ = try stopImplementation(
+ id: id,
+ journalLifecycle: true,
+ preserveResolvedAdmissionForRestart: true
+ )
+ }
+ var snapshotLifecycleStarted = false
+ defer {
+ if wasRunning, !snapshotLifecycleStarted {
+ try? prepareRetainedResolvedAdmissionForRestart(
+ plan: snapshotRuntimeIdentity.resolvedPlan
+ )
+ _ = try? startImplementation(id: id, journalLifecycle: true)
+ }
}
let liveMachineIdentifierPath = machine.bootMode == .efi
? machineFirmwareIdentifierPath(id: id) : nil
@@ -2368,6 +2753,7 @@ public final class MachineManager: @unchecked Sendable {
snapshotID: snapshotID,
snapshotAuthority: snapshotAuthority
)
+ snapshotLifecycleStarted = true
var publishedRootfs = false
var publishedKernel = false
@@ -2415,6 +2801,9 @@ public final class MachineManager: @unchecked Sendable {
}
failLifecycle(lifecycle, stepID: "snapshot.failed", rolledBack: true)
if wasRunning {
+ try? prepareRetainedResolvedAdmissionForRestart(
+ plan: snapshotRuntimeIdentity.resolvedPlan
+ )
_ = try? startImplementation(id: id, journalLifecycle: true)
}
if let error = error as? MachineManagerError {
@@ -2429,9 +2818,15 @@ public final class MachineManager: @unchecked Sendable {
)
if wasRunning, journalCompleted {
do {
+ try prepareRetainedResolvedAdmissionForRestart(
+ plan: snapshotRuntimeIdentity.resolvedPlan
+ )
_ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch let firstError {
do {
+ try prepareRetainedResolvedAdmissionForRestart(
+ plan: snapshotRuntimeIdentity.resolvedPlan
+ )
_ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch {
throw MachineManagerError.persistence(
@@ -2799,7 +3194,11 @@ public final class MachineManager: @unchecked Sendable {
do {
try advanceLifecycleToPublishing(lifecycle)
if wasRunning {
- _ = try stopImplementation(id: machineID, journalLifecycle: false)
+ _ = try stopImplementation(
+ id: machineID,
+ journalLifecycle: false,
+ preserveResolvedAdmissionForRestart: true
+ )
}
try restoreManagedArtifacts(
machine: machine,
@@ -2822,8 +3221,24 @@ public final class MachineManager: @unchecked Sendable {
}
let status: DoryMachineStatus
if wasRunning, shouldRestartAfterSnapshotRestore(targetIdentity) {
+ try prepareRetainedResolvedAdmissionForRestart(
+ plan: sourceIdentity.resolvedPlan
+ )
status = try startAndWaitUntilReady(id: machineID, journalLifecycle: false)
} else {
+ if wasRunning {
+ do {
+ try markResolvedAdmissionStopped(plan: sourceIdentity.resolvedPlan)
+ } catch {
+ // The restored target is already committed and its old plan is no longer
+ // launch authority. Keep the conservative running reservation for daemon
+ // restart reconciliation instead of falsely rolling back the restore.
+ lock.lock()
+ machines[machineID]?.lastError =
+ "snapshot restored; resource settlement requires recovery: \(error)"
+ lock.unlock()
+ }
+ }
status = self.status(id: machineID)
?? DoryMachineStatus(id: machineID, state: .stopped)
}
@@ -2834,6 +3249,9 @@ public final class MachineManager: @unchecked Sendable {
return status
} catch let error as MachineManagerError {
if wasRunning {
+ try? prepareRetainedResolvedAdmissionForRestart(
+ plan: sourceIdentity.resolvedPlan
+ )
_ = try? startImplementation(id: machineID, journalLifecycle: false)
}
failLifecycle(lifecycle, stepID: "restore.failed", rolledBack: true)
@@ -2843,6 +3261,9 @@ public final class MachineManager: @unchecked Sendable {
if error is MachineLifecycleInjectedCrash { throw error }
#endif
if wasRunning {
+ try? prepareRetainedResolvedAdmissionForRestart(
+ plan: sourceIdentity.resolvedPlan
+ )
_ = try? startImplementation(id: machineID, journalLifecycle: false)
}
failLifecycle(lifecycle, stepID: "restore.failed", rolledBack: true)
@@ -5093,6 +5514,8 @@ public final class MachineManager: @unchecked Sendable {
var processToStop: HvProcess?
var agentSocketPath: String?
var lifecycleReadinessSucceeded = false
+ var admissionPlan: DoryResolvedMachinePlan?
+ var requiresAdmissionCommit = false
lock.lock()
guard var entry = machines[machineID], entry.launchID == launchID else {
lock.unlock()
@@ -5100,6 +5523,7 @@ public final class MachineManager: @unchecked Sendable {
}
handoffServer = entry.handoffServer
entry.handoffServer = nil
+ admissionPlan = entry.activeResolvedPlan
switch result {
case let .success(handoff):
guard handoff.ready.machineID == machineID else {
@@ -5107,34 +5531,84 @@ public final class MachineManager: @unchecked Sendable {
entry.lastError = "handoff machine id mismatch: \(handoff.ready.machineID)"
entry.launchID = nil
entry.runtimeAddress = nil
+ entry.activeResolvedPlan = nil
processToStop = entry.process
break
}
entry.handoff = handoff
- entry.state = .running
entry.lastError = nil
- entry.process?.disableRestarts()
- agentSocketPath = handoff.ready.agentSocketPath
- lifecycleReadinessSucceeded = true
+ requiresAdmissionCommit = admissionPlan != nil
+ && productionResourceAdmissionLedger != nil
+ if requiresAdmissionCommit {
+ entry.state = .starting
+ } else {
+ entry.state = .running
+ entry.process?.disableRestarts()
+ agentSocketPath = handoff.ready.agentSocketPath
+ lifecycleReadinessSucceeded = true
+ }
case let .failure(error):
entry.state = .failed
entry.lastError = "\(error)"
entry.launchID = nil
entry.runtimeAddress = nil
+ entry.activeResolvedPlan = nil
processToStop = entry.process
}
machines[machineID] = entry
lock.unlock()
+ handoffServer?.stop()
+ processToStop?.stop()
+
operationLock.lock()
- if lifecycleReadinessSucceeded {
+ if requiresAdmissionCommit, let admissionPlan {
+ do {
+ try markResolvedAdmissionRunning(plan: admissionPlan)
+ lock.lock()
+ if var current = machines[machineID], current.launchID == launchID,
+ current.state == .starting {
+ current.state = .running
+ current.lastError = nil
+ current.process?.disableRestarts()
+ agentSocketPath = current.handoff?.ready.agentSocketPath
+ machines[machineID] = current
+ lifecycleReadinessSucceeded = true
+ }
+ lock.unlock()
+ if lifecycleReadinessSucceeded {
+ completeActiveStartLifecycle(id: machineID)
+ } else {
+ try markResolvedAdmissionStopped(plan: admissionPlan)
+ failActiveStartLifecycle(
+ id: machineID,
+ stepID: "start.readiness-state-changed"
+ )
+ }
+ } catch {
+ lock.lock()
+ if var current = machines[machineID], current.launchID == launchID {
+ processToStop = current.process
+ current.state = .failed
+ current.lastError = "resource admission rejected readiness: \(error)"
+ current.handoff = nil
+ current.launchID = nil
+ current.runtimeAddress = nil
+ current.activeResolvedPlan = nil
+ machines[machineID] = current
+ }
+ lock.unlock()
+ try? markResolvedAdmissionStopped(plan: admissionPlan)
+ failActiveStartLifecycle(id: machineID, stepID: "start.admission-failed")
+ }
+ } else if lifecycleReadinessSucceeded {
completeActiveStartLifecycle(id: machineID)
} else {
+ try? markResolvedAdmissionStopped(plan: admissionPlan)
failActiveStartLifecycle(id: machineID, stepID: "start.readiness-failed")
}
operationLock.unlock()
- handoffServer?.stop()
processToStop?.stop()
if let agentSocketPath {
DispatchQueue.global(qos: .utility).async { [weak self] in
@@ -7885,6 +8359,7 @@ private struct PreparedMachineStart {
private struct PendingResolvedMachineStart {
var machine: DoryMachineConfiguration
+ var plan: DoryResolvedMachinePlan
var backend: MachineBackendDescriptor
var runtimeBuildIdentifier: String
var runtimeComponents: [DoryResolvedBackendComponentEvidence]
@@ -7905,6 +8380,7 @@ private struct MachineEntry {
var runtimeAddress: String?
var currentBalloonTargetMB: UInt64?
var lastError: String?
+ var activeResolvedPlan: DoryResolvedMachinePlan?
var runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility(
virtualHardwareABIVersion:
DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
index c4ab10d5..b79a4767 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
@@ -413,6 +413,45 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
#expect(plan.launchArtifacts.count == 2)
#expect(plan.devices.clockSynchronization)
#expect(plan.devices.gracefulShutdown)
+ let started = try context.machineManager.start(id: "qualified-headless")
+ #expect(started.state == .running)
+ #expect(try context.planning.resourceLedger.snapshot().leases.first {
+ $0.binding.machineID == "qualified-headless"
+ }?.state == .running)
+ let stopped = try context.machineManager.stop(id: "qualified-headless")
+ #expect(stopped.state == .stopped)
+ #expect(try context.planning.resourceLedger.snapshot().leases.first {
+ $0.binding.machineID == "qualified-headless"
+ }?.state == .stopped)
+ let restarted = try context.machineManager.start(id: "qualified-headless")
+ #expect(restarted.state == .running)
+ #expect(try context.planning.plans.read(id: "qualified-headless").planRevision == 2)
+ #expect(try context.planning.resourceLedger.snapshot().leases.first {
+ $0.binding.machineID == "qualified-headless"
+ }?.state == .running)
+ let snapshot = try context.machineManager.snapshot(
+ id: "qualified-headless",
+ snapshotID: "running-admission"
+ )
+ #expect(snapshot.machineID == "qualified-headless")
+ #expect(context.machineManager.status(id: "qualified-headless")?.state == .running)
+ #expect(try context.planning.plans.read(id: "qualified-headless").planRevision == 2)
+ #expect(try context.planning.resourceLedger.snapshot().leases.first {
+ $0.binding.machineID == "qualified-headless"
+ }?.state == .running)
+ let restored = try context.machineManager.restoreSnapshot(
+ machineID: "qualified-headless",
+ snapshotID: snapshot.id
+ )
+ #expect(restored.state == .stopped)
+ #expect(restored.runtimeIdentity.mode == .requiresReplanning)
+ #expect(try context.planning.resourceLedger.snapshot().leases.first {
+ $0.binding.machineID == "qualified-headless"
+ }?.state == .stopped)
+ try context.machineManager.delete(id: "qualified-headless")
+ #expect(try context.planning.resourceLedger.snapshot().leases.contains {
+ $0.binding.machineID == "qualified-headless"
+ } == false)
}
@Test("trust-floor failure exposes no manager and a fresh activation can retry")
@@ -775,7 +814,8 @@ private final class ProductionTrustFixture: @unchecked Sendable {
trustFloorDirectorySyncFails: Bool = false,
trustFloorActivationState: ProductionTrustFloorActivationState? = nil
) throws {
- helperDigest = Self.digest(Data("verified-helper".utf8))
+ let helperData = Data("#!/bin/sh\nexec /bin/sleep 30\n".utf8)
+ helperDigest = Self.digest(helperData)
hostState = ProductionHostState(host)
root = FileManager.default.temporaryDirectory.appendingPathComponent(
"dory-production-trust-\(UUID().uuidString)",
@@ -792,7 +832,7 @@ private final class ProductionTrustFixture: @unchecked Sendable {
let vz = root.appendingPathComponent("dory-vmm").path
let raw = root.appendingPathComponent("dory-hv").path
for path in [vz, raw] {
- try Data("#!/bin/sh\nexit 0\n".utf8).write(to: URL(fileURLWithPath: path))
+ try helperData.write(to: URL(fileURLWithPath: path))
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: path)
}
machineConfiguration = MachineManagerConfiguration(
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
index 4fa8b16c..1e60270c 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryVirtualMachineResourceAdmissionLedgerTests.swift
@@ -106,6 +106,63 @@ final class DoryVirtualMachineResourceAdmissionLedgerTests: XCTestCase {
}
}
+ func testRunningLeaseCanBeReopenedOnlyForTheSameRetainedPlan() throws {
+ try withFixture("retained-running-restart") { fixture in
+ let reserved = try fixture.ledger.reserveStarting(
+ binding: fixture.binding("machine-a"),
+ hostFacts: fixture.host,
+ workload: .desktop,
+ resources: fixture.resources
+ )
+ let plan = fixture.plan(binding: reserved.binding, evidence: reserved.evidence)
+ let bound = try fixture.ledger.bind(
+ leaseID: reserved.leaseID,
+ to: plan,
+ expectedLeaseRevision: reserved.leaseRevision
+ )
+ let running = try fixture.ledger.markRunning(
+ leaseID: bound.leaseID,
+ plan: plan,
+ hostFacts: fixture.host,
+ expectedLeaseRevision: bound.leaseRevision
+ )
+
+ var changedPlan = plan
+ changedPlan.planRevision += 1
+ XCTAssertThrowsError(try fixture.ledger.prepareRetainedRunningForRestart(
+ leaseID: running.leaseID,
+ plan: changedPlan,
+ expectedLeaseRevision: running.leaseRevision
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .planMismatch
+ )
+ }
+ XCTAssertEqual(try fixture.ledger.snapshot().leases.first, running)
+
+ let restarting = try fixture.ledger.prepareRetainedRunningForRestart(
+ leaseID: running.leaseID,
+ plan: plan,
+ expectedLeaseRevision: running.leaseRevision
+ )
+ XCTAssertEqual(restarting.state, .starting)
+ XCTAssertEqual(restarting.leaseRevision, running.leaseRevision + 1)
+ XCTAssertEqual(restarting.boundPlanSHA256, running.boundPlanSHA256)
+ XCTAssertEqual(restarting.evidence, running.evidence)
+ XCTAssertThrowsError(try fixture.ledger.prepareRetainedRunningForRestart(
+ leaseID: restarting.leaseID,
+ plan: plan,
+ expectedLeaseRevision: restarting.leaseRevision
+ )) { error in
+ XCTAssertEqual(
+ error as? DoryVirtualMachineResourceAdmissionLedgerError,
+ .invalidLeaseState(.starting)
+ )
+ }
+ }
+ }
+
func testStoppedMachineRestartAtomicallyReplacesItsStorageReservation() throws {
try withFixture("stopped-restart") { fixture in
let first = try fixture.ledger.reserveStarting(
From c1c4ee8cf596bb9826ab7c4d722c558398ad87d1 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 21:09:32 +0000
Subject: [PATCH 042/338] feat(vm): make typed settings workspace-authoritative
---
Dory/Models/AppStore.swift | 9 +-
Dory/Runtime/Doryd/DorydClient.swift | 128 +++++
DoryTests/DorydClientTests.swift | 52 ++
.../DoryMachineTypedWriteAuthority.swift | 208 ++++++-
.../DorydKit/DoryWorkspaceRepository.swift | 3 +-
.../Sources/DorydKit/DorydService.swift | 12 +-
.../Sources/DorydKit/MachineManager.swift | 518 ++++++++++++++----
.../DorydKitTests/DorydServiceTests.swift | 31 ++
...eManagerResolvedPlanIntegrationTests.swift | 124 +++++
9 files changed, 964 insertions(+), 121 deletions(-)
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index c16278ac..300937e7 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5079,10 +5079,11 @@ final class AppStore {
memoryMB: status.memoryMB.flatMap { Int(exactly: $0) },
mounts: status.shares.map(Self.mountPair(fromDoryd:)),
env: status.environment,
- virtualMachineSettings: DorydMachineTypedSettings(
- legacyEnvironment: status.environment,
- displayMode: status.displayMode
- ),
+ virtualMachineSettings: status.typedSettings
+ ?? DorydMachineTypedSettings(
+ legacyEnvironment: status.environment,
+ displayMode: status.displayMode
+ ),
address: status.configuredAddress,
displayMode: status.displayMode,
bootMode: status.bootMode
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index 0879e3e7..0eb4a454 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -762,6 +762,7 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable {
var installerMediaAttached: Bool = false
var shares: [DorydMachineShareConfiguration] = []
var environment: [String: String] = [:]
+ var typedSettings: DorydMachineTypedSettings? = nil
var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil
}
@@ -1808,6 +1809,9 @@ nonisolated final class DorydClient: @unchecked Sendable {
return nil
}
let environment = machineEnvironment(from: dictionary["env"])
+ guard let typedSettings = machineTypedSettings(from: dictionary) else {
+ return nil
+ }
guard let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt(
from: dictionary,
legacyEnvironment: environment
@@ -1839,11 +1843,135 @@ nonisolated final class DorydClient: @unchecked Sendable {
?? false,
shares: machineShares(from: dictionary["shares"]),
environment: environment,
+ typedSettings: typedSettings.value,
runtimeIdentity: runtimeIdentity,
installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value
)
}
+ private struct ParsedMachineTypedSettings {
+ var value: DorydMachineTypedSettings?
+ }
+
+ nonisolated private static func machineTypedSettings(
+ from dictionary: NSDictionary
+ ) -> ParsedMachineTypedSettings? {
+ guard let encoded = dictionary["typedSettings"] else {
+ return ParsedMachineTypedSettings(value: nil)
+ }
+ guard let value = encoded as? NSDictionary,
+ let keys = value.allKeys as? [String],
+ Set(keys).isSubset(of: [
+ "guestIdentityIntent", "clipboardPolicy",
+ "desktopRuntimePreference", "desktopGraphicsPreference",
+ ]), keys.count == Set(keys).count else {
+ return nil
+ }
+
+ var identity = DoryVMGuestIdentityIntent.unspecified
+ if let encodedIdentity = value["guestIdentityIntent"] {
+ guard let rawIdentity = encodedIdentity as? NSDictionary,
+ let identityKeys = rawIdentity.allKeys as? [String],
+ Set(identityKeys).isSubset(of: ["account", "desktop"]),
+ identityKeys.count == Set(identityKeys).count else { return nil }
+ if let encodedAccount = rawIdentity["account"] {
+ guard let raw = encodedAccount as? NSDictionary,
+ let rawKeys = raw.allKeys as? [String],
+ Set(rawKeys).isSubset(of: ["username", "numericUserID"]),
+ rawKeys.count == Set(rawKeys).count else { return nil }
+ let username: String?
+ if let encoded = raw["username"] {
+ guard let string = encoded as? String,
+ DoryVMGuestAccountIntent.isValidUsername(string) else { return nil }
+ username = string
+ } else { username = nil }
+ let numericUserID: UInt32?
+ if let encoded = raw["numericUserID"] {
+ guard let number = encoded as? NSNumber,
+ CFGetTypeID(number) != CFBooleanGetTypeID(),
+ number.doubleValue == Double(number.uint32Value),
+ DoryVMGuestAccountIntent.isValidNumericUserID(number.uint32Value)
+ else { return nil }
+ numericUserID = number.uint32Value
+ } else { numericUserID = nil }
+ let account = DoryVMGuestAccountIntent(
+ username: username,
+ numericUserID: numericUserID
+ )
+ guard account.isValidForPersistence else { return nil }
+ identity.account = account
+ }
+ if let encodedDesktop = rawIdentity["desktop"] {
+ guard let raw = encodedDesktop as? NSDictionary,
+ let rawKeys = raw.allKeys as? [String],
+ Set(rawKeys).isSubset(of: [
+ "distributionIdentifier", "displayName", "version",
+ "desktopEnvironment",
+ ]), rawKeys.count == Set(rawKeys).count else { return nil }
+ func string(_ key: String, validating: (String) -> Bool) -> String? {
+ guard let encoded = raw[key] else { return nil }
+ guard let value = encoded as? String, validating(value) else { return nil }
+ return value
+ }
+ let distribution = string(
+ "distributionIdentifier",
+ validating: DoryVMDesktopIdentityIntent.isValidDistributionIdentifier
+ )
+ if raw["distributionIdentifier"] != nil, distribution == nil { return nil }
+ let displayName = string(
+ "displayName", validating: DoryVMDesktopIdentityIntent.isValidLabel
+ )
+ if raw["displayName"] != nil, displayName == nil { return nil }
+ let version = string(
+ "version", validating: DoryVMDesktopIdentityIntent.isValidLabel
+ )
+ if raw["version"] != nil, version == nil { return nil }
+ let desktopEnvironment = string(
+ "desktopEnvironment", validating: DoryVMDesktopIdentityIntent.isValidLabel
+ )
+ if raw["desktopEnvironment"] != nil, desktopEnvironment == nil { return nil }
+ let desktop = DoryVMDesktopIdentityIntent(
+ distributionIdentifier: distribution,
+ displayName: displayName,
+ version: version,
+ desktopEnvironment: desktopEnvironment
+ )
+ guard desktop.isValidForPersistence else { return nil }
+ identity.desktop = desktop
+ }
+ }
+
+ let clipboard: DoryVMClipboardPolicy?
+ if let encoded = value["clipboardPolicy"] {
+ guard let raw = encoded as? NSDictionary,
+ let rawKeys = raw.allKeys as? [String],
+ Set(rawKeys) == ["text", "image", "files"],
+ let text = (raw["text"] as? String).flatMap(DoryVMClipboardDirection.init),
+ let image = (raw["image"] as? String).flatMap(DoryVMClipboardDirection.init),
+ let files = (raw["files"] as? String).flatMap(DoryVMClipboardDirection.init)
+ else { return nil }
+ clipboard = DoryVMClipboardPolicy(text: text, image: image, files: files)
+ } else { clipboard = nil }
+ let runtime: DoryDesktopVMMPreference?
+ if let encoded = value["desktopRuntimePreference"] {
+ guard let raw = encoded as? String,
+ let parsed = DoryDesktopVMMPreference(rawValue: raw) else { return nil }
+ runtime = parsed
+ } else { runtime = nil }
+ let graphics: DoryDesktopGraphicsPreference?
+ if let encoded = value["desktopGraphicsPreference"] {
+ guard let raw = encoded as? String,
+ let parsed = DoryDesktopGraphicsPreference(rawValue: raw) else { return nil }
+ graphics = parsed
+ } else { graphics = nil }
+ return ParsedMachineTypedSettings(value: DorydMachineTypedSettings(
+ guestIdentityIntent: identity,
+ clipboardPolicy: clipboard,
+ runtimePreference: runtime,
+ graphicsPreference: graphics
+ ))
+ }
+
private struct ParsedInstalledDesktopPayloadReceipt {
var value: DorydInstalledDesktopPayloadReceipt?
}
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index c46d5f22..0a307db3 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -168,6 +168,46 @@ struct DorydClientTests {
#expect(try await client.engineSleep() == DorydCommandResult(ok: true, message: ""))
}
+ @Test func machineListPrefersExactTypedSettingsAndRejectsMalformedClaims() async throws {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService()
+ service.setMachineTypedSettings("dev", [
+ "guestIdentityIntent": [
+ "account": [
+ "username": "developer",
+ "numericUserID": UInt32(1_000),
+ ] as NSDictionary,
+ "desktop": [
+ "distributionIdentifier": "ubuntu",
+ "displayName": "Ubuntu",
+ ] as NSDictionary,
+ ] as NSDictionary,
+ "clipboardPolicy": [
+ "text": "bidirectional", "image": "bidirectional", "files": "off",
+ ] as NSDictionary,
+ "desktopRuntimePreference": "accelerated",
+ "desktopGraphicsPreference": "virgl-venus",
+ ])
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+
+ let client = DorydClient(endpoint: listener.endpoint)
+ let status = try #require((try await client.machineList()).first { $0.id == "dev" })
+ #expect(status.environment.isEmpty)
+ #expect(status.typedSettings?.guestIdentityIntent.account?.username == "developer")
+ #expect(status.typedSettings?.guestIdentityIntent.desktop?.distributionIdentifier
+ == "ubuntu")
+ #expect(status.typedSettings?.runtimePreference == .accelerated)
+ #expect(status.typedSettings?.graphicsPreference == .virglVenus)
+
+ service.setMachineTypedSettings("dev", ["unknown": "claim"])
+ await #expect(throws: (any Error).self) {
+ _ = try await client.machineList()
+ }
+ }
+
@MainActor
@Test func readsDoctorJSONAndIncidentsOverXPC() async throws {
let listener = NSXPCListener.anonymous()
@@ -2354,6 +2394,18 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
}
)
}
+
+ func setMachineTypedSettings(_ machineID: String, _ typedSettings: NSDictionary) {
+ lock.lock()
+ defer { lock.unlock() }
+ guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else {
+ return
+ }
+ current["typedSettings"] = typedSettings
+ current["env"] = [] as [NSDictionary]
+ current["displayMode"] = "desktop"
+ machines[machineID] = current.copy() as? NSDictionary
+ }
var machineStopCount: Int {
lock.lock(); defer { lock.unlock() }
return _machineStopCount
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
index b7aaf3fc..d78ce8d7 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
@@ -32,12 +32,122 @@ public enum DoryMachineTypedSettingUpdate: Sendable
}
}
-/// Typed public write authority for the compatibility MachineManager.
+public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Hashable {
+ public var guestIdentityIntent: DoryVMGuestIdentityIntent
+ public var clipboardPolicy: DoryVMClipboardPolicy?
+ public var runtimePreference: DoryDesktopVMMPreference?
+ public var graphicsPreference: DoryDesktopGraphicsPreference?
+
+ public init(definition: DoryVirtualMachineDefinition) throws {
+ guestIdentityIntent = definition.guestIdentityIntent
+ guard definition.display.enabled else {
+ clipboardPolicy = nil
+ runtimePreference = nil
+ graphicsPreference = nil
+ return
+ }
+ clipboardPolicy = definition.clipboardPolicy
+ switch (definition.backendPreference.mode, definition.backendPreference.backend) {
+ case (.automatic, nil):
+ runtimePreference = .automatic
+ case (.preferred, .doryHypervisor?):
+ runtimePreference = .accelerated
+ case (.preferred, .appleVirtualizationFramework?):
+ runtimePreference = .compatible
+ default:
+ throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime(
+ "desktopRuntimePreference"
+ )
+ }
+ switch definition.graphics.acceptableLevels {
+ case [.hardwareAccelerated3D, .hostAcceleratedDisplay, .software]:
+ graphicsPreference = .automatic
+ case [.hardwareAccelerated3D]:
+ graphicsPreference = .virglVenus
+ case [.software]:
+ graphicsPreference = .software
+ default:
+ throw DoryMachineTypedWriteAuthorityError.unsupportedByLegacyRuntime(
+ "desktopGraphicsPreference"
+ )
+ }
+ }
+
+ public var xpcDictionary: NSDictionary {
+ DoryMachineTypedSettingsPatch(
+ guestUsername: update(guestIdentityIntent.account?.username),
+ guestNumericUserID: update(guestIdentityIntent.account?.numericUserID),
+ desktopDistributionIdentifier: update(
+ guestIdentityIntent.desktop?.distributionIdentifier
+ ),
+ desktopDisplayName: update(guestIdentityIntent.desktop?.displayName),
+ desktopVersion: update(guestIdentityIntent.desktop?.version),
+ desktopEnvironment: update(
+ guestIdentityIntent.desktop?.desktopEnvironment
+ ),
+ clipboardPolicy: update(clipboardPolicy),
+ runtimePreference: update(runtimePreference),
+ graphicsPreference: update(graphicsPreference)
+ ).xpcDictionary
+ }
+
+ public func applyingAsReplacement(
+ to definition: DoryVirtualMachineDefinition,
+ displayMode: DoryMachineDisplayMode
+ ) throws -> DoryVirtualMachineDefinition {
+ try replacementPatch.applying(to: definition, displayMode: displayMode)
+ }
+
+ public var replacementPatch: DoryMachineTypedSettingsPatch {
+ DoryMachineTypedSettingsPatch(
+ guestUsername: replacement(guestIdentityIntent.account?.username),
+ guestNumericUserID: replacement(guestIdentityIntent.account?.numericUserID),
+ desktopDistributionIdentifier: replacement(
+ guestIdentityIntent.desktop?.distributionIdentifier
+ ),
+ desktopDisplayName: replacement(guestIdentityIntent.desktop?.displayName),
+ desktopVersion: replacement(guestIdentityIntent.desktop?.version),
+ desktopEnvironment: replacement(
+ guestIdentityIntent.desktop?.desktopEnvironment
+ ),
+ clipboardPolicy: replacement(clipboardPolicy),
+ runtimePreference: replacement(runtimePreference),
+ graphicsPreference: replacement(graphicsPreference)
+ )
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(guestIdentityIntent.account?.username)
+ hasher.combine(guestIdentityIntent.account?.numericUserID)
+ hasher.combine(guestIdentityIntent.desktop?.distributionIdentifier)
+ hasher.combine(guestIdentityIntent.desktop?.displayName)
+ hasher.combine(guestIdentityIntent.desktop?.version)
+ hasher.combine(guestIdentityIntent.desktop?.desktopEnvironment)
+ hasher.combine(clipboardPolicy?.text.rawValue)
+ hasher.combine(clipboardPolicy?.image.rawValue)
+ hasher.combine(clipboardPolicy?.files.rawValue)
+ hasher.combine(runtimePreference?.rawValue)
+ hasher.combine(graphicsPreference?.rawValue)
+ }
+
+ private func update(
+ _ value: Value?
+ ) -> DoryMachineTypedSettingUpdate {
+ value.map(DoryMachineTypedSettingUpdate.set) ?? .unchanged
+ }
+
+ private func replacement(
+ _ value: Value?
+ ) -> DoryMachineTypedSettingUpdate {
+ value.map(DoryMachineTypedSettingUpdate.set) ?? .clear
+ }
+}
+
+/// Typed public write authority shared by native workspace records and the legacy bridge.
///
-/// MachineManager still reads legacy `machine.json` environment values while M0 migration is in
-/// progress. Public callers never provide that dictionary: this patch accepts only non-secret,
-/// bounded intent and back-projects the seven explicitly owned compatibility keys. Unchanged
-/// fields preserve their exact legacy bytes, including values too old or unsafe to migrate.
+/// Native workspaces apply this patch directly to their versioned definition. Legacy workspaces
+/// back-project only the explicitly owned compatibility keys. Public callers never provide the
+/// persisted environment dictionary, and unchanged legacy fields retain their exact bytes.
public struct DoryMachineTypedSettingsPatch: Sendable, Equatable {
public var guestUsername: DoryMachineTypedSettingUpdate
public var guestNumericUserID: DoryMachineTypedSettingUpdate
@@ -333,6 +443,80 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable {
return environment
}
+ public func applying(
+ to source: DoryVirtualMachineDefinition,
+ displayMode: DoryMachineDisplayMode
+ ) throws -> DoryVirtualMachineDefinition {
+ try validate(displayMode: displayMode)
+ var definition = source
+ var account = definition.guestIdentityIntent.account ?? DoryVMGuestAccountIntent()
+ Self.apply(guestUsername, to: &account.username)
+ Self.apply(guestNumericUserID, to: &account.numericUserID)
+ definition.guestIdentityIntent.account = account.isEmpty ? nil : account
+
+ var desktop = definition.guestIdentityIntent.desktop
+ ?? DoryVMDesktopIdentityIntent()
+ Self.apply(
+ desktopDistributionIdentifier,
+ to: &desktop.distributionIdentifier
+ )
+ Self.apply(desktopDisplayName, to: &desktop.displayName)
+ Self.apply(desktopVersion, to: &desktop.version)
+ Self.apply(desktopEnvironment, to: &desktop.desktopEnvironment)
+ definition.guestIdentityIntent.desktop = desktop.isEmpty ? nil : desktop
+
+ switch clipboardPolicy {
+ case .unchanged:
+ break
+ case .clear:
+ definition.clipboardPolicy = displayMode == .desktop
+ ? .legacyDesktop(.bidirectional) : .disabled
+ case let .set(policy):
+ definition.clipboardPolicy = policy
+ }
+ switch runtimePreference {
+ case .unchanged:
+ break
+ case .clear, .set(.automatic):
+ definition.backendPreference = DoryVMBackendPreference()
+ case .set(.accelerated):
+ definition.backendPreference = DoryVMBackendPreference(
+ mode: .preferred,
+ backend: .doryHypervisor
+ )
+ case .set(.compatible):
+ definition.backendPreference = DoryVMBackendPreference(
+ mode: .preferred,
+ backend: .appleVirtualizationFramework
+ )
+ }
+ switch graphicsPreference {
+ case .unchanged:
+ break
+ case .clear, .set(.automatic):
+ definition.graphics = DoryVMGraphicsPolicy(
+ acceptableLevels: [
+ .hardwareAccelerated3D,
+ .hostAcceleratedDisplay,
+ .software,
+ ]
+ )
+ case .set(.virgl), .set(.virglVenus):
+ definition.graphics = DoryVMGraphicsPolicy(
+ acceptableLevels: [.hardwareAccelerated3D]
+ )
+ case .set(.software):
+ definition.graphics = DoryVMGraphicsPolicy(acceptableLevels: [.software])
+ }
+ let issues = definition.validate()
+ guard issues.isEmpty else {
+ throw DoryMachineTypedWriteAuthorityError.invalidField(
+ issues.first?.field ?? "definition"
+ )
+ }
+ return definition
+ }
+
private mutating func decodeGuestIdentity(_ raw: Any, allowsClears: Bool) throws {
if raw is NSNull {
guard allowsClears else {
@@ -531,6 +715,20 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable {
return nil
}
+ private static func apply(
+ _ update: DoryMachineTypedSettingUpdate,
+ to value: inout Value?
+ ) {
+ switch update {
+ case .unchanged:
+ break
+ case let .set(replacement):
+ value = replacement
+ case .clear:
+ value = nil
+ }
+ }
+
private static func hasOnlyKeys(_ dictionary: NSDictionary, allowed: Set) -> Bool {
dictionary.allKeys.allSatisfy { key in
guard let key = key as? String else { return false }
diff --git a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
index d44ba858..fec2aff8 100644
--- a/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryWorkspaceRepository.swift
@@ -136,8 +136,9 @@ public struct DoryWorkspaceLegacyProjectionReconcileResult: Sendable, Equatable
/// paths or credentials because `DoryVirtualMachineDefinition` contains resolver references only.
public final class DoryWorkspaceRepository: @unchecked Sendable {
public static let recordFileName = "workspace-v2.json"
+ public static let recordTemporaryPrefix = ".workspace-v2."
- private static let temporaryPrefix = ".workspace-v2."
+ private static let temporaryPrefix = recordTemporaryPrefix
private static let maximumRecordBytes = 16 * 1_024 * 1_024
public let root: String
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index 8481d791..ded030ef 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -241,12 +241,11 @@ public final class DorydService: NSObject, DorydControl {
xpcDictionary: config,
allowsClears: false
)
- var machine = try DoryMachineConfiguration(xpcDictionary: config)
- machine.environment = try typedSettings.applying(
- to: [:],
- displayMode: machine.displayMode
+ let machine = try DoryMachineConfiguration(xpcDictionary: config)
+ var status = try machineManager.create(
+ machine,
+ typedSettings: typedSettings.isEmpty ? nil : typedSettings
)
- var status = try machineManager.create(machine)
if machineManager.configuredLaunchPolicy == .perWorkspaceAuthority {
guard let productionPlanningController else {
throw MachineManagerError.persistence(
@@ -1630,6 +1629,9 @@ private extension DoryMachineStatus {
dictionary["displayMode"] = displayMode.rawValue
dictionary["bootMode"] = bootMode.rawValue
dictionary["installerMediaAttached"] = installerMediaAttached
+ if let typedSettings {
+ dictionary["typedSettings"] = typedSettings.xpcDictionary
+ }
dictionary["runtimeIdentity"] = runtimeIdentity.xpcDictionary
if let installedDesktopPayloadReceipt {
dictionary["installedDesktopPayloadReceipt"] =
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 9ce542f1..8be92f52 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -327,6 +327,7 @@ public struct DoryMachineStatus: Sendable, Equatable {
public var installerMediaAttached: Bool
public var shares: [DoryMachineShareConfiguration]
public var environment: [String: String]
+ public var typedSettings: DoryMachineTypedSettingsSnapshot?
public var runtimeIdentity: DoryMachineRuntimeIdentity
public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt?
@@ -353,6 +354,7 @@ public struct DoryMachineStatus: Sendable, Equatable {
installerMediaAttached: Bool = false,
shares: [DoryMachineShareConfiguration] = [],
environment: [String: String] = [:],
+ typedSettings: DoryMachineTypedSettingsSnapshot? = nil,
runtimeIdentity: DoryMachineRuntimeIdentity = .legacyCompatibility(
virtualHardwareABIVersion:
DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
@@ -381,6 +383,7 @@ public struct DoryMachineStatus: Sendable, Equatable {
self.installerMediaAttached = installerMediaAttached
self.shares = shares
self.environment = environment
+ self.typedSettings = typedSettings
self.runtimeIdentity = runtimeIdentity
self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt
}
@@ -401,6 +404,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
public var address: String?
public var shares: [DoryMachineShareConfiguration]
public var environment: [String: String]
+ public var typedSettings: DoryMachineTypedSettingsSnapshot?
public var bootMode: DoryMachineBootMode
public var machineIdentifierPath: String?
public var nvramPath: String?
@@ -423,6 +427,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
address: String? = nil,
shares: [DoryMachineShareConfiguration] = [],
environment: [String: String] = [:],
+ typedSettings: DoryMachineTypedSettingsSnapshot? = nil,
bootMode: DoryMachineBootMode = .linuxKernel,
machineIdentifierPath: String? = nil,
nvramPath: String? = nil,
@@ -447,6 +452,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
self.address = address
self.shares = shares
self.environment = environment
+ self.typedSettings = typedSettings
self.bootMode = bootMode
self.machineIdentifierPath = machineIdentifierPath
self.nvramPath = nvramPath
@@ -470,6 +476,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
case address
case shares
case environment
+ case typedSettings
case bootMode
case machineIdentifierPath
case nvramPath
@@ -495,6 +502,10 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
address: try container.decodeIfPresent(String.self, forKey: .address),
shares: try container.decodeIfPresent([DoryMachineShareConfiguration].self, forKey: .shares) ?? [],
environment: try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:],
+ typedSettings: try container.decodeIfPresent(
+ DoryMachineTypedSettingsSnapshot.self,
+ forKey: .typedSettings
+ ),
bootMode: try container.decodeIfPresent(DoryMachineBootMode.self, forKey: .bootMode) ?? .linuxKernel,
machineIdentifierPath: try container.decodeIfPresent(String.self, forKey: .machineIdentifierPath),
nvramPath: try container.decodeIfPresent(String.self, forKey: .nvramPath),
@@ -926,7 +937,10 @@ public final class MachineManager: @unchecked Sendable {
}
@discardableResult
- public func create(_ machine: DoryMachineConfiguration) throws -> DoryMachineStatus {
+ public func create(
+ _ machine: DoryMachineConfiguration,
+ typedSettings: DoryMachineTypedSettingsPatch? = nil
+ ) throws -> DoryMachineStatus {
operationLock.lock()
defer { operationLock.unlock() }
guard Self.isValidID(machine.id) else {
@@ -934,6 +948,18 @@ public final class MachineManager: @unchecked Sendable {
}
var machine = machine
machine.address = try Self.normalizedAddress(machine.address)
+ if launchPolicy == .perWorkspaceAuthority {
+ guard machine.environment.isEmpty else {
+ throw MachineManagerError.persistence(
+ "native workspace creation does not accept persisted environment values"
+ )
+ }
+ } else if let typedSettings {
+ machine.environment = try typedSettings.applying(
+ to: machine.environment,
+ displayMode: machine.displayMode
+ )
+ }
try Self.validateLaunchConfiguration(machine)
lock.lock()
let exists = machines[machine.id] != nil || deletingMachineIDs.contains(machine.id)
@@ -1013,6 +1039,20 @@ public final class MachineManager: @unchecked Sendable {
let initialRuntimeIdentity = runtimeIdentityForUnplannedMachine()
let authoritativeLegacyData = try DoryMachineConfigurationMigrationBridge
.encodeLegacy(preparedMachine)
+ var nativeDefinition: DoryVirtualMachineDefinition?
+ if launchPolicy == .perWorkspaceAuthority {
+ let facts = try workspaceMigrationFacts(for: preparedMachine)
+ let migration = try DoryMachineConfigurationMigrationBridge.migrate(
+ preparedMachine,
+ facts: facts
+ )
+ let definition = try (typedSettings ?? DoryMachineTypedSettingsPatch()).applying(
+ to: migration.definition,
+ displayMode: preparedMachine.displayMode
+ )
+ try workspaceRepository.create(definition)
+ nativeDefinition = definition
+ }
// The launch authority is durable before machine.json becomes discoverable. A crash in
// this window leaves an incomplete, non-loadable directory, never a newly created VM that
// startup can mistake for pre-companion legacy state.
@@ -1021,7 +1061,10 @@ public final class MachineManager: @unchecked Sendable {
machineID: preparedMachine.id,
authoritativeLegacyData: authoritativeLegacyData
)
- try persist(preparedMachine)
+ try persist(
+ preparedMachine,
+ reconcilesLegacyProjection: launchPolicy != .perWorkspaceAuthority
+ )
// machine.json is now durable. Preserve this exact state on a marker-transition error so
// restart can complete it; never report success until the committed marker is directory-
// durable. Keeping the committed marker also distinguishes later metadata loss from an
@@ -1054,6 +1097,7 @@ public final class MachineManager: @unchecked Sendable {
installerMediaAttached: preparedMachine.installerISOPath != nil,
shares: preparedMachine.shares,
environment: preparedMachine.environment,
+ typedSettings: try nativeDefinition.map(DoryMachineTypedSettingsSnapshot.init),
runtimeIdentity: initialRuntimeIdentity
)
}
@@ -1136,36 +1180,18 @@ public final class MachineManager: @unchecked Sendable {
"authoritative machine metadata is unavailable for production planning"
)
}
- let facts = try workspaceMigrationFacts(for: entry.configuration)
- let factsData = try Self.workspaceMigrationAuthorityData(facts)
- let migration = try DoryMachineConfigurationMigrationBridge.migrate(
- entry.configuration,
- facts: facts
- )
- let definition: DoryVirtualMachineDefinition
+ let authority: MachineWorkspaceAuthority
do {
- definition = try workspaceRepository.reconcileLegacyProjection(
- migration.definition,
- authoritativeLegacyData: legacyData,
- authoritativeMigrationFactsData: factsData
- ).definition
- let persisted = try workspaceRepository.readLegacyProjection(
- id: id,
- authoritativeLegacyData: legacyData,
- authoritativeMigrationFactsData: factsData
+ authority = try workspaceAuthority(
+ machine: entry.configuration,
+ authoritativeLegacyData: legacyData
)
- guard persisted == definition else {
- throw MachineManagerError.persistence(
- "workspace projection changed during production planning"
- )
- }
- } catch let error as MachineManagerError {
- throw error
} catch {
throw MachineManagerError.persistence(
- "workspace projection is unavailable for production planning: \(error)"
+ "workspace authority is unavailable for production planning: \(error)"
)
}
+ let definition = authority.definition
let canonicalDefinitionData = try Self.canonicalDefinitionData(definition)
let plans: any DoryResolvedMachinePlanStoring = resolvedLaunchPlanStore
@@ -1195,7 +1221,7 @@ public final class MachineManager: @unchecked Sendable {
"workspace launch artifacts are not representable"
)
}
- let bindings = Dictionary(grouping: migration.artifactBindings, by: \.reference)
+ let bindings = Dictionary(grouping: authority.migration.artifactBindings, by: \.reference)
var publications: [DoryDaemonVirtualMachinePlanningArtifactPublication] = []
publications.reserveCapacity(requirements.count)
for requirement in requirements {
@@ -1226,7 +1252,7 @@ public final class MachineManager: @unchecked Sendable {
planning: DoryDaemonVirtualMachinePlanningRequest(
definition: definition,
canonicalDefinitionData: canonicalDefinitionData,
- machine: entry.configuration,
+ machine: authority.runtimeMachine,
publication: planPublication
),
workspacePublication: .retainExistingExact
@@ -1345,11 +1371,14 @@ public final class MachineManager: @unchecked Sendable {
try revalidateDurableRuntimeIdentity(
id: id,
expected: expectedDurableIdentity,
- expectedMachine: prepared.machine
+ expectedMachine: prepared.authoritativeMachine
)
}
let lifecycle = try journalLifecycle
- ? beginLifecycleStart(machine: prepared.machine, targetIdentity: identity)
+ ? beginLifecycleStart(
+ machine: prepared.authoritativeMachine,
+ targetIdentity: identity
+ )
: nil
do {
if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
@@ -1436,11 +1465,14 @@ public final class MachineManager: @unchecked Sendable {
try revalidateDurableRuntimeIdentity(
id: id,
expected: expectedRuntimeIdentity,
- expectedMachine: prepared.machine
+ expectedMachine: prepared.authoritativeMachine
)
}
let lifecycle = try journalLifecycle
- ? beginLifecycleStart(machine: prepared.machine, targetIdentity: runtimeIdentity)
+ ? beginLifecycleStart(
+ machine: prepared.authoritativeMachine,
+ targetIdentity: runtimeIdentity
+ )
: nil
do {
@@ -1522,16 +1554,16 @@ public final class MachineManager: @unchecked Sendable {
) throws {
guard let preparedLegacyData = prepared.authoritativeLegacyData,
let currentLegacyData = Self.readPrivateMetadata(
- path: machineConfigPath(id: prepared.machine.id)
+ path: machineConfigPath(id: prepared.authoritativeMachine.id)
),
currentLegacyData == preparedLegacyData,
let decoded = try? JSONDecoder().decode(
DoryMachineConfiguration.self,
from: currentLegacyData
),
- decoded == prepared.machine,
+ decoded == prepared.authoritativeMachine,
let currentDefinition = reconcileWorkspaceProjection(
- machine: prepared.machine,
+ machine: prepared.authoritativeMachine,
authoritativeLegacyData: currentLegacyData
),
currentDefinition == expectedDefinition,
@@ -1920,19 +1952,20 @@ public final class MachineManager: @unchecked Sendable {
}
lock.unlock()
- let machine = entry.configuration
- try ensureInstalledLinuxBootBundleIfNeeded(machine)
- try materializeInstalledLinuxBootRuntimeIfNeeded(machine)
+ let authoritativeMachine = entry.configuration
+ try ensureInstalledLinuxBootBundleIfNeeded(authoritativeMachine)
+ try materializeInstalledLinuxBootRuntimeIfNeeded(authoritativeMachine)
let authoritativeLegacyData = Self.readPrivateMetadata(
- path: machineConfigPath(id: machine.id)
+ path: machineConfigPath(id: authoritativeMachine.id)
)
var authoritativeDefinition: DoryVirtualMachineDefinition?
+ var runtimeMachine = authoritativeMachine
if let authoritativeLegacyData {
if requiresAuthoritativeDefinition {
guard let decoded = try? JSONDecoder().decode(
DoryMachineConfiguration.self,
from: authoritativeLegacyData
- ), decoded == machine else {
+ ), decoded == authoritativeMachine else {
throw MachineManagerError.persistence(
"authoritative machine metadata changed before resolved launch"
)
@@ -1941,18 +1974,30 @@ public final class MachineManager: @unchecked Sendable {
// Boot-bundle materialization can change authoritative inspection facts without
// changing machine.json. Reconcile those facts immediately before launch.
authoritativeDefinition = reconcileWorkspaceProjection(
- machine: machine,
+ machine: authoritativeMachine,
authoritativeLegacyData: authoritativeLegacyData
)
+ if authoritativeDefinition != nil {
+ do {
+ runtimeMachine = try workspaceAuthority(
+ machine: authoritativeMachine,
+ authoritativeLegacyData: authoritativeLegacyData
+ ).runtimeMachine
+ } catch {
+ throw MachineManagerError.persistence(
+ "workspace runtime projection is unavailable: \(error)"
+ )
+ }
+ }
} else if requiresAuthoritativeDefinition {
throw MachineManagerError.persistence(
"authoritative machine metadata is unavailable for resolved launch"
)
}
- try Self.validateLaunchConfiguration(machine)
- try validateManagedMachineArtifacts(machine)
+ try Self.validateLaunchConfiguration(runtimeMachine)
+ try validateManagedMachineArtifacts(runtimeMachine)
if !requiresAuthoritativeDefinition {
- try validateRuntimeAvailability(machine)
+ try validateRuntimeAvailability(runtimeMachine)
}
if requiresAuthoritativeDefinition, authoritativeDefinition == nil {
throw MachineManagerError.persistence(
@@ -1961,7 +2006,8 @@ public final class MachineManager: @unchecked Sendable {
}
let definitionData = try authoritativeDefinition.map(Self.canonicalDefinitionData)
return PreparedMachineStart(
- machine: machine,
+ machine: runtimeMachine,
+ authoritativeMachine: authoritativeMachine,
definition: authoritativeDefinition,
canonicalDefinitionData: definitionData,
authoritativeLegacyData: authoritativeLegacyData
@@ -2525,6 +2571,19 @@ public final class MachineManager: @unchecked Sendable {
)
}
let (current, wasRunning) = try configurationAndRunningState(id: id)
+ let nativeRecord: DoryWorkspaceRepositoryRecord?
+ if launchPolicy == .perWorkspaceAuthority {
+ let record = try workspaceRepository.readPersistedRecord(id: id)
+ nativeRecord = record.legacyConfigurationSHA256 == nil
+ && record.legacyMigrationFactsSHA256 == nil ? record : nil
+ } else {
+ nativeRecord = nil
+ }
+ if nativeRecord != nil, updatesEnvironment {
+ throw MachineManagerError.persistence(
+ "native workspace updates do not accept persisted environment values"
+ )
+ }
var updated = current
if let memoryMB {
updated.memoryMB = memoryMB
@@ -2541,7 +2600,7 @@ public final class MachineManager: @unchecked Sendable {
if updatesEnvironment {
updated.environment = environment ?? [:]
}
- if let typedSettingsPatch {
+ if let typedSettingsPatch, nativeRecord == nil {
updated.environment = try typedSettingsPatch.applying(
to: current.environment,
displayMode: updated.displayMode
@@ -2562,7 +2621,54 @@ public final class MachineManager: @unchecked Sendable {
}
}
try Self.validateLaunchConfiguration(updated)
- guard updated != current else {
+ var nativeDefinition: DoryVirtualMachineDefinition?
+ if let nativeRecord {
+ let facts = try workspaceMigrationFacts(for: updated)
+ var migration = try DoryMachineConfigurationMigrationBridge.migrate(
+ updated,
+ facts: facts
+ )
+ var candidate = migration.definition
+ candidate.lifecycle = nativeRecord.definition.lifecycle
+ candidate.backendPreference = nativeRecord.definition.backendPreference
+ candidate.graphics = nativeRecord.definition.graphics
+ candidate.guestIdentityIntent = nativeRecord.definition.guestIdentityIntent
+ candidate.clipboardPolicy = nativeRecord.definition.clipboardPolicy
+ if let typedSettingsPatch {
+ candidate = try typedSettingsPatch.applying(
+ to: candidate,
+ displayMode: updated.displayMode
+ )
+ }
+ if updated == current, candidate == nativeRecord.definition {
+ return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
+ }
+ guard nativeRecord.definition.lifecycle.revision < UInt64.max else {
+ throw MachineManagerError.persistence("workspace revision is exhausted")
+ }
+ guard nativeRecord.definition.lifecycle.updatedAtUnixMilliseconds < Int64.max else {
+ throw MachineManagerError.persistence("workspace timestamp is exhausted")
+ }
+ let now = Int64(max(0, Date().timeIntervalSince1970 * 1_000))
+ candidate.lifecycle = DoryVMLifecycleMetadata(
+ revision: nativeRecord.definition.lifecycle.revision + 1,
+ createdAtUnixMilliseconds:
+ nativeRecord.definition.lifecycle.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: max(
+ now,
+ nativeRecord.definition.lifecycle.updatedAtUnixMilliseconds + 1
+ )
+ )
+ guard Self.nativeDefinition(candidate, isCompatibleWith: migration.definition) else {
+ throw MachineManagerError.persistence(
+ "native workspace update is not representable by the compatibility runtime"
+ )
+ }
+ migration.definition = candidate
+ _ = try migration.legacyConfiguration()
+ nativeDefinition = candidate
+ }
+ guard updated != current || nativeDefinition != nil else {
return status(id: id) ?? DoryMachineStatus(id: id, state: .stopped)
}
if launchPolicy == .perWorkspaceAuthority {
@@ -2574,7 +2680,21 @@ public final class MachineManager: @unchecked Sendable {
updated.installerISOPath == nil {
try ensureInstalledLinuxBootBundleIfNeeded(updated)
}
- try persist(updated)
+ try persist(
+ updated,
+ reconcilesLegacyProjection: nativeRecord == nil
+ )
+ if let nativeRecord, let nativeDefinition {
+ do {
+ try workspaceRepository.replace(
+ nativeDefinition,
+ expectedRevision: nativeRecord.definition.lifecycle.revision
+ )
+ } catch {
+ try? persist(current, reconcilesLegacyProjection: false)
+ throw error
+ }
+ }
try publishConfiguration(updated)
// Every desired-state mutation invalidates the former plan. The workspace remains
// stopped until planning publishes a replacement exact runtime identity.
@@ -2737,6 +2857,7 @@ public final class MachineManager: @unchecked Sendable {
address: machine.address,
shares: machine.shares,
environment: machine.environment,
+ typedSettings: nativeTypedSettingsSnapshot(id: id),
bootMode: machine.bootMode,
machineIdentifierPath: machine.bootMode == .efi ? machineIdentifierPath : nil,
nvramPath: machine.bootMode == .efi ? nvramPath : nil,
@@ -3113,7 +3234,10 @@ public final class MachineManager: @unchecked Sendable {
installedDesktopPayloadReceipt:
Self.configurationReceipt(restoring: snapshot)
)
- let created = try create(machine)
+ let created = try create(
+ machine,
+ typedSettings: snapshot.typedSettings?.replacementPatch
+ )
do {
if snapshot.bootMode == .efi {
guard let snapshotNVRAMPath = snapshot.nvramPath else {
@@ -3174,6 +3298,70 @@ public final class MachineManager: @unchecked Sendable {
restoredMachine.environment = snapshot.environment
restoredMachine.installedDesktopPayloadReceipt =
Self.configurationReceipt(restoring: snapshot)
+ let restoredNativeWorkspace: (
+ definition: DoryVirtualMachineDefinition,
+ expectedRevision: UInt64
+ )?
+ if launchPolicy == .perWorkspaceAuthority {
+ let record = try workspaceRepository.readPersistedRecord(id: machineID)
+ if record.legacyConfigurationSHA256 == nil,
+ record.legacyMigrationFactsSHA256 == nil {
+ // A native workspace never regains the legacy environment as desired-state
+ // authority. Older or imported snapshot metadata may still contain compatibility
+ // keys (or secrets); typed snapshot intent is restored through the definition
+ // below and the raw dictionary remains read-only historical evidence.
+ restoredMachine.environment = [:]
+ let facts = try workspaceMigrationFacts(for: restoredMachine)
+ let migration = try DoryMachineConfigurationMigrationBridge.migrate(
+ restoredMachine,
+ facts: facts
+ )
+ var candidate = migration.definition
+ candidate.lifecycle = record.definition.lifecycle
+ candidate.backendPreference = record.definition.backendPreference
+ candidate.graphics = record.definition.graphics
+ candidate.guestIdentityIntent = record.definition.guestIdentityIntent
+ candidate.clipboardPolicy = record.definition.clipboardPolicy
+ if let typedSettings = snapshot.typedSettings {
+ candidate = try typedSettings.applyingAsReplacement(
+ to: candidate,
+ displayMode: restoredMachine.displayMode
+ )
+ }
+ guard record.definition.lifecycle.revision < UInt64.max,
+ record.definition.lifecycle.updatedAtUnixMilliseconds < Int64.max else {
+ throw MachineManagerError.persistence(
+ "workspace lifecycle authority is exhausted"
+ )
+ }
+ let now = Int64(max(0, Date().timeIntervalSince1970 * 1_000))
+ candidate.lifecycle = DoryVMLifecycleMetadata(
+ revision: record.definition.lifecycle.revision + 1,
+ createdAtUnixMilliseconds:
+ record.definition.lifecycle.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: max(
+ now,
+ record.definition.lifecycle.updatedAtUnixMilliseconds + 1
+ )
+ )
+ guard Self.nativeDefinition(
+ candidate,
+ isCompatibleWith: migration.definition
+ ) else {
+ throw MachineManagerError.persistence(
+ "snapshot typed settings do not match the restored machine"
+ )
+ }
+ restoredNativeWorkspace = (
+ candidate,
+ record.definition.lifecycle.revision
+ )
+ } else {
+ restoredNativeWorkspace = nil
+ }
+ } else {
+ restoredNativeWorkspace = nil
+ }
let sourceIdentity = try currentRuntimeIdentity(id: machineID)
let targetIdentity = runtimeIdentityAfterSnapshotRestore(
snapshot.runtimeIdentity,
@@ -3205,7 +3393,21 @@ public final class MachineManager: @unchecked Sendable {
snapshot: snapshot,
operationID: lifecycle.operation.operationID
) {
- try persist(restoredMachine)
+ try persist(
+ restoredMachine,
+ reconcilesLegacyProjection: restoredNativeWorkspace == nil
+ )
+ if let restoredNativeWorkspace {
+ do {
+ try workspaceRepository.replace(
+ restoredNativeWorkspace.definition,
+ expectedRevision: restoredNativeWorkspace.expectedRevision
+ )
+ } catch {
+ try? persist(machine, reconcilesLegacyProjection: false)
+ throw error
+ }
+ }
try persistRuntimeIdentity(
targetIdentity,
configuration: restoredMachine
@@ -3492,6 +3694,7 @@ public final class MachineManager: @unchecked Sendable {
}
private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus {
+ let typedSettings = nativeTypedSettingsSnapshot(id: id)
if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true {
return DoryMachineStatus(
id: id,
@@ -3506,6 +3709,7 @@ public final class MachineManager: @unchecked Sendable {
installerMediaAttached: entry.configuration.installerISOPath != nil,
shares: entry.configuration.shares,
environment: entry.configuration.environment,
+ typedSettings: typedSettings,
runtimeIdentity: entry.runtimeIdentity,
installedDesktopPayloadReceipt:
entry.configuration.effectiveInstalledDesktopPayloadReceipt
@@ -3534,12 +3738,25 @@ public final class MachineManager: @unchecked Sendable {
installerMediaAttached: entry.configuration.installerISOPath != nil,
shares: entry.configuration.shares,
environment: entry.configuration.environment,
+ typedSettings: typedSettings,
runtimeIdentity: entry.runtimeIdentity,
installedDesktopPayloadReceipt:
entry.configuration.effectiveInstalledDesktopPayloadReceipt
)
}
+ private func nativeTypedSettingsSnapshot(
+ id: String
+ ) -> DoryMachineTypedSettingsSnapshot? {
+ guard launchPolicy == .perWorkspaceAuthority,
+ let record = try? workspaceRepository.readPersistedRecord(id: id),
+ record.legacyConfigurationSHA256 == nil,
+ record.legacyMigrationFactsSHA256 == nil else {
+ return nil
+ }
+ return try? DoryMachineTypedSettingsSnapshot(definition: record.definition)
+ }
+
private func processConfiguration(
for machine: DoryMachineConfiguration,
handoffPath: String?,
@@ -4996,7 +5213,10 @@ public final class MachineManager: @unchecked Sendable {
_ = rmdir(owner)
}
- private func persist(_ machine: DoryMachineConfiguration) throws {
+ private func persist(
+ _ machine: DoryMachineConfiguration,
+ reconcilesLegacyProjection: Bool = true
+ ) throws {
let fileManager = FileManager.default
let directory = machineStateDirectory(id: machine.id)
let temporaryPath = "\(directory)/\(Self.machineMetadataTemporaryPrefix)\(UUID().uuidString)"
@@ -5026,7 +5246,7 @@ public final class MachineManager: @unchecked Sendable {
// The rename above is the commit point. Projection is intentionally best-effort and
// ordered afterwards: a v2 failure must never roll back or hide working legacy metadata.
resolvedLaunchIdentities.removeValue(forKey: machine.id)
- if let authoritativeLegacyData {
+ if reconcilesLegacyProjection, let authoritativeLegacyData {
_ = reconcileWorkspaceProjection(
machine: machine,
authoritativeLegacyData: authoritativeLegacyData
@@ -5088,23 +5308,18 @@ public final class MachineManager: @unchecked Sendable {
authoritativeLegacyData: Data
) -> DoryVirtualMachineDefinition? {
do {
- let facts = try workspaceMigrationFacts(for: machine)
- let migration = try DoryMachineConfigurationMigrationBridge.migrate(
- machine,
- facts: facts
- )
- let result = try workspaceRepository.reconcileLegacyProjection(
- migration.definition,
- authoritativeLegacyData: authoritativeLegacyData,
- authoritativeMigrationFactsData: try Self.workspaceMigrationAuthorityData(facts)
+ let authority = try workspaceAuthority(
+ machine: machine,
+ authoritativeLegacyData: authoritativeLegacyData
)
setWorkspaceProjectionDiagnostic(
DoryWorkspaceProjectionDiagnostic(
- state: result.state == .unchanged ? .current : .regenerated
+ state: authority.reconcileState == .unchanged
+ ? .current : .regenerated
),
id: machine.id
)
- return result.definition
+ return authority.definition
} catch let error as DoryMachineConfigurationMigrationError {
setWorkspaceProjectionDiagnostic(
DoryWorkspaceProjectionDiagnostic(
@@ -5138,6 +5353,84 @@ public final class MachineManager: @unchecked Sendable {
}
}
+ private func workspaceAuthority(
+ machine: DoryMachineConfiguration,
+ authoritativeLegacyData: Data
+ ) throws -> MachineWorkspaceAuthority {
+ let facts = try workspaceMigrationFacts(for: machine)
+ var migration = try DoryMachineConfigurationMigrationBridge.migrate(
+ machine,
+ facts: facts
+ )
+ let factsData = try Self.workspaceMigrationAuthorityData(facts)
+ let currentRecord: DoryWorkspaceRepositoryRecord?
+ do {
+ currentRecord = try workspaceRepository.readPersistedRecord(id: machine.id)
+ } catch let error as DoryWorkspaceRepositoryError {
+ if case .workspaceNotFound = error {
+ currentRecord = nil
+ } else {
+ throw error
+ }
+ }
+
+ let definition: DoryVirtualMachineDefinition
+ let isNative: Bool
+ let reconcileState: DoryWorkspaceLegacyProjectionReconcileState
+ if let currentRecord,
+ currentRecord.legacyConfigurationSHA256 == nil,
+ currentRecord.legacyMigrationFactsSHA256 == nil {
+ guard Self.nativeDefinition(
+ currentRecord.definition,
+ isCompatibleWith: migration.definition
+ ) else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(machine.id)
+ }
+ definition = currentRecord.definition
+ isNative = true
+ reconcileState = .unchanged
+ } else {
+ let result = try workspaceRepository.reconcileLegacyProjection(
+ migration.definition,
+ authoritativeLegacyData: authoritativeLegacyData,
+ authoritativeMigrationFactsData: factsData
+ )
+ let persisted = try workspaceRepository.readLegacyProjection(
+ id: machine.id,
+ authoritativeLegacyData: authoritativeLegacyData,
+ authoritativeMigrationFactsData: factsData
+ )
+ guard result.definition == persisted else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(machine.id)
+ }
+ definition = persisted
+ isNative = false
+ reconcileState = result.state
+ }
+ migration.definition = definition
+ return MachineWorkspaceAuthority(
+ definition: definition,
+ migration: migration,
+ migrationFactsData: factsData,
+ runtimeMachine: try migration.legacyConfiguration(),
+ isNative: isNative,
+ reconcileState: reconcileState
+ )
+ }
+
+ private static func nativeDefinition(
+ _ definition: DoryVirtualMachineDefinition,
+ isCompatibleWith compatibility: DoryVirtualMachineDefinition
+ ) -> Bool {
+ var expected = compatibility
+ expected.lifecycle = definition.lifecycle
+ expected.backendPreference = definition.backendPreference
+ expected.graphics = definition.graphics
+ expected.guestIdentityIntent = definition.guestIdentityIntent
+ expected.clipboardPolicy = definition.clipboardPolicy
+ return expected == definition && definition.validate().isEmpty
+ }
+
private static func canonicalDefinitionData(
_ definition: DoryVirtualMachineDefinition
) throws -> Data {
@@ -6214,6 +6507,7 @@ public final class MachineManager: @unchecked Sendable {
Self.nativeCreationPrecommitMarkerName,
DoryMachineRuntimeIdentityStore.recordFileName,
DoryMachineRuntimeIdentityStore.headFileName,
+ DoryWorkspaceRepository.recordFileName,
])
for id in ids where Self.isValidID(id) {
let directory = stateDirectory + "/" + id
@@ -6241,6 +6535,7 @@ public final class MachineManager: @unchecked Sendable {
|| entry.hasPrefix(
DoryMachineRuntimeIdentityStore.headTemporaryPrefix
)
+ || entry.hasPrefix(DoryWorkspaceRepository.recordTemporaryPrefix)
}
let containsOnlyPrivateRuntimeTemporaries = entries.filter { entry in
entry.hasPrefix(
@@ -6248,9 +6543,15 @@ public final class MachineManager: @unchecked Sendable {
) || entry.hasPrefix(
DoryMachineRuntimeIdentityStore.headTemporaryPrefix
)
+ || entry.hasPrefix(DoryWorkspaceRepository.recordTemporaryPrefix)
}.allSatisfy { entry in
Self.isPrivateRegularFile(path: directory + "/" + entry)
}
+ let hasValidWorkspaceAuthority = !entries.contains(
+ DoryWorkspaceRepository.recordFileName
+ ) || Self.isPrivateRegularFile(
+ path: directory + "/" + DoryWorkspaceRepository.recordFileName
+ )
let hasValidManagedArtifacts =
(!entries.contains("rootfs.ext4")
|| Self.isPrivateRegularFile(path: directory + "/rootfs.ext4"))
@@ -6263,6 +6564,7 @@ public final class MachineManager: @unchecked Sendable {
&& containsOnlyCreationEntries
&& containsOnlyPrivateRuntimeTemporaries
&& hasValidManagedArtifacts
+ && hasValidWorkspaceAuthority
guard isMarkerInitializationCrash || isAuthenticatedInterruptedCreation else {
continue
}
@@ -6525,8 +6827,8 @@ public final class MachineManager: @unchecked Sendable {
}
entry = current
lock.unlock()
- guard entry.configuration == machine,
- entry.process == nil, entry.handoffServer == nil,
+ let authoritativeMachine = entry.configuration
+ guard entry.process == nil, entry.handoffServer == nil,
entry.state == .created || entry.state == .stopped else {
throw MachineManagerError.persistence(
"machine \(machine.id) must be exactly stopped before planning"
@@ -6543,31 +6845,21 @@ public final class MachineManager: @unchecked Sendable {
let decoded = try? JSONDecoder().decode(
DoryMachineConfiguration.self,
from: legacyData
- ), decoded == machine else {
+ ), decoded == authoritativeMachine else {
throw MachineManagerError.persistence(
"authoritative machine metadata changed before planning"
)
}
- let facts = try workspaceMigrationFacts(for: machine)
- let factsData = try Self.workspaceMigrationAuthorityData(facts)
- let migration = try DoryMachineConfigurationMigrationBridge.migrate(
- machine,
- facts: facts
- )
- let reconciled = try workspaceRepository.reconcileLegacyProjection(
- migration.definition,
- authoritativeLegacyData: legacyData,
- authoritativeMigrationFactsData: factsData
- ).definition
- let loaded = try workspaceRepository.readLegacyProjection(
- id: machine.id,
- authoritativeLegacyData: legacyData,
- authoritativeMigrationFactsData: factsData
+ let workspace = try workspaceAuthority(
+ machine: authoritativeMachine,
+ authoritativeLegacyData: legacyData
)
- guard reconciled == loaded, loaded == definition,
- try Self.canonicalDefinitionData(loaded) == canonicalDefinitionData else {
+ guard workspace.runtimeMachine == machine,
+ workspace.definition == definition,
+ try Self.canonicalDefinitionData(workspace.definition)
+ == canonicalDefinitionData else {
throw MachineManagerError.persistence(
- "planning request does not match the authoritative workspace projection"
+ "planning request does not match authoritative workspace state"
)
}
@@ -6577,8 +6869,8 @@ public final class MachineManager: @unchecked Sendable {
let authority = DoryDaemonVirtualMachinePlanningMachineAuthority(
machineID: machine.id,
legacyConfigurationSHA256: Self.sha256(data: legacyData),
- migrationFactsSHA256: Self.sha256(data: factsData),
- sourceDefinitionRevision: loaded.lifecycle.revision,
+ migrationFactsSHA256: Self.sha256(data: workspace.migrationFactsData),
+ sourceDefinitionRevision: workspace.definition.lifecycle.revision,
sourceDefinitionSHA256: Self.sha256(data: canonicalDefinitionData),
runtimeIdentitySHA256: Self.sha256(data: runtimeIdentityData)
)
@@ -6600,11 +6892,12 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.persistence("machine manager is unavailable")
}
try self.revalidatePlanningMutationAuthority(
- machine: machine,
+ authoritativeMachine: authoritativeMachine,
+ runtimeMachine: machine,
definition: definition,
canonicalDefinitionData: canonicalDefinitionData,
legacyData: legacyData,
- migrationFactsData: factsData,
+ migrationFactsData: workspace.migrationFactsData,
runtimeIdentitySHA256: authority.runtimeIdentitySHA256
)
},
@@ -6622,7 +6915,8 @@ public final class MachineManager: @unchecked Sendable {
}
private func revalidatePlanningMutationAuthority(
- machine: DoryMachineConfiguration,
+ authoritativeMachine: DoryMachineConfiguration,
+ runtimeMachine: DoryMachineConfiguration,
definition: DoryVirtualMachineDefinition,
canonicalDefinitionData: Data,
legacyData: Data,
@@ -6631,19 +6925,19 @@ public final class MachineManager: @unchecked Sendable {
) throws {
operationLock.lock()
defer { operationLock.unlock() }
- guard activePlanningMutationIDs.contains(machine.id) else {
+ guard activePlanningMutationIDs.contains(authoritativeMachine.id) else {
throw MachineManagerError.persistence("planning mutation authority was released")
}
let entry: MachineEntry
lock.lock()
- guard let current = machines[machine.id],
- !deletingMachineIDs.contains(machine.id) else {
+ guard let current = machines[authoritativeMachine.id],
+ !deletingMachineIDs.contains(authoritativeMachine.id) else {
lock.unlock()
- throw MachineManagerError.unknownMachine(machine.id)
+ throw MachineManagerError.unknownMachine(authoritativeMachine.id)
}
entry = current
lock.unlock()
- guard entry.configuration == machine,
+ guard entry.configuration == authoritativeMachine,
entry.process == nil, entry.handoffServer == nil,
entry.state == .created || entry.state == .stopped,
entry.runtimeIdentity.validate().isEmpty,
@@ -6652,30 +6946,32 @@ public final class MachineManager: @unchecked Sendable {
)
== runtimeIdentitySHA256,
let currentLegacyData = Self.readPrivateMetadata(
- path: machineConfigPath(id: machine.id)
+ path: machineConfigPath(id: authoritativeMachine.id)
), currentLegacyData == legacyData,
let decoded = try? JSONDecoder().decode(
DoryMachineConfiguration.self,
from: currentLegacyData
- ), decoded == machine else {
+ ), decoded == authoritativeMachine else {
throw MachineManagerError.persistence(
"authoritative machine state changed during planning"
)
}
- let currentFacts = try workspaceMigrationFacts(for: machine)
- let currentFactsData = try Self.workspaceMigrationAuthorityData(currentFacts)
- guard currentFactsData == migrationFactsData else {
+ let currentWorkspace = try workspaceAuthority(
+ machine: authoritativeMachine,
+ authoritativeLegacyData: currentLegacyData
+ )
+ guard currentWorkspace.migrationFactsData == migrationFactsData else {
throw MachineManagerError.persistence(
"authoritative migration facts changed during planning"
)
}
- let currentDefinition = try workspaceRepository.readLegacyProjection(
- id: machine.id,
- authoritativeLegacyData: currentLegacyData,
- authoritativeMigrationFactsData: currentFactsData
- )
- guard currentDefinition == definition,
- try Self.canonicalDefinitionData(currentDefinition)
+ guard currentWorkspace.runtimeMachine == runtimeMachine else {
+ throw MachineManagerError.persistence(
+ "authoritative machine projection changed during planning"
+ )
+ }
+ guard currentWorkspace.definition == definition,
+ try Self.canonicalDefinitionData(currentWorkspace.definition)
== canonicalDefinitionData else {
throw MachineManagerError.persistence(
"authoritative workspace projection changed during planning"
@@ -8352,11 +8648,21 @@ private struct MachineLifecycleJournalCompletionPending: Error, Sendable {}
private struct PreparedMachineStart {
var machine: DoryMachineConfiguration
+ var authoritativeMachine: DoryMachineConfiguration
var definition: DoryVirtualMachineDefinition?
var canonicalDefinitionData: Data?
var authoritativeLegacyData: Data?
}
+private struct MachineWorkspaceAuthority {
+ var definition: DoryVirtualMachineDefinition
+ var migration: DoryMachineConfigurationMigrationResult
+ var migrationFactsData: Data
+ var runtimeMachine: DoryMachineConfiguration
+ var isNative: Bool
+ var reconcileState: DoryWorkspaceLegacyProjectionReconcileState
+}
+
private struct PendingResolvedMachineStart {
var machine: DoryMachineConfiguration
var plan: DoryResolvedMachinePlan
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 8fb3258a..a5422905 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -1466,6 +1466,9 @@ final class DorydServiceTests: XCTestCase {
"id": "planned",
"kernelPath": doryTestKernelPath,
"rootfsPath": doryTestRootfsPath,
+ "guestIdentityIntent": [
+ "account": ["username": "developer"] as NSDictionary,
+ ] as NSDictionary,
]) { ok, _, message in
XCTAssertFalse(ok)
XCTAssertTrue(message.contains("production planning failed closed"), message)
@@ -1475,11 +1478,39 @@ final class DorydServiceTests: XCTestCase {
let captured = try XCTUnwrap(controller.captured)
XCTAssertEqual(captured.request.planning.machine.id, "planned")
+ XCTAssertEqual(captured.request.planning.machine.environment["DORY_GUEST_USER"], "developer")
XCTAssertEqual(captured.request.planning.definition.identity.id, "planned")
+ XCTAssertEqual(
+ captured.request.planning.definition.guestIdentityIntent.account?.username,
+ "developer"
+ )
XCTAssertEqual(captured.request.workspacePublication, .retainExistingExact)
XCTAssertEqual(captured.artifacts.count, 2)
XCTAssertTrue(captured.artifacts.allSatisfy { $0.path.hasPrefix(base + "/planned/") })
XCTAssertEqual(manager.status(id: "planned")?.runtimeIdentity.mode, .requiresReplanning)
+ XCTAssertEqual(
+ manager.status(id: "planned")?.typedSettings?
+ .guestIdentityIntent.account?.username,
+ "developer"
+ )
+ XCTAssertTrue(manager.status(id: "planned")?.environment.isEmpty == true)
+ let persisted = try JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: Data(contentsOf: URL(fileURLWithPath: base + "/planned/machine.json"))
+ )
+ XCTAssertTrue(persisted.environment.isEmpty)
+ let listReply = expectation(description: "native typed status projection")
+ service.machineList { rows, message in
+ XCTAssertEqual(message, "")
+ let row = (rows as? [NSDictionary])?.first { $0["id"] as? String == "planned" }
+ XCTAssertEqual((row?["env"] as? [NSDictionary])?.count, 0)
+ let typed = row?["typedSettings"] as? NSDictionary
+ let identity = typed?["guestIdentityIntent"] as? NSDictionary
+ let account = identity?["account"] as? NSDictionary
+ XCTAssertEqual(account?["username"] as? String, "developer")
+ listReply.fulfill()
+ }
+ wait(for: [listReply], timeout: 5)
XCTAssertThrowsError(try manager.start(id: "planned"))
let updateReply = expectation(description: "production update planning rejection")
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
index 8a7ac144..5723de20 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -711,10 +711,118 @@ struct MachineManagerResolvedPlanIntegrationTests {
}
}
+ @Test("native typed settings persist outside machine environment")
+ func nativeTypedSettingsAreWorkspaceAuthority() throws {
+ let state = try makeState("native-typed-settings")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ let manager = makeManager(state: state, policy: .perWorkspaceAuthority)
+ let created = try manager.create(
+ DoryMachineConfiguration(
+ id: "typed",
+ kernelPath: doryTestKernelPath,
+ rootfsPath: doryTestRootfsPath,
+ memoryMB: 2_048,
+ cpuCount: 2,
+ displayMode: .desktop
+ ),
+ typedSettings: DoryMachineTypedSettingsPatch(
+ guestUsername: .set("developer"),
+ guestNumericUserID: .set(1_000),
+ desktopDistributionIdentifier: .set("ubuntu"),
+ desktopDisplayName: .set("Ubuntu"),
+ clipboardPolicy: .set(.legacyDesktop(.bidirectional)),
+ runtimePreference: .set(.accelerated),
+ graphicsPreference: .set(.virglVenus)
+ )
+ )
+ #expect(created.environment.isEmpty)
+ #expect(created.typedSettings?.guestIdentityIntent.account?.username == "developer")
+ #expect(created.typedSettings?.runtimePreference == .accelerated)
+
+ let machineData = try Data(contentsOf: URL(
+ fileURLWithPath: state + "/typed/machine.json"
+ ))
+ let persistedMachine = try JSONDecoder().decode(
+ DoryMachineConfiguration.self,
+ from: machineData
+ )
+ #expect(persistedMachine.environment.isEmpty)
+ let repository = DoryWorkspaceRepository(root: state)
+ let createdRecord = try repository.readPersistedRecord(id: "typed")
+ #expect(createdRecord.legacyConfigurationSHA256 == nil)
+ #expect(createdRecord.legacyMigrationFactsSHA256 == nil)
+ #expect(createdRecord.definition.guestIdentityIntent.account?.username == "developer")
+ #expect(createdRecord.definition.backendPreference.backend == .doryHypervisor)
+ #expect(createdRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D])
+ let snapshot = try manager.snapshot(id: "typed", snapshotID: "typed-baseline")
+ #expect(snapshot.typedSettings == created.typedSettings)
+
+ let updated = try manager.update(
+ id: "typed",
+ typedSettingsPatch: DoryMachineTypedSettingsPatch(
+ guestUsername: .set("builder"),
+ desktopDisplayName: .set("Ubuntu Builder"),
+ graphicsPreference: .set(.software)
+ )
+ )
+ #expect(updated.environment.isEmpty)
+ #expect(updated.typedSettings?.guestIdentityIntent.account?.username == "builder")
+ #expect(updated.typedSettings?.guestIdentityIntent.account?.numericUserID == 1_000)
+ #expect(updated.typedSettings?.graphicsPreference == .software)
+ let updatedRecord = try repository.readPersistedRecord(id: "typed")
+ #expect(updatedRecord.definition.lifecycle.revision == 2)
+ #expect(updatedRecord.definition.guestIdentityIntent.desktop?.displayName
+ == "Ubuntu Builder")
+ #expect(updatedRecord.definition.guestIdentityIntent.desktop?.distributionIdentifier
+ == "ubuntu")
+ #expect(updatedRecord.definition.graphics.acceptableLevels == [.software])
+ _ = try manager.update(id: "typed")
+ #expect(try repository.readPersistedRecord(id: "typed").definition.lifecycle.revision == 2)
+
+ let clone = try manager.cloneSnapshot(
+ machineID: "typed",
+ snapshotID: snapshot.id,
+ newID: "typed-clone"
+ )
+ #expect(clone.environment.isEmpty)
+ #expect(clone.typedSettings == created.typedSettings)
+ let cloneRecord = try repository.readPersistedRecord(id: "typed-clone")
+ #expect(cloneRecord.legacyConfigurationSHA256 == nil)
+ #expect(cloneRecord.definition.guestIdentityIntent.account?.username == "developer")
+
+ var snapshotWithLegacyEnvironment = snapshot
+ snapshotWithLegacyEnvironment.environment = ["SHOULD_NOT_PERSIST": "opaque-secret"]
+ try JSONEncoder().encode(snapshotWithLegacyEnvironment).write(
+ to: URL(fileURLWithPath: state + "/typed/snapshots/typed-baseline.json")
+ )
+
+ let restored = try manager.restoreSnapshot(
+ machineID: "typed",
+ snapshotID: snapshot.id
+ )
+ #expect(restored.environment.isEmpty)
+ #expect(restored.typedSettings == created.typedSettings)
+ #expect(restored.runtimeIdentity.mode == .requiresReplanning)
+ let restoredRecord = try repository.readPersistedRecord(id: "typed")
+ #expect(restoredRecord.definition.lifecycle.revision == 3)
+ #expect(restoredRecord.definition.guestIdentityIntent.account?.username == "developer")
+ #expect(restoredRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D])
+
+ let restarted = makeManager(state: state, policy: .perWorkspaceAuthority)
+ let restartedStatus = try #require(restarted.status(id: "typed"))
+ #expect(restartedStatus.environment.isEmpty)
+ #expect(restartedStatus.typedSettings == created.typedSettings)
+ #expect(restartedStatus.runtimeIdentity.mode == .requiresReplanning)
+ }
+
@Test("native creation authority is durable before machine metadata becomes discoverable")
func nativeCreationCrashOrderingNeverMintsLegacy() throws {
let state = try makeState("native-create-order")
defer { try? FileManager.default.removeItem(atPath: state) }
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o700],
+ ofItemAtPath: state
+ )
let directory = state + "/native"
try FileManager.default.createDirectory(
atPath: directory,
@@ -755,6 +863,22 @@ struct MachineManagerResolvedPlanIntegrationTests {
[.posixPermissions: 0o600],
ofItemAtPath: markerPath
)
+ let rootfsBytes = try #require(
+ (try FileManager.default.attributesOfItem(atPath: rootfs)[.size] as? NSNumber)?
+ .uint64Value
+ )
+ let interruptedDefinition = try DoryMachineConfigurationMigrationBridge.migrate(
+ machine,
+ facts: DoryMachineConfigurationMigrationFacts(
+ guestArchitecture: .arm64,
+ systemDiskCapacityBytes: rootfsBytes,
+ lifecycle: DoryVMLifecycleMetadata(
+ createdAtUnixMilliseconds: 1,
+ updatedAtUnixMilliseconds: 1
+ )
+ )
+ ).definition
+ try DoryWorkspaceRepository(root: state).create(interruptedDefinition)
try DoryMachineRuntimeIdentityStore(root: state).publish(
expected,
machineID: "native",
From d17b405ea0601c86e68e5e1c70b86292513e65ce Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 21:11:41 +0000
Subject: [PATCH 043/338] fix(vm): preserve exact desktop graphics preference
---
.../DoryMachineConfigurationMigration.swift | 6 +++++-
.../DorydKit/DoryMachineTypedWriteAuthority.swift | 8 +++++++-
.../DoryMachineConfigurationMigrationTests.swift | 2 +-
...MachineManagerResolvedPlanIntegrationTests.swift | 13 +++++++------
4 files changed, 20 insertions(+), 9 deletions(-)
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
index 06733161..ed59bfdb 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
@@ -314,6 +314,8 @@ public struct DoryMachineConfigurationMigrationResult: Sendable, Equatable {
preference = .automatic
case [.hardwareAccelerated3D]:
preference = .virglVenus
+ case [.hostAcceleratedDisplay]:
+ preference = .virgl
case [.software]:
preference = .software
default:
@@ -769,7 +771,9 @@ public enum DoryMachineConfigurationMigrationBridge {
DoryVMGraphicsPolicy(
acceptableLevels: [.hardwareAccelerated3D, .hostAcceleratedDisplay, .software]
)
- case .virgl, .virglVenus:
+ case .virgl:
+ DoryVMGraphicsPolicy(acceptableLevels: [.hostAcceleratedDisplay])
+ case .virglVenus:
DoryVMGraphicsPolicy(acceptableLevels: [.hardwareAccelerated3D])
case .software:
DoryVMGraphicsPolicy(acceptableLevels: [.software])
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
index d78ce8d7..a0e0f5dc 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
@@ -64,6 +64,8 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha
graphicsPreference = .automatic
case [.hardwareAccelerated3D]:
graphicsPreference = .virglVenus
+ case [.hostAcceleratedDisplay]:
+ graphicsPreference = .virgl
case [.software]:
graphicsPreference = .software
default:
@@ -501,7 +503,11 @@ public struct DoryMachineTypedSettingsPatch: Sendable, Equatable {
.software,
]
)
- case .set(.virgl), .set(.virglVenus):
+ case .set(.virgl):
+ definition.graphics = DoryVMGraphicsPolicy(
+ acceptableLevels: [.hostAcceleratedDisplay]
+ )
+ case .set(.virglVenus):
definition.graphics = DoryVMGraphicsPolicy(
acceptableLevels: [.hardwareAccelerated3D]
)
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
index 358292fa..2e2c2c93 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
@@ -63,7 +63,7 @@ struct DoryMachineConfigurationMigrationTests {
mode: .preferred,
backend: .doryHypervisor
))
- #expect(migrated.definition.graphics.acceptableLevels == [.hardwareAccelerated3D])
+ #expect(migrated.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay])
#expect(migrated.definition.resources.memoryBytes == 8 * gibibyte)
#expect(migrated.definition.resources.virtualCPUCount == 6)
#expect(migrated.definition.storage[0].capacityBytes == 96 * gibibyte)
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
index 5723de20..6370b548 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -732,7 +732,7 @@ struct MachineManagerResolvedPlanIntegrationTests {
desktopDisplayName: .set("Ubuntu"),
clipboardPolicy: .set(.legacyDesktop(.bidirectional)),
runtimePreference: .set(.accelerated),
- graphicsPreference: .set(.virglVenus)
+ graphicsPreference: .set(.virgl)
)
)
#expect(created.environment.isEmpty)
@@ -753,7 +753,8 @@ struct MachineManagerResolvedPlanIntegrationTests {
#expect(createdRecord.legacyMigrationFactsSHA256 == nil)
#expect(createdRecord.definition.guestIdentityIntent.account?.username == "developer")
#expect(createdRecord.definition.backendPreference.backend == .doryHypervisor)
- #expect(createdRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D])
+ #expect(created.typedSettings?.graphicsPreference == .virgl)
+ #expect(createdRecord.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay])
let snapshot = try manager.snapshot(id: "typed", snapshotID: "typed-baseline")
#expect(snapshot.typedSettings == created.typedSettings)
@@ -762,20 +763,20 @@ struct MachineManagerResolvedPlanIntegrationTests {
typedSettingsPatch: DoryMachineTypedSettingsPatch(
guestUsername: .set("builder"),
desktopDisplayName: .set("Ubuntu Builder"),
- graphicsPreference: .set(.software)
+ graphicsPreference: .set(.virglVenus)
)
)
#expect(updated.environment.isEmpty)
#expect(updated.typedSettings?.guestIdentityIntent.account?.username == "builder")
#expect(updated.typedSettings?.guestIdentityIntent.account?.numericUserID == 1_000)
- #expect(updated.typedSettings?.graphicsPreference == .software)
+ #expect(updated.typedSettings?.graphicsPreference == .virglVenus)
let updatedRecord = try repository.readPersistedRecord(id: "typed")
#expect(updatedRecord.definition.lifecycle.revision == 2)
#expect(updatedRecord.definition.guestIdentityIntent.desktop?.displayName
== "Ubuntu Builder")
#expect(updatedRecord.definition.guestIdentityIntent.desktop?.distributionIdentifier
== "ubuntu")
- #expect(updatedRecord.definition.graphics.acceptableLevels == [.software])
+ #expect(updatedRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D])
_ = try manager.update(id: "typed")
#expect(try repository.readPersistedRecord(id: "typed").definition.lifecycle.revision == 2)
@@ -806,7 +807,7 @@ struct MachineManagerResolvedPlanIntegrationTests {
let restoredRecord = try repository.readPersistedRecord(id: "typed")
#expect(restoredRecord.definition.lifecycle.revision == 3)
#expect(restoredRecord.definition.guestIdentityIntent.account?.username == "developer")
- #expect(restoredRecord.definition.graphics.acceptableLevels == [.hardwareAccelerated3D])
+ #expect(restoredRecord.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay])
let restarted = makeManager(state: state, policy: .perWorkspaceAuthority)
let restartedStatus = try #require(restarted.status(id: "typed"))
From 72205d85a714afd1858636da40fb94bb36726a92 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 21:15:51 +0000
Subject: [PATCH 044/338] feat(vm): qualify exact desktop device contracts
---
.../Sources/dory-hv/DesktopMode.swift | 3 +-
.../DoryVirtualMachineCapabilities.swift | 26 ++++++++-----
.../Sources/DoryVMMKit/DoryVMM.swift | 3 +-
.../DoryMachineConfigurationMigration.swift | 2 +-
.../DoryVirtualMachineCapabilitiesTests.swift | 37 ++++++++++++++++++-
...ryMachineConfigurationMigrationTests.swift | 2 +
.../Tests/DorydKitTests/DoryVMMKitTests.swift | 2 +-
7 files changed, 58 insertions(+), 17 deletions(-)
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
index 74db508a..66054434 100644
--- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
@@ -240,7 +240,8 @@ enum DesktopMode {
} else {
self.sshAgentBridge = nil
}
- if configuration.genericGuest || configuration.resolvedDevices?.clipboard == false {
+ if configuration.resolvedDevices?.clipboard == false
+ || (configuration.resolvedDevices == nil && configuration.genericGuest) {
self.clipboard = nil
} else {
let clipboardControl = DorydKit.AgentControl(configuration: .init(
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
index b9ea570f..f75c80d1 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
@@ -1332,14 +1332,19 @@ public enum DoryAppleSiliconCapabilityEvaluator {
let isGraphical = request.graphics != .none
let isLinuxVZ = request.guest.family == .linux
&& request.backend == .appleVirtualizationFramework
- if devices.audioInput, !(isLinuxVZ && isGraphical) {
+ let isLinuxRawHV = request.guest.family == .linux
+ && request.backend == .doryHypervisor
+ let isLinuxDesktopRuntime = isGraphical && (isLinuxVZ || isLinuxRawHV)
+ let audioIsImplemented = isLinuxVZ && isGraphical
+ || isLinuxRawHV && isGraphical && devices.audioInput == devices.audioOutput
+ if devices.audioInput, !audioIsImplemented {
return unavailable(
tier: tier,
code: .audioInputUnsupported,
message: "The selected guest/backend contract does not implement host audio input."
)
}
- if devices.audioOutput, !(isLinuxVZ && isGraphical) {
+ if devices.audioOutput, !audioIsImplemented {
return unavailable(
tier: tier,
code: .audioOutputUnsupported,
@@ -1371,20 +1376,21 @@ public enum DoryAppleSiliconCapabilityEvaluator {
)
}
- if devices.directorySharing {
+ if devices.directorySharing,
+ !(request.guest.family == .linux
+ && (request.backend == .doryHypervisor
+ || request.backend == .appleVirtualizationFramework)) {
return unavailable(
tier: tier,
code: .directorySharingUnsupported,
- message: "Directory sharing requires a digest-bound guest-integration "
- + "qualification that is not yet modeled."
+ message: "The selected guest/backend contract does not implement directory sharing."
)
}
- if devices.clipboard {
+ if devices.clipboard, !isLinuxDesktopRuntime {
return unavailable(
tier: tier,
code: .clipboardIntegrationUnsupported,
- message: "Clipboard integration requires a digest-bound guest-integration "
- + "qualification that is not yet modeled."
+ message: "The selected guest/backend contract does not implement clipboard integration."
)
}
if devices.clockSynchronization {
@@ -1398,11 +1404,11 @@ public enum DoryAppleSiliconCapabilityEvaluator {
)
}
}
- if devices.dynamicDisplay {
+ if devices.dynamicDisplay, !isLinuxDesktopRuntime {
return unavailable(
tier: tier,
code: .dynamicDisplayUnsupported,
- message: "Dynamic display resizing is not yet part of a qualified backend contract."
+ message: "The selected guest/backend contract does not implement dynamic display resizing."
)
}
if devices.gracefulShutdown {
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
index 68473565..f645a290 100644
--- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
@@ -373,8 +373,7 @@ public enum DoryVZConfigurationBuilder {
}
}
if let devices = spec.resolvedDevices {
- guard devices.networkAttachment == .sharedNAT,
- !devices.dynamicDisplay else {
+ guard devices.networkAttachment == .sharedNAT else {
throw DoryVZMachineError.validation(
"resolved device contract contains a device not implemented by this VZ launch"
)
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
index ed59bfdb..b5d82d34 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
@@ -639,7 +639,7 @@ public enum DoryMachineConfigurationMigrationBridge {
networkMode: .sharedNAT,
display: isDesktop ? DoryVMDisplayConfiguration() : .disabled,
audio: isDesktop
- ? DoryVMAudioConfiguration()
+ ? DoryVMAudioConfiguration(inputEnabled: true, outputEnabled: true)
: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: false),
input: isDesktop
? DoryVMInputConfiguration()
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
index 7c37a918..48749748 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
@@ -818,7 +818,7 @@ struct VirtualMachineCapabilitiesTests {
graphics: .software,
devices: DoryVirtualMachineDeviceCapabilityRequest(networkAttachment: .disconnected)
)
- let spoofedGuestTools = evaluate(
+ let qualifiedVZSharing = evaluate(
family: .linux,
media: .virtualDisk,
source: .userProvided,
@@ -826,10 +826,43 @@ struct VirtualMachineCapabilitiesTests {
graphics: .software,
devices: DoryVirtualMachineDeviceCapabilityRequest(directorySharing: true)
)
+ let qualifiedRawDesktopDevices = DoryVirtualMachineDeviceCapabilityRequest(
+ audioInput: true,
+ audioOutput: true,
+ keyboard: true,
+ pointer: true,
+ directorySharing: true,
+ clipboard: true,
+ clockSynchronization: true,
+ dynamicDisplay: true,
+ gracefulShutdown: true
+ )
+ let qualifiedRawDesktop = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .hostAcceleratedDisplay,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ devices: qualifiedRawDesktopDevices
+ )
+ let unsupportedAudioShape = evaluate(
+ family: .linux,
+ media: .installedLinuxBootBundle,
+ source: .userProvided,
+ backend: .doryHypervisor,
+ graphics: .hostAcceleratedDisplay,
+ mediaArtifactSHA256: Self.guestArtifactSHA256,
+ devices: DoryVirtualMachineDeviceCapabilityRequest(audioOutput: true)
+ )
#expect(resolved.resolvedDevices == .minimumBootable)
#expect(disconnected.availability.reason?.code == .networkAttachmentUnsupported)
- #expect(spoofedGuestTools.availability.reason?.code == .directorySharingUnsupported)
+ #expect(qualifiedVZSharing.availability.isUsable)
+ #expect(qualifiedVZSharing.resolvedDevices?.directorySharing == true)
+ #expect(qualifiedRawDesktop.availability.isUsable)
+ #expect(qualifiedRawDesktop.resolvedDevices == qualifiedRawDesktopDevices)
+ #expect(unsupportedAudioShape.availability.reason?.code == .audioOutputUnsupported)
}
@Test("version-one descriptor JSON remains readable with conservative device defaults")
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
index 2e2c2c93..89acb085 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
@@ -64,6 +64,8 @@ struct DoryMachineConfigurationMigrationTests {
backend: .doryHypervisor
))
#expect(migrated.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay])
+ #expect(migrated.definition.audio.inputEnabled)
+ #expect(migrated.definition.audio.outputEnabled)
#expect(migrated.definition.resources.memoryBytes == 8 * gibibyte)
#expect(migrated.definition.resources.virtualCPUCount == 6)
#expect(migrated.definition.storage[0].capacityBytes == 96 * gibibyte)
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
index 856244a5..014b8a63 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryVMMKitTests.swift
@@ -621,7 +621,7 @@ final class DoryVMMKitTests: XCTestCase {
directorySharing: false,
clipboard: false,
clockSynchronization: false,
- dynamicDisplay: false,
+ dynamicDisplay: true,
gracefulShutdown: false
)
let configuration = try DoryVZConfigurationBuilder.makeConfiguration(
From d1eb2d203baa7d5b60bd8a199c1cb74af21fef58 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 21:24:18 +0000
Subject: [PATCH 045/338] feat(components): require VM qualification manifests
---
scripts/build-components.py | 249 ++++++++++++++++++++++++++++++-
scripts/test-build-components.sh | 112 +++++++++++++-
2 files changed, 356 insertions(+), 5 deletions(-)
diff --git a/scripts/build-components.py b/scripts/build-components.py
index ac6c7694..751faebc 100755
--- a/scripts/build-components.py
+++ b/scripts/build-components.py
@@ -5,6 +5,7 @@
import argparse
import base64
+import binascii
import datetime as dt
import hashlib
import json
@@ -19,8 +20,14 @@
CATALOG_KIND = "dev.dory.component-catalog"
-CATALOG_SCHEMA = 1
+CATALOG_SCHEMA = 2
ARCHITECTURE = "arm64"
+QUALIFICATION_KIND = "dev.dory.virtual-machine-qualification-manifest"
+QUALIFICATION_SCHEMA = 1
+QUALIFICATION_COMPONENT = "linux-desktop"
+QUALIFICATION_PATH = "virtual-machine-qualification.json"
+DEFAULT_CATALOG_PUBLIC_KEY = "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4="
+MAX_QUALIFICATION_BYTES = 2 * 1024 * 1024
def fail(message: str) -> NoReturn:
@@ -126,7 +133,223 @@ def generated_at(value: str | None) -> str:
return parsed.astimezone(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
-def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[dict]:
+def unique_json_object(pairs: list[tuple[str, object]]) -> dict:
+ result: dict = {}
+ for key, value in pairs:
+ if key in result:
+ fail(f"qualification manifest contains duplicate key: {key}")
+ result[key] = value
+ return result
+
+
+def exact_keys(value: object, required: set[str], optional: set[str], label: str) -> dict:
+ if not isinstance(value, dict):
+ fail(f"{label} must be an object")
+ actual = set(value)
+ if not required <= actual or not actual <= required | optional:
+ fail(f"{label} has missing or unknown fields")
+ return value
+
+
+def nonempty_string(value: object, label: str, maximum_bytes: int = 256) -> str:
+ if not isinstance(value, str) or not value or len(value.encode("utf-8")) > maximum_bytes:
+ fail(f"{label} must be a bounded non-empty string")
+ return value
+
+
+def sha256_value(value: object, label: str) -> str:
+ text = nonempty_string(value, label, 64)
+ if len(text) != 64 or any(character not in "0123456789abcdef" for character in text):
+ fail(f"{label} must be a lowercase SHA-256 digest")
+ return text
+
+
+def positive_integer_value(value: object, label: str) -> int:
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+ fail(f"{label} must be a positive integer")
+ return value
+
+
+def boolean_value(value: object, label: str) -> bool:
+ if not isinstance(value, bool):
+ fail(f"{label} must be a boolean")
+ return value
+
+
+def validate_qualification_record(value: object, index: int) -> dict:
+ label = f"qualification record {index}"
+ required = {
+ "qualificationIdentity", "guest", "bootMediaKind", "bootMediaSource",
+ "backend", "backendImplementationIdentifier", "backendRuntimeBuildIdentifier",
+ "virtualHardwareABIVersion", "graphics", "devices",
+ "hostHardwareModelIdentifier", "hostOperatingSystemBuild", "components",
+ "virtioGPUKernelAndDeviceSupportQualified", "venusVulkanGuestRuntimeQualified",
+ }
+ record = exact_keys(
+ value,
+ required,
+ {"immutableArtifactSHA256", "mutableProvenance"},
+ label,
+ )
+ nonempty_string(record["qualificationIdentity"], f"{label} identity")
+
+ guest = exact_keys(record["guest"], {"family", "architecture"}, set(), f"{label} guest")
+ if guest["family"] not in {"linux", "windows", "macos"}:
+ fail(f"{label} guest family is unsupported")
+ if guest["architecture"] not in {"arm64", "x86_64"}:
+ fail(f"{label} guest architecture is unsupported")
+ if record["bootMediaKind"] not in {
+ "installer-iso", "virtual-disk", "installed-linux-boot-bundle",
+ "macos-restore-image",
+ }:
+ fail(f"{label} boot media kind is unsupported")
+ if record["bootMediaSource"] not in {
+ "dory-bundled", "vendor-download", "user-provided",
+ }:
+ fail(f"{label} boot media source is unsupported")
+ if record["backend"] not in {
+ "dory-hypervisor", "apple-virtualization-framework", "qemu-hvf",
+ }:
+ fail(f"{label} backend is unsupported")
+ if record["graphics"] not in {
+ "none", "software", "host-accelerated-display", "hardware-accelerated-3d",
+ }:
+ fail(f"{label} graphics contract is unsupported")
+ nonempty_string(record["backendImplementationIdentifier"], f"{label} implementation")
+ nonempty_string(record["backendRuntimeBuildIdentifier"], f"{label} runtime build")
+ positive_integer_value(record["virtualHardwareABIVersion"], f"{label} ABI")
+ nonempty_string(record["hostHardwareModelIdentifier"], f"{label} host model")
+ nonempty_string(record["hostOperatingSystemBuild"], f"{label} host build")
+
+ immutable = record.get("immutableArtifactSHA256")
+ mutable = record.get("mutableProvenance")
+ if (immutable is None) == (mutable is None):
+ fail(f"{label} must have exactly one immutable or mutable media identity")
+ if immutable is not None:
+ sha256_value(immutable, f"{label} immutable artifact")
+ if mutable is not None:
+ provenance = exact_keys(
+ mutable,
+ {"repositoryIdentity", "mediaIdentity", "revision"},
+ set(),
+ f"{label} mutable provenance",
+ )
+ nonempty_string(provenance["repositoryIdentity"], f"{label} repository identity")
+ nonempty_string(provenance["mediaIdentity"], f"{label} media identity")
+ positive_integer_value(provenance["revision"], f"{label} media revision")
+
+ devices = exact_keys(
+ record["devices"],
+ {
+ "networkAttachment", "audioInput", "audioOutput", "keyboard", "pointer",
+ "directorySharing", "clipboard", "clockSynchronization", "dynamicDisplay",
+ "gracefulShutdown",
+ },
+ set(),
+ f"{label} devices",
+ )
+ if devices["networkAttachment"] not in {
+ "disconnected", "shared-nat", "bridged", "isolated",
+ }:
+ fail(f"{label} network attachment is unsupported")
+ for key in set(devices) - {"networkAttachment"}:
+ boolean_value(devices[key], f"{label} device {key}")
+
+ components = record["components"]
+ if not isinstance(components, list) or not components:
+ fail(f"{label} components must be a non-empty array")
+ identifiers: list[str] = []
+ for component_index, value in enumerate(components):
+ component = exact_keys(
+ value,
+ {"componentIdentifier", "buildIdentifier", "artifactSHA256"},
+ set(),
+ f"{label} component {component_index}",
+ )
+ identifiers.append(nonempty_string(
+ component["componentIdentifier"], f"{label} component identifier"
+ ))
+ nonempty_string(component["buildIdentifier"], f"{label} component build")
+ sha256_value(component["artifactSHA256"], f"{label} component digest")
+ if identifiers != sorted(identifiers) or len(set(identifiers)) != len(identifiers):
+ fail(f"{label} components must be uniquely sorted by identifier")
+ boolean_value(
+ record["virtioGPUKernelAndDeviceSupportQualified"],
+ f"{label} VirtIO GPU qualification",
+ )
+ boolean_value(
+ record["venusVulkanGuestRuntimeQualified"],
+ f"{label} Venus qualification",
+ )
+ return record
+
+
+def load_qualification_manifest(
+ path: pathlib.Path,
+ *,
+ release_version: str,
+ public_key_base64: str,
+) -> dict:
+ path = regular_file(path.resolve(), "VM qualification manifest")
+ if path.stat().st_size > MAX_QUALIFICATION_BYTES:
+ fail("VM qualification manifest exceeds the maximum size")
+ try:
+ public_key = base64.b64decode(public_key_base64, validate=True)
+ except (ValueError, binascii.Error):
+ fail("catalog public key is malformed")
+ if len(public_key) != 32:
+ fail("catalog public key must be a 32-byte Ed25519 key")
+ try:
+ manifest = json.loads(
+ path.read_text(encoding="utf-8"),
+ object_pairs_hook=unique_json_object,
+ )
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
+ fail(f"VM qualification manifest is unreadable: {error}")
+ manifest = exact_keys(
+ manifest,
+ {
+ "kind", "schemaVersion", "manifestIdentity", "catalogReleaseVersion",
+ "architecture", "signingKeyID", "records",
+ },
+ set(),
+ "VM qualification manifest",
+ )
+ expected_key_id = hashlib.sha256(public_key).hexdigest()
+ if manifest["kind"] != QUALIFICATION_KIND:
+ fail("VM qualification manifest kind is invalid")
+ schema_version = manifest["schemaVersion"]
+ if (
+ isinstance(schema_version, bool)
+ or not isinstance(schema_version, int)
+ or schema_version != QUALIFICATION_SCHEMA
+ ):
+ fail("VM qualification manifest schema is unsupported")
+ nonempty_string(manifest["manifestIdentity"], "VM qualification manifest identity")
+ if manifest["catalogReleaseVersion"] != release_version:
+ fail("VM qualification manifest release does not match the catalog")
+ if manifest["architecture"] != ARCHITECTURE:
+ fail("VM qualification manifest architecture is unsupported")
+ if manifest["signingKeyID"] != expected_key_id:
+ fail("VM qualification manifest signing key does not match the catalog trust root")
+ records = manifest["records"]
+ if not isinstance(records, list) or not records:
+ fail("VM qualification manifest must contain records")
+ validated = [
+ validate_qualification_record(record, index)
+ for index, record in enumerate(records)
+ ]
+ identities = [record["qualificationIdentity"] for record in validated]
+ if len(set(identities)) != len(identities):
+ fail("VM qualification identities must be unique")
+ return manifest
+
+
+def component_specs(
+ source_root: pathlib.Path,
+ kubectl: pathlib.Path,
+ qualification_manifest: pathlib.Path,
+) -> list[dict]:
desktop_specs = []
for distro, display, summary in (
(
@@ -232,6 +455,12 @@ def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[di
"delivery": "none",
"executable": False,
},
+ {
+ "path": QUALIFICATION_PATH,
+ "source": qualification_manifest,
+ "delivery": "none",
+ "executable": False,
+ },
],
},
*desktop_specs,
@@ -361,6 +590,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--minimum-app-version")
parser.add_argument("--generated-at")
parser.add_argument("--signer", type=pathlib.Path)
+ parser.add_argument("--qualification-manifest", required=True, type=pathlib.Path)
+ parser.add_argument("--catalog-public-key", default=DEFAULT_CATALOG_PUBLIC_KEY)
parser.add_argument("--skip-source-verification", action="store_true")
return parser.parse_args()
@@ -374,6 +605,11 @@ def main() -> None:
kubectl = regular_file(args.kubectl.resolve(), "kubectl")
compression_tool = pathlib.Path("/usr/bin/compression_tool")
regular_file(compression_tool, "macOS compression_tool")
+ qualification_manifest = load_qualification_manifest(
+ args.qualification_manifest,
+ release_version=args.version,
+ public_key_base64=args.catalog_public_key,
+ )
if not args.skip_source_verification:
validate_sources(repo, source_root, kubectl)
@@ -398,7 +634,7 @@ def main() -> None:
"assets": [],
}
]
- for spec in component_specs(source_root, kubectl):
+ for spec in component_specs(source_root, kubectl, args.qualification_manifest.resolve()):
assets = [
materialize_asset(
version=args.version,
@@ -431,6 +667,13 @@ def main() -> None:
"minimumAppVersion": args.minimum_app_version or args.version,
"architecture": ARCHITECTURE,
"components": releases,
+ "virtualMachineQualification": {
+ "component": QUALIFICATION_COMPONENT,
+ "path": QUALIFICATION_PATH,
+ "manifestIdentity": qualification_manifest["manifestIdentity"],
+ "manifestFormatVersion": qualification_manifest["schemaVersion"],
+ "signingKeyID": qualification_manifest["signingKeyID"],
+ },
}
catalog_path = staging / "catalog.json"
catalog_path.write_text(
diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh
index 5ae3265c..9483d4cd 100755
--- a/scripts/test-build-components.sh
+++ b/scripts/test-build-components.sh
@@ -8,6 +8,7 @@ trap 'rm -rf "$TMP"' EXIT
SOURCE="$TMP/source"
CORE_APP="$TMP/Dory.app"
OUTPUT="$TMP/components/arm64"
+QUALIFICATION="$TMP/virtual-machine-qualification.json"
mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS"
write_fixture() {
@@ -38,6 +39,62 @@ for distro in debian ubuntu kali; do
done
printf 'schema=fixture\n' > "$SOURCE/kernel-build-arm64-desktop.stamp"
+python3 - "$QUALIFICATION" <<'PY'
+import base64
+import hashlib
+import json
+import pathlib
+import sys
+
+key = base64.b64decode("AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=")
+digest = lambda byte: byte * 64
+manifest = {
+ "kind": "dev.dory.virtual-machine-qualification-manifest",
+ "schemaVersion": 1,
+ "manifestIdentity": "dory-release-9.8.7-apple-silicon",
+ "catalogReleaseVersion": "9.8.7",
+ "architecture": "arm64",
+ "signingKeyID": hashlib.sha256(key).hexdigest(),
+ "records": [{
+ "qualificationIdentity": "linux-desktop-arm64-mac16-1-25a1",
+ "guest": {"family": "linux", "architecture": "arm64"},
+ "bootMediaKind": "installed-linux-boot-bundle",
+ "bootMediaSource": "dory-bundled",
+ "immutableArtifactSHA256": digest("a"),
+ "backend": "dory-hypervisor",
+ "backendImplementationIdentifier": "dory.raw-hv-linux",
+ "backendRuntimeBuildIdentifier": "9.8.7",
+ "virtualHardwareABIVersion": 1,
+ "graphics": "hardware-accelerated-3d",
+ "devices": {
+ "networkAttachment": "shared-nat",
+ "audioInput": True,
+ "audioOutput": True,
+ "keyboard": True,
+ "pointer": True,
+ "directorySharing": True,
+ "clipboard": True,
+ "clockSynchronization": True,
+ "dynamicDisplay": True,
+ "gracefulShutdown": True,
+ },
+ "hostHardwareModelIdentifier": "Mac16,1",
+ "hostOperatingSystemBuild": "25A1",
+ "components": [{
+ "componentIdentifier": "dory-hv",
+ "buildIdentifier": "9.8.7",
+ "artifactSHA256": digest("b"),
+ }],
+ "virtioGPUKernelAndDeviceSupportQualified": True,
+ "venusVulkanGuestRuntimeQualified": True,
+ }],
+}
+pathlib.Path(sys.argv[1]).write_text(
+ json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n",
+ encoding="utf-8",
+)
+PY
+
build() {
"$ROOT/scripts/build-components.py" \
--version 9.8.7 \
@@ -48,13 +105,14 @@ build() {
--output "$OUTPUT" \
--asset-base-url https://example.invalid/dory \
--generated-at 2026-07-16T00:00:00Z \
+ --qualification-manifest "$QUALIFICATION" \
--skip-source-verification
}
build
build
-python3 - "$OUTPUT" "$ROOT" <<'PY'
+python3 - "$OUTPUT" "$ROOT" "$QUALIFICATION" <<'PY'
import hashlib
import importlib.util
import json
@@ -65,9 +123,10 @@ import tempfile
root = pathlib.Path(sys.argv[1])
repo = pathlib.Path(sys.argv[2])
+qualification_source = pathlib.Path(sys.argv[3])
catalog = json.loads((root / "catalog.json").read_text())
assert catalog["kind"] == "dev.dory.component-catalog"
-assert catalog["schemaVersion"] == 1
+assert catalog["schemaVersion"] == 2
assert catalog["architecture"] == "arm64"
assert [item["id"] for item in catalog["components"]] == [
"docker-core",
@@ -81,6 +140,20 @@ assert [item["id"] for item in catalog["components"]] == [
assert catalog["components"][0]["assets"] == []
assert catalog["components"][0]["downloadBytes"] == 4096
assert catalog["components"][0]["installedBytes"] == 8192
+qualification = catalog["virtualMachineQualification"]
+assert qualification["component"] == "linux-desktop"
+assert qualification["path"] == "virtual-machine-qualification.json"
+assert qualification["manifestIdentity"] == "dory-release-9.8.7-apple-silicon"
+assert qualification["manifestFormatVersion"] == 1
+linux_desktop = next(item for item in catalog["components"] if item["id"] == "linux-desktop")
+qualification_assets = [
+ item for item in linux_desktop["assets"]
+ if item["path"] == "virtual-machine-qualification.json"
+]
+assert len(qualification_assets) == 1
+assert qualification_assets[0]["installedSHA256"] == hashlib.sha256(
+ qualification_source.read_bytes()
+).hexdigest()
for component in catalog["components"][1:]:
assert component["downloadBytes"] == sum(
@@ -138,4 +211,39 @@ else:
assert (repo / ".git").is_dir()
PY
+cp "$QUALIFICATION" "$TMP/invalid-qualification.json"
+python3 - "$TMP/invalid-qualification.json" <<'PY'
+import json
+import pathlib
+import sys
+path = pathlib.Path(sys.argv[1])
+manifest = json.loads(path.read_text(encoding="utf-8"))
+manifest["catalogReleaseVersion"] = "9.8.6"
+path.write_text(json.dumps(manifest) + "\n", encoding="utf-8")
+PY
+if "$ROOT/scripts/build-components.py" \
+ --version 9.8.7 \
+ --core-artifact "$TMP/Dory-test.dmg" \
+ --core-app "$CORE_APP" \
+ --kubectl "$TMP/kubectl" \
+ --source-root "$SOURCE" \
+ --output "$TMP/rejected-components" \
+ --qualification-manifest "$TMP/invalid-qualification.json" \
+ --skip-source-verification 2>/dev/null; then
+ echo "component packaging accepted a qualification manifest for another release" >&2
+ exit 1
+fi
+
+if "$ROOT/scripts/build-components.py" \
+ --version 9.8.7 \
+ --core-artifact "$TMP/Dory-test.dmg" \
+ --core-app "$CORE_APP" \
+ --kubectl "$TMP/kubectl" \
+ --source-root "$SOURCE" \
+ --output "$TMP/missing-qualification-components" \
+ --skip-source-verification 2>/dev/null; then
+ echo "component packaging accepted a catalog without qualification evidence" >&2
+ exit 1
+fi
+
echo "component packaging test passed"
From 312a79608e2b59e718308e636ddac3122359d83b Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 21:41:36 +0000
Subject: [PATCH 046/338] fix(vm): distinguish direct kernels from boot bundles
---
.../DoryVirtualMachineBackendPlanner.swift | 2 +-
.../DoryVirtualMachineCapabilities.swift | 12 +++-
.../DoryVirtualMachineDefinition.swift | 5 +-
...lMachineProductionPlanningController.swift | 3 +
...yDaemonVirtualMachineProductionTrust.swift | 4 ++
.../DoryMachineConfigurationMigration.swift | 2 +-
.../DorydKit/DoryResolvedMachinePlan.swift | 12 ++--
.../LinuxMachineBackendAdapters.swift | 35 +++++++----
.../Sources/DorydKit/MachineManager.swift | 58 ++++++++++++++++++-
...oryVirtualMachineBackendPlannerTests.swift | 4 ++
.../DoryVirtualMachineCapabilitiesTests.swift | 2 +-
...onVirtualMachineProductionTrustTests.swift | 53 +++++++++++------
...ryMachineConfigurationMigrationTests.swift | 2 +-
.../DorydKitTests/MachineBackendTests.swift | 10 ++--
...eManagerResolvedPlanIntegrationTests.swift | 41 ++++++++++++-
scripts/build-components.py | 2 +-
scripts/test-build-components.sh | 2 +-
17 files changed, 196 insertions(+), 53 deletions(-)
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
index 70ce50d2..0bb5f256 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineBackendPlanner.swift
@@ -281,7 +281,7 @@ public enum DoryAppleSiliconVirtualMachineBackendPlanner {
bootMedia: DoryBootMediaKind
) -> [DoryVirtualizationBackendIdentity] {
switch (guest.family, bootMedia) {
- case (.linux, .installedLinuxBootBundle):
+ case (.linux, .linuxKernel), (.linux, .installedLinuxBootBundle):
return [.doryHypervisor]
case (.linux, _):
return [.appleVirtualizationFramework, .qemuHypervisorFramework]
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
index f75c80d1..457c267c 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineCapabilities.swift
@@ -23,6 +23,9 @@ public struct DoryGuestPlatform: Codable, Sendable, Equatable, Hashable {
/// The format presented to the virtual machine's firmware or boot loader.
public enum DoryBootMediaKind: String, Codable, Sendable, CaseIterable, Hashable {
+ /// One immutable Linux kernel image used by the existing direct-kernel launch path. This is
+ /// distinct from the portable kernel+initrd+root-device bundle created after an EFI install.
+ case linuxKernel = "linux-kernel"
case installerISO = "installer-iso"
case virtualDisk = "virtual-disk"
case installedLinuxBootBundle = "installed-linux-boot-bundle"
@@ -914,7 +917,8 @@ public enum DoryAppleSiliconCapabilityEvaluator {
) -> Bool {
switch family {
case .linux:
- media == .installerISO || media == .virtualDisk || media == .installedLinuxBootBundle
+ media == .linuxKernel || media == .installerISO || media == .virtualDisk
+ || media == .installedLinuxBootBundle
case .windows:
media == .installerISO || media == .virtualDisk
case .macOS:
@@ -927,9 +931,11 @@ public enum DoryAppleSiliconCapabilityEvaluator {
) -> Bool {
switch (request.guest.family, request.backend) {
case (.linux, .doryHypervisor):
- return request.bootMedia.kind == .installedLinuxBootBundle
+ return request.bootMedia.kind == .linuxKernel
+ || request.bootMedia.kind == .installedLinuxBootBundle
case (.linux, .appleVirtualizationFramework):
- return request.bootMedia.kind == .installerISO
+ return request.bootMedia.kind == .linuxKernel
+ || request.bootMedia.kind == .installerISO
|| request.bootMedia.kind == .virtualDisk
|| request.bootMedia.kind == .installedLinuxBootBundle
case (.linux, .qemuHypervisorFramework),
diff --git a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
index 1354ac71..033be09b 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryVirtualMachineDefinition.swift
@@ -701,7 +701,7 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
let roleMatchesKind: Bool
switch device.kind {
- case .virtualDisk, .installedLinuxBootBundle:
+ case .linuxKernel, .virtualDisk, .installedLinuxBootBundle:
roleMatchesKind = device.role == .system
case .installerISO, .macOSRestoreImage:
roleMatchesKind = device.role == .installer || device.role == .recovery
@@ -987,7 +987,8 @@ public struct DoryVirtualMachineDefinition: Codable, Sendable, Equatable {
) -> Bool {
switch family {
case .linux:
- kind == .installerISO || kind == .virtualDisk || kind == .installedLinuxBootBundle
+ kind == .linuxKernel || kind == .installerISO || kind == .virtualDisk
+ || kind == .installedLinuxBootBundle
case .windows:
kind == .installerISO || kind == .virtualDisk
case .macOS:
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
index f30a9c28..92e04203 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionPlanningController.swift
@@ -209,6 +209,9 @@ public final class DoryDaemonVirtualMachineProductionPlanningController:
$0.id == usage.identifier && $0.artifact == requirement.reference
}) else { return nil }
switch boot.kind {
+ case .linuxKernel:
+ guard machine.bootMode == .linuxKernel else { return nil }
+ paths.insert(machine.kernelPath)
case .installedLinuxBootBundle:
paths.insert(machine.kernelPath)
case .installerISO:
diff --git a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
index 508e2a2e..b9e7cd4f 100644
--- a/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryDaemonVirtualMachineProductionTrust.swift
@@ -639,6 +639,8 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
} catch {
throw DoryDaemonProductionTrustInventoryError.mediaInvalid
}
+ case .linuxKernel:
+ inspection = nil
case .installedLinuxBootBundle:
do { _ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: artifact.path) }
catch { throw DoryDaemonProductionTrustInventoryError.mediaInvalid }
@@ -790,6 +792,8 @@ final class DoryProductionDaemonVirtualMachineTrustInventory:
} catch {
throw DoryDaemonProductionTrustInventoryError.mediaInvalid
}
+ case .linuxKernel:
+ inspection = nil
case .installedLinuxBootBundle:
do {
_ = try DoryInstalledLinuxBootBundle.verifyContents(atPath: artifact.path)
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
index b5d82d34..2966257e 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineConfigurationMigration.swift
@@ -516,7 +516,7 @@ public enum DoryMachineConfigurationMigrationBridge {
switch bootContract {
case .managedDirectKernel:
boot = normalBoot(
- kind: .installedLinuxBootBundle,
+ kind: .linuxKernel,
source: configuration.environment["DORY_CUSTOM_LINUX"] == "1"
? .userProvided : .bundledByDory,
artifact: kernelReference
diff --git a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
index 6b718233..58172452 100644
--- a/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryResolvedMachinePlan.swift
@@ -914,14 +914,18 @@ public struct DoryResolvedMachinePlan: Codable, Sendable, Equatable, Hashable {
let runtimeCombinationIsImplemented: Bool
switch (guest.family, backend) {
case (.linux, .doryHypervisor):
- runtimeCombinationIsImplemented = bootMedia.media.kind == .installedLinuxBootBundle
+ runtimeCombinationIsImplemented = bootMedia.media.kind == .linuxKernel
+ || bootMedia.media.kind == .installedLinuxBootBundle
case (.linux, .appleVirtualizationFramework),
- (.linux, .qemuHypervisorFramework),
- (.windows, .qemuHypervisorFramework):
- runtimeCombinationIsImplemented = bootMedia.media.kind == .installerISO
+ (.linux, .qemuHypervisorFramework):
+ runtimeCombinationIsImplemented = bootMedia.media.kind == .linuxKernel
+ || bootMedia.media.kind == .installerISO
|| bootMedia.media.kind == .virtualDisk
|| (backend == .appleVirtualizationFramework
&& bootMedia.media.kind == .installedLinuxBootBundle)
+ case (.windows, .qemuHypervisorFramework):
+ runtimeCombinationIsImplemented = bootMedia.media.kind == .installerISO
+ || bootMedia.media.kind == .virtualDisk
case (.macOS, .appleVirtualizationFramework):
runtimeCombinationIsImplemented = bootMedia.media.kind == .macOSRestoreImage
|| bootMedia.media.kind == .virtualDisk
diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
index c5feed06..f1abeb52 100644
--- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
+++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
@@ -361,7 +361,7 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable
implementationIdentifier: "dory.raw-hv-linux.compatibility.v1",
guestFamilies: [.linux],
guestArchitectures: [.arm64],
- bootMediaKinds: [.installedLinuxBootBundle],
+ bootMediaKinds: [.linuxKernel, .installedLinuxBootBundle],
lifecycle: .currentMachineManager
)
@@ -382,15 +382,26 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable
executablePath: executablePath,
executableIsAvailable: executableIsAvailable,
operations: operations,
- validateMachine: { machine, _ in
+ validateMachine: { machine, capability in
guard machine.displayMode == .desktop else {
return "The current raw-HV machine path is implemented only for desktop Linux."
}
guard machine.installerISOPath == nil else {
return "The raw-HV machine path cannot boot attached installer media."
}
- guard machine.bootMode == .linuxKernel || machine.bootMode == .efi else {
- return "The machine boot mode is not implemented by the raw-HV adapter."
+ switch capability.request.bootMedia.kind {
+ case .linuxKernel:
+ guard machine.bootMode == .linuxKernel,
+ !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else {
+ return "A raw-HV Linux-kernel plan requires one raw direct-boot kernel."
+ }
+ case .installedLinuxBootBundle:
+ guard machine.bootMode == .efi,
+ DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else {
+ return "A raw-HV installed-Linux plan requires a verified boot bundle."
+ }
+ default:
+ return "The selected media is not implemented by the raw-HV adapter."
}
do {
guard try DoryDesktopVMMPreference(environment: machine.environment) != .compatible else {
@@ -399,10 +410,6 @@ public final class RawHVLinuxMachineBackend: MachineBackend, @unchecked Sendable
} catch {
return "The machine contains an invalid desktop backend preference."
}
- if machine.bootMode == .efi,
- !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) {
- return "An EFI-installed raw-HV machine requires a verified installed-Linux boot bundle."
- }
return nil
}
)
@@ -423,7 +430,7 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @
implementationIdentifier: "dory.vz-linux.compatibility.v1",
guestFamilies: [.linux],
guestArchitectures: [.arm64],
- bootMediaKinds: [.installedLinuxBootBundle, .installerISO, .virtualDisk],
+ bootMediaKinds: [.linuxKernel, .installedLinuxBootBundle, .installerISO, .virtualDisk],
lifecycle: .currentMachineManager
)
@@ -446,11 +453,17 @@ public final class VirtualizationFrameworkLinuxMachineBackend: MachineBackend, @
operations: operations,
validateMachine: { machine, capability in
switch capability.request.bootMedia.kind {
- case .installedLinuxBootBundle:
+ case .linuxKernel:
guard machine.bootMode == .linuxKernel,
+ machine.installerISOPath == nil,
+ !DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else {
+ return "A direct Linux kernel plan requires one raw kernel without installer media."
+ }
+ case .installedLinuxBootBundle:
+ guard machine.bootMode == .efi,
machine.installerISOPath == nil,
DoryInstalledLinuxBootBundle.isBundle(atPath: machine.kernelPath) else {
- return "A direct Linux VZ plan requires an installed-Linux boot bundle without installer media."
+ return "An installed Linux VZ plan requires a verified boot bundle without installer media."
}
case .installerISO:
guard machine.bootMode == .efi, machine.displayMode == .desktop,
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 8be92f52..7c0df1c6 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -5380,15 +5380,29 @@ public final class MachineManager: @unchecked Sendable {
if let currentRecord,
currentRecord.legacyConfigurationSHA256 == nil,
currentRecord.legacyMigrationFactsSHA256 == nil {
- guard Self.nativeDefinition(
+ let nativeDefinition: DoryVirtualMachineDefinition
+ if let migrated = try migrateNativeDirectKernelMediaIfNeeded(
currentRecord.definition,
+ compatibility: migration.definition
+ ) {
+ try workspaceRepository.replace(
+ migrated,
+ expectedRevision: currentRecord.definition.lifecycle.revision
+ )
+ nativeDefinition = migrated
+ reconcileState = .published
+ } else {
+ nativeDefinition = currentRecord.definition
+ reconcileState = .unchanged
+ }
+ guard Self.nativeDefinition(
+ nativeDefinition,
isCompatibleWith: migration.definition
) else {
throw DoryWorkspaceRepositoryError.staleLegacyProjection(machine.id)
}
- definition = currentRecord.definition
+ definition = nativeDefinition
isNative = true
- reconcileState = .unchanged
} else {
let result = try workspaceRepository.reconcileLegacyProjection(
migration.definition,
@@ -5418,6 +5432,44 @@ public final class MachineManager: @unchecked Sendable {
)
}
+ /// Native records written before the raw direct-kernel media kind existed used the portable
+ /// installed-bundle label for both shapes. The compatibility machine still proves which bytes
+ /// are actually launched, so upgrade only that one exact alias and advance desired-state
+ /// authority instead of continuing to misclassify a raw kernel at production trust time.
+ private func migrateNativeDirectKernelMediaIfNeeded(
+ _ definition: DoryVirtualMachineDefinition,
+ compatibility: DoryVirtualMachineDefinition
+ ) throws -> DoryVirtualMachineDefinition? {
+ guard compatibility.boot.devices.count == 1,
+ compatibility.boot.devices[0].kind == .linuxKernel,
+ definition.boot.devices.count == 1,
+ definition.boot.devices[0].kind == .installedLinuxBootBundle else {
+ return nil
+ }
+ var legacyCompatibility = compatibility
+ legacyCompatibility.boot.devices[0].kind = .installedLinuxBootBundle
+ guard Self.nativeDefinition(
+ definition,
+ isCompatibleWith: legacyCompatibility
+ ), definition.lifecycle.revision < UInt64.max,
+ definition.lifecycle.updatedAtUnixMilliseconds < Int64.max else {
+ return nil
+ }
+ var migrated = definition
+ migrated.boot = compatibility.boot
+ migrated.lifecycle = DoryVMLifecycleMetadata(
+ revision: definition.lifecycle.revision + 1,
+ createdAtUnixMilliseconds: definition.lifecycle.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: definition.lifecycle.updatedAtUnixMilliseconds + 1
+ )
+ guard migrated.validate().isEmpty else {
+ throw DoryWorkspaceRepositoryError.staleLegacyProjection(
+ definition.identity.id
+ )
+ }
+ return migrated
+ }
+
private static func nativeDefinition(
_ definition: DoryVirtualMachineDefinition,
isCompatibleWith compatibility: DoryVirtualMachineDefinition
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
index 710e98d9..fef9c11f 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineBackendPlannerTests.swift
@@ -68,6 +68,10 @@ struct DoryVirtualMachineBackendPlannerTests {
for: linux,
bootMedia: .installedLinuxBootBundle
) == [.doryHypervisor])
+ #expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends(
+ for: linux,
+ bootMedia: .linuxKernel
+ ) == [.doryHypervisor])
#expect(DoryAppleSiliconVirtualMachineBackendPlanner.defaultBackends(
for: macOS,
bootMedia: .macOSRestoreImage
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
index 48749748..be26b2f0 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineCapabilitiesTests.swift
@@ -150,7 +150,7 @@ struct VirtualMachineCapabilitiesTests {
== .trustedGuestImageGraphicsQualificationUnavailable)
}
- @Test("native hypervisor only accepts the post-install direct-boot bundle")
+ @Test("native hypervisor rejects installer media while VZ accepts it")
func nativeHypervisorRejectsInstallerISO() {
let native = evaluate(
family: .linux,
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
index b79a4767..280b1656 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
@@ -395,7 +395,7 @@ struct DoryDaemonVirtualMachineProductionTrustTests {
let completed = LockedPlanningCreateReply()
service.machineCreate([
"id": "qualified-headless",
- "kernelPath": fixture.mediaPath,
+ "kernelPath": fixture.directKernelPath,
"rootfsPath": qualifiedDisk,
"displayMode": "headless",
"memoryMB": UInt64(2_048),
@@ -776,6 +776,8 @@ private final class ProductionTrustFixture: @unchecked Sendable {
let runtimeBuildIdentifier: String
let mediaPath: String
let mediaDigest: String
+ let directKernelPath: String
+ let directKernelDigest: String
let mediaReference = DoryVMResolverReference(
namespace: "artifact",
identifier: "qualified-linux-boot"
@@ -855,6 +857,15 @@ private final class ProductionTrustFixture: @unchecked Sendable {
toPath: mediaPath
)
mediaDigest = try DoryComponentCatalogVerifier.fileDigest(mediaPath)
+ directKernelPath = root.appendingPathComponent("qualified-direct-kernel").path
+ try Data("qualified-direct-kernel".utf8).write(
+ to: URL(fileURLWithPath: directKernelPath)
+ )
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: directKernelPath
+ )
+ directKernelDigest = try DoryComponentCatalogVerifier.fileDigest(directKernelPath)
storagePath = root.appendingPathComponent("qualified-linux.raw").path
try Data(repeating: 0x5a, count: 4_096).write(
to: URL(fileURLWithPath: storagePath)
@@ -1152,24 +1163,30 @@ private final class ProductionTrustFixture: @unchecked Sendable {
VirtualizationFrameworkLinuxMachineBackend.backendDescriptor.implementationIdentifier,
"dory-vmm"),
]
+ let media = [
+ (DoryBootMediaKind.installedLinuxBootBundle, mediaDigest, "bundle"),
+ (DoryBootMediaKind.linuxKernel, directKernelDigest, "kernel"),
+ ]
let records = backends.flatMap { backend, implementation, component in
- [("minimum", devices), ("headless", headlessDevices)].map { suffix, devices in
- DoryVirtualMachineQualificationRecord(
- qualificationIdentity: "\(component)-\(suffix)-qualification",
- guest: guest,
- bootMediaKind: .installedLinuxBootBundle,
- bootMediaSource: .bundledByDory,
- immutableArtifactSHA256: mediaDigest,
- backend: backend,
- backendImplementationIdentifier: implementation,
- backendRuntimeBuildIdentifier: runtimeBuildIdentifier,
- virtualHardwareABIVersion: 1,
- graphics: .none,
- devices: devices,
- hostHardwareModelIdentifier: host.hardwareModelIdentifier,
- hostOperatingSystemBuild: host.operatingSystemBuild,
- components: [components.first { $0.componentIdentifier == component }!]
- )
+ media.flatMap { mediaKind, mediaDigest, mediaSuffix in
+ [("minimum", devices), ("headless", headlessDevices)].map { suffix, devices in
+ DoryVirtualMachineQualificationRecord(
+ qualificationIdentity: "\(component)-\(mediaSuffix)-\(suffix)-qualification",
+ guest: guest,
+ bootMediaKind: mediaKind,
+ bootMediaSource: .bundledByDory,
+ immutableArtifactSHA256: mediaDigest,
+ backend: backend,
+ backendImplementationIdentifier: implementation,
+ backendRuntimeBuildIdentifier: runtimeBuildIdentifier,
+ virtualHardwareABIVersion: 1,
+ graphics: .none,
+ devices: devices,
+ hostHardwareModelIdentifier: host.hardwareModelIdentifier,
+ hostOperatingSystemBuild: host.operatingSystemBuild,
+ components: [components.first { $0.componentIdentifier == component }!]
+ )
+ }
}
}
let manifest = DoryVirtualMachineQualificationManifest(
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
index 89acb085..323b5f0a 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineConfigurationMigrationTests.swift
@@ -57,7 +57,7 @@ struct DoryMachineConfigurationMigrationTests {
#expect(migrated.bootContract == .managedDirectKernel)
#expect(migrated.definition.workload == .desktop)
#expect(migrated.definition.boot.phase == .normal)
- #expect(migrated.definition.boot.devices[0].kind == .installedLinuxBootBundle)
+ #expect(migrated.definition.boot.devices[0].kind == .linuxKernel)
#expect(migrated.definition.boot.devices[0].source == .bundledByDory)
#expect(migrated.definition.backendPreference == DoryVMBackendPreference(
mode: .preferred,
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
index 93e09ed2..d3c7af94 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
@@ -8,7 +8,7 @@ final class MachineBackendTests: XCTestCase {
XCTAssertEqual(raw.identity, .doryHypervisor)
XCTAssertEqual(raw.guestFamilies, [.linux])
XCTAssertEqual(raw.guestArchitectures, [.arm64])
- XCTAssertEqual(raw.bootMediaKinds, [.installedLinuxBootBundle])
+ XCTAssertEqual(raw.bootMediaKinds, [.linuxKernel, .installedLinuxBootBundle])
XCTAssertEqual(raw.lifecycle, .currentMachineManager)
let vz = VirtualizationFrameworkLinuxMachineBackend.backendDescriptor
@@ -17,7 +17,7 @@ final class MachineBackendTests: XCTestCase {
XCTAssertEqual(vz.guestArchitectures, [.arm64])
XCTAssertEqual(
vz.bootMediaKinds,
- [.installedLinuxBootBundle, .installerISO, .virtualDisk]
+ [.linuxKernel, .installedLinuxBootBundle, .installerISO, .virtualDisk]
)
XCTAssertFalse(vz.lifecycle.pause)
XCTAssertFalse(vz.lifecycle.resume)
@@ -86,7 +86,7 @@ final class MachineBackendTests: XCTestCase {
machine: rawMachine(),
capabilityPlan: capabilityPlan(
backend: .doryHypervisor,
- media: .installedLinuxBootBundle
+ media: .linuxKernel
)
)
@@ -147,7 +147,7 @@ final class MachineBackendTests: XCTestCase {
id: "vz-direct-linux",
kernelPath: bundle.path,
rootfsPath: "/fixture/linux.raw",
- bootMode: .linuxKernel,
+ bootMode: .efi,
displayMode: .headless
)
let registry = try BackendRegistry(backends: [
@@ -228,7 +228,7 @@ final class MachineBackendTests: XCTestCase {
machine: machine,
capabilityPlan: capabilityPlan(
backend: .doryHypervisor,
- media: .installedLinuxBootBundle
+ media: .linuxKernel
)
))
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
index 6370b548..4f7455ad 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -753,6 +753,7 @@ struct MachineManagerResolvedPlanIntegrationTests {
#expect(createdRecord.legacyMigrationFactsSHA256 == nil)
#expect(createdRecord.definition.guestIdentityIntent.account?.username == "developer")
#expect(createdRecord.definition.backendPreference.backend == .doryHypervisor)
+ #expect(createdRecord.definition.boot.devices.first?.kind == .linuxKernel)
#expect(created.typedSettings?.graphicsPreference == .virgl)
#expect(createdRecord.definition.graphics.acceptableLevels == [.hostAcceleratedDisplay])
let snapshot = try manager.snapshot(id: "typed", snapshotID: "typed-baseline")
@@ -816,6 +817,44 @@ struct MachineManagerResolvedPlanIntegrationTests {
#expect(restartedStatus.runtimeIdentity.mode == .requiresReplanning)
}
+ @Test("native direct-kernel records upgrade the historical bundle alias exactly once")
+ func nativeDirectKernelAliasMigrates() throws {
+ let state = try makeState("native-kernel-media-upgrade")
+ defer { try? FileManager.default.removeItem(atPath: state) }
+ do {
+ let manager = makeManager(state: state, policy: .perWorkspaceAuthority)
+ _ = try createMachine(id: "native", manager: manager)
+ }
+
+ let repository = DoryWorkspaceRepository(root: state)
+ let current = try repository.readPersistedRecord(id: "native").definition
+ #expect(current.boot.devices.first?.kind == .linuxKernel)
+ var historicalAlias = current
+ historicalAlias.boot.devices[0].kind = .installedLinuxBootBundle
+ historicalAlias.lifecycle = DoryVMLifecycleMetadata(
+ revision: current.lifecycle.revision + 1,
+ createdAtUnixMilliseconds: current.lifecycle.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: current.lifecycle.updatedAtUnixMilliseconds + 1
+ )
+ try repository.replace(
+ historicalAlias,
+ expectedRevision: current.lifecycle.revision
+ )
+
+ let machineBytes = try Data(contentsOf: URL(
+ fileURLWithPath: state + "/native/machine.json"
+ ))
+ let restarted = makeManager(state: state, policy: .perWorkspaceAuthority)
+ let status = try #require(restarted.status(id: "native"))
+ #expect(status.runtimeIdentity.mode == .requiresReplanning)
+ let upgraded = try repository.readPersistedRecord(id: "native").definition
+ #expect(upgraded.boot.devices.first?.kind == .linuxKernel)
+ #expect(upgraded.lifecycle.revision == historicalAlias.lifecycle.revision + 1)
+ #expect(try Data(contentsOf: URL(
+ fileURLWithPath: state + "/native/machine.json"
+ )) == machineBytes)
+ }
+
@Test("native creation authority is durable before machine metadata becomes discoverable")
func nativeCreationCrashOrderingNeverMintsLegacy() throws {
let state = try makeState("native-create-order")
@@ -1424,7 +1463,7 @@ struct MachineManagerResolvedPlanIntegrationTests {
let artifact = digest("a")
let devices = DoryVirtualMachineDeviceCapabilityRequest.minimumBootable
let media = DoryBootMedia(
- kind: .installedLinuxBootBundle,
+ kind: request.definition.boot.devices[0].kind,
source: .userProvided,
artifactSHA256: artifact
)
diff --git a/scripts/build-components.py b/scripts/build-components.py
index 751faebc..cab3ac95 100755
--- a/scripts/build-components.py
+++ b/scripts/build-components.py
@@ -199,7 +199,7 @@ def validate_qualification_record(value: object, index: int) -> dict:
if guest["architecture"] not in {"arm64", "x86_64"}:
fail(f"{label} guest architecture is unsupported")
if record["bootMediaKind"] not in {
- "installer-iso", "virtual-disk", "installed-linux-boot-bundle",
+ "linux-kernel", "installer-iso", "virtual-disk", "installed-linux-boot-bundle",
"macos-restore-image",
}:
fail(f"{label} boot media kind is unsupported")
diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh
index 9483d4cd..c65b4345 100755
--- a/scripts/test-build-components.sh
+++ b/scripts/test-build-components.sh
@@ -58,7 +58,7 @@ manifest = {
"records": [{
"qualificationIdentity": "linux-desktop-arm64-mac16-1-25a1",
"guest": {"family": "linux", "architecture": "arm64"},
- "bootMediaKind": "installed-linux-boot-bundle",
+ "bootMediaKind": "linux-kernel",
"bootMediaSource": "dory-bundled",
"immutableArtifactSHA256": digest("a"),
"backend": "dory-hypervisor",
From a2e500be5195e549569cbf436932b2cf288ffa04 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 21:44:50 +0000
Subject: [PATCH 047/338] fix(components): bind qualifications to candidate
bytes
---
scripts/build-components.py | 76 ++++++++++++++++++++++++++++++++
scripts/test-build-components.sh | 69 +++++++++++++++++++++++++----
2 files changed, 137 insertions(+), 8 deletions(-)
diff --git a/scripts/build-components.py b/scripts/build-components.py
index cab3ac95..1646f82e 100755
--- a/scripts/build-components.py
+++ b/scripts/build-components.py
@@ -345,6 +345,77 @@ def load_qualification_manifest(
return manifest
+def validate_qualification_bindings(
+ manifest: dict,
+ *,
+ source_root: pathlib.Path,
+ core_app: pathlib.Path,
+) -> None:
+ """Bind catalog-authenticated qualification claims to the exact shipped candidate bytes."""
+ runtime_contracts = {
+ "dory-hypervisor": (
+ "dory.raw-hv-linux.compatibility.v1",
+ "dory-hv",
+ ),
+ "apple-virtualization-framework": (
+ "dory.vz-linux.compatibility.v1",
+ "dory-vmm",
+ ),
+ }
+ runtime_evidence: dict[str, tuple[str, str]] = {}
+ for backend in {record["backend"] for record in manifest["records"]}:
+ if backend not in runtime_contracts:
+ fail(f"qualification backend is not shipped by the current Apple Silicon product: {backend}")
+ implementation, component = runtime_contracts[backend]
+ helper = regular_file(
+ core_app / "Contents" / "Helpers" / component,
+ f"{component} candidate helper",
+ )
+ if helper.stat().st_mode & 0o111 == 0:
+ fail(f"{component} candidate helper is not executable")
+ digest = sha256(helper)
+ runtime_evidence[backend] = (implementation, digest)
+
+ bundled_media = {
+ "linux-kernel": {
+ sha256(regular_file(source_root / "Image", "headless Linux kernel")),
+ sha256(regular_file(source_root / "Image-desktop", "desktop Linux kernel")),
+ },
+ "virtual-disk": {
+ sha256(regular_file(source_root / "initfs-arm64.ext4", "headless Linux rootfs")),
+ *{
+ sha256(regular_file(
+ source_root / f"dory-desktop-{distro}-rootfs-arm64.ext4",
+ f"{distro} desktop rootfs",
+ ))
+ for distro in ("debian", "ubuntu", "kali")
+ },
+ },
+ }
+
+ for index, record in enumerate(manifest["records"]):
+ label = f"qualification record {index}"
+ implementation, runtime_digest = runtime_evidence[record["backend"]]
+ if record["backendImplementationIdentifier"] != implementation:
+ fail(f"{label} implementation does not match the shipped adapter")
+ expected_build = f"sha256:{runtime_digest}"
+ if record["backendRuntimeBuildIdentifier"] != expected_build:
+ fail(f"{label} runtime build does not match the shipped helper")
+ expected_components = [{
+ "componentIdentifier": runtime_contracts[record["backend"]][1],
+ "buildIdentifier": expected_build,
+ "artifactSHA256": runtime_digest,
+ }]
+ if record["components"] != expected_components:
+ fail(f"{label} component evidence does not match the shipped helper")
+
+ if record["bootMediaSource"] == "dory-bundled":
+ accepted = bundled_media.get(record["bootMediaKind"])
+ digest = record.get("immutableArtifactSHA256")
+ if accepted is None or digest not in accepted:
+ fail(f"{label} bundled media digest is not an exact packaged artifact")
+
+
def component_specs(
source_root: pathlib.Path,
kubectl: pathlib.Path,
@@ -610,6 +681,11 @@ def main() -> None:
release_version=args.version,
public_key_base64=args.catalog_public_key,
)
+ validate_qualification_bindings(
+ qualification_manifest,
+ source_root=source_root,
+ core_app=core_app,
+ )
if not args.skip_source_verification:
validate_sources(repo, source_root, kubectl)
diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh
index c65b4345..e49f08fd 100755
--- a/scripts/test-build-components.sh
+++ b/scripts/test-build-components.sh
@@ -9,7 +9,7 @@ SOURCE="$TMP/source"
CORE_APP="$TMP/Dory.app"
OUTPUT="$TMP/components/arm64"
QUALIFICATION="$TMP/virtual-machine-qualification.json"
-mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS"
+mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS" "$CORE_APP/Contents/Helpers"
write_fixture() {
local path="$1" bytes="$2"
@@ -19,6 +19,9 @@ write_fixture() {
write_fixture "$TMP/Dory-test.dmg" 4096
write_fixture "$CORE_APP/Contents/MacOS/Dory" 8192
+write_fixture "$CORE_APP/Contents/Helpers/dory-hv" 4096
+write_fixture "$CORE_APP/Contents/Helpers/dory-vmm" 4096
+chmod 0755 "$CORE_APP/Contents/Helpers/dory-hv" "$CORE_APP/Contents/Helpers/dory-vmm"
write_fixture "$TMP/kubectl" 16384
chmod 0755 "$TMP/kubectl"
write_fixture "$SOURCE/Image" 131072
@@ -39,7 +42,7 @@ for distro in debian ubuntu kali; do
done
printf 'schema=fixture\n' > "$SOURCE/kernel-build-arm64-desktop.stamp"
-python3 - "$QUALIFICATION" <<'PY'
+python3 - "$QUALIFICATION" "$SOURCE" "$CORE_APP" <<'PY'
import base64
import hashlib
import json
@@ -48,6 +51,10 @@ import sys
key = base64.b64decode("AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=")
digest = lambda byte: byte * 64
+source = pathlib.Path(sys.argv[2])
+app = pathlib.Path(sys.argv[3])
+file_digest = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
+runtime_digest = file_digest(app / "Contents/Helpers/dory-hv")
manifest = {
"kind": "dev.dory.virtual-machine-qualification-manifest",
"schemaVersion": 1,
@@ -60,10 +67,10 @@ manifest = {
"guest": {"family": "linux", "architecture": "arm64"},
"bootMediaKind": "linux-kernel",
"bootMediaSource": "dory-bundled",
- "immutableArtifactSHA256": digest("a"),
+ "immutableArtifactSHA256": file_digest(source / "Image-desktop"),
"backend": "dory-hypervisor",
- "backendImplementationIdentifier": "dory.raw-hv-linux",
- "backendRuntimeBuildIdentifier": "9.8.7",
+ "backendImplementationIdentifier": "dory.raw-hv-linux.compatibility.v1",
+ "backendRuntimeBuildIdentifier": f"sha256:{runtime_digest}",
"virtualHardwareABIVersion": 1,
"graphics": "hardware-accelerated-3d",
"devices": {
@@ -82,8 +89,8 @@ manifest = {
"hostOperatingSystemBuild": "25A1",
"components": [{
"componentIdentifier": "dory-hv",
- "buildIdentifier": "9.8.7",
- "artifactSHA256": digest("b"),
+ "buildIdentifier": f"sha256:{runtime_digest}",
+ "artifactSHA256": runtime_digest,
}],
"virtioGPUKernelAndDeviceSupportQualified": True,
"venusVulkanGuestRuntimeQualified": True,
@@ -139,7 +146,7 @@ assert [item["id"] for item in catalog["components"]] == [
]
assert catalog["components"][0]["assets"] == []
assert catalog["components"][0]["downloadBytes"] == 4096
-assert catalog["components"][0]["installedBytes"] == 8192
+assert catalog["components"][0]["installedBytes"] == 16384
qualification = catalog["virtualMachineQualification"]
assert qualification["component"] == "linux-desktop"
assert qualification["path"] == "virtual-machine-qualification.json"
@@ -234,6 +241,52 @@ if "$ROOT/scripts/build-components.py" \
exit 1
fi
+cp "$QUALIFICATION" "$TMP/mismatched-helper-qualification.json"
+python3 - "$TMP/mismatched-helper-qualification.json" <<'PY'
+import json
+import pathlib
+import sys
+path = pathlib.Path(sys.argv[1])
+manifest = json.loads(path.read_text(encoding="utf-8"))
+manifest["records"][0]["components"][0]["artifactSHA256"] = "c" * 64
+path.write_text(json.dumps(manifest) + "\n", encoding="utf-8")
+PY
+if "$ROOT/scripts/build-components.py" \
+ --version 9.8.7 \
+ --core-artifact "$TMP/Dory-test.dmg" \
+ --core-app "$CORE_APP" \
+ --kubectl "$TMP/kubectl" \
+ --source-root "$SOURCE" \
+ --output "$TMP/mismatched-helper-components" \
+ --qualification-manifest "$TMP/mismatched-helper-qualification.json" \
+ --skip-source-verification 2>/dev/null; then
+ echo "component packaging accepted qualification evidence for different helper bytes" >&2
+ exit 1
+fi
+
+cp "$QUALIFICATION" "$TMP/mismatched-media-qualification.json"
+python3 - "$TMP/mismatched-media-qualification.json" <<'PY'
+import json
+import pathlib
+import sys
+path = pathlib.Path(sys.argv[1])
+manifest = json.loads(path.read_text(encoding="utf-8"))
+manifest["records"][0]["immutableArtifactSHA256"] = "d" * 64
+path.write_text(json.dumps(manifest) + "\n", encoding="utf-8")
+PY
+if "$ROOT/scripts/build-components.py" \
+ --version 9.8.7 \
+ --core-artifact "$TMP/Dory-test.dmg" \
+ --core-app "$CORE_APP" \
+ --kubectl "$TMP/kubectl" \
+ --source-root "$SOURCE" \
+ --output "$TMP/mismatched-media-components" \
+ --qualification-manifest "$TMP/mismatched-media-qualification.json" \
+ --skip-source-verification 2>/dev/null; then
+ echo "component packaging accepted qualification evidence for different bundled media" >&2
+ exit 1
+fi
+
if "$ROOT/scripts/build-components.py" \
--version 9.8.7 \
--core-artifact "$TMP/Dory-test.dmg" \
From 27fa6b3b92b5bd2acfb8a478b8b82c7dddd7f670 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 21:52:45 +0000
Subject: [PATCH 048/338] feat(ui): surface machine runtime evidence
---
Dory/Features/Machines/MachinesView.swift | 42 +++++++
Dory/Models/AppStore.swift | 1 +
Dory/Models/Models.swift | 103 ++++++++++++++++++
Dory/Runtime/Doryd/DorydClient.swift | 5 +
DoryTests/DorydClientTests.swift | 38 +++++++
.../Sources/DorydKit/DorydService.swift | 1 +
6 files changed, 190 insertions(+)
diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift
index 45cbc1fa..643f4722 100644
--- a/Dory/Features/Machines/MachinesView.swift
+++ b/Dory/Features/Machines/MachinesView.swift
@@ -165,6 +165,9 @@ private struct MachineCard: View {
.padding(.bottom, 12)
}
+ runtimeEvidence
+ .padding(.bottom, 12)
+
if let command = store.machineTerminalCommand(machine) {
HStack(spacing: 6) {
Image(systemName: "terminal").font(.system(size: 11)).foregroundStyle(p.text3)
@@ -313,6 +316,45 @@ private struct MachineCard: View {
}
}
+ private var runtimeEvidence: some View {
+ LazyVGrid(
+ columns: [GridItem(.adaptive(minimum: 104, maximum: 190), spacing: 6)],
+ alignment: .leading,
+ spacing: 6
+ ) {
+ ForEach(machine.runtimeEvidence) { evidence in
+ HStack(spacing: 4) {
+ Image(systemName: evidence.systemImage)
+ .font(.system(size: 9, weight: .semibold))
+ Text(evidence.label)
+ .font(.system(size: 10, weight: .semibold))
+ .lineLimit(1)
+ }
+ .foregroundStyle(runtimeEvidenceColor(evidence.tone))
+ .padding(.horizontal, 7)
+ .padding(.vertical, 4)
+ .background(runtimeEvidenceBackground(evidence.tone), in: Capsule())
+ .help(evidence.detail)
+ }
+ }
+ }
+
+ private func runtimeEvidenceColor(_ tone: MachineRuntimeEvidenceTone) -> Color {
+ switch tone {
+ case .standard: p.text2
+ case .positive: p.green
+ case .warning: p.amber
+ }
+ }
+
+ private func runtimeEvidenceBackground(_ tone: MachineRuntimeEvidenceTone) -> Color {
+ switch tone {
+ case .standard: p.pill
+ case .positive: p.greenWeak
+ case .warning: p.amberWeak
+ }
+ }
+
private func actionButton(_ systemImage: String, _ title: String, prominent: Bool, enabled: Bool = true, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 6) {
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index 300937e7..be0055fa 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5192,6 +5192,7 @@ final class AppStore {
bootMode: status.bootMode,
installerMediaAttached: status.installerMediaAttached,
runtimeIdentity: status.runtimeIdentity,
+ agentBuild: status.agentBuild,
mounts: status.shares.map(Self.mountPair(fromDoryd:))
)
}
diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift
index 07022f82..bc50ab9d 100644
--- a/Dory/Models/Models.swift
+++ b/Dory/Models/Models.swift
@@ -302,12 +302,115 @@ struct Machine: Identifiable, Hashable, Sendable {
var bootMode: MachineBootMode = .linuxKernel
var installerMediaAttached: Bool = false
var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
+ var agentBuild: String? = nil
var mounts: [MountPair] = []
var id: String { name }
var badgeColor: Color { Color(hex: badgeHex) }
var actionLabel: String { status == .running ? "Stop" : "Start" }
var isEmulated: Bool { !arch.isEmpty && arch != MachineArch.host.rawValue }
+
+ var runtimeEvidence: [MachineRuntimeEvidence] {
+ var evidence: [MachineRuntimeEvidence] = []
+ switch runtimeIdentity.mode {
+ case "resolved-plan":
+ evidence.append(MachineRuntimeEvidence(
+ id: "authority",
+ label: runtimeIdentity.supportTier == "supported" ? "Supported" : "Preview",
+ systemImage: runtimeIdentity.supportTier == "supported"
+ ? "checkmark.seal.fill" : "exclamationmark.triangle.fill",
+ tone: runtimeIdentity.supportTier == "supported" ? .positive : .warning,
+ detail: runtimeIdentity.runtimeQualification?.qualificationIdentity
+ ?? "Resolved plan \(runtimeIdentity.planRevision ?? 0)"
+ ))
+ if let backend = runtimeIdentity.backend {
+ evidence.append(MachineRuntimeEvidence(
+ id: "backend",
+ label: Self.backendLabel(backend),
+ systemImage: "cpu",
+ tone: .standard,
+ detail: runtimeIdentity.backendRuntimeBuildIdentifier ?? backend
+ ))
+ }
+ if displayMode == .desktop {
+ evidence.append(MachineRuntimeEvidence(
+ id: "graphics",
+ label: Self.graphicsLabel(runtimeIdentity.graphics),
+ systemImage: "display",
+ tone: runtimeIdentity.graphics == "hardware-accelerated-3d"
+ ? .positive : .standard,
+ detail: runtimeIdentity.graphicsQualification?.manifestIdentity
+ ?? "No separate signed graphics qualification"
+ ))
+ }
+ if runtimeIdentity.selectionDisposition == "approved-fallback" {
+ evidence.append(MachineRuntimeEvidence(
+ id: "fallback",
+ label: "Approved fallback",
+ systemImage: "arrow.triangle.branch",
+ tone: .warning,
+ detail: runtimeIdentity.fallbackAuthorizationIdentity ?? "Approved alternative"
+ ))
+ }
+ case "requires-replanning":
+ evidence.append(MachineRuntimeEvidence(
+ id: "authority",
+ label: "Needs planning",
+ systemImage: "exclamationmark.triangle.fill",
+ tone: .warning,
+ detail: runtimeIdentity.invalidationReason ?? "No current launch plan"
+ ))
+ default:
+ evidence.append(MachineRuntimeEvidence(
+ id: "authority",
+ label: "Compatibility",
+ systemImage: "arrow.triangle.2.circlepath",
+ tone: .standard,
+ detail: "Legacy compatibility launch authority"
+ ))
+ }
+ evidence.append(MachineRuntimeEvidence(
+ id: "tools",
+ label: agentBuild == nil ? "Tools unavailable" : "Tools ready",
+ systemImage: agentBuild == nil ? "wrench.and.screwdriver" : "wrench.and.screwdriver.fill",
+ tone: agentBuild == nil ? .warning : .positive,
+ detail: agentBuild ?? "The guest has not reported a Dory Tools build"
+ ))
+ return evidence
+ }
+
+ private static func backendLabel(_ backend: String) -> String {
+ switch backend {
+ case "dory-hypervisor": "Raw HV"
+ case "apple-virtualization-framework": "Virtualization.framework"
+ case "qemu-hvf": "QEMU/HVF"
+ default: backend
+ }
+ }
+
+ private static func graphicsLabel(_ graphics: String?) -> String {
+ switch graphics {
+ case "hardware-accelerated-3d": "Qualified 3D"
+ case "host-accelerated-display": "Accelerated display"
+ case "software": "Software graphics"
+ case "none": "No graphics"
+ default: "Graphics unknown"
+ }
+ }
+}
+
+enum MachineRuntimeEvidenceTone: Hashable, Sendable {
+ case standard
+ case positive
+ case warning
+}
+
+struct MachineRuntimeEvidence: Identifiable, Hashable, Sendable {
+ var id: String
+ var label: String
+ var systemImage: String
+ var tone: MachineRuntimeEvidenceTone
+ var detail: String
}
enum LogLevel: String, Sendable {
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index 0eb4a454..ac925b68 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -450,6 +450,7 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha
var backendImplementationIdentifier: String? = nil
var backendRuntimeBuildIdentifier: String? = nil
var supportTier: String? = nil
+ var graphics: String? = nil
var selectionDisposition: String? = nil
var fallbackAuthorizationIdentity: String? = nil
var experimentalAuthorizationIdentity: String? = nil
@@ -492,6 +493,7 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha
&& backendImplementationIdentifier == nil
&& backendRuntimeBuildIdentifier == nil
&& supportTier == nil
+ && graphics == nil
&& selectionDisposition == nil
&& fallbackAuthorizationIdentity == nil
&& experimentalAuthorizationIdentity == nil
@@ -512,6 +514,9 @@ nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Ha
backendImplementationIdentifier?.isSafeEvidenceIdentifier == true,
backendRuntimeBuildIdentifier?.isSafeEvidenceIdentifier == true,
let supportTier,
+ let graphics,
+ ["none", "software", "host-accelerated-display", "hardware-accelerated-3d"]
+ .contains(graphics),
let selectionDisposition,
["primary", "explicit-alternative", "approved-fallback"]
.contains(selectionDisposition),
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 0a307db3..fc557fea 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -469,6 +469,10 @@ struct DorydClientTests {
"artifactSHA256": String(repeating: "6", count: 64),
"provenanceReceiptIdentity": "orphan-receipt",
]
+ let unsupportedGraphics = NSMutableDictionary(
+ dictionary: validResolvedRuntimeIdentity()
+ )
+ unsupportedGraphics["graphics"] = "automatic"
for identity in [
[
"schemaVersion": 2,
@@ -488,6 +492,7 @@ struct DorydClientTests {
resolvedWithoutComponentsOrMedia,
mixedQualification,
orphanProvenance,
+ unsupportedGraphics,
] {
let listener = NSXPCListener.anonymous()
let service = FakeDorydService(runtimeIdentityOverride: identity)
@@ -518,6 +523,38 @@ struct DorydClientTests {
}
}
+ @Test func machineRuntimeEvidenceSurfacesPlanGraphicsBackendAndGuestTools() async throws {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService(runtimeIdentityOverride: validResolvedRuntimeIdentity())
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+
+ let status = try #require(
+ (try await DorydClient(endpoint: listener.endpoint).machineList()).first
+ )
+ let machine = AppStore.machine(fromDoryd: status)
+ #expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d")
+ #expect(machine.runtimeEvidence.map(\.label) == [
+ "Supported", "Raw HV", "Qualified 3D", "Tools ready",
+ ])
+ #expect(machine.runtimeEvidence.first { $0.id == "authority" }?.detail
+ == "runtime-qualification-1")
+
+ var replanning = machine
+ replanning.runtimeIdentity = DorydMachineRuntimeIdentity(
+ schemaVersion: 1,
+ mode: "requires-replanning",
+ virtualHardwareABIVersion: 1,
+ invalidationReason: "restored-snapshot"
+ )
+ replanning.agentBuild = nil
+ #expect(replanning.runtimeEvidence.map(\.label) == [
+ "Needs planning", "Tools unavailable",
+ ])
+ }
+
@Test func snapshotArtifactEvidenceIsAbsentOnlyForLegacyAndOtherwiseExact() async throws {
let malformedEvidence = [
"schemaVersion": 1,
@@ -740,6 +777,7 @@ struct DorydClientTests {
"backendImplementationIdentifier": "dev.dory.raw-hv-linux",
"backendRuntimeBuildIdentifier": "runtime-1",
"supportTier": "supported",
+ "graphics": "hardware-accelerated-3d",
"selectionDisposition": "primary",
"runtimeQualification": [
"qualificationIdentity": "runtime-qualification-1",
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index ded030ef..25ed532b 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -1707,6 +1707,7 @@ private extension DoryMachineRuntimeIdentity {
dictionary["backendImplementationIdentifier"] = plan.backendImplementationIdentifier
dictionary["backendRuntimeBuildIdentifier"] = plan.backendRuntimeBuildIdentifier
dictionary["supportTier"] = plan.supportTier.rawValue
+ dictionary["graphics"] = plan.graphics.rawValue
if let selectionDisposition = plan.selectionEvidence?.disposition {
dictionary["selectionDisposition"] = selectionDisposition.rawValue
}
From 31f2451c1b774f2981b781337a49640fb912288e Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 22:13:47 +0000
Subject: [PATCH 049/338] feat(components): bind catalog v2 authority
---
.../DoryOperations/DoryComponents.swift | 232 +++++++++++++++++-
.../DoryComponentsTests.swift | 164 +++++++++++++
...ualMachineQualificationManifestTests.swift | 30 ++-
...onVirtualMachineProductionTrustTests.swift | 30 ++-
...yInstalledDesktopPayloadReceiptTests.swift | 1 +
scripts/build-components.py | 121 ++++++++-
scripts/test-build-components.sh | 31 +++
7 files changed, 593 insertions(+), 16 deletions(-)
diff --git a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift
index a1769191..4f610444 100644
--- a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift
+++ b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift
@@ -2,6 +2,7 @@ import Compression
import CryptoKit
import Darwin
import Foundation
+import Security
public enum DoryComponentID: String, Codable, CaseIterable, Hashable, Sendable {
case dockerCore = "docker-core"
@@ -88,6 +89,49 @@ public enum DoryComponentCompression: String, Codable, Sendable {
case lzfse
}
+public enum DoryComponentArtifactRole: String, Codable, Sendable {
+ case hostHelper = "host-helper"
+ case hostCLI = "host-cli"
+ case guestKernel = "guest-kernel"
+ case guestDisk = "guest-disk"
+ case guestUpdate = "guest-update"
+ case guestPackageManifest = "guest-package-manifest"
+ case qualificationEvidence = "qualification-evidence"
+ case buildMetadata = "build-metadata"
+}
+
+public struct DoryComponentHostRequirements: Codable, Sendable, Equatable {
+ public let platform: String
+ public let minimumVersion: String
+
+ public init(platform: String, minimumVersion: String) {
+ self.platform = platform
+ self.minimumVersion = minimumVersion
+ }
+}
+
+public struct DoryComponentProvenance: Codable, Sendable, Equatable {
+ public let sourceCommit: String
+ public let builder: String
+ public let recipeDigest: String
+ public let sbomDigest: String
+ public let attestationDigest: String
+
+ public init(
+ sourceCommit: String,
+ builder: String,
+ recipeDigest: String,
+ sbomDigest: String,
+ attestationDigest: String
+ ) {
+ self.sourceCommit = sourceCommit
+ self.builder = builder
+ self.recipeDigest = recipeDigest.lowercased()
+ self.sbomDigest = sbomDigest.lowercased()
+ self.attestationDigest = attestationDigest.lowercased()
+ }
+}
+
public struct DoryComponentAsset: Codable, Sendable, Equatable {
public let path: String
public let url: String
@@ -97,6 +141,8 @@ public struct DoryComponentAsset: Codable, Sendable, Equatable {
public let sha256: String
public let installedSHA256: String
public let executable: Bool
+ public let role: DoryComponentArtifactRole?
+ public let codeRequirement: String?
public init(
path: String,
@@ -106,7 +152,9 @@ public struct DoryComponentAsset: Codable, Sendable, Equatable {
installedBytes: UInt64,
sha256: String,
installedSHA256: String,
- executable: Bool = false
+ executable: Bool = false,
+ role: DoryComponentArtifactRole? = nil,
+ codeRequirement: String? = nil
) {
self.path = path
self.url = url
@@ -116,6 +164,8 @@ public struct DoryComponentAsset: Codable, Sendable, Equatable {
self.sha256 = sha256.lowercased()
self.installedSHA256 = installedSHA256.lowercased()
self.executable = executable
+ self.role = role
+ self.codeRequirement = codeRequirement
}
}
@@ -128,6 +178,12 @@ public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable {
public let downloadBytes: UInt64
public let installedBytes: UInt64
public let assets: [DoryComponentAsset]
+ public let architectures: [String]?
+ public let hostRequirements: DoryComponentHostRequirements?
+ public let provides: [String]?
+ public let requires: [String]?
+ public let provenance: DoryComponentProvenance?
+ public let qualification: [String]?
public init(
id: DoryComponentID,
@@ -137,7 +193,13 @@ public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable {
dependencies: [DoryComponentID] = [.dockerCore],
downloadBytes: UInt64,
installedBytes: UInt64,
- assets: [DoryComponentAsset]
+ assets: [DoryComponentAsset],
+ architectures: [String]? = nil,
+ hostRequirements: DoryComponentHostRequirements? = nil,
+ provides: [String]? = nil,
+ requires: [String]? = nil,
+ provenance: DoryComponentProvenance? = nil,
+ qualification: [String]? = nil
) {
self.id = id
self.version = version
@@ -147,6 +209,12 @@ public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable {
self.downloadBytes = downloadBytes
self.installedBytes = installedBytes
self.assets = assets
+ self.architectures = architectures
+ self.hostRequirements = hostRequirements
+ self.provides = provides
+ self.requires = requires
+ self.provenance = provenance
+ self.qualification = qualification
}
}
@@ -313,7 +381,12 @@ public enum DoryComponentCatalogVerifier {
throw DoryComponentError.invalidCatalog("Docker Core must be the first, unique, payload-free entry")
}
for component in catalog.components {
- try validate(component, available: Set(ids))
+ try validate(
+ component,
+ available: Set(ids),
+ schemaVersion: catalog.schemaVersion,
+ catalogArchitecture: catalog.architecture
+ )
}
if catalog.schemaVersion == DoryComponentCatalog.oldestSupportedSchemaVersion,
catalog.virtualMachineQualification != nil {
@@ -322,14 +395,16 @@ public enum DoryComponentCatalogVerifier {
)
}
if let qualification = catalog.virtualMachineQualification {
+ let qualificationComponent = catalog.component(qualification.component)
guard catalog.schemaVersion == DoryComponentCatalog.schemaVersion,
qualification.component.isRemovable,
safeRelativePath(qualification.path),
!qualification.manifestIdentity.isEmpty,
qualification.manifestFormatVersion > 0,
!qualification.signingKeyID.isEmpty,
- catalog.component(qualification.component)?.assets.contains(where: {
- $0.path == qualification.path
+ qualificationComponent?.qualification?.isEmpty == false,
+ qualificationComponent?.assets.contains(where: {
+ $0.path == qualification.path && $0.role == .qualificationEvidence
}) == true else {
throw DoryComponentError.invalidCatalog(
"VM qualification authority is incomplete or is not a declared component asset"
@@ -358,7 +433,12 @@ public enum DoryComponentCatalogVerifier {
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
- private static func validate(_ component: DoryComponentRelease, available: Set) throws {
+ private static func validate(
+ _ component: DoryComponentRelease,
+ available: Set,
+ schemaVersion: Int,
+ catalogArchitecture: String
+ ) throws {
guard validVersion(component.version),
!component.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!component.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
@@ -367,6 +447,24 @@ public enum DoryComponentCatalogVerifier {
component.dependencies.allSatisfy(available.contains) else {
throw DoryComponentError.invalidCatalog("invalid metadata for \(component.id.rawValue)")
}
+ if schemaVersion == DoryComponentCatalog.oldestSupportedSchemaVersion {
+ guard component.architectures == nil,
+ component.hostRequirements == nil,
+ component.provides == nil,
+ component.requires == nil,
+ component.provenance == nil,
+ component.qualification == nil,
+ component.assets.allSatisfy({ $0.role == nil && $0.codeRequirement == nil }) else {
+ throw DoryComponentError.invalidCatalog(
+ "schema-v1 component contains schema-v2 authority"
+ )
+ }
+ } else {
+ try validateVersionTwoMetadata(
+ component,
+ catalogArchitecture: catalogArchitecture
+ )
+ }
if component.id == .dockerCore {
guard component.dependencies.isEmpty,
component.downloadBytes > 0,
@@ -402,6 +500,57 @@ public enum DoryComponentCatalogVerifier {
}
}
+ private static func validateVersionTwoMetadata(
+ _ component: DoryComponentRelease,
+ catalogArchitecture: String
+ ) throws {
+ guard component.architectures == [catalogArchitecture],
+ let host = component.hostRequirements,
+ host.platform == "macos",
+ validVersion(host.minimumVersion),
+ let provides = component.provides,
+ !provides.isEmpty,
+ Set(provides).count == provides.count,
+ provides.allSatisfy(validContractReference),
+ let requires = component.requires,
+ Set(requires).count == requires.count,
+ requires.allSatisfy(validContractReference),
+ let provenance = component.provenance,
+ validSourceCommit(provenance.sourceCommit),
+ validBoundedIdentifier(provenance.builder),
+ validDigest(provenance.recipeDigest),
+ validDigest(provenance.sbomDigest),
+ validDigest(provenance.attestationDigest),
+ let qualification = component.qualification,
+ Set(qualification).count == qualification.count,
+ qualification.allSatisfy(validBoundedIdentifier) else {
+ throw DoryComponentError.invalidCatalog(
+ "incomplete schema-v2 authority for \(component.id.rawValue)"
+ )
+ }
+ for asset in component.assets {
+ guard let role = asset.role else {
+ throw DoryComponentError.invalidCatalog(
+ "schema-v2 asset role is missing for \(asset.path)"
+ )
+ }
+ if asset.executable {
+ guard role == .hostHelper || role == .hostCLI,
+ let requirement = asset.codeRequirement,
+ validCodeRequirement(requirement) else {
+ throw DoryComponentError.invalidCatalog(
+ "executable authority is incomplete for \(asset.path)"
+ )
+ }
+ } else if asset.codeRequirement != nil
+ || role == .hostHelper || role == .hostCLI {
+ throw DoryComponentError.invalidCatalog(
+ "non-executable asset has executable authority for \(asset.path)"
+ )
+ }
+ }
+ }
+
private static func validateDependencyGraph(_ components: [DoryComponentRelease]) throws {
let byID = Dictionary(uniqueKeysWithValues: components.map { ($0.id, $0) })
func visit(_ id: DoryComponentID, path: Set) throws {
@@ -418,7 +567,43 @@ public enum DoryComponentCatalogVerifier {
}
private static func validDigest(_ value: String) -> Bool {
- value.count == 64 && value.allSatisfy { $0.isHexDigit }
+ value.utf8.count == 64 && value.utf8.allSatisfy {
+ ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102)
+ }
+ }
+
+ private static func validSourceCommit(_ value: String) -> Bool {
+ (value.utf8.count == 40 || value.utf8.count == 64) && value.utf8.allSatisfy {
+ ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102)
+ }
+ }
+
+ private static func validBoundedIdentifier(_ value: String) -> Bool {
+ !value.isEmpty && value.utf8.count <= 256 && value.utf8.allSatisfy {
+ ($0 >= 48 && $0 <= 57)
+ || ($0 >= 65 && $0 <= 90)
+ || ($0 >= 97 && $0 <= 122)
+ || ".,_:+@/-".utf8.contains($0)
+ }
+ }
+
+ private static func validContractReference(_ value: String) -> Bool {
+ !value.isEmpty && value.utf8.count <= 256 && value.utf8.allSatisfy {
+ ($0 >= 48 && $0 <= 57)
+ || ($0 >= 65 && $0 <= 90)
+ || ($0 >= 97 && $0 <= 122)
+ || ".:_@/+<>=-".utf8.contains($0)
+ }
+ }
+
+ private static func validCodeRequirement(_ value: String) -> Bool {
+ guard !value.isEmpty, value.utf8.count <= 4_096 else { return false }
+ var requirement: SecRequirement?
+ return SecRequirementCreateWithString(
+ value as CFString,
+ SecCSFlags(),
+ &requirement
+ ) == errSecSuccess && requirement != nil
}
private static func validVersion(_ value: String) -> Bool {
@@ -745,6 +930,7 @@ public struct DoryComponentStore: Sendable {
ofItemAtPath: output
)
try verifyFile(output, bytes: asset.installedBytes, digest: asset.installedSHA256)
+ try Self.verifyCodeRequirement(output, requirement: asset.codeRequirement)
try Self.syncFile(output)
}
let record = DoryInstalledComponent(
@@ -798,6 +984,10 @@ public struct DoryComponentStore: Sendable {
bytes: asset.installedBytes,
digest: asset.installedSHA256
)
+ try Self.verifyCodeRequirement(
+ payload + "/" + asset.path,
+ requirement: asset.codeRequirement
+ )
}
}
@@ -1045,6 +1235,34 @@ public struct DoryComponentStore: Sendable {
)
}
+ private static func verifyCodeRequirement(
+ _ path: String,
+ requirement requirementText: String?
+ ) throws {
+ guard let requirementText else { return }
+ var staticCode: SecStaticCode?
+ var requirement: SecRequirement?
+ guard SecStaticCodeCreateWithPath(
+ URL(fileURLWithPath: path) as CFURL,
+ SecCSFlags(),
+ &staticCode
+ ) == errSecSuccess,
+ let staticCode,
+ SecRequirementCreateWithString(
+ requirementText as CFString,
+ SecCSFlags(),
+ &requirement
+ ) == errSecSuccess,
+ let requirement,
+ SecStaticCodeCheckValidity(
+ staticCode,
+ SecCSFlags(rawValue: kSecCSCheckAllArchitectures),
+ requirement
+ ) == errSecSuccess else {
+ throw DoryComponentError.invalidAsset(path)
+ }
+ }
+
private func readRecord(_ type: T.Type, at path: String) throws -> T {
guard let data = try? PrivateRecordFile.read(at: path, maximumBytes: 2 * 1_024 * 1_024),
let value = try? JSONDecoder().decode(type, from: data) else {
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift
index 9f597bc6..bd05154d 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift
@@ -101,6 +101,130 @@ final class DoryComponentsTests: XCTestCase {
}
}
+ func testSchemaTwoBindsCapabilitiesHostArtifactsProvenanceAndQualification() throws {
+ let provenance = DoryComponentProvenance(
+ sourceCommit: String(repeating: "a", count: 40),
+ builder: "dory.release.apple-silicon",
+ recipeDigest: String(repeating: "b", count: 64),
+ sbomDigest: String(repeating: "c", count: 64),
+ attestationDigest: String(repeating: "d", count: 64)
+ )
+ let host = DoryComponentHostRequirements(platform: "macos", minimumVersion: "15.0")
+ let core = DoryComponentRelease(
+ id: .dockerCore,
+ version: "0.5.0",
+ displayName: "Docker Core",
+ summary: "Signed Dory application",
+ dependencies: [],
+ downloadBytes: 100,
+ installedBytes: 200,
+ assets: [],
+ architectures: ["arm64"],
+ hostRequirements: host,
+ provides: ["app.dory-core@0.5.0", "backend.rawhv-linux@1"],
+ requires: [],
+ provenance: provenance,
+ qualification: []
+ )
+ let payload = Data("kernel".utf8)
+ let asset = DoryComponentAsset(
+ path: "dory-desktop-kernel-arm64.lzfse",
+ url: "https://example.invalid/kernel",
+ downloadBytes: UInt64(payload.count),
+ installedBytes: UInt64(payload.count),
+ sha256: digest(payload),
+ installedSHA256: digest(payload),
+ role: .guestKernel
+ )
+ let desktop = DoryComponentRelease(
+ id: .linuxDesktop,
+ version: "0.5.0",
+ displayName: "Linux Desktop Runtime",
+ summary: "Qualified accelerated desktop runtime",
+ downloadBytes: asset.downloadBytes,
+ installedBytes: asset.installedBytes,
+ assets: [asset],
+ architectures: ["arm64"],
+ hostRequirements: host,
+ provides: ["device.virtio-gpu.venus@1"],
+ requires: ["app.dory-core>=0.5.0"],
+ provenance: provenance,
+ qualification: ["linux-desktop-arm64.mac16-1.25a1"]
+ )
+ let catalog = DoryComponentCatalog(
+ releaseVersion: "0.5.0",
+ generatedAt: "2026-07-16T12:00:00Z",
+ minimumAppVersion: "0.5.0",
+ architecture: "arm64",
+ components: [core, desktop]
+ )
+
+ XCTAssertNoThrow(try DoryComponentCatalogVerifier.validate(
+ catalog,
+ expectedArchitecture: "arm64",
+ appVersion: "0.5.0"
+ ))
+ XCTAssertEqual(
+ try JSONDecoder().decode(DoryComponentCatalog.self, from: encoded(catalog)),
+ catalog
+ )
+
+ let incomplete = DoryComponentRelease(
+ id: .linuxDesktop,
+ version: "0.5.0",
+ displayName: "Linux Desktop Runtime",
+ summary: "Missing capability authority",
+ downloadBytes: asset.downloadBytes,
+ installedBytes: asset.installedBytes,
+ assets: [asset],
+ architectures: ["arm64"],
+ hostRequirements: host,
+ requires: ["app.dory-core>=0.5.0"],
+ provenance: provenance,
+ qualification: []
+ )
+ XCTAssertThrowsError(try DoryComponentCatalogVerifier.validate(
+ DoryComponentCatalog(
+ releaseVersion: "0.5.0",
+ generatedAt: "2026-07-16T12:00:00Z",
+ minimumAppVersion: "0.5.0",
+ architecture: "arm64",
+ components: [core, incomplete]
+ ),
+ expectedArchitecture: "arm64",
+ appVersion: "0.5.0"
+ ))
+ }
+
+ func testSchemaOneDecodesWithoutVersionTwoAuthorityAndCannotSmuggleIt() throws {
+ let legacy = catalog(components: [core()])
+ let decoded = try JSONDecoder().decode(DoryComponentCatalog.self, from: encoded(legacy))
+ XCTAssertNil(decoded.components[0].architectures)
+ XCTAssertNil(decoded.components[0].provenance)
+ XCTAssertNoThrow(try DoryComponentCatalogVerifier.validate(
+ decoded,
+ expectedArchitecture: "arm64",
+ appVersion: "0.4.0"
+ ))
+
+ let smuggled = DoryComponentRelease(
+ id: .dockerCore,
+ version: "0.4.0",
+ displayName: "Docker Core",
+ summary: "Docker, Compose, Buildx, networking, and storage",
+ dependencies: [],
+ downloadBytes: 100,
+ installedBytes: 200,
+ assets: [],
+ architectures: ["arm64"]
+ )
+ XCTAssertThrowsError(try DoryComponentCatalogVerifier.validate(
+ catalog(components: [smuggled]),
+ expectedArchitecture: "arm64",
+ appVersion: "0.4.0"
+ ))
+ }
+
func testCatalogRejectsDuplicatePathsCyclesAndDisagreeingSizes() throws {
let payload = Data("kubectl".utf8)
let asset = try plainAsset(path: "kubectl", data: payload)
@@ -388,6 +512,45 @@ final class DoryComponentsTests: XCTestCase {
XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o700)
}
+ func testExecutableInstallRejectsMismatchedCodeRequirement() throws {
+ let fixture = try Fixture(name: "code-requirement")
+ defer { fixture.cleanup() }
+ let payload = try Data(contentsOf: URL(fileURLWithPath: "/usr/bin/true"))
+ let source = try fixture.write(payload, name: "signed-tool")
+ let asset = DoryComponentAsset(
+ path: "signed-tool",
+ url: source.absoluteString,
+ downloadBytes: UInt64(payload.count),
+ installedBytes: UInt64(payload.count),
+ sha256: digest(payload),
+ installedSHA256: digest(payload),
+ executable: true,
+ role: .hostCLI,
+ codeRequirement: #"identifier "dev.dory.not-the-installed-tool""#
+ )
+ let component = DoryComponentRelease(
+ id: .kubernetes,
+ version: "1.0.0",
+ displayName: "Kubernetes",
+ summary: "Signed executable requirement regression",
+ downloadBytes: asset.downloadBytes,
+ installedBytes: asset.installedBytes,
+ assets: [asset]
+ )
+
+ XCTAssertThrowsError(try fixture.store.install(
+ component,
+ catalogDigest: String(repeating: "a", count: 64),
+ downloadedAssets: [asset.path: source.path]
+ )) { error in
+ guard case let .invalidAsset(path) = error as? DoryComponentError else {
+ return XCTFail("expected invalid executable asset, got \(error)")
+ }
+ XCTAssertTrue(path.hasSuffix("/signed-tool"))
+ }
+ XCTAssertNil(try fixture.store.installedComponent(.kubernetes))
+ }
+
func testCachedCatalogIsReverifiedEveryTime() throws {
let fixture = try Fixture(name: "catalog-cache")
defer { fixture.cleanup() }
@@ -481,6 +644,7 @@ final class DoryComponentsTests: XCTestCase {
private func catalog(components: [DoryComponentRelease]) -> DoryComponentCatalog {
DoryComponentCatalog(
+ schemaVersion: 1,
releaseVersion: "0.4.0",
generatedAt: "2026-07-16T12:00:00Z",
minimumAppVersion: "0.4.0",
diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift
index 71bcf487..b072e60c 100644
--- a/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift
+++ b/dory-core-swift/Tests/DoryOperationsTests/DoryVirtualMachineQualificationManifestTests.swift
@@ -245,13 +245,25 @@ private final class QualificationFixture {
records: [record]
)
manifestData = try Self.encoded(manifest)
+ let isVersionTwo = catalogSchemaVersion == DoryComponentCatalog.schemaVersion
+ let provenance = isVersionTwo ? DoryComponentProvenance(
+ sourceCommit: String(repeating: "1", count: 40),
+ builder: "dory.qualification.fixture",
+ recipeDigest: String(repeating: "2", count: 64),
+ sbomDigest: String(repeating: "3", count: 64),
+ attestationDigest: Self.digest(manifestData)
+ ) : nil
+ let hostRequirements = isVersionTwo
+ ? DoryComponentHostRequirements(platform: "macos", minimumVersion: "14.0")
+ : nil
let asset = DoryComponentAsset(
path: manifestPath,
url: "https://example.invalid/\(manifestPath)",
downloadBytes: UInt64(manifestData.count),
installedBytes: UInt64(manifestData.count),
sha256: Self.digest(manifestData),
- installedSHA256: Self.digest(manifestData)
+ installedSHA256: Self.digest(manifestData),
+ role: isVersionTwo ? .qualificationEvidence : nil
)
let core = DoryComponentRelease(
id: .dockerCore,
@@ -261,7 +273,13 @@ private final class QualificationFixture {
dependencies: [],
downloadBytes: 1,
installedBytes: 1,
- assets: []
+ assets: [],
+ architectures: isVersionTwo ? ["arm64"] : nil,
+ hostRequirements: hostRequirements,
+ provides: isVersionTwo ? ["app.dory-core@1.0.0"] : nil,
+ requires: isVersionTwo ? [] : nil,
+ provenance: provenance,
+ qualification: isVersionTwo ? [] : nil
)
let machines = DoryComponentRelease(
id: .linuxMachines,
@@ -271,7 +289,13 @@ private final class QualificationFixture {
dependencies: [.dockerCore],
downloadBytes: UInt64(manifestData.count),
installedBytes: UInt64(manifestData.count),
- assets: [asset]
+ assets: [asset],
+ architectures: isVersionTwo ? ["arm64"] : nil,
+ hostRequirements: hostRequirements,
+ provides: isVersionTwo ? ["guest.linux-headless.arm64@1"] : nil,
+ requires: isVersionTwo ? ["app.dory-core>=1.0.0"] : nil,
+ provenance: provenance,
+ qualification: isVersionTwo ? [record.qualificationIdentity] : nil
)
catalog = DoryComponentCatalog(
schemaVersion: catalogSchemaVersion,
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
index 280b1656..82e69206 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionTrustTests.swift
@@ -1197,13 +1197,25 @@ private final class ProductionTrustFixture: @unchecked Sendable {
records: records
)
let manifestData = try Self.encoded(manifest)
+ let isVersionTwo = schemaVersion == DoryComponentCatalog.schemaVersion
+ let provenance = isVersionTwo ? DoryComponentProvenance(
+ sourceCommit: String(repeating: "1", count: 40),
+ builder: "dory.production-trust.fixture",
+ recipeDigest: String(repeating: "2", count: 64),
+ sbomDigest: String(repeating: "3", count: 64),
+ attestationDigest: Self.digest(manifestData)
+ ) : nil
+ let hostRequirements = isVersionTwo
+ ? DoryComponentHostRequirements(platform: "macos", minimumVersion: "14.0")
+ : nil
let asset = DoryComponentAsset(
path: manifestPath,
url: "https://example.invalid/qualification.json",
downloadBytes: UInt64(manifestData.count),
installedBytes: UInt64(manifestData.count),
sha256: Self.digest(manifestData),
- installedSHA256: Self.digest(manifestData)
+ installedSHA256: Self.digest(manifestData),
+ role: isVersionTwo ? .qualificationEvidence : nil
)
let release = DoryComponentRelease(
id: .linuxMachines,
@@ -1213,7 +1225,13 @@ private final class ProductionTrustFixture: @unchecked Sendable {
dependencies: [.dockerCore],
downloadBytes: UInt64(manifestData.count),
installedBytes: UInt64(manifestData.count),
- assets: [asset]
+ assets: [asset],
+ architectures: isVersionTwo ? ["arm64"] : nil,
+ hostRequirements: hostRequirements,
+ provides: isVersionTwo ? ["guest.linux-headless.arm64@1"] : nil,
+ requires: isVersionTwo ? ["app.dory-core>=\(appVersion)"] : nil,
+ provenance: provenance,
+ qualification: isVersionTwo ? records.map(\.qualificationIdentity) : nil
)
let core = DoryComponentRelease(
id: .dockerCore,
@@ -1223,7 +1241,13 @@ private final class ProductionTrustFixture: @unchecked Sendable {
dependencies: [],
downloadBytes: 1,
installedBytes: 1,
- assets: []
+ assets: [],
+ architectures: isVersionTwo ? ["arm64"] : nil,
+ hostRequirements: hostRequirements,
+ provides: isVersionTwo ? ["app.dory-core@\(releaseVersion)"] : nil,
+ requires: isVersionTwo ? [] : nil,
+ provenance: provenance,
+ qualification: isVersionTwo ? [] : nil
)
let catalog = DoryComponentCatalog(
schemaVersion: schemaVersion,
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift
index 41f7c9cf..9e6e857e 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryInstalledDesktopPayloadReceiptTests.swift
@@ -198,6 +198,7 @@ struct DoryInstalledDesktopPayloadReceiptTests {
assets: []
)
let catalog = DoryComponentCatalog(
+ schemaVersion: 1,
releaseVersion: "0.4.0",
generatedAt: "2026-08-20T00:00:00Z",
minimumAppVersion: "0.4.5",
diff --git a/scripts/build-components.py b/scripts/build-components.py
index 1646f82e..935f2392 100755
--- a/scripts/build-components.py
+++ b/scripts/build-components.py
@@ -121,6 +121,49 @@ def validate_sources(repo: pathlib.Path, source_root: pathlib.Path, kubectl: pat
fail(f"kubectl does not contain arm64 code: {' '.join(archs)}")
+def designated_code_requirement(path: pathlib.Path) -> str:
+ completed = subprocess.run(
+ ["codesign", "-d", "-r-", str(path)],
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ if completed.returncode != 0:
+ fail(f"could not read the designated requirement for {path}")
+ for line in (completed.stdout + "\n" + completed.stderr).splitlines():
+ if "designated =>" in line:
+ requirement = line.split("designated =>", 1)[1].strip()
+ if requirement and len(requirement.encode("utf-8")) <= 4096:
+ return requirement
+ fail(f"codesign returned no designated requirement for {path}")
+
+
+def source_commit(repo: pathlib.Path, explicit: str | None) -> str:
+ value = explicit or run(["git", "rev-parse", "HEAD"], cwd=repo)
+ value = value.strip().lower()
+ if len(value) not in {40, 64} or any(character not in "0123456789abcdef" for character in value):
+ fail("source commit must be an exact lowercase Git digest")
+ return value
+
+
+def recipe_digest(repo: pathlib.Path) -> str:
+ inputs = [
+ repo / "scripts/build-components.py",
+ repo / "guest/kernel/build.sh",
+ repo / "guest/initfs/build.sh",
+ repo / "guest/desktop/build.sh",
+ ]
+ digest = hashlib.sha256()
+ for path in inputs:
+ file = regular_file(path, "component build recipe")
+ relative = str(file.relative_to(repo)).encode("utf-8")
+ digest.update(len(relative).to_bytes(4, "big"))
+ digest.update(relative)
+ digest.update(file.read_bytes())
+ return digest.hexdigest()
+
+
def generated_at(value: str | None) -> str:
if value:
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
@@ -420,6 +463,7 @@ def component_specs(
source_root: pathlib.Path,
kubectl: pathlib.Path,
qualification_manifest: pathlib.Path,
+ minimum_app_version: str,
) -> list[dict]:
desktop_specs = []
for distro, display, summary in (
@@ -445,30 +489,39 @@ def component_specs(
"displayName": display,
"summary": summary,
"dependencies": ["docker-core", "linux-desktop"],
+ "provides": [f"guest.linux-desktop.{distro}.arm64@1"],
+ "requires": [
+ f"app.dory-core>={minimum_app_version}",
+ "guest.linux-desktop-runtime.arm64@1",
+ ],
"assets": [
{
"path": f"dory-desktop-{distro}-rootfs-arm64.ext4.lzfse",
"source": source_root / f"dory-desktop-{distro}-rootfs-arm64.ext4",
"delivery": "lzfse-stored",
"executable": False,
+ "role": "guest-disk",
},
{
"path": f"dory-desktop-{distro}-build-arm64.stamp",
"source": source_root / f"dory-desktop-{distro}-build-arm64.stamp",
"delivery": "none",
"executable": False,
+ "role": "build-metadata",
},
{
"path": f"dory-desktop-{distro}-packages-arm64.txt",
"source": source_root / f"dory-desktop-{distro}-packages-arm64.txt",
"delivery": "none",
"executable": False,
+ "role": "guest-package-manifest",
},
{
"path": f"dory-desktop-{distro}-update-arm64.tar",
"source": source_root / f"dory-desktop-{distro}-update-arm64.tar",
"delivery": "none",
"executable": False,
+ "role": "guest-update",
},
],
}
@@ -479,12 +532,15 @@ def component_specs(
"displayName": "Kubernetes",
"summary": "kubectl and Dory's local k3s workflow. The selected k3s image downloads when you create the cluster.",
"dependencies": ["docker-core"],
+ "provides": ["cli.kubectl@1"],
+ "requires": [f"app.dory-core>={minimum_app_version}"],
"assets": [
{
"path": "kubectl",
"source": kubectl,
"delivery": "none",
"executable": True,
+ "role": "host-cli",
}
],
},
@@ -493,18 +549,22 @@ def component_specs(
"displayName": "Linux Machines",
"summary": "Headless VPS-style Linux machines with terminals, services, and persistent disks.",
"dependencies": ["docker-core"],
+ "provides": ["guest.linux-headless.arm64@1"],
+ "requires": [f"app.dory-core>={minimum_app_version}"],
"assets": [
{
"path": "dory-hv-kernel-arm64",
"source": source_root / "Image",
"delivery": "lzfse-expanded",
"executable": False,
+ "role": "guest-kernel",
},
{
"path": "dory-machine-rootfs-arm64.ext4",
"source": source_root / "initfs-arm64.ext4",
"delivery": "lzfse-expanded",
"executable": False,
+ "role": "guest-disk",
},
],
},
@@ -513,24 +573,33 @@ def component_specs(
"displayName": "Linux Desktop Runtime",
"summary": "The graphical VM kernel shared by independently installable desktop distributions.",
"dependencies": ["docker-core"],
+ "provides": [
+ "guest.linux-desktop-runtime.arm64@1",
+ "device.virtio-gpu.virgl2@1",
+ "device.virtio-gpu.venus@1",
+ ],
+ "requires": [f"app.dory-core>={minimum_app_version}"],
"assets": [
{
"path": "dory-desktop-kernel-arm64.lzfse",
"source": source_root / "Image-desktop",
"delivery": "lzfse-stored",
"executable": False,
+ "role": "guest-kernel",
},
{
"path": "kernel-build-arm64-desktop.stamp",
"source": source_root / "kernel-build-arm64-desktop.stamp",
"delivery": "none",
"executable": False,
+ "role": "build-metadata",
},
{
"path": QUALIFICATION_PATH,
"source": qualification_manifest,
"delivery": "none",
"executable": False,
+ "role": "qualification-evidence",
},
],
},
@@ -555,6 +624,7 @@ def materialize_asset(
output: pathlib.Path,
asset_base_url: str,
compression_tool: pathlib.Path,
+ allow_test_code_requirement: bool,
) -> dict:
source = regular_file(pathlib.Path(asset["source"]), f"{component_id} source")
delivery = asset["delivery"]
@@ -588,8 +658,9 @@ def materialize_asset(
compression = "none"
installed_bytes = download_bytes
installed_digest = download_digest
- return {
+ result = {
"path": asset["path"],
+ "role": asset["role"],
"url": f"{asset_base_url.rstrip('/')}/{artifact_name}",
"compression": compression,
"downloadBytes": download_bytes,
@@ -598,6 +669,13 @@ def materialize_asset(
"installedSHA256": installed_digest,
"executable": bool(asset["executable"]),
}
+ if asset["executable"]:
+ result["codeRequirement"] = (
+ f'identifier "dev.dory.test.{installed_digest[:16]}"'
+ if allow_test_code_requirement
+ else designated_code_requirement(source)
+ )
+ return result
def sign_catalog(catalog_path: pathlib.Path, signer: pathlib.Path) -> str:
@@ -662,6 +740,9 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--generated-at")
parser.add_argument("--signer", type=pathlib.Path)
parser.add_argument("--qualification-manifest", required=True, type=pathlib.Path)
+ parser.add_argument("--sbom", required=True, type=pathlib.Path)
+ parser.add_argument("--source-commit")
+ parser.add_argument("--builder-identity", default="dory.build-components.v2")
parser.add_argument("--catalog-public-key", default=DEFAULT_CATALOG_PUBLIC_KEY)
parser.add_argument("--skip-source-verification", action="store_true")
return parser.parse_args()
@@ -686,6 +767,15 @@ def main() -> None:
source_root=source_root,
core_app=core_app,
)
+ sbom = regular_file(args.sbom.resolve(), "component SBOM")
+ minimum_app_version = args.minimum_app_version or args.version
+ provenance = {
+ "sourceCommit": source_commit(repo, args.source_commit),
+ "builder": nonempty_string(args.builder_identity, "builder identity"),
+ "recipeDigest": recipe_digest(repo),
+ "sbomDigest": sha256(sbom),
+ "attestationDigest": sha256(args.qualification_manifest.resolve()),
+ }
if not args.skip_source_verification:
validate_sources(repo, source_root, kubectl)
@@ -708,9 +798,27 @@ def main() -> None:
"downloadBytes": byte_size(core_artifact),
"installedBytes": tree_size(core_app),
"assets": [],
+ "architectures": [ARCHITECTURE],
+ "hostRequirements": {"platform": "macos", "minimumVersion": "14.0"},
+ "provides": [
+ f"app.dory-core@{args.version}",
+ "backend.rawhv-linux@1",
+ "backend.vz-linux@1",
+ ],
+ "requires": [],
+ "provenance": provenance,
+ "qualification": [],
}
]
- for spec in component_specs(source_root, kubectl, args.qualification_manifest.resolve()):
+ qualification_ids = [
+ record["qualificationIdentity"] for record in qualification_manifest["records"]
+ ]
+ for spec in component_specs(
+ source_root,
+ kubectl,
+ args.qualification_manifest.resolve(),
+ minimum_app_version,
+ ):
assets = [
materialize_asset(
version=args.version,
@@ -719,6 +827,7 @@ def main() -> None:
output=staging,
asset_base_url=asset_base_url,
compression_tool=compression_tool,
+ allow_test_code_requirement=args.skip_source_verification,
)
for asset in spec["assets"]
]
@@ -732,6 +841,12 @@ def main() -> None:
"downloadBytes": sum(asset["downloadBytes"] for asset in assets),
"installedBytes": sum(asset["installedBytes"] for asset in assets),
"assets": assets,
+ "architectures": [ARCHITECTURE],
+ "hostRequirements": {"platform": "macos", "minimumVersion": "14.0"},
+ "provides": spec["provides"],
+ "requires": spec["requires"],
+ "provenance": provenance,
+ "qualification": qualification_ids if spec["id"] == "linux-desktop" else [],
}
)
@@ -740,7 +855,7 @@ def main() -> None:
"schemaVersion": CATALOG_SCHEMA,
"releaseVersion": args.version,
"generatedAt": generated_at(args.generated_at),
- "minimumAppVersion": args.minimum_app_version or args.version,
+ "minimumAppVersion": minimum_app_version,
"architecture": ARCHITECTURE,
"components": releases,
"virtualMachineQualification": {
diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh
index e49f08fd..5de07130 100755
--- a/scripts/test-build-components.sh
+++ b/scripts/test-build-components.sh
@@ -9,6 +9,7 @@ SOURCE="$TMP/source"
CORE_APP="$TMP/Dory.app"
OUTPUT="$TMP/components/arm64"
QUALIFICATION="$TMP/virtual-machine-qualification.json"
+SBOM="$TMP/Dory-test.cdx.json"
mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS" "$CORE_APP/Contents/Helpers"
write_fixture() {
@@ -24,6 +25,7 @@ write_fixture "$CORE_APP/Contents/Helpers/dory-vmm" 4096
chmod 0755 "$CORE_APP/Contents/Helpers/dory-hv" "$CORE_APP/Contents/Helpers/dory-vmm"
write_fixture "$TMP/kubectl" 16384
chmod 0755 "$TMP/kubectl"
+printf '{"bomFormat":"CycloneDX","specVersion":"1.6","components":[]}\n' > "$SBOM"
write_fixture "$SOURCE/Image" 131072
write_fixture "$SOURCE/initfs-arm64.ext4" 262144
write_fixture "$SOURCE/Image-desktop" 196608
@@ -113,6 +115,7 @@ build() {
--asset-base-url https://example.invalid/dory \
--generated-at 2026-07-16T00:00:00Z \
--qualification-manifest "$QUALIFICATION" \
+ --sbom "$SBOM" \
--skip-source-verification
}
@@ -147,6 +150,21 @@ assert [item["id"] for item in catalog["components"]] == [
assert catalog["components"][0]["assets"] == []
assert catalog["components"][0]["downloadBytes"] == 4096
assert catalog["components"][0]["installedBytes"] == 16384
+for component in catalog["components"]:
+ assert component["architectures"] == ["arm64"]
+ assert component["hostRequirements"] == {
+ "platform": "macos", "minimumVersion": "14.0",
+ }
+ assert component["provides"]
+ assert isinstance(component["requires"], list)
+ assert component["provenance"]["sourceCommit"]
+ assert component["provenance"]["builder"] == "dory.build-components.v2"
+ assert len(component["provenance"]["recipeDigest"]) == 64
+ assert component["provenance"]["sbomDigest"] == hashlib.sha256(
+ pathlib.Path(sys.argv[3]).with_name("Dory-test.cdx.json").read_bytes()
+ ).hexdigest()
+ assert len(component["provenance"]["attestationDigest"]) == 64
+ assert isinstance(component["qualification"], list)
qualification = catalog["virtualMachineQualification"]
assert qualification["component"] == "linux-desktop"
assert qualification["path"] == "virtual-machine-qualification.json"
@@ -158,6 +176,7 @@ qualification_assets = [
if item["path"] == "virtual-machine-qualification.json"
]
assert len(qualification_assets) == 1
+assert qualification_assets[0]["role"] == "qualification-evidence"
assert qualification_assets[0]["installedSHA256"] == hashlib.sha256(
qualification_source.read_bytes()
).hexdigest()
@@ -170,6 +189,14 @@ for component in catalog["components"][1:]:
item["installedBytes"] for item in component["assets"]
)
for asset in component["assets"]:
+ assert asset["role"] in {
+ "host-cli", "guest-kernel", "guest-disk", "guest-update",
+ "guest-package-manifest", "qualification-evidence", "build-metadata",
+ }
+ if asset["executable"]:
+ assert asset["codeRequirement"].startswith('identifier "dev.dory.test.')
+ else:
+ assert "codeRequirement" not in asset
artifact = root / asset["url"].rsplit("/", 1)[-1]
assert artifact.is_file()
assert artifact.stat().st_size == asset["downloadBytes"]
@@ -236,6 +263,7 @@ if "$ROOT/scripts/build-components.py" \
--source-root "$SOURCE" \
--output "$TMP/rejected-components" \
--qualification-manifest "$TMP/invalid-qualification.json" \
+ --sbom "$SBOM" \
--skip-source-verification 2>/dev/null; then
echo "component packaging accepted a qualification manifest for another release" >&2
exit 1
@@ -259,6 +287,7 @@ if "$ROOT/scripts/build-components.py" \
--source-root "$SOURCE" \
--output "$TMP/mismatched-helper-components" \
--qualification-manifest "$TMP/mismatched-helper-qualification.json" \
+ --sbom "$SBOM" \
--skip-source-verification 2>/dev/null; then
echo "component packaging accepted qualification evidence for different helper bytes" >&2
exit 1
@@ -282,6 +311,7 @@ if "$ROOT/scripts/build-components.py" \
--source-root "$SOURCE" \
--output "$TMP/mismatched-media-components" \
--qualification-manifest "$TMP/mismatched-media-qualification.json" \
+ --sbom "$SBOM" \
--skip-source-verification 2>/dev/null; then
echo "component packaging accepted qualification evidence for different bundled media" >&2
exit 1
@@ -294,6 +324,7 @@ if "$ROOT/scripts/build-components.py" \
--kubectl "$TMP/kubectl" \
--source-root "$SOURCE" \
--output "$TMP/missing-qualification-components" \
+ --sbom "$SBOM" \
--skip-source-verification 2>/dev/null; then
echo "component packaging accepted a catalog without qualification evidence" >&2
exit 1
From 14b17b4d0b0a5ed54606ec9eee9170b99784e46e Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 22:26:46 +0000
Subject: [PATCH 050/338] fix(security): hide machine environment from status
---
Dory/Models/AppStore.swift | 6 +-
DoryTests/DorydClientTests.swift | 4 +-
.../DoryMachineTypedWriteAuthority.swift | 68 ++++++++++++++++
.../Sources/DorydKit/DorydService.swift | 6 --
.../Sources/DorydKit/MachineManager.swift | 12 ++-
.../DoryMachineTypedWriteAuthorityTests.swift | 37 +++++++++
.../DorydKitTests/DorydServiceTests.swift | 77 ++++++++++---------
7 files changed, 162 insertions(+), 48 deletions(-)
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index be0055fa..d5d1a39e 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5078,7 +5078,7 @@ final class AppStore {
cpus: status.cpuCount,
memoryMB: status.memoryMB.flatMap { Int(exactly: $0) },
mounts: status.shares.map(Self.mountPair(fromDoryd:)),
- env: status.environment,
+ env: [:],
virtualMachineSettings: status.typedSettings
?? DorydMachineTypedSettings(
legacyEnvironment: status.environment,
@@ -5162,7 +5162,7 @@ final class AppStore {
.first { !$0.isEmpty } ?? status.state
let isDesktop = status.displayMode == .desktop
let isCustomLinux = status.bootMode == .efi
- let typedSettings = DorydMachineTypedSettings(
+ let typedSettings = status.typedSettings ?? DorydMachineTypedSettings(
legacyEnvironment: status.environment,
displayMode: status.displayMode
)
@@ -5467,7 +5467,7 @@ final class AppStore {
// EFI machines are user-provided installer workspaces. Boot mode is authoritative;
// do not depend on the legacy DORY_CUSTOM_LINUX compatibility marker.
guard status.bootMode != .efi else { continue }
- let typedSettings = DorydMachineTypedSettings(
+ let typedSettings = status.typedSettings ?? DorydMachineTypedSettings(
legacyEnvironment: status.environment,
displayMode: status.displayMode
)
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index fc557fea..ec1bb4db 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -955,7 +955,7 @@ struct DorydClientTests {
#expect(currentSettings.address == "192.168.215.40")
#expect(currentSettings.displayMode == .desktop)
#expect(currentSettings.mounts == [MountPair(host: "/Users/me/src", guest: "/workspace/src", readOnly: true)])
- #expect(currentSettings.env == ["ANTHROPIC_API_KEY": "test-token"])
+ #expect(currentSettings.env.isEmpty)
store.toggleMachine(machine)
try await waitUntil {
@@ -2440,7 +2440,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
return
}
current["typedSettings"] = typedSettings
- current["env"] = [] as [NSDictionary]
+ current.removeObject(forKey: "env")
current["displayMode"] = "desktop"
machines[machineID] = current.copy() as? NSDictionary
}
diff --git a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
index a0e0f5dc..34f6e6a6 100644
--- a/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
+++ b/dory-core-swift/Sources/DorydKit/DoryMachineTypedWriteAuthority.swift
@@ -75,6 +75,74 @@ public struct DoryMachineTypedSettingsSnapshot: Codable, Sendable, Equatable, Ha
}
}
+ /// Projects only the bounded, non-secret compatibility fields understood by the typed
+ /// machine-settings contract. Opaque legacy environment entries remain daemon-private and
+ /// are never copied into status/XPC diagnostics.
+ public init(
+ legacyEnvironment: [String: String],
+ displayMode: DoryMachineDisplayMode
+ ) {
+ let username = legacyEnvironment[
+ DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey
+ ].flatMap { DoryVMGuestAccountIntent.isValidUsername($0) ? $0 : nil }
+ let numericUserID = legacyEnvironment[
+ DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey
+ ].flatMap(UInt32.init).flatMap {
+ DoryVMGuestAccountIntent.isValidNumericUserID($0) ? $0 : nil
+ }
+ let account = DoryVMGuestAccountIntent(
+ username: username,
+ numericUserID: numericUserID
+ )
+ let desktop: DoryVMDesktopIdentityIntent?
+ if displayMode == .desktop {
+ func safeLabel(_ key: String) -> String? {
+ legacyEnvironment[key].flatMap {
+ DoryVMDesktopIdentityIntent.isValidLabel($0) ? $0 : nil
+ }
+ }
+ let distributionIdentifier = legacyEnvironment[
+ DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey
+ ].flatMap {
+ DoryVMDesktopIdentityIntent.isValidDistributionIdentifier($0) ? $0 : nil
+ }
+ let candidate = DoryVMDesktopIdentityIntent(
+ distributionIdentifier: distributionIdentifier,
+ displayName: safeLabel(
+ DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey
+ ),
+ version: safeLabel(
+ DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey
+ ),
+ desktopEnvironment: safeLabel(
+ DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey
+ )
+ )
+ desktop = candidate.isValidForPersistence ? candidate : nil
+ } else {
+ desktop = nil
+ }
+ guestIdentityIntent = DoryVMGuestIdentityIntent(
+ account: account.isValidForPersistence ? account : nil,
+ desktop: desktop
+ )
+ if displayMode == .desktop {
+ let clipboard = DoryDesktopClipboardPolicy(environment: legacyEnvironment)
+ clipboardPolicy = DoryVMClipboardDirection(rawValue: clipboard.rawValue)
+ .map(DoryVMClipboardPolicy.legacyDesktop)
+ runtimePreference = (try? DoryDesktopVMMPreference(
+ environment: legacyEnvironment
+ )) ?? .automatic
+ graphicsPreference = (try? DoryDesktopGraphicsPreference(
+ environment: legacyEnvironment
+ )) ?? .automatic
+ } else {
+ clipboardPolicy = nil
+ runtimePreference = nil
+ graphicsPreference = nil
+ }
+ }
+
public var xpcDictionary: NSDictionary {
DoryMachineTypedSettingsPatch(
guestUsername: update(guestIdentityIntent.account?.username),
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index 25ed532b..01bf2c25 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -1616,12 +1616,6 @@ private extension DoryMachineStatus {
dictionary["runtimeAddress"] = runtimeAddress
}
dictionary["shares"] = shares.map(\.xpcDictionary)
- dictionary["env"] = environment.sorted(by: { $0.key < $1.key }).map { key, value in
- [
- "key": key,
- "value": value,
- ] as NSDictionary
- }
dictionary["handoffFDCount"] = handoffFDCount
dictionary["memoryMB"] = memoryMB
dictionary["currentBalloonTargetMB"] = currentBalloonTargetMB
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 7c0df1c6..ac5e0f1a 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -1085,6 +1085,12 @@ public final class MachineManager: @unchecked Sendable {
runtimeIdentity: initialRuntimeIdentity
)
lock.unlock()
+ let typedSettingsSnapshot = try nativeDefinition.map(
+ DoryMachineTypedSettingsSnapshot.init
+ ) ?? DoryMachineTypedSettingsSnapshot(
+ legacyEnvironment: preparedMachine.environment,
+ displayMode: preparedMachine.displayMode
+ )
return DoryMachineStatus(
id: preparedMachine.id,
state: .created,
@@ -1097,7 +1103,7 @@ public final class MachineManager: @unchecked Sendable {
installerMediaAttached: preparedMachine.installerISOPath != nil,
shares: preparedMachine.shares,
environment: preparedMachine.environment,
- typedSettings: try nativeDefinition.map(DoryMachineTypedSettingsSnapshot.init),
+ typedSettings: typedSettingsSnapshot,
runtimeIdentity: initialRuntimeIdentity
)
}
@@ -3695,6 +3701,10 @@ public final class MachineManager: @unchecked Sendable {
private func statusLocked(id: String, entry: MachineEntry) -> DoryMachineStatus {
let typedSettings = nativeTypedSettingsSnapshot(id: id)
+ ?? DoryMachineTypedSettingsSnapshot(
+ legacyEnvironment: entry.configuration.environment,
+ displayMode: entry.configuration.displayMode
+ )
if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true {
return DoryMachineStatus(
id: id,
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift
index 01296261..a6fcd1ad 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryMachineTypedWriteAuthorityTests.swift
@@ -70,6 +70,43 @@ struct DoryMachineTypedWriteAuthorityTests {
#expect(runtime["DORY_DESKTOP_GRAPHICS"] == "software")
}
+ @Test("legacy status projection exposes only bounded typed settings")
+ func safeLegacyStatusProjection() {
+ let snapshot = DoryMachineTypedSettingsSnapshot(
+ legacyEnvironment: [
+ "DORY_GUEST_USER": "developer",
+ "DORY_GUEST_UID": "1000",
+ "DORY_DESKTOP_DISTRO": "ubuntu",
+ "DORY_DESKTOP_NAME": "Ubuntu",
+ "DORY_CLIPBOARD_POLICY": "host-to-guest",
+ "DORY_DESKTOP_VMM": "accelerated",
+ "DORY_DESKTOP_GRAPHICS": "virgl-venus",
+ "PRIVATE_TOKEN": "must-never-cross-xpc",
+ ],
+ displayMode: .desktop
+ )
+
+ #expect(snapshot.guestIdentityIntent.account?.username == "developer")
+ #expect(snapshot.guestIdentityIntent.account?.numericUserID == 1_000)
+ #expect(snapshot.guestIdentityIntent.desktop?.distributionIdentifier == "ubuntu")
+ #expect(snapshot.guestIdentityIntent.desktop?.displayName == "Ubuntu")
+ #expect(snapshot.clipboardPolicy?.text == .hostToGuest)
+ #expect(snapshot.runtimePreference == .accelerated)
+ #expect(snapshot.graphicsPreference == .virglVenus)
+ #expect(snapshot.xpcDictionary.description.contains("must-never-cross-xpc") == false)
+
+ let invalid = DoryMachineTypedSettingsSnapshot(
+ legacyEnvironment: [
+ "DORY_GUEST_USER": "../../unsafe",
+ "DORY_GUEST_UID": "not-a-uid",
+ "PRIVATE_TOKEN": "opaque",
+ ],
+ displayMode: .desktop
+ )
+ #expect(invalid.guestIdentityIntent.account == nil)
+ #expect(invalid.xpcDictionary.description.contains("opaque") == false)
+ }
+
@Test("update clear is explicit and field scoped")
func explicitClear() throws {
let source = DoryMachineTypedSettingsPatch(
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index a5422905..15cc171a 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -5,16 +5,6 @@ import DoryOperations
import XCTest
final class DorydServiceTests: XCTestCase {
- private static func environmentValues(_ body: NSDictionary) -> [String: String] {
- let rows = body["env"] as? [NSDictionary] ?? []
- return Dictionary(uniqueKeysWithValues: rows.compactMap { row in
- guard let key = row["key"] as? String, let value = row["value"] as? String else {
- return nil
- }
- return (key, value)
- })
- }
-
func testPublishedPortRepairDetailUsesValidatedGvproxyReceiptCounts() {
let startedAt = Date()
let receipt = PublishedPortReconcileReceipt(
@@ -1103,10 +1093,13 @@ final class DorydServiceTests: XCTestCase {
XCTAssertTrue(ok, message)
XCTAssertEqual(body["state"] as? String, "created")
XCTAssertEqual(body["address"] as? String, "192.168.215.40")
- let values = Self.environmentValues(body)
- XCTAssertEqual(values["DORY_GUEST_USER"], "developer")
- XCTAssertEqual(values["DORY_GUEST_UID"], "1000")
- XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "off")
+ XCTAssertNil(body["env"])
+ let typed = body["typedSettings"] as? NSDictionary
+ let identity = typed?["guestIdentityIntent"] as? NSDictionary
+ let account = identity?["account"] as? NSDictionary
+ XCTAssertEqual(account?["username"] as? String, "developer")
+ XCTAssertEqual((account?["numericUserID"] as? NSNumber)?.uint32Value, 1_000)
+ XCTAssertNil(typed?["clipboardPolicy"])
create.fulfill()
}
wait(for: [create], timeout: 5)
@@ -1130,8 +1123,11 @@ final class DorydServiceTests: XCTestCase {
let statuses = body as? [NSDictionary]
XCTAssertEqual(statuses?.first?["id"] as? String, "dev")
XCTAssertEqual(statuses?.first?["address"] as? String, "192.168.215.40")
- let env = statuses?.first?["env"] as? [NSDictionary]
- XCTAssertTrue(env?.contains { $0["value"] as? String == "developer" } == true)
+ XCTAssertNil(statuses?.first?["env"])
+ let typed = statuses?.first?["typedSettings"] as? NSDictionary
+ let identity = typed?["guestIdentityIntent"] as? NSDictionary
+ let account = identity?["account"] as? NSDictionary
+ XCTAssertEqual(account?["username"] as? String, "developer")
list.fulfill()
}
wait(for: [list], timeout: 5)
@@ -1172,10 +1168,12 @@ final class DorydServiceTests: XCTestCase {
XCTAssertEqual(shares?.first?["hostPath"] as? String, share)
XCTAssertEqual(shares?.first?["guestPath"] as? String, "/workspace/src")
XCTAssertEqual(shares?.first?["readOnly"] as? Bool, true)
- let values = Self.environmentValues(body)
- XCTAssertEqual(values["DORY_GUEST_USER"], "builder")
- XCTAssertEqual(values["DORY_GUEST_UID"], "1000")
- XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "off")
+ XCTAssertNil(body["env"])
+ let typed = body["typedSettings"] as? NSDictionary
+ let identity = typed?["guestIdentityIntent"] as? NSDictionary
+ let account = identity?["account"] as? NSDictionary
+ XCTAssertEqual(account?["username"] as? String, "builder")
+ XCTAssertEqual((account?["numericUserID"] as? NSNumber)?.uint32Value, 1_000)
update.fulfill()
}
wait(for: [update], timeout: 5)
@@ -1395,14 +1393,17 @@ final class DorydServiceTests: XCTestCase {
"desktopGraphicsPreference": "virgl-venus",
]) { ok, body, message in
XCTAssertTrue(ok, message)
- let values = Self.environmentValues(body)
- XCTAssertEqual(values["DORY_GUEST_USER"], "developer")
- XCTAssertEqual(values["DORY_GUEST_UID"], "1000")
- XCTAssertEqual(values["DORY_DESKTOP_DISTRO"], "ubuntu")
- XCTAssertEqual(values["DORY_DESKTOP_NAME"], "Ubuntu")
- XCTAssertEqual(values["DORY_CLIPBOARD_POLICY"], "bidirectional")
- XCTAssertEqual(values["DORY_DESKTOP_VMM"], "accelerated")
- XCTAssertEqual(values["DORY_DESKTOP_GRAPHICS"], "virgl-venus")
+ XCTAssertNil(body["env"])
+ let typed = body["typedSettings"] as? NSDictionary
+ let identity = typed?["guestIdentityIntent"] as? NSDictionary
+ let account = identity?["account"] as? NSDictionary
+ let desktop = identity?["desktop"] as? NSDictionary
+ XCTAssertEqual(account?["username"] as? String, "developer")
+ XCTAssertEqual((account?["numericUserID"] as? NSNumber)?.uint32Value, 1_000)
+ XCTAssertEqual(desktop?["distributionIdentifier"] as? String, "ubuntu")
+ XCTAssertEqual(desktop?["displayName"] as? String, "Ubuntu")
+ XCTAssertEqual(typed?["desktopRuntimePreference"] as? String, "accelerated")
+ XCTAssertEqual(typed?["desktopGraphicsPreference"] as? String, "virgl-venus")
typedCreate.fulfill()
}
wait(for: [typedCreate], timeout: 5)
@@ -1418,13 +1419,17 @@ final class DorydServiceTests: XCTestCase {
"desktopGraphicsPreference": "software",
]) { ok, body, message in
XCTAssertTrue(ok, message)
- let values = Self.environmentValues(body)
- XCTAssertEqual(values["DORY_DESKTOP_NAME"], "Ubuntu Legacy")
- XCTAssertEqual(values["DORY_GUEST_USER"], "../../unsafe-old-user")
- XCTAssertEqual(values["DORY_GUEST_UID"], "not-a-uid")
- XCTAssertEqual(values["PRIVATE_TOKEN"], "opaque-legacy-value")
- XCTAssertEqual(values["DORY_DESKTOP_VMM"], "compatible")
- XCTAssertEqual(values["DORY_DESKTOP_GRAPHICS"], "software")
+ XCTAssertNil(body["env"])
+ XCTAssertFalse(body.description.contains("opaque-legacy-value"))
+ let typed = body["typedSettings"] as? NSDictionary
+ let identity = typed?["guestIdentityIntent"] as? NSDictionary
+ let account = identity?["account"] as? NSDictionary
+ let desktop = identity?["desktop"] as? NSDictionary
+ XCTAssertNil(account?["username"])
+ XCTAssertNil(account?["numericUserID"])
+ XCTAssertEqual(desktop?["displayName"] as? String, "Ubuntu Legacy")
+ XCTAssertEqual(typed?["desktopRuntimePreference"] as? String, "compatible")
+ XCTAssertEqual(typed?["desktopGraphicsPreference"] as? String, "software")
typedUpdate.fulfill()
}
wait(for: [typedUpdate], timeout: 5)
@@ -1503,7 +1508,7 @@ final class DorydServiceTests: XCTestCase {
service.machineList { rows, message in
XCTAssertEqual(message, "")
let row = (rows as? [NSDictionary])?.first { $0["id"] as? String == "planned" }
- XCTAssertEqual((row?["env"] as? [NSDictionary])?.count, 0)
+ XCTAssertNil(row?["env"])
let typed = row?["typedSettings"] as? NSDictionary
let identity = typed?["guestIdentityIntent"] as? NSDictionary
let account = identity?["account"] as? NSDictionary
From 2f95f4e45de53cbb322c5fb1d35cbbd807ba1abc Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 22:35:49 +0000
Subject: [PATCH 051/338] feat(vm): add durable pause and resume lifecycle
---
.../LinuxMachineBackendAdapters.swift | 25 +-
.../Sources/DorydKit/MachineBackend.swift | 7 +-
.../Sources/DorydKit/MachineManager.swift | 314 +++++++++++++++++-
...ePlanningTransactionCoordinatorTests.swift | 4 +-
...neProductionPlanningCompositionTests.swift | 4 +-
...onVirtualMachineRuntimePlanningTests.swift | 4 +-
.../DorydKitTests/MachineBackendTests.swift | 25 +-
...agerLifecycleJournalIntegrationTests.swift | 26 ++
...eManagerResolvedPlanIntegrationTests.swift | 4 +-
.../DorydKitTests/MachineManagerTests.swift | 43 +++
10 files changed, 429 insertions(+), 27 deletions(-)
diff --git a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
index f1abeb52..b0bdd18e 100644
--- a/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
+++ b/dory-core-swift/Sources/DorydKit/LinuxMachineBackendAdapters.swift
@@ -12,19 +12,27 @@ public struct MachineBackendCompatibilityOperations: Sendable {
public var start: MachineBackendLifecycleHandler
public var authorizedStart: MachineBackendAuthorizedStartHandler
public var stop: MachineBackendLifecycleHandler
+ public var pause: MachineBackendLifecycleHandler
+ public var resume: MachineBackendLifecycleHandler
public init(
start: @escaping MachineBackendLifecycleHandler,
- stop: @escaping MachineBackendLifecycleHandler
+ stop: @escaping MachineBackendLifecycleHandler,
+ pause: @escaping MachineBackendLifecycleHandler,
+ resume: @escaping MachineBackendLifecycleHandler
) {
self.start = start
authorizedStart = { binding in try start(binding.machineID) }
self.stop = stop
+ self.pause = pause
+ self.resume = resume
}
public init(
authorizedStart: @escaping MachineBackendAuthorizedStartHandler,
- stop: @escaping MachineBackendLifecycleHandler
+ stop: @escaping MachineBackendLifecycleHandler,
+ pause: @escaping MachineBackendLifecycleHandler,
+ resume: @escaping MachineBackendLifecycleHandler
) {
start = { _ in
throw MachineBackendFailure(
@@ -34,6 +42,8 @@ public struct MachineBackendCompatibilityOperations: Sendable {
}
self.authorizedStart = authorizedStart
self.stop = stop
+ self.pause = pause
+ self.resume = resume
}
}
@@ -55,6 +65,7 @@ private extension MachineBackendRuntimeState {
case .created: self = .created
case .starting: self = .starting
case .running: self = .running
+ case .paused: self = .paused
case .stopped: self = .stopped
case .failed: self = .failed
}
@@ -241,7 +252,10 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable {
message: "The runtime request belongs to a different backend adapter."
)
}
- return unsupportedOperation(.pause)
+ guard descriptor.lifecycle.pause else {
+ return unsupportedOperation(.pause)
+ }
+ return perform(.pause, machineID: request.machineID, operation: operations.pause)
}
func resume(_ request: MachineBackendRuntimeRequest) -> MachineBackendOperationResult {
@@ -252,7 +266,10 @@ private final class LinuxMachineBackendAdapterCore: @unchecked Sendable {
message: "The runtime request belongs to a different backend adapter."
)
}
- return unsupportedOperation(.resume)
+ guard descriptor.lifecycle.resume else {
+ return unsupportedOperation(.resume)
+ }
+ return perform(.resume, machineID: request.machineID, operation: operations.resume)
}
private func unavailableProbe(
diff --git a/dory-core-swift/Sources/DorydKit/MachineBackend.swift b/dory-core-swift/Sources/DorydKit/MachineBackend.swift
index 603365a3..b137da6b 100644
--- a/dory-core-swift/Sources/DorydKit/MachineBackend.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineBackend.swift
@@ -16,12 +16,13 @@ public struct MachineBackendLifecycleCapabilities: Codable, Sendable, Equatable,
self.resume = resume
}
- /// `MachineManager` currently exposes start and stop, but not pause and resume.
+ /// Lifecycle operations implemented by the current supervised helper boundary. Pause keeps
+ /// the helper and its admitted resources resident; resume continues that exact process.
public static let currentMachineManager = MachineBackendLifecycleCapabilities(
start: true,
stop: true,
- pause: false,
- resume: false
+ pause: true,
+ resume: true
)
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index ac5e0f1a..9d757a9e 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -298,6 +298,7 @@ public enum DoryMachineState: String, Sendable, Equatable {
case created
case starting
case running
+ case paused
case stopped
case failed
}
@@ -932,6 +933,28 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.persistence("machine manager is unavailable")
}
return MachineBackendRuntimeObservation(try self.stop(id: id))
+ },
+ pause: { [weak self] id in
+ guard let self else {
+ throw MachineManagerError.persistence("machine manager is unavailable")
+ }
+ return MachineBackendRuntimeObservation(
+ try self.performAuthorizedResolvedBackendPause(
+ id: id,
+ expectedBackend: backend
+ )
+ )
+ },
+ resume: { [weak self] id in
+ guard let self else {
+ throw MachineManagerError.persistence("machine manager is unavailable")
+ }
+ return MachineBackendRuntimeObservation(
+ try self.performAuthorizedResolvedBackendResume(
+ id: id,
+ expectedBackend: backend
+ )
+ )
}
)
}
@@ -2302,7 +2325,7 @@ public final class MachineManager: @unchecked Sendable {
lock.lock()
guard var entry = machines[machineID],
entry.launchID == launchID,
- [.starting, .running].contains(entry.state),
+ [.starting, .running, .paused].contains(entry.state),
entry.process?.isRunningOrRestarting != true else {
lock.unlock()
return
@@ -2337,6 +2360,200 @@ public final class MachineManager: @unchecked Sendable {
return try stopImplementation(id: id, journalLifecycle: true)
}
+ public func pause(id: String) throws -> DoryMachineStatus {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: id)
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
+ let identity = try currentRuntimeIdentity(id: id)
+ switch identity.mode {
+ case .legacyCompatibility:
+ return try pauseImplementation(id: id, journalLifecycle: true)
+ case .requiresReplanning:
+ throw MachineManagerError.persistence(
+ "machine \(id) requires replanning and cannot be paused"
+ )
+ case .resolvedPlan:
+ guard let backend = identity.resolvedPlan?.backend,
+ let registry = resolvedLaunchRegistry else {
+ throw MachineManagerError.persistence(
+ "resolved pause infrastructure is not installed"
+ )
+ }
+ let result = registry.pause(.init(machineID: id, backend: backend))
+ guard result.isSuccess, result.observation?.machineID == id,
+ result.observation?.state == .paused else {
+ throw MachineManagerError.persistence(
+ "resolved backend pause failed: "
+ + (result.failure?.message ?? "backend returned no paused observation")
+ )
+ }
+ return status(id: id) ?? DoryMachineStatus(id: id, state: .paused)
+ }
+ }
+
+ public func resume(id: String) throws -> DoryMachineStatus {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: id)
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
+ let identity = try currentRuntimeIdentity(id: id)
+ switch identity.mode {
+ case .legacyCompatibility:
+ return try resumeImplementation(id: id, journalLifecycle: true)
+ case .requiresReplanning:
+ throw MachineManagerError.persistence(
+ "machine \(id) requires replanning and cannot be resumed"
+ )
+ case .resolvedPlan:
+ guard let backend = identity.resolvedPlan?.backend,
+ let registry = resolvedLaunchRegistry else {
+ throw MachineManagerError.persistence(
+ "resolved resume infrastructure is not installed"
+ )
+ }
+ let result = registry.resume(.init(machineID: id, backend: backend))
+ guard result.isSuccess, result.observation?.machineID == id,
+ result.observation?.state == .running else {
+ throw MachineManagerError.persistence(
+ "resolved backend resume failed: "
+ + (result.failure?.message ?? "backend returned no running observation")
+ )
+ }
+ return status(id: id) ?? DoryMachineStatus(id: id, state: .running)
+ }
+ }
+
+ private func performAuthorizedResolvedBackendPause(
+ id: String,
+ expectedBackend: DoryVirtualizationBackendIdentity
+ ) throws -> DoryMachineStatus {
+ let identity = try currentRuntimeIdentity(id: id)
+ guard identity.mode == .resolvedPlan,
+ identity.resolvedPlan?.backend == expectedBackend else {
+ throw MachineManagerError.persistence(
+ "resolved pause adapter does not match durable runtime authority"
+ )
+ }
+ return try pauseImplementation(id: id, journalLifecycle: true)
+ }
+
+ private func performAuthorizedResolvedBackendResume(
+ id: String,
+ expectedBackend: DoryVirtualizationBackendIdentity
+ ) throws -> DoryMachineStatus {
+ let identity = try currentRuntimeIdentity(id: id)
+ guard identity.mode == .resolvedPlan,
+ identity.resolvedPlan?.backend == expectedBackend else {
+ throw MachineManagerError.persistence(
+ "resolved resume adapter does not match durable runtime authority"
+ )
+ }
+ return try resumeImplementation(id: id, journalLifecycle: true)
+ }
+
+ private func pauseImplementation(
+ id: String,
+ journalLifecycle: Bool
+ ) throws -> DoryMachineStatus {
+ lock.lock()
+ guard let entry = machines[id] else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ guard entry.state == .running, let process = entry.process else {
+ lock.unlock()
+ throw MachineManagerError.persistence("machine \(id) is not running")
+ }
+ let machine = entry.configuration
+ let runtimeIdentity = entry.runtimeIdentity
+ lock.unlock()
+ let lifecycle = try journalLifecycle
+ ? beginLifecyclePause(machine: machine, runtimeIdentity: runtimeIdentity)
+ : nil
+ do {
+ if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
+ guard process.suspend() else {
+ throw MachineManagerError.persistence("could not pause machine \(id)")
+ }
+ lock.lock()
+ guard var current = machines[id], current.process === process,
+ current.state == .running else {
+ lock.unlock()
+ _ = process.resume()
+ throw MachineManagerError.persistence(
+ "machine \(id) changed while pause was being committed"
+ )
+ }
+ current.state = .paused
+ current.lastError = nil
+ machines[id] = current
+ lock.unlock()
+ if let lifecycle {
+ _ = completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "paused machine has an unfinished pause journal"
+ )
+ }
+ return status(id: id) ?? DoryMachineStatus(id: id, state: .paused)
+ } catch {
+ if let lifecycle { failLifecycle(lifecycle, stepID: "pause.failed") }
+ throw error
+ }
+ }
+
+ private func resumeImplementation(
+ id: String,
+ journalLifecycle: Bool
+ ) throws -> DoryMachineStatus {
+ lock.lock()
+ guard let entry = machines[id] else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ guard entry.state == .paused, let process = entry.process else {
+ lock.unlock()
+ throw MachineManagerError.persistence("machine \(id) is not paused")
+ }
+ let machine = entry.configuration
+ let runtimeIdentity = entry.runtimeIdentity
+ lock.unlock()
+ let lifecycle = try journalLifecycle
+ ? beginLifecycleResume(machine: machine, runtimeIdentity: runtimeIdentity)
+ : nil
+ do {
+ if let lifecycle { try advanceLifecycleToPublishing(lifecycle) }
+ guard process.resume() else {
+ throw MachineManagerError.persistence("could not resume machine \(id)")
+ }
+ lock.lock()
+ guard var current = machines[id], current.process === process,
+ current.state == .paused else {
+ lock.unlock()
+ _ = process.suspend()
+ throw MachineManagerError.persistence(
+ "machine \(id) changed while resume was being committed"
+ )
+ }
+ current.state = .running
+ current.lastError = nil
+ machines[id] = current
+ lock.unlock()
+ if let lifecycle {
+ _ = completeCommittedLifecycle(
+ lifecycle,
+ diagnostic: "resumed machine has an unfinished resume journal"
+ )
+ }
+ return status(id: id) ?? DoryMachineStatus(id: id, state: .running)
+ } catch {
+ if let lifecycle { failLifecycle(lifecycle, stepID: "resume.failed") }
+ throw error
+ }
+ }
+
private func stopImplementation(
id: String,
journalLifecycle: Bool,
@@ -2347,13 +2564,19 @@ public final class MachineManager: @unchecked Sendable {
lock.unlock()
throw MachineManagerError.unknownMachine(id)
}
- let wasActive = [.starting, .running].contains(entry.state) && entry.process != nil
+ let sourceState = lifecycleState(for: entry.state)
+ let wasActive = [.starting, .running, .paused].contains(entry.state)
+ && entry.process != nil
let machine = entry.configuration
let runtimeIdentity = entry.runtimeIdentity
let admissionPlan = entry.activeResolvedPlan ?? runtimeIdentity.resolvedPlan
lock.unlock()
let lifecycle = try journalLifecycle && wasActive
- ? beginLifecycleStop(machine: machine, runtimeIdentity: runtimeIdentity)
+ ? beginLifecycleStop(
+ machine: machine,
+ runtimeIdentity: runtimeIdentity,
+ sourceState: sourceState
+ )
: nil
var stopCommitted = false
do {
@@ -2793,7 +3016,9 @@ public final class MachineManager: @unchecked Sendable {
guard Self.isValidID(snapshotID) else {
throw MachineManagerError.invalidID(snapshotID)
}
- let (machine, wasRunning) = try configurationAndRunningState(id: id)
+ let (machine, sourceMachineState) = try configurationAndPowerState(id: id)
+ let wasRunning = [.starting, .running].contains(sourceMachineState)
+ let wasPaused = sourceMachineState == .paused
let snapshotRuntimeIdentity = try currentRuntimeIdentity(id: id)
try Self.validateLaunchConfiguration(machine)
try ensurePrivateSnapshotDirectory(machineID: id)
@@ -2876,7 +3101,7 @@ public final class MachineManager: @unchecked Sendable {
let lifecycle = try beginLifecycleSnapshot(
machine: machine,
runtimeIdentity: snapshotRuntimeIdentity,
- sourceState: .stopped,
+ sourceState: wasPaused ? .paused : .stopped,
snapshotID: snapshotID,
snapshotAuthority: snapshotAuthority
)
@@ -3705,7 +3930,8 @@ public final class MachineManager: @unchecked Sendable {
legacyEnvironment: entry.configuration.environment,
displayMode: entry.configuration.displayMode
)
- if [.starting, .running].contains(entry.state), entry.process?.isRunningOrRestarting != true {
+ if [.starting, .running, .paused].contains(entry.state),
+ entry.process?.isRunningOrRestarting != true {
return DoryMachineStatus(
id: id,
state: .failed,
@@ -4730,10 +4956,27 @@ public final class MachineManager: @unchecked Sendable {
// Process.isRunning can briefly read false at the spawn/termination callback boundary.
// The supervisor lifecycle is authoritative: a running/starting entry with an active or
// scheduled helper must be stopped and restarted for a configuration transaction.
+ guard entry.state != .paused else {
+ throw MachineManagerError.persistence(
+ "machine \(id) must be resumed or stopped before this mutation"
+ )
+ }
let active = [.starting, .running].contains(entry.state) && entry.process != nil
return (entry.configuration, active)
}
+ private func configurationAndPowerState(
+ id: String
+ ) throws -> (DoryMachineConfiguration, DoryMachineState) {
+ lock.lock()
+ defer { lock.unlock() }
+ guard let entry = machines[id] else {
+ throw MachineManagerError.unknownMachine(id)
+ }
+ try validateManagedMachineArtifacts(entry.configuration)
+ return (entry.configuration, entry.state)
+ }
+
private func publishConfiguration(_ configuration: DoryMachineConfiguration) throws {
let identity = runtimeIdentityForUnplannedMachine(reason: .definitionChanged)
var identityPersistenceError: String?
@@ -7144,6 +7387,7 @@ public final class MachineManager: @unchecked Sendable {
private func lifecycleState(for state: DoryMachineState) -> DoryWorkspaceLifecycleState {
switch state {
case .starting, .running: .running
+ case .paused: .paused
case .created, .stopped: .stopped
case .failed: .failed
}
@@ -7273,13 +7517,14 @@ public final class MachineManager: @unchecked Sendable {
private func beginLifecycleStop(
machine: DoryMachineConfiguration,
- runtimeIdentity: DoryMachineRuntimeIdentity
+ runtimeIdentity: DoryMachineRuntimeIdentity,
+ sourceState: DoryWorkspaceLifecycleState
) throws -> MachineLifecycleJournalContext {
try beginLifecycleOperation(
kind: .stopping,
source: lifecycleCondition(
machine: machine,
- state: .running,
+ state: sourceState,
runtimeIdentity: runtimeIdentity
),
target: lifecycleCondition(
@@ -7292,6 +7537,48 @@ public final class MachineManager: @unchecked Sendable {
)
}
+ private func beginLifecyclePause(
+ machine: DoryMachineConfiguration,
+ runtimeIdentity: DoryMachineRuntimeIdentity
+ ) throws -> MachineLifecycleJournalContext {
+ try beginLifecycleOperation(
+ kind: .pausing,
+ source: lifecycleCondition(
+ machine: machine,
+ state: .running,
+ runtimeIdentity: runtimeIdentity
+ ),
+ target: lifecycleCondition(
+ machine: machine,
+ state: .paused,
+ runtimeIdentity: runtimeIdentity
+ ),
+ targetResourceID: nil,
+ readiness: false
+ )
+ }
+
+ private func beginLifecycleResume(
+ machine: DoryMachineConfiguration,
+ runtimeIdentity: DoryMachineRuntimeIdentity
+ ) throws -> MachineLifecycleJournalContext {
+ try beginLifecycleOperation(
+ kind: .resuming,
+ source: lifecycleCondition(
+ machine: machine,
+ state: .paused,
+ runtimeIdentity: runtimeIdentity
+ ),
+ target: lifecycleCondition(
+ machine: machine,
+ state: .running,
+ runtimeIdentity: runtimeIdentity
+ ),
+ targetResourceID: nil,
+ readiness: true
+ )
+ }
+
private func beginLifecycleSnapshot(
machine: DoryMachineConfiguration,
runtimeIdentity: DoryMachineRuntimeIdentity,
@@ -7765,8 +8052,15 @@ public final class MachineManager: @unchecked Sendable {
try failRecoveredLifecycle(lease, rolledBack: false)
diagnostics[id] = "interrupted deletion preserved existing machine data"
}
- case .importing, .provisioning, .pausing, .resuming,
- .suspending, .cloning, .updating, .repairing:
+ case .pausing:
+ try failRecoveredLifecycle(lease, rolledBack: false)
+ diagnostics[id] =
+ "interrupted pause was recovered as stopped after helper cleanup"
+ case .resuming:
+ try failRecoveredLifecycle(lease, rolledBack: false)
+ diagnostics[id] =
+ "interrupted resume was recovered as stopped after helper cleanup"
+ case .importing, .provisioning, .suspending, .cloning, .updating, .repairing:
try failRecoveredLifecycle(lease, rolledBack: false)
diagnostics[id] = "unsupported interrupted lifecycle mutation requires repair"
}
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
index dddf11dd..f64c5f35 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachinePlanningTransactionCoordinatorTests.swift
@@ -558,7 +558,9 @@ private final class TransactionFixture: @unchecked Sendable {
executablePath: "/fixture/dory-hv",
operations: MachineBackendCompatibilityOperations(
start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) },
- stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }
+ stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) },
+ pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) },
+ resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }
),
executableIsAvailable: { _ in true }
)])
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
index 3bfb8d6c..f84c9f81 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineProductionPlanningCompositionTests.swift
@@ -173,7 +173,9 @@ private final class CompositionFixture: @unchecked Sendable {
executablePath: "/fixture/dory-hv",
operations: MachineBackendCompatibilityOperations(
start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) },
- stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }
+ stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) },
+ pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) },
+ resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }
),
executableIsAvailable: { _ in true }
)
diff --git a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
index 4e735789..97cc9442 100644
--- a/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DoryDaemonVirtualMachineRuntimePlanningTests.swift
@@ -321,7 +321,9 @@ private struct Fixture {
executablePath: "/fixture/dory-hv",
operations: MachineBackendCompatibilityOperations(
start: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) },
- stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) }
+ stop: { id in MachineBackendRuntimeObservation(machineID: id, state: .stopped) },
+ pause: { id in MachineBackendRuntimeObservation(machineID: id, state: .paused) },
+ resume: { id in MachineBackendRuntimeObservation(machineID: id, state: .running) }
),
executableIsAvailable: { _ in true }
)])
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
index d3c7af94..0b07a36a 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineBackendTests.swift
@@ -19,8 +19,8 @@ final class MachineBackendTests: XCTestCase {
vz.bootMediaKinds,
[.linuxKernel, .installedLinuxBootBundle, .installerISO, .virtualDisk]
)
- XCTAssertFalse(vz.lifecycle.pause)
- XCTAssertFalse(vz.lifecycle.resume)
+ XCTAssertTrue(vz.lifecycle.pause)
+ XCTAssertTrue(vz.lifecycle.resume)
}
func testProbeFailsClosedForMissingAndUnavailableComponents() {
@@ -250,7 +250,7 @@ final class MachineBackendTests: XCTestCase {
XCTAssertEqual(result.failure?.code, .machineConfigurationIncompatible)
}
- func testPauseAndResumeAreExplicitlyUnsupportedAndDoNotInvokeLauncher() {
+ func testPauseAndResumeDispatchThroughTheSelectedBackend() {
let recorder = recordingOperations()
let backend = availableRawBackend(operations: recorder.operations)
let request = MachineBackendRuntimeRequest(
@@ -258,9 +258,14 @@ final class MachineBackendTests: XCTestCase {
backend: .doryHypervisor
)
- XCTAssertEqual(backend.pause(request).failure?.code, .lifecycleOperationUnsupported)
- XCTAssertEqual(backend.resume(request).failure?.code, .lifecycleOperationUnsupported)
- XCTAssertEqual(recorder.events, [])
+ let paused = backend.pause(request)
+ XCTAssertTrue(paused.isSuccess)
+ XCTAssertEqual(paused.observation?.state, .paused)
+
+ let resumed = backend.resume(request)
+ XCTAssertTrue(resumed.isSuccess)
+ XCTAssertEqual(resumed.observation?.state, .running)
+ XCTAssertEqual(recorder.events, ["pause:raw-linux", "resume:raw-linux"])
}
func testBackendPlanRoundTripsWithPlannerCapabilityEvidence() throws {
@@ -382,6 +387,14 @@ private final class OperationRecorder: @unchecked Sendable {
stop: { [weak self] id in
self?.append("stop:\(id)")
return MachineBackendRuntimeObservation(machineID: id, state: .stopped)
+ },
+ pause: { [weak self] id in
+ self?.append("pause:\(id)")
+ return MachineBackendRuntimeObservation(machineID: id, state: .paused)
+ },
+ resume: { [weak self] id in
+ self?.append("resume:\(id)")
+ return MachineBackendRuntimeObservation(machineID: id, state: .running)
}
)
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift
index 5fd3cdb2..5636fd7d 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerLifecycleJournalIntegrationTests.swift
@@ -6,6 +6,32 @@ import Foundation
import XCTest
final class MachineManagerLifecycleJournalIntegrationTests: XCTestCase {
+ func testPauseAndResumePublishExactLifecycleTransitions() throws {
+ let fixture = try LifecycleFixture(name: #function)
+ defer { fixture.cleanup() }
+ let manager = fixture.makeManager()
+ _ = try fixture.createMachine(manager)
+ XCTAssertEqual(try manager.start(id: fixture.machineID).state, .running)
+
+ let paused = try manager.pause(id: fixture.machineID)
+ XCTAssertEqual(paused.state, .paused)
+ let pauseRecord = try waitForJournal(fixture, kind: .workspacePause, status: .completed)
+ let pauseOperation = try fixture.store.acquire(pauseRecord.plan.id)
+ .readWorkspaceLifecycleOperation()
+ XCTAssertEqual(pauseOperation.kind, .pausing)
+ XCTAssertEqual(pauseOperation.source.state, .running)
+ XCTAssertEqual(pauseOperation.target.state, .paused)
+
+ let resumed = try manager.resume(id: fixture.machineID)
+ XCTAssertEqual(resumed.state, .running)
+ let resumeRecord = try waitForJournal(fixture, kind: .workspaceResume, status: .completed)
+ let resumeOperation = try fixture.store.acquire(resumeRecord.plan.id)
+ .readWorkspaceLifecycleOperation()
+ XCTAssertEqual(resumeOperation.kind, .resuming)
+ XCTAssertEqual(resumeOperation.source.state, .paused)
+ XCTAssertEqual(resumeOperation.target.state, .running)
+ }
+
func testStartJournalRemainsActiveUntilReadyAndFencesConcurrentMutation() throws {
let fixture = try LifecycleFixture(name: #function, requiresReadyHandoff: true)
defer { fixture.cleanup() }
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
index 4f7455ad..475c8d7a 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerResolvedPlanIntegrationTests.swift
@@ -108,7 +108,9 @@ struct MachineManagerResolvedPlanIntegrationTests {
}
return try managerOperations.authorizedStart(changed)
},
- stop: managerOperations.stop
+ stop: managerOperations.stop,
+ pause: managerOperations.pause,
+ resume: managerOperations.resume
)
let registry = try rawRegistry(operations: mutatingOperations)
let resolver = ClosureLaunchResolver { request in
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index 9db4c23e..7785c40f 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -82,6 +82,49 @@ final class MachineManagerTests: XCTestCase {
XCTAssertTrue(manager.list().isEmpty)
}
+ func testPauseResumePreservesTheRunningHelperAndStopAcceptsPausedMachine() throws {
+ let base = "/tmp/dory-machine-pause-resume-\(getpid())-\(UInt32.random(in: 0..
Date: Thu, 20 Aug 2026 22:39:48 +0000
Subject: [PATCH 052/338] feat(vm): expose pause and resume controls
---
Dory/Features/Machines/MachinesView.swift | 17 ++++-
Dory/Models/AppStore.swift | 33 +++++++--
Dory/Runtime/Doryd/DorydClient.swift | 18 +++++
DoryTests/DorydClientTests.swift | 74 +++++++++++++++++++
.../Sources/DorydKit/DorydControl.swift | 2 +
.../Sources/DorydKit/DorydService.swift | 18 +++++
dory-core-swift/Sources/dorydctl/main.swift | 10 ++-
.../DorydKitTests/DorydServiceTests.swift | 18 +++++
8 files changed, 180 insertions(+), 10 deletions(-)
diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift
index 643f4722..bff341b6 100644
--- a/Dory/Features/Machines/MachinesView.swift
+++ b/Dory/Features/Machines/MachinesView.swift
@@ -125,6 +125,8 @@ private struct MachineCard: View {
@State private var confirmingDelete = false
private var isRunning: Bool { machine.status == .running }
+ private var isPaused: Bool { machine.status == .paused }
+ private var isActive: Bool { isRunning || isPaused }
private var hasAssignedAddress: Bool { DoryDNS.ipv4Bytes(machine.ip) != nil }
var body: some View {
@@ -150,10 +152,10 @@ private struct MachineCard: View {
HStack(alignment: .top, spacing: 0) {
metric("CPU", isRunning ? String(format: "%.1f%%", machine.cpuPercent) : "—")
- metric("MEMORY", isRunning ? machine.memoryDisplay : "—")
+ metric("MEMORY", isActive ? machine.memoryDisplay : "—")
VStack(alignment: .leading, spacing: 3) {
Text(hasAssignedAddress ? "ADDRESS" : "DNS NAME").font(.system(size: 10, weight: .semibold)).foregroundStyle(p.text3).tracking(0.4)
- Text(machine.ip).font(.mono(12.5, weight: .semibold)).foregroundStyle(isRunning ? p.accentText : p.text3).lineLimit(1)
+ Text(machine.ip).font(.mono(12.5, weight: .semibold)).foregroundStyle(isActive ? p.accentText : p.text3).lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -191,9 +193,18 @@ private struct MachineCard: View {
Divider().overlay(p.border)
HStack(spacing: 8) {
- actionButton(isRunning ? "stop.fill" : "play.fill", isRunning ? "Stop" : "Start", prominent: !isRunning) {
+ actionButton(
+ isRunning ? "stop.fill" : "play.fill",
+ isRunning ? "Stop" : (isPaused ? "Resume" : "Start"),
+ prominent: !isRunning
+ ) {
store.toggleMachine(machine)
}
+ if isRunning {
+ actionButton("pause.fill", "Pause", prominent: false) {
+ store.pauseMachine(machine)
+ }
+ }
if machine.displayMode == .desktop {
actionButton("display", "Desktop", prominent: false, enabled: store.canOpenMachineDesktop(machine)) {
store.openMachineDesktop(machine)
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index d5d1a39e..2945effb 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5152,7 +5152,7 @@ final class AppStore {
switch status.state {
case "running":
runState = .running
- case "starting":
+ case "paused", "starting":
runState = .paused
default:
runState = .stopped
@@ -5361,19 +5361,42 @@ final class AppStore {
func toggleMachine(_ machine: Machine) {
guard requireDorydMachines() else { return }
guard let idx = machines.firstIndex(where: { $0.id == machine.id }) else { return }
- let wasRunning = machines[idx].status == .running
+ let previousState = machines[idx].status
let name = machine.name
busyMachines.insert(name)
Task {
defer { busyMachines.remove(name) }
do {
- if wasRunning {
+ switch previousState {
+ case .running:
_ = try await dorydClient.machineStop(name)
- } else {
+ case .paused:
+ _ = try await dorydClient.machineResume(name)
+ case .stopped:
_ = try await dorydClient.machineStart(name)
}
} catch {
- actionError = "Could not \(wasRunning ? "stop" : "start") \(name): \(error)"
+ let action = switch previousState {
+ case .running: "stop"
+ case .paused: "resume"
+ case .stopped: "start"
+ }
+ actionError = "Could not \(action) \(name): \(error)"
+ }
+ await refreshMachines()
+ }
+ }
+
+ func pauseMachine(_ machine: Machine) {
+ guard requireDorydMachines(), machine.status == .running else { return }
+ guard !busyMachines.contains(machine.name) else { return }
+ busyMachines.insert(machine.name)
+ Task {
+ defer { busyMachines.remove(machine.name) }
+ do {
+ _ = try await dorydClient.machinePause(machine.name)
+ } catch {
+ actionError = "Could not pause \(machine.name): \(error)"
}
await refreshMachines()
}
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index ac925b68..a942e661 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -17,6 +17,8 @@ nonisolated protocol DorydControlXPC {
func machineCreate(_ config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
+ func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
+ func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void)
func machineList(reply: @escaping (NSArray, String) -> Void)
@@ -1270,6 +1272,22 @@ nonisolated final class DorydClient: @unchecked Sendable {
}
}
+ func machinePause(_ machineID: String) async throws -> DorydMachineStatus {
+ try await withTimeout(atLeast: 30).statusCommand { proxy, reply in
+ proxy.machinePause(machineID, reply: reply)
+ } decode: {
+ Self.machineStatus(from: $0)
+ }
+ }
+
+ func machineResume(_ machineID: String) async throws -> DorydMachineStatus {
+ try await withTimeout(atLeast: 30).statusCommand { proxy, reply in
+ proxy.machineResume(machineID, reply: reply)
+ } decode: {
+ Self.machineStatus(from: $0)
+ }
+ }
+
func machineUpdate(
_ machineID: String,
memoryMB: UInt64? = nil,
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index ec1bb4db..3f1e1f74 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -258,6 +258,8 @@ struct DorydClientTests {
)
))
let startedMachine = try await client.machineStart("dev")
+ let pausedMachine = try await client.machinePause("dev")
+ let resumedMachine = try await client.machineResume("dev")
let machineStats = try await client.machineStats("dev")
let execResult = try await client.machineExec("dev", argv: ["/bin/sh", "-lc", "cargo --version"])
let provisionedMachine = try await client.machineProvision("dev", recipe: "rust")
@@ -368,6 +370,10 @@ struct DorydClientTests {
#expect(startedMachine.environment["DORY_GUEST_USER"] == "developer")
#expect(startedMachine.environment["DORY_DESKTOP_DISTRO"] == "ubuntu")
#expect(startedMachine.displayMode == .desktop)
+ #expect(pausedMachine.state == "paused")
+ #expect(pausedMachine.pid == startedMachine.pid)
+ #expect(resumedMachine.state == "running")
+ #expect(resumedMachine.pid == startedMachine.pid)
#expect(execResult.stdout == "cargo 1.0\n")
#expect(execResult.exitCode == 0)
#expect(machineStats.cpuPercent == 12.5)
@@ -970,6 +976,20 @@ struct DorydClientTests {
}
#expect(service.machineStartCount == 1)
+ machine = try #require(store.machines.first { $0.name == "dev" })
+ store.pauseMachine(machine)
+ try await waitUntil {
+ store.machines.first { $0.name == "dev" }?.status == .paused
+ }
+ #expect(service.machinePauseCount == 1)
+
+ machine = try #require(store.machines.first { $0.name == "dev" })
+ store.toggleMachine(machine)
+ try await waitUntil {
+ store.machines.first { $0.name == "dev" }?.status == .running
+ }
+ #expect(service.machineResumeCount == 1)
+
machine = try #require(store.machines.first { $0.name == "dev" })
let editResult = await store.editMachine(
machine,
@@ -2368,6 +2388,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
]
private var _machineStartCount = 0
private var _machineStopCount = 0
+ private var _machinePauseCount = 0
+ private var _machineResumeCount = 0
private var _machineDeleteCount = 0
private var _machineDeleteOK = true
private var _machineDeleteMessage = ""
@@ -2448,6 +2470,14 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
lock.lock(); defer { lock.unlock() }
return _machineStopCount
}
+ var machinePauseCount: Int {
+ lock.lock(); defer { lock.unlock() }
+ return _machinePauseCount
+ }
+ var machineResumeCount: Int {
+ lock.lock(); defer { lock.unlock() }
+ return _machineResumeCount
+ }
var machineDeleteCount: Int {
lock.lock(); defer { lock.unlock() }
return _machineDeleteCount
@@ -2710,6 +2740,50 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
reply(true, row, "")
}
+ func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) {
+ lock.lock()
+ let current = machines[machineID]
+ let row = Self.machineRow(
+ id: machineID,
+ state: "paused",
+ pid: (current?["pid"] as? NSNumber)?.int32Value ?? 1234,
+ agentBuild: current?["agentBuild"] as? String,
+ handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0,
+ memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048,
+ cpuCount: Self.int(current?["cpuCount"]) ?? 2,
+ address: current?["address"] as? String,
+ displayMode: current?["displayMode"] as? String ?? "headless",
+ shares: Self.shareRows(current?["shares"]),
+ environment: Self.environmentRows(current?["env"])
+ )
+ _machinePauseCount += 1
+ machines[machineID] = row
+ lock.unlock()
+ reply(true, row, "")
+ }
+
+ func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) {
+ lock.lock()
+ let current = machines[machineID]
+ let row = Self.machineRow(
+ id: machineID,
+ state: "running",
+ pid: (current?["pid"] as? NSNumber)?.int32Value ?? 1234,
+ agentBuild: current?["agentBuild"] as? String,
+ handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0,
+ memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048,
+ cpuCount: Self.int(current?["cpuCount"]) ?? 2,
+ address: current?["address"] as? String,
+ displayMode: current?["displayMode"] as? String ?? "headless",
+ shares: Self.shareRows(current?["shares"]),
+ environment: Self.environmentRows(current?["env"])
+ )
+ _machineResumeCount += 1
+ machines[machineID] = row
+ lock.unlock()
+ reply(true, row, "")
+ }
+
func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) {
lock.lock()
_machineUpdateCount += 1
diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift
index ff247df2..aa6c3343 100644
--- a/dory-core-swift/Sources/DorydKit/DorydControl.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift
@@ -17,6 +17,8 @@ import Foundation
func machineCreate(_ config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
+ func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
+ func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void)
func machineList(reply: @escaping (NSArray, String) -> Void)
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index 01bf2c25..5d663604 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -283,6 +283,24 @@ public final class DorydService: NSObject, DorydControl {
}
}
+ public func machinePause(
+ _ machineID: String,
+ reply: @escaping (Bool, NSDictionary, String) -> Void
+ ) {
+ machineControl(machineID, action: "pause", reply: reply) { manager, id in
+ try manager.pause(id: id)
+ }
+ }
+
+ public func machineResume(
+ _ machineID: String,
+ reply: @escaping (Bool, NSDictionary, String) -> Void
+ ) {
+ machineControl(machineID, action: "resume", reply: reply) { manager, id in
+ try manager.resume(id: id)
+ }
+ }
+
public func machineUpdate(
_ machineID: String,
config: NSDictionary,
diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift
index f065238f..cd28c1f7 100644
--- a/dory-core-swift/Sources/dorydctl/main.swift
+++ b/dory-core-swift/Sources/dorydctl/main.swift
@@ -166,7 +166,7 @@ func usage(exitCode: Int32 = 2) -> Never {
dorydctl [global] machine stats NAME
dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software]
dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics] [--attach-installer | --eject-installer]
- dorydctl [global] machine start|stop|delete NAME
+ dorydctl [global] machine start|stop|pause|resume|delete NAME
dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...]
dorydctl [global] machine shell NAME
dorydctl [global] machine provision NAME --recipe RECIPE
@@ -1050,7 +1050,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
}
func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
- let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|delete|exec|shell|provision|snapshots|snapshot|backup")
+ let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|resume|delete|exec|shell|provision|snapshots|snapshot|backup")
switch subcommand {
case "list":
let rows: NSArray = try client.call { proxy, finish in
@@ -1160,6 +1160,12 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand {
$0.machineStop(name, reply: $1)
})
+ case "pause":
+ let name = try cursor.take("usage: dorydctl machine pause NAME")
+ try emitJSON(try client.statusCommand { $0.machinePause(name, reply: $1) })
+ case "resume":
+ let name = try cursor.take("usage: dorydctl machine resume NAME")
+ try emitJSON(try client.statusCommand { $0.machineResume(name, reply: $1) })
case "update":
try runMachineUpdate(cursor: &cursor, client: client)
case "delete", "rm":
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 15cc171a..02cf86ff 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -1117,6 +1117,24 @@ final class DorydServiceTests: XCTestCase {
}
wait(for: [start], timeout: 5)
+ let pause = expectation(description: "machinePause reply")
+ proxy.machinePause("dev") { ok, body, message in
+ XCTAssertTrue(ok, message)
+ XCTAssertEqual(body["state"] as? String, "paused")
+ XCTAssertNotNil(body["pid"])
+ pause.fulfill()
+ }
+ wait(for: [pause], timeout: 5)
+
+ let resume = expectation(description: "machineResume reply")
+ proxy.machineResume("dev") { ok, body, message in
+ XCTAssertTrue(ok, message)
+ XCTAssertEqual(body["state"] as? String, "running")
+ XCTAssertNotNil(body["pid"])
+ resume.fulfill()
+ }
+ wait(for: [resume], timeout: 5)
+
let list = expectation(description: "machineList reply")
proxy.machineList { body, message in
XCTAssertEqual(message, "")
From b0202439f85e7550cd2855579ecce3287c03e8c8 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 22:41:11 +0000
Subject: [PATCH 053/338] feat(vm): add fenced machine restart
---
.../Sources/DorydKit/MachineManager.swift | 25 +++++++++++++
.../DorydKitTests/MachineManagerTests.swift | 37 +++++++++++++++++++
2 files changed, 62 insertions(+)
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 9d757a9e..b44f346c 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -2426,6 +2426,31 @@ public final class MachineManager: @unchecked Sendable {
}
}
+ public func restart(id: String) throws -> DoryMachineStatus {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+ try requireNoActivePlanningMutation(id: id)
+ let directMutation = try retainDirectWorkspaceMutationLock(id: id)
+ defer { releaseDirectWorkspaceMutationLock(id: id, retention: directMutation) }
+
+ lock.lock()
+ guard let entry = machines[id] else {
+ lock.unlock()
+ throw MachineManagerError.unknownMachine(id)
+ }
+ guard [.running, .paused].contains(entry.state), entry.process != nil else {
+ lock.unlock()
+ throw MachineManagerError.persistence(
+ "machine \(id) must be running or paused before restart"
+ )
+ }
+ lock.unlock()
+
+ _ = try stopImplementation(id: id, journalLifecycle: true)
+ try refreshResolvedAdmissionForStartIfNeeded(id: id)
+ return try startImplementation(id: id, journalLifecycle: true)
+ }
+
private func performAuthorizedResolvedBackendPause(
id: String,
expectedBackend: DoryVirtualizationBackendIdentity
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index 7785c40f..3253caa2 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -125,6 +125,43 @@ final class MachineManagerTests: XCTestCase {
XCTAssertNil(stopped.pid)
}
+ func testRestartReplacesTheHelperFromRunningAndPausedStates() throws {
+ let base = "/tmp/dory-machine-restart-\(getpid())-\(UInt32.random(in: 0..
Date: Thu, 20 Aug 2026 22:42:55 +0000
Subject: [PATCH 054/338] feat(vm): expose machine restart controls
---
Dory/Features/Machines/MachinesView.swift | 6 ++++
Dory/Models/AppStore.swift | 15 ++++++++
Dory/Runtime/Doryd/DorydClient.swift | 9 +++++
DoryTests/DorydClientTests.swift | 35 +++++++++++++++++++
.../Sources/DorydKit/DorydControl.swift | 1 +
.../Sources/DorydKit/DorydService.swift | 9 +++++
dory-core-swift/Sources/dorydctl/main.swift | 9 +++--
.../DorydKitTests/DorydServiceTests.swift | 9 +++++
8 files changed, 91 insertions(+), 2 deletions(-)
diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift
index bff341b6..340ca8cc 100644
--- a/Dory/Features/Machines/MachinesView.swift
+++ b/Dory/Features/Machines/MachinesView.swift
@@ -246,6 +246,12 @@ private struct MachineCard: View {
private var overflowMenu: some View {
Menu {
+ if isActive {
+ Button { store.restartMachine(machine) } label: {
+ Label("Restart", systemImage: "arrow.clockwise")
+ }
+ Divider()
+ }
Button { store.takeSnapshot(machine, note: "") } label: {
Label("Snapshot", systemImage: "camera.aperture")
}
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index 2945effb..0ee230e6 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5402,6 +5402,21 @@ final class AppStore {
}
}
+ func restartMachine(_ machine: Machine) {
+ guard requireDorydMachines(), machine.status != .stopped else { return }
+ guard !busyMachines.contains(machine.name) else { return }
+ busyMachines.insert(machine.name)
+ Task {
+ defer { busyMachines.remove(machine.name) }
+ do {
+ _ = try await dorydClient.machineRestart(machine.name)
+ } catch {
+ actionError = "Could not restart \(machine.name): \(error)"
+ }
+ await refreshMachines()
+ }
+ }
+
func setMachineInstallerMedia(_ machine: Machine, attached: Bool) {
guard requireDorydMachines(), machine.bootMode == .efi else { return }
guard !busyMachines.contains(machine.name) else { return }
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index a942e661..540cd9d9 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -19,6 +19,7 @@ nonisolated protocol DorydControlXPC {
func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
+ func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void)
func machineList(reply: @escaping (NSArray, String) -> Void)
@@ -1288,6 +1289,14 @@ nonisolated final class DorydClient: @unchecked Sendable {
}
}
+ func machineRestart(_ machineID: String) async throws -> DorydMachineStatus {
+ try await withTimeout(atLeast: 120).statusCommand { proxy, reply in
+ proxy.machineRestart(machineID, reply: reply)
+ } decode: {
+ Self.machineStatus(from: $0)
+ }
+ }
+
func machineUpdate(
_ machineID: String,
memoryMB: UInt64? = nil,
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 3f1e1f74..4654848e 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -260,6 +260,7 @@ struct DorydClientTests {
let startedMachine = try await client.machineStart("dev")
let pausedMachine = try await client.machinePause("dev")
let resumedMachine = try await client.machineResume("dev")
+ let restartedMachine = try await client.machineRestart("dev")
let machineStats = try await client.machineStats("dev")
let execResult = try await client.machineExec("dev", argv: ["/bin/sh", "-lc", "cargo --version"])
let provisionedMachine = try await client.machineProvision("dev", recipe: "rust")
@@ -374,6 +375,8 @@ struct DorydClientTests {
#expect(pausedMachine.pid == startedMachine.pid)
#expect(resumedMachine.state == "running")
#expect(resumedMachine.pid == startedMachine.pid)
+ #expect(restartedMachine.state == "running")
+ #expect(restartedMachine.pid == 1235)
#expect(execResult.stdout == "cargo 1.0\n")
#expect(execResult.exitCode == 0)
#expect(machineStats.cpuPercent == 12.5)
@@ -990,6 +993,11 @@ struct DorydClientTests {
}
#expect(service.machineResumeCount == 1)
+ machine = try #require(store.machines.first { $0.name == "dev" })
+ store.restartMachine(machine)
+ try await waitUntil { service.machineRestartCount == 1 }
+ #expect(store.machines.first { $0.name == "dev" }?.status == .running)
+
machine = try #require(store.machines.first { $0.name == "dev" })
let editResult = await store.editMachine(
machine,
@@ -2390,6 +2398,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
private var _machineStopCount = 0
private var _machinePauseCount = 0
private var _machineResumeCount = 0
+ private var _machineRestartCount = 0
private var _machineDeleteCount = 0
private var _machineDeleteOK = true
private var _machineDeleteMessage = ""
@@ -2478,6 +2487,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
lock.lock(); defer { lock.unlock() }
return _machineResumeCount
}
+ var machineRestartCount: Int {
+ lock.lock(); defer { lock.unlock() }
+ return _machineRestartCount
+ }
var machineDeleteCount: Int {
lock.lock(); defer { lock.unlock() }
return _machineDeleteCount
@@ -2784,6 +2797,28 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
reply(true, row, "")
}
+ func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) {
+ lock.lock()
+ let current = machines[machineID]
+ let row = Self.machineRow(
+ id: machineID,
+ state: "running",
+ pid: ((current?["pid"] as? NSNumber)?.int32Value ?? 1234) + 1,
+ agentBuild: current?["agentBuild"] as? String,
+ handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0,
+ memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048,
+ cpuCount: Self.int(current?["cpuCount"]) ?? 2,
+ address: current?["address"] as? String,
+ displayMode: current?["displayMode"] as? String ?? "headless",
+ shares: Self.shareRows(current?["shares"]),
+ environment: Self.environmentRows(current?["env"])
+ )
+ _machineRestartCount += 1
+ machines[machineID] = row
+ lock.unlock()
+ reply(true, row, "")
+ }
+
func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) {
lock.lock()
_machineUpdateCount += 1
diff --git a/dory-core-swift/Sources/DorydKit/DorydControl.swift b/dory-core-swift/Sources/DorydKit/DorydControl.swift
index aa6c3343..dc7df97c 100644
--- a/dory-core-swift/Sources/DorydKit/DorydControl.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydControl.swift
@@ -19,6 +19,7 @@ import Foundation
func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
+ func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void)
func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void)
func machineList(reply: @escaping (NSArray, String) -> Void)
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index 5d663604..fe8095fe 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -301,6 +301,15 @@ public final class DorydService: NSObject, DorydControl {
}
}
+ public func machineRestart(
+ _ machineID: String,
+ reply: @escaping (Bool, NSDictionary, String) -> Void
+ ) {
+ machineControl(machineID, action: "restart", reply: reply) { manager, id in
+ try manager.restart(id: id)
+ }
+ }
+
public func machineUpdate(
_ machineID: String,
config: NSDictionary,
diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift
index cd28c1f7..81506c92 100644
--- a/dory-core-swift/Sources/dorydctl/main.swift
+++ b/dory-core-swift/Sources/dorydctl/main.swift
@@ -166,7 +166,7 @@ func usage(exitCode: Int32 = 2) -> Never {
dorydctl [global] machine stats NAME
dorydctl [global] machine create NAME (--kernel PATH --rootfs PATH | --installer-iso PATH [--disk-size-gb N]) [--memory-mb N] [--cpus N] [--display-mode headless|desktop] [--dns-target IPv4] [--share TAG=HOST:GUEST[:ro|rw] | JSON] [--guest-user NAME] [--guest-uid N] [--desktop-distro ID] [--desktop-name NAME] [--desktop-version VERSION] [--desktop-environment NAME] [--clipboard off|host-to-guest|guest-to-host|bidirectional] [--runtime auto|accelerated|compatible] [--graphics auto|virgl|virgl-venus|software]
dorydctl [global] machine update NAME [--memory-mb N] [--cpus N] [--dns-target IPv4 | --clear-dns-target] [--share TAG=HOST:GUEST[:ro|rw] | JSON ... | --clear-shares] [typed create options | --clear-guest-account | --clear-desktop-identity | --clear-clipboard | --clear-runtime | --clear-graphics] [--attach-installer | --eject-installer]
- dorydctl [global] machine start|stop|pause|resume|delete NAME
+ dorydctl [global] machine start|stop|pause|resume|restart|delete NAME
dorydctl [global] machine exec NAME [--json] [--cwd PATH] [--env KEY=VALUE] [--env-json-stdin] [--timeout-ms N] [--output-limit-bytes N] -- COMMAND [ARG...]
dorydctl [global] machine shell NAME
dorydctl [global] machine provision NAME --recipe RECIPE
@@ -1050,7 +1050,7 @@ func runBalloon(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
}
func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
- let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|resume|delete|exec|shell|provision|snapshots|snapshot|backup")
+ let subcommand = try cursor.take("usage: dorydctl machine list|status|create|update|desktop-update|start|stop|pause|resume|restart|delete|exec|shell|provision|snapshots|snapshot|backup")
switch subcommand {
case "list":
let rows: NSArray = try client.call { proxy, finish in
@@ -1166,6 +1166,11 @@ func runMachine(cursor: inout ArgumentCursor, client: DorydCtlClient) throws {
case "resume":
let name = try cursor.take("usage: dorydctl machine resume NAME")
try emitJSON(try client.statusCommand { $0.machineResume(name, reply: $1) })
+ case "restart":
+ let name = try cursor.take("usage: dorydctl machine restart NAME")
+ try emitJSON(try client.withTimeout(atLeast: engineShutdownTimeout).statusCommand {
+ $0.machineRestart(name, reply: $1)
+ })
case "update":
try runMachineUpdate(cursor: &cursor, client: client)
case "delete", "rm":
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 02cf86ff..cc5784ba 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -1135,6 +1135,15 @@ final class DorydServiceTests: XCTestCase {
}
wait(for: [resume], timeout: 5)
+ let restart = expectation(description: "machineRestart reply")
+ proxy.machineRestart("dev") { ok, body, message in
+ XCTAssertTrue(ok, message)
+ XCTAssertEqual(body["state"] as? String, "running")
+ XCTAssertNotNil(body["pid"])
+ restart.fulfill()
+ }
+ wait(for: [restart], timeout: 5)
+
let list = expectation(description: "machineList reply")
proxy.machineList { body, message in
XCTAssertEqual(message, "")
From 5f93bf704ace87cfacec45aa0c7668cbaca5bd1d Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 22:45:19 +0000
Subject: [PATCH 055/338] fix(vm): account for paused machine resources
---
.../Sources/DorydKit/HealthReporter.swift | 11 +++++++++++
.../Sources/DorydKit/MachineManager.swift | 16 ++++++++++++++++
.../DorydKitTests/HealthReporterTests.swift | 10 ++++++++++
.../DorydKitTests/MachineManagerTests.swift | 12 +++++++++++-
4 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift
index 94a90ea4..4ce88e95 100644
--- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift
+++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift
@@ -1912,10 +1912,12 @@ public final class HealthReporter: @unchecked Sendable {
let failed = statuses.filter { $0.state == .failed }
let starting = statuses.filter { $0.state == .starting }
let running = statuses.filter { $0.state == .running }
+ let paused = statuses.filter { $0.state == .paused }
let stopped = statuses.filter { $0.state == .stopped || $0.state == .created }
let data = [
"total": String(statuses.count),
"running": String(running.count),
+ "paused": String(paused.count),
"starting": String(starting.count),
"stopped": String(stopped.count),
"failed": String(failed.count),
@@ -1941,6 +1943,15 @@ public final class HealthReporter: @unchecked Sendable {
detail: starting.map(\.id).joined(separator: ", "),
data: data
)
+ } else if !paused.isEmpty && running.isEmpty {
+ summary = HealthCheck(
+ id: "machine.local",
+ status: .pass,
+ code: "machine.paused",
+ title: "Local machine paused",
+ detail: paused.map(\.id).joined(separator: ", "),
+ data: data
+ )
} else {
summary = HealthCheck(
id: "machine.local",
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index b44f346c..65f36f5c 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -4708,6 +4708,22 @@ public final class MachineManager: @unchecked Sendable {
public func memorySnapshots() -> [GuestMemorySnapshot] {
list().compactMap { status in
+ if status.state == .paused {
+ let residentMB = max(1, status.currentBalloonTargetMB ?? status.memoryMB)
+ return GuestMemorySnapshot(
+ id: "machine.\(status.id)",
+ kind: .virtualMachine,
+ telemetry: DoryTelemetry(
+ memTotalKB: residentMB * 1024,
+ memAvailableKB: 0,
+ psiSomeAvg10: 0,
+ psiFullAvg10: 0
+ ),
+ currentTargetMB: residentMB,
+ maximumTargetMB: status.memoryMB,
+ canBalloon: false
+ )
+ }
guard status.state == .running, status.agentSocketPath != nil else {
return nil
}
diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
index a1f66705..10c4eacb 100644
--- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
@@ -389,6 +389,16 @@ final class HealthReporterTests: XCTestCase {
XCTAssertTrue(doctor.results.contains { $0.id == "machine.local" })
XCTAssertTrue(doctor.results.contains { $0.id == "machine.local.dev" })
XCTAssertNil(try doctor.jsonString().range(of: "sk-opaque-value"))
+
+ _ = try manager.pause(id: "dev")
+ let pausedHealth = reporter.report()
+ let pausedSummary = try XCTUnwrap(
+ pausedHealth.results.first { $0.id == "machine.local" }
+ )
+ XCTAssertEqual(pausedSummary.status, .pass)
+ XCTAssertEqual(pausedSummary.code, "machine.paused")
+ XCTAssertEqual(pausedSummary.data["running"], "0")
+ XCTAssertEqual(pausedSummary.data["paused"], "1")
}
func testResolvedMachineEvidencePinsPlanBackendMediaComponentsAndQualifications() throws {
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index 3253caa2..dab19f83 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -3702,7 +3702,7 @@ final class MachineManagerTests: XCTestCase {
])
}
- func testMemorySnapshotsIncludeRunningMachineTelemetry() throws {
+ func testMemorySnapshotsIncludeRunningTelemetryAndResidentPausedMemory() throws {
let base = "/tmp/dory-machine-memory-\(getpid())-\(UInt32.random(in: 0..
Date: Thu, 20 Aug 2026 22:51:24 +0000
Subject: [PATCH 056/338] feat(vm): preserve workspaces across host sleep
---
.../DorydKit/HostWakeCoordinator.swift | 30 +-
.../DorydKit/MachineHostPowerController.swift | 274 ++++++++++++++++++
.../Sources/DorydKit/MachineManager.swift | 2 +-
dory-core-swift/Sources/doryd/main.swift | 5 +
.../HostWakeCoordinatorTests.swift | 28 ++
.../DorydKitTests/MachineManagerTests.swift | 53 ++++
6 files changed, 390 insertions(+), 2 deletions(-)
create mode 100644 dory-core-swift/Sources/DorydKit/MachineHostPowerController.swift
diff --git a/dory-core-swift/Sources/DorydKit/HostWakeCoordinator.swift b/dory-core-swift/Sources/DorydKit/HostWakeCoordinator.swift
index 1e734736..7b8685b3 100644
--- a/dory-core-swift/Sources/DorydKit/HostWakeCoordinator.swift
+++ b/dory-core-swift/Sources/DorydKit/HostWakeCoordinator.swift
@@ -45,6 +45,10 @@ public protocol WakeClockSyncing: Sendable {
func syncAgentClock(now: Date) -> AgentClockSyncResult
}
+public protocol HostWakeHandling: Sendable {
+ func recoverAfterHostWake(now: Date) -> HostWakeActionResult
+}
+
public struct HostSleepActionResult: Sendable, Equatable {
public var name: String
public var attempted: Bool
@@ -73,6 +77,20 @@ public struct AgentClockSyncResult: Sendable, Equatable {
}
}
+public struct HostWakeActionResult: Sendable, Equatable {
+ public var name: String
+ public var attempted: Bool
+ public var recovered: Bool
+ public var detail: String?
+
+ public init(name: String, attempted: Bool, recovered: Bool, detail: String? = nil) {
+ self.name = name
+ self.attempted = attempted
+ self.recovered = recovered
+ self.detail = detail
+ }
+}
+
public struct DNSProbeTarget: Sendable, Equatable {
public var host: String
public var port: UInt16
@@ -170,6 +188,7 @@ public final class SystemDNSProbe: DNSProbing, @unchecked Sendable {
public struct HostWakeResult: Sendable, Equatable {
public var at: Date
+ public var actions: [HostWakeActionResult]
public var clockSyncs: [AgentClockSyncResult]
public var dnsProbes: [DNSProbeResult]
public var networkReconciliations: [String]
@@ -183,6 +202,7 @@ public struct HostSleepResult: Sendable, Equatable {
public final class HostWakeCoordinator: @unchecked Sendable {
private let powerSource: PowerEventSource
private let sleepHandlers: [HostSleepHandling]
+ private let wakeHandlers: [HostWakeHandling]
private let clockSyncers: [WakeClockSyncing]
private let dnsProbe: DNSProbing
private let networkReconcilers: [WakeNetworkReconciling]
@@ -194,6 +214,7 @@ public final class HostWakeCoordinator: @unchecked Sendable {
public init(
powerSource: PowerEventSource = IOKitPowerEventSource(),
sleepHandlers: [HostSleepHandling] = [],
+ wakeHandlers: [HostWakeHandling] = [],
clockSyncers: [WakeClockSyncing] = [],
dnsProbe: DNSProbing = SystemDNSProbe(),
networkReconcilers: [WakeNetworkReconciling] = [],
@@ -201,6 +222,7 @@ public final class HostWakeCoordinator: @unchecked Sendable {
) {
self.powerSource = powerSource
self.sleepHandlers = sleepHandlers
+ self.wakeHandlers = wakeHandlers
self.clockSyncers = clockSyncers
self.dnsProbe = dnsProbe
self.networkReconcilers = networkReconcilers
@@ -237,6 +259,9 @@ public final class HostWakeCoordinator: @unchecked Sendable {
@discardableResult
public func handleWake(now: Date = Date()) -> HostWakeResult {
+ // Resume daemon-owned workspaces before contacting their guest agents. A machine that Dory
+ // paused for host sleep must be running before clock synchronization can succeed.
+ let actions = wakeHandlers.map { $0.recoverAfterHostWake(now: now) }
let clockResults = clockSyncers.map { $0.syncAgentClock(now: now) }
let dnsResults = dnsProbe.probe()
// Reconcile after clock and baseline DNS recovery so PAC expiry, DHCP resolver changes,
@@ -244,6 +269,7 @@ public final class HostWakeCoordinator: @unchecked Sendable {
let networkResults = networkReconcilers.map { $0.reconcileAfterWake(now: now) }
let result = HostWakeResult(
at: now,
+ actions: actions,
clockSyncs: clockResults,
dnsProbes: dnsResults,
networkReconciliations: networkResults
@@ -278,9 +304,11 @@ public final class HostWakeCoordinator: @unchecked Sendable {
}
private func incidentDetail(_ result: HostWakeResult) -> String {
+ let wakeAttempts = result.actions.filter(\.attempted).count
+ let wakeFailures = result.actions.filter { $0.attempted && !$0.recovered }.count
let attempted = result.clockSyncs.filter(\.attempted).count
let failed = result.clockSyncs.filter { $0.error != nil }.count
let dnsOK = result.dnsProbes.filter(\.resolved).count
- return "clock_syncs=\(attempted) clock_errors=\(failed) dns_ok=\(dnsOK)/\(result.dnsProbes.count) network_reconciles=\(result.networkReconciliations.count)"
+ return "wake_actions=\(wakeAttempts) wake_errors=\(wakeFailures) clock_syncs=\(attempted) clock_errors=\(failed) dns_ok=\(dnsOK)/\(result.dnsProbes.count) network_reconciles=\(result.networkReconciliations.count)"
}
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineHostPowerController.swift b/dory-core-swift/Sources/DorydKit/MachineHostPowerController.swift
new file mode 100644
index 00000000..7116ea88
--- /dev/null
+++ b/dory-core-swift/Sources/DorydKit/MachineHostPowerController.swift
@@ -0,0 +1,274 @@
+import Darwin
+import Foundation
+
+/// Pauses running workspaces before host sleep and resumes only the workspaces paused by this
+/// controller. The intent is durable so recreating doryd between the sleep and wake callbacks does
+/// not turn a manually paused workspace into a running one or lose an automatic resume.
+public final class MachineHostPowerController: HostSleepHandling, HostWakeHandling, @unchecked Sendable {
+ private struct Record: Codable, Equatable {
+ var schemaVersion: Int
+ var machineIDs: [String]
+ }
+
+ private static let schemaVersion = 1
+ private static let recordName = ".host-sleep-paused-v1.json"
+ private let manager: MachineManager
+ private let stateDirectory: String
+ private let lock = NSLock()
+
+ public init(manager: MachineManager, stateDirectory: String? = nil) {
+ self.manager = manager
+ self.stateDirectory = stateDirectory ?? manager.managedStateDirectory
+ }
+
+ public func prepareForHostSleep(now: Date) -> HostSleepActionResult {
+ _ = now
+ lock.lock()
+ defer { lock.unlock() }
+
+ let previous: Set
+ do {
+ previous = Set(try readRecord()?.machineIDs ?? [])
+ } catch {
+ return HostSleepActionResult(
+ name: "machines",
+ attempted: true,
+ slept: false,
+ detail: "could not read durable host-sleep authority: \(error)"
+ )
+ }
+
+ let newlyRunning = Set(manager.list().filter { $0.state == .running }.map(\.id))
+ var pending = previous.union(newlyRunning)
+ guard !pending.isEmpty else {
+ return HostSleepActionResult(
+ name: "machines",
+ attempted: false,
+ slept: false,
+ detail: "no running machines"
+ )
+ }
+
+ // Publish intent before sending SIGSTOP. A crash after this point may leave an ID whose
+ // process is still running; wake recovery treats that as already recovered and never starts
+ // a stopped workspace.
+ do {
+ try writeRecord(machineIDs: pending)
+ } catch {
+ return HostSleepActionResult(
+ name: "machines",
+ attempted: true,
+ slept: false,
+ detail: "could not persist host-sleep intent: \(error)"
+ )
+ }
+
+ var failures: [String] = []
+ for id in pending.sorted() {
+ guard let status = manager.status(id: id) else {
+ pending.remove(id)
+ continue
+ }
+ if status.state == .paused {
+ continue
+ }
+ guard status.state == .running else {
+ pending.remove(id)
+ continue
+ }
+ do {
+ guard try manager.pause(id: id).state == .paused else {
+ failures.append("\(id): pause returned a non-paused state")
+ pending.remove(id)
+ continue
+ }
+ } catch {
+ failures.append("\(id): \(error)")
+ pending.remove(id)
+ }
+ }
+
+ do {
+ try writeRecord(machineIDs: pending)
+ } catch {
+ failures.append("could not commit paused-machine authority: \(error)")
+ }
+ return HostSleepActionResult(
+ name: "machines",
+ attempted: true,
+ slept: failures.isEmpty,
+ detail: "paused=\(pending.count)/\(newlyRunning.count)"
+ + (failures.isEmpty ? "" : " errors=\(failures.joined(separator: "; "))")
+ )
+ }
+
+ public func recoverAfterHostWake(now: Date) -> HostWakeActionResult {
+ _ = now
+ lock.lock()
+ defer { lock.unlock() }
+
+ var pending: Set
+ do {
+ pending = Set(try readRecord()?.machineIDs ?? [])
+ } catch {
+ return HostWakeActionResult(
+ name: "machines",
+ attempted: true,
+ recovered: false,
+ detail: "could not read durable host-sleep authority: \(error)"
+ )
+ }
+ guard !pending.isEmpty else {
+ return HostWakeActionResult(
+ name: "machines",
+ attempted: false,
+ recovered: true,
+ detail: "no host-paused machines"
+ )
+ }
+
+ let attemptedCount = pending.count
+ var failures: [String] = []
+ for id in pending.sorted() {
+ guard let status = manager.status(id: id) else {
+ pending.remove(id)
+ continue
+ }
+ if status.state == .running {
+ pending.remove(id)
+ continue
+ }
+ guard status.state == .paused else {
+ // Never start a stopped, failed, or otherwise non-resident workspace merely because
+ // it was running before the host went to sleep.
+ pending.remove(id)
+ continue
+ }
+ do {
+ guard try manager.resume(id: id).state == .running else {
+ failures.append("\(id): resume returned a non-running state")
+ continue
+ }
+ pending.remove(id)
+ } catch {
+ failures.append("\(id): \(error)")
+ }
+ }
+
+ do {
+ try writeRecord(machineIDs: pending)
+ } catch {
+ failures.append("could not retire host-sleep authority: \(error)")
+ }
+ return HostWakeActionResult(
+ name: "machines",
+ attempted: true,
+ recovered: failures.isEmpty && pending.isEmpty,
+ detail: "resumed=\(attemptedCount - pending.count)/\(attemptedCount)"
+ + (failures.isEmpty ? "" : " errors=\(failures.joined(separator: "; "))")
+ )
+ }
+
+ private var recordPath: String { stateDirectory + "/" + Self.recordName }
+
+ private func readRecord() throws -> Record? {
+ var statBuffer = stat()
+ if lstat(recordPath, &statBuffer) != 0 {
+ if errno == ENOENT { return nil }
+ throw filesystem("inspect host-sleep record")
+ }
+ guard (statBuffer.st_mode & S_IFMT) == S_IFREG,
+ statBuffer.st_uid == geteuid(),
+ statBuffer.st_nlink == 1,
+ statBuffer.st_mode & 0o077 == 0 else {
+ throw filesystem("reject unsafe host-sleep record")
+ }
+ let data = try Data(contentsOf: URL(fileURLWithPath: recordPath), options: .mappedIfSafe)
+ let record = try JSONDecoder().decode(Record.self, from: data)
+ guard record.schemaVersion == Self.schemaVersion,
+ record.machineIDs == Array(Set(record.machineIDs)).sorted(),
+ record.machineIDs.allSatisfy(Self.isValidMachineID) else {
+ throw filesystem("reject invalid host-sleep record")
+ }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ guard try encoder.encode(record) == data else {
+ throw filesystem("reject non-canonical host-sleep record")
+ }
+ return record
+ }
+
+ private func writeRecord(machineIDs: Set) throws {
+ let sortedIDs = machineIDs.sorted()
+ guard sortedIDs.allSatisfy(Self.isValidMachineID) else {
+ throw filesystem("refuse invalid machine identifier")
+ }
+ if sortedIDs.isEmpty {
+ if unlink(recordPath) != 0, errno != ENOENT {
+ throw filesystem("remove host-sleep record")
+ }
+ try synchronizeDirectory()
+ return
+ }
+
+ try FileManager.default.createDirectory(
+ atPath: stateDirectory,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = try encoder.encode(Record(
+ schemaVersion: Self.schemaVersion,
+ machineIDs: sortedIDs
+ ))
+ let temporaryPath = stateDirectory + "/.host-sleep-paused-v1.tmp-" + UUID().uuidString
+ let descriptor = open(temporaryPath, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600)
+ guard descriptor >= 0 else { throw filesystem("create host-sleep temporary record") }
+ var shouldRemoveTemporary = true
+ defer {
+ _ = close(descriptor)
+ if shouldRemoveTemporary { _ = unlink(temporaryPath) }
+ }
+ let wrote = data.withUnsafeBytes { bytes -> Bool in
+ guard let base = bytes.baseAddress else { return data.isEmpty }
+ var offset = 0
+ while offset < bytes.count {
+ let result = write(descriptor, base.advanced(by: offset), bytes.count - offset)
+ if result < 0 {
+ if errno == EINTR { continue }
+ return false
+ }
+ offset += result
+ }
+ return true
+ }
+ guard wrote, fsync(descriptor) == 0 else {
+ throw filesystem("write host-sleep record")
+ }
+ guard rename(temporaryPath, recordPath) == 0 else {
+ throw filesystem("publish host-sleep record")
+ }
+ shouldRemoveTemporary = false
+ try synchronizeDirectory()
+ }
+
+ private func synchronizeDirectory() throws {
+ let descriptor = open(stateDirectory, O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
+ guard descriptor >= 0 else { throw filesystem("open host-sleep record directory") }
+ defer { _ = close(descriptor) }
+ guard fsync(descriptor) == 0 else { throw filesystem("sync host-sleep record directory") }
+ }
+
+ private static func isValidMachineID(_ id: String) -> Bool {
+ id.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil
+ }
+
+ private func filesystem(_ action: String) -> NSError {
+ NSError(
+ domain: "DoryMachineHostPowerController",
+ code: Int(errno),
+ userInfo: [NSLocalizedDescriptionKey: action + ": " + String(cString: strerror(errno))]
+ )
+ }
+}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 65f36f5c..a81e3439 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -4709,7 +4709,7 @@ public final class MachineManager: @unchecked Sendable {
public func memorySnapshots() -> [GuestMemorySnapshot] {
list().compactMap { status in
if status.state == .paused {
- let residentMB = max(1, status.currentBalloonTargetMB ?? status.memoryMB)
+ let residentMB = max(1, status.currentBalloonTargetMB)
return GuestMemorySnapshot(
id: "machine.\(status.id)",
kind: .virtualMachine,
diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift
index ef044da2..7858b97c 100644
--- a/dory-core-swift/Sources/doryd/main.swift
+++ b/dory-core-swift/Sources/doryd/main.swift
@@ -201,6 +201,7 @@ let idlePolicyStore = IdlePolicyStore(home: dorydEnvironment.home, environment:
dockerTier?.containerSummariesForIdle() ?? .ok([])
}
var sleepHandlers: [HostSleepHandling] = []
+var wakeHandlers: [HostWakeHandling] = []
var clockSyncers: [WakeClockSyncing] = []
if let dockerTier {
sleepHandlers.append(PolicyAwareHostSleepHandler(
@@ -213,10 +214,14 @@ if let dockerTier {
clockSyncers.append(dockerTier)
}
if let machineManager {
+ let machinePowerController = MachineHostPowerController(manager: machineManager)
+ sleepHandlers.append(machinePowerController)
+ wakeHandlers.append(machinePowerController)
clockSyncers.append(machineManager)
}
let wakeCoordinator = HostWakeCoordinator(
sleepHandlers: sleepHandlers,
+ wakeHandlers: wakeHandlers,
clockSyncers: clockSyncers,
dnsProbe: SystemDNSProbe(targets: dnsTargets),
networkReconcilers: [corporateConnectivity],
diff --git a/dory-core-swift/Tests/DorydKitTests/HostWakeCoordinatorTests.swift b/dory-core-swift/Tests/DorydKitTests/HostWakeCoordinatorTests.swift
index 696b4814..76458a90 100644
--- a/dory-core-swift/Tests/DorydKitTests/HostWakeCoordinatorTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/HostWakeCoordinatorTests.swift
@@ -43,8 +43,10 @@ final class HostWakeCoordinatorTests: XCTestCase {
])
let writer = IncidentWriter(path: base + "/incidents.jsonl")
let network = TestNetworkReconciler()
+ let wake = TestWakeHandler()
let coordinator = HostWakeCoordinator(
powerSource: source,
+ wakeHandlers: [wake],
clockSyncers: [clock],
dnsProbe: dns,
networkReconcilers: [network],
@@ -54,6 +56,8 @@ final class HostWakeCoordinatorTests: XCTestCase {
try coordinator.start()
source.emitWake()
+ XCTAssertEqual(wake.wakeDates.count, 1)
+ XCTAssertEqual(coordinator.lastWake?.actions.first?.recovered, true)
XCTAssertEqual(clock.syncDates.count, 1)
XCTAssertEqual(dns.probeCount, 1)
XCTAssertEqual(network.wakeDates.count, 1)
@@ -62,6 +66,7 @@ final class HostWakeCoordinatorTests: XCTestCase {
XCTAssertEqual(coordinator.lastWake?.networkReconciliations, ["network reconciled"])
let incident = try XCTUnwrap(writer.read(limit: 1).first)
XCTAssertEqual(incident.type, "host.wake")
+ XCTAssertTrue(incident.detail?.contains("wake_actions=1") ?? false)
XCTAssertTrue(incident.detail?.contains("dns_ok=1/1") ?? false)
}
@@ -163,6 +168,29 @@ private final class TestClockSyncer: WakeClockSyncing, @unchecked Sendable {
}
}
+private final class TestWakeHandler: HostWakeHandling, @unchecked Sendable {
+ private let lock = NSLock()
+ private var dates: [Date] = []
+
+ var wakeDates: [Date] {
+ lock.lock()
+ defer { lock.unlock() }
+ return dates
+ }
+
+ func recoverAfterHostWake(now: Date) -> HostWakeActionResult {
+ lock.lock()
+ dates.append(now)
+ lock.unlock()
+ return HostWakeActionResult(
+ name: "test",
+ attempted: true,
+ recovered: true,
+ detail: "recovered"
+ )
+ }
+}
+
private final class TestDNSProbe: DNSProbing, @unchecked Sendable {
private let lock = NSLock()
private let results: [DNSProbeResult]
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index dab19f83..22091369 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -125,6 +125,59 @@ final class MachineManagerTests: XCTestCase {
XCTAssertNil(stopped.pid)
}
+ func testHostPowerControllerDurablyResumesOnlyMachinesItPaused() throws {
+ let base = "/tmp/dory-machine-host-power-\(getpid())-\(UInt32.random(in: 0..
Date: Thu, 20 Aug 2026 22:57:05 +0000
Subject: [PATCH 057/338] feat(guest): version agent capabilities
---
.../Sources/DoryCore/DoryCore.swift | 30 +++++++++++++++--
.../DorydKitTests/AgentControlTests.swift | 4 ++-
dory-core/agent/src/dispatch.rs | 32 +++++++++++++++++++
dory-core/ffi/src/agent_forward.rs | 11 ++++++-
dory-core/ffi/src/remote.rs | 15 +++++++++
dory-core/pb/proto/agent.proto | 7 ++++
dory-core/remote/src/agent_client.rs | 7 ++++
dory-core/remote/src/ssh.rs | 7 ++++
8 files changed, 108 insertions(+), 5 deletions(-)
diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift
index 28b9e08d..5b10c5c8 100644
--- a/dory-core-swift/Sources/DoryCore/DoryCore.swift
+++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift
@@ -122,17 +122,35 @@ public final class DoryDataplaneHandle: @unchecked Sendable {
}
}
+public struct DoryAgentCapability: Sendable, Equatable, Hashable {
+ public var id: String
+ public var version: UInt32
+
+ public init(id: String, version: UInt32) {
+ self.id = id
+ self.version = version
+ }
+}
+
public struct DoryAgentInfo: Sendable, Equatable {
public var protocolVersion: UInt32
public var kernel: String
public var agentBuild: String
public var uptimeSeconds: UInt64
+ public var capabilities: [DoryAgentCapability]
- public init(protocolVersion: UInt32, kernel: String, agentBuild: String, uptimeSeconds: UInt64) {
+ public init(
+ protocolVersion: UInt32,
+ kernel: String,
+ agentBuild: String,
+ uptimeSeconds: UInt64,
+ capabilities: [DoryAgentCapability] = []
+ ) {
self.protocolVersion = protocolVersion
self.kernel = kernel
self.agentBuild = agentBuild
self.uptimeSeconds = uptimeSeconds
+ self.capabilities = capabilities
}
}
@@ -334,7 +352,10 @@ public final class DoryAgentControlHandle: @unchecked Sendable {
protocolVersion: raw.protoVersion,
kernel: raw.kernel,
agentBuild: raw.agentBuild,
- uptimeSeconds: raw.uptimeSecs
+ uptimeSeconds: raw.uptimeSecs,
+ capabilities: raw.capabilities.map {
+ DoryAgentCapability(id: $0.id, version: $0.version)
+ }
)
}
@@ -441,7 +462,10 @@ public final class DoryRemoteAgentHandle: @unchecked Sendable {
protocolVersion: raw.protoVersion,
kernel: raw.kernel,
agentBuild: raw.agentBuild,
- uptimeSeconds: raw.uptimeSecs
+ uptimeSeconds: raw.uptimeSecs,
+ capabilities: raw.capabilities.map {
+ DoryAgentCapability(id: $0.id, version: $0.version)
+ }
)
}
diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
index bfaf6862..a7bcc293 100644
--- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
@@ -17,6 +17,7 @@ final class AgentControlTests: XCTestCase {
XCTAssertEqual(counter.value, 0)
let info = try control.info()
XCTAssertEqual(info.agentBuild, "fake-agent")
+ XCTAssertEqual(info.capabilities, [DoryAgentCapability(id: "exec", version: 1)])
XCTAssertEqual(counter.value, 1)
XCTAssertFalse(try control.clockSync(now: Date(timeIntervalSince1970: 1.5)))
@@ -58,7 +59,8 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda
protocolVersion: 1,
kernel: "Linux fake",
agentBuild: "fake-agent",
- uptimeSeconds: 42
+ uptimeSeconds: 42,
+ capabilities: [DoryAgentCapability(id: "exec", version: 1)]
)
}
diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs
index 8ad70016..ca1d43b7 100644
--- a/dory-core/agent/src/dispatch.rs
+++ b/dory-core/agent/src/dispatch.rs
@@ -19,6 +19,23 @@ pub fn agent_build() -> String {
concat!("dory-agent/", env!("CARGO_PKG_VERSION")).to_string()
}
+pub fn agent_capabilities() -> Vec {
+ [
+ ("clock-sync", 1),
+ ("exec", 1),
+ ("exec-stdin", 1),
+ ("ports-watch", 1),
+ ("sync-push", 1),
+ ("telemetry", 1),
+ ]
+ .into_iter()
+ .map(|(id, version)| agent::AgentCapability {
+ id: id.to_string(),
+ version,
+ })
+ .collect()
+}
+
pub fn err(code: i32, message: &str) -> agent::AgentResponse {
agent::AgentResponse {
result: Some(Res::Error(agent::RpcError {
@@ -53,6 +70,7 @@ fn info() -> agent::InfoResponse {
kernel: kernel(),
agent_build: agent_build(),
uptime_secs: uptime_secs(),
+ capabilities: agent_capabilities(),
}
}
@@ -172,6 +190,20 @@ mod tests {
assert_eq!(i.proto_version, dory_proto::handshake::PROTO_VERSION);
assert!(i.agent_build.starts_with("dory-agent/"));
assert!(!i.kernel.is_empty());
+ assert_eq!(
+ i.capabilities
+ .iter()
+ .map(|capability| (capability.id.as_str(), capability.version))
+ .collect::>(),
+ vec![
+ ("clock-sync", 1),
+ ("exec", 1),
+ ("exec-stdin", 1),
+ ("ports-watch", 1),
+ ("sync-push", 1),
+ ("telemetry", 1),
+ ]
+ );
}
other => panic!("expected Info, got {other:?}"),
}
diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs
index 6f63e5ab..262d10e1 100644
--- a/dory-core/ffi/src/agent_forward.rs
+++ b/dory-core/ffi/src/agent_forward.rs
@@ -14,7 +14,8 @@ use dory_remote::AgentClient;
use tokio::net::UnixStream;
use crate::remote::{
- exec_result, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, RemoteFfiError, TelemetryFfi,
+ exec_result, AgentCapabilityFfi, AgentInfoFfi, ExecEnvFfi, ExecResultFfi, RemoteFfiError,
+ TelemetryFfi,
};
#[derive(uniffi::Record)]
@@ -114,6 +115,14 @@ impl AgentControl {
kernel: i.kernel,
agent_build: i.agent_build,
uptime_secs: i.uptime_secs,
+ capabilities: i
+ .capabilities
+ .into_iter()
+ .map(|capability| AgentCapabilityFfi {
+ id: capability.id,
+ version: capability.version,
+ })
+ .collect(),
})
}
diff --git a/dory-core/ffi/src/remote.rs b/dory-core/ffi/src/remote.rs
index b17f5807..4414c932 100644
--- a/dory-core/ffi/src/remote.rs
+++ b/dory-core/ffi/src/remote.rs
@@ -62,12 +62,19 @@ pub struct RemoteConfig {
pub build: String,
}
+#[derive(uniffi::Record)]
+pub struct AgentCapabilityFfi {
+ pub id: String,
+ pub version: u32,
+}
+
#[derive(uniffi::Record)]
pub struct AgentInfoFfi {
pub proto_version: u32,
pub kernel: String,
pub agent_build: String,
pub uptime_secs: u64,
+ pub capabilities: Vec,
}
#[derive(uniffi::Record)]
@@ -167,6 +174,14 @@ impl RemoteAgent {
kernel: i.kernel,
agent_build: i.agent_build,
uptime_secs: i.uptime_secs,
+ capabilities: i
+ .capabilities
+ .into_iter()
+ .map(|capability| AgentCapabilityFfi {
+ id: capability.id,
+ version: capability.version,
+ })
+ .collect(),
})
}
diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto
index 7fe17388..451b2567 100644
--- a/dory-core/pb/proto/agent.proto
+++ b/dory-core/pb/proto/agent.proto
@@ -13,11 +13,18 @@ message ClockSyncResponse {
}
message InfoRequest {}
+message AgentCapability {
+ string id = 1;
+ uint32 version = 2;
+}
message InfoResponse {
uint32 proto_version = 1;
string kernel = 2;
string agent_build = 3;
uint64 uptime_secs = 4;
+ // Independently versioned guest-tool capabilities. New optional entries do not require a
+ // transport protocol bump; callers must require the exact capability/version they consume.
+ repeated AgentCapability capabilities = 5;
}
message ListenPort {
diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs
index 02ba3209..cd269dca 100644
--- a/dory-core/remote/src/agent_client.rs
+++ b/dory-core/remote/src/agent_client.rs
@@ -266,6 +266,10 @@ mod tests {
kernel: "Linux fake 6.12".into(),
agent_build: "fake-agent".into(),
uptime_secs: 42,
+ capabilities: vec![agent::AgentCapability {
+ id: "exec".into(),
+ version: 1,
+ }],
}),
Some(Method::ClockSync(_)) => Res::ClockSync(agent::ClockSyncResponse { synced: true }),
Some(Method::PortsWatch(_)) => Res::PortsWatch(agent::PortsWatchResponse::default()),
@@ -299,6 +303,9 @@ mod tests {
let info = client.info().await.unwrap();
assert_eq!(info.proto_version, dory_proto::handshake::PROTO_VERSION);
assert_eq!(info.agent_build, "fake-agent");
+ assert_eq!(info.capabilities.len(), 1);
+ assert_eq!(info.capabilities[0].id, "exec");
+ assert_eq!(info.capabilities[0].version, 1);
assert_eq!(info.uptime_secs, 42);
let clock = client.clock_sync(1_700_000_000_000_000_000).await.unwrap();
diff --git a/dory-core/remote/src/ssh.rs b/dory-core/remote/src/ssh.rs
index 2cc4851b..fe1bb521 100644
--- a/dory-core/remote/src/ssh.rs
+++ b/dory-core/remote/src/ssh.rs
@@ -167,6 +167,10 @@ mod tests {
kernel: "Linux vps 6.12".into(),
agent_build: "fake-remote-agent".into(),
uptime_secs: 7,
+ capabilities: vec![agent::AgentCapability {
+ id: "exec".into(),
+ version: 1,
+ }],
}),
_ => Res::Error(agent::RpcError {
code: 400,
@@ -277,6 +281,9 @@ mod tests {
let info = agent.client.info().await.expect("info rpc over ssh");
assert_eq!(info.proto_version, dory_proto::handshake::PROTO_VERSION);
assert_eq!(info.agent_build, "fake-remote-agent");
+ assert_eq!(info.capabilities.len(), 1);
+ assert_eq!(info.capabilities[0].id, "exec");
+ assert_eq!(info.capabilities[0].version, 1);
assert_eq!(info.uptime_secs, 7);
}
From 9152e13634cc48460dabc82a0030a1006e393bc1 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 23:04:00 +0000
Subject: [PATCH 058/338] feat(guest): expose tools capability health
---
.../Sources/dory-hv/DesktopMode.swift | 2 +
.../Sources/DoryCore/DoryCore.swift | 17 +++-
.../Sources/DoryVMMKit/DoryVMM.swift | 6 ++
.../Sources/DorydKit/DorydService.swift | 8 ++
.../Sources/DorydKit/HealthReporter.swift | 95 ++++++++++++++++++-
.../Sources/DorydKit/MachineManager.swift | 8 ++
.../Sources/DorydKit/VmmHandoff.swift | 7 ++
.../DorydKitTests/DorydServiceTests.swift | 16 ++++
.../DorydKitTests/HealthReporterTests.swift | 40 ++++++++
.../Tests/DorydKitTests/VmmHandoffTests.swift | 8 ++
10 files changed, 205 insertions(+), 2 deletions(-)
diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
index 66054434..48f0143e 100644
--- a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
+++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift
@@ -366,6 +366,8 @@ enum DesktopMode {
ready: VmmReadyMessage(
machineID: configuration.machineID,
agentBuild: info.agentBuild,
+ agentProtocolVersion: info.protocolVersion,
+ agentCapabilities: info.capabilities,
agentSocketPath: configuration.agentSocketPath,
shellSocketPath: configuration.shellSocketPath,
detail: "raw-HV desktop running with \(graphicsDisplayName) graphics; dory-agent answered protocol \(info.protocolVersion)"
diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift
index 5b10c5c8..6e4cd425 100644
--- a/dory-core-swift/Sources/DoryCore/DoryCore.swift
+++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift
@@ -122,7 +122,7 @@ public final class DoryDataplaneHandle: @unchecked Sendable {
}
}
-public struct DoryAgentCapability: Sendable, Equatable, Hashable {
+public struct DoryAgentCapability: Sendable, Equatable, Hashable, Codable {
public var id: String
public var version: UInt32
@@ -130,6 +130,11 @@ public struct DoryAgentCapability: Sendable, Equatable, Hashable {
self.id = id
self.version = version
}
+
+ public var isValid: Bool {
+ version > 0 && id.utf8.count <= 63
+ && id.wholeMatch(of: /[a-z][a-z0-9]*(?:-[a-z0-9]+)*/) != nil
+ }
}
public struct DoryAgentInfo: Sendable, Equatable {
@@ -152,6 +157,16 @@ public struct DoryAgentInfo: Sendable, Equatable {
self.uptimeSeconds = uptimeSeconds
self.capabilities = capabilities
}
+
+ public var capabilitiesAreCanonical: Bool {
+ capabilities.allSatisfy(\.isValid)
+ && capabilities == capabilities.sorted { $0.id < $1.id }
+ && Set(capabilities.map(\.id)).count == capabilities.count
+ }
+
+ public func supports(_ id: String, minimumVersion: UInt32 = 1) -> Bool {
+ capabilities.contains { $0.id == id && $0.version >= minimumVersion }
+ }
}
public struct DoryListenPort: Sendable, Equatable, Hashable {
diff --git a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
index f645a290..ef321a92 100644
--- a/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
+++ b/dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
@@ -962,6 +962,8 @@ public enum DoryVMMMain {
machineID: String,
handoffSocketPath: String,
agentBuild: String?,
+ agentProtocolVersion: UInt32? = nil,
+ agentCapabilities: [DoryAgentCapability] = [],
agentSocketPath: String?,
dockerdSocketPath: String?,
shellSocketPath: String?,
@@ -973,6 +975,8 @@ public enum DoryVMMMain {
ready: VmmReadyMessage(
machineID: machineID,
agentBuild: agentBuild,
+ agentProtocolVersion: agentProtocolVersion,
+ agentCapabilities: agentCapabilities,
agentSocketPath: agentSocketPath,
dockerdSocketPath: dockerdSocketPath,
shellSocketPath: shellSocketPath,
@@ -1211,6 +1215,8 @@ public enum DoryVMMMain {
machineID: machineID,
handoffSocketPath: handoffSocketPath,
agentBuild: agentInfo.agentBuild,
+ agentProtocolVersion: agentInfo.protocolVersion,
+ agentCapabilities: agentInfo.capabilities,
agentSocketPath: agentSocketPath,
dockerdSocketPath: machineID == "docker" ? dockerdSocketPath : nil,
shellSocketPath: shellSocketPath,
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index fe8095fe..0e2228b3 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -1621,6 +1621,14 @@ private extension DoryMachineStatus {
if let agentBuild {
dictionary["agentBuild"] = agentBuild
}
+ if let agentProtocolVersion {
+ dictionary["agentProtocolVersion"] = agentProtocolVersion
+ }
+ if !agentCapabilities.isEmpty {
+ dictionary["agentCapabilities"] = agentCapabilities.map {
+ ["id": $0.id, "version": $0.version] as NSDictionary
+ }
+ }
if let agentSocketPath {
dictionary["agentSocketPath"] = agentSocketPath
}
diff --git a/dory-core-swift/Sources/DorydKit/HealthReporter.swift b/dory-core-swift/Sources/DorydKit/HealthReporter.swift
index 4ce88e95..365550a1 100644
--- a/dory-core-swift/Sources/DorydKit/HealthReporter.swift
+++ b/dory-core-swift/Sources/DorydKit/HealthReporter.swift
@@ -1962,7 +1962,100 @@ public final class HealthReporter: @unchecked Sendable {
data: data
)
}
- return [summary] + statuses.map(Self.machineEvidenceCheck)
+ return [summary] + statuses.flatMap {
+ [Self.machineEvidenceCheck($0), Self.machineToolsCheck($0)]
+ }
+ }
+
+ static func machineToolsCheck(_ status: DoryMachineStatus) -> HealthCheck {
+ var data = ["state": status.state.rawValue]
+ guard [.running, .paused].contains(status.state) else {
+ return HealthCheck(
+ id: "machine.local.\(status.id).tools",
+ status: .skip,
+ code: "machine.tools.inactive",
+ title: "Dory Tools inactive",
+ detail: "\(status.id) is not resident",
+ data: data
+ )
+ }
+ guard let build = status.agentBuild, !build.isEmpty else {
+ return HealthCheck(
+ id: "machine.local.\(status.id).tools",
+ status: .warn,
+ code: "machine.tools.unavailable",
+ title: "Dory Tools unavailable",
+ detail: "\(status.id) has not reported a guest-tools handshake",
+ action: "Install or repair the qualified Dory Tools pack in this workspace.",
+ data: data
+ )
+ }
+ data["build"] = build
+ guard let protocolVersion = status.agentProtocolVersion else {
+ return HealthCheck(
+ id: "machine.local.\(status.id).tools",
+ status: .warn,
+ code: "machine.tools.unversioned",
+ title: "Dory Tools unversioned",
+ detail: "\(status.id) reported \(build) without a protocol contract",
+ action: "Update the workspace's Dory Tools pack.",
+ data: data
+ )
+ }
+ data["protocol_version"] = String(protocolVersion)
+ let capabilities = status.agentCapabilities
+ let canonical = capabilities.allSatisfy(\.isValid)
+ && capabilities == capabilities.sorted { $0.id < $1.id }
+ && Set(capabilities.map(\.id)).count == capabilities.count
+ guard protocolVersion == DoryCore.protocolVersion(), canonical else {
+ data["capabilities"] = capabilities.map { "\($0.id)@\($0.version)" }
+ .joined(separator: ",")
+ return HealthCheck(
+ id: "machine.local.\(status.id).tools",
+ status: .fail,
+ code: "machine.tools.invalid_handshake",
+ title: "Dory Tools handshake invalid",
+ detail: "\(status.id) reported an incompatible or malformed tools contract",
+ action: "Stop the workspace and repair its Dory Tools pack before using integrations.",
+ data: data
+ )
+ }
+ guard !capabilities.isEmpty else {
+ return HealthCheck(
+ id: "machine.local.\(status.id).tools",
+ status: .warn,
+ code: "machine.tools.legacy_handshake",
+ title: "Dory Tools need an update",
+ detail: "\(status.id) reported protocol \(protocolVersion) without versioned capabilities",
+ action: "Update the workspace's Dory Tools pack to enable integration health.",
+ data: data
+ )
+ }
+
+ let required = ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"]
+ let versions = Dictionary(uniqueKeysWithValues: capabilities.map { ($0.id, $0.version) })
+ let missing = required.filter { (versions[$0] ?? 0) < 1 }
+ data["capabilities"] = capabilities.map { "\($0.id)@\($0.version)" }.joined(separator: ",")
+ if !missing.isEmpty {
+ data["missing_capabilities"] = missing.joined(separator: ",")
+ return HealthCheck(
+ id: "machine.local.\(status.id).tools",
+ status: .warn,
+ code: "machine.tools.partial",
+ title: "Dory Tools integrations are partial",
+ detail: "\(status.id) is missing \(missing.joined(separator: ", "))",
+ action: "Update or repair the workspace's Dory Tools pack.",
+ data: data
+ )
+ }
+ return HealthCheck(
+ id: "machine.local.\(status.id).tools",
+ status: .pass,
+ code: "machine.tools.ready",
+ title: "Dory Tools ready",
+ detail: "\(status.id) reported \(build) with \(capabilities.count) versioned capabilities",
+ data: data
+ )
}
/// Emits exact, non-secret launch evidence for one workspace. Environment values, host paths,
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index a81e3439..95a016cc 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -310,6 +310,8 @@ public struct DoryMachineStatus: Sendable, Equatable {
public var lastError: String?
public var handoffSocketPath: String?
public var agentBuild: String?
+ public var agentProtocolVersion: UInt32?
+ public var agentCapabilities: [DoryAgentCapability]
public var agentSocketPath: String?
public var dockerdSocketPath: String?
public var shellSocketPath: String?
@@ -339,6 +341,8 @@ public struct DoryMachineStatus: Sendable, Equatable {
lastError: String? = nil,
handoffSocketPath: String? = nil,
agentBuild: String? = nil,
+ agentProtocolVersion: UInt32? = nil,
+ agentCapabilities: [DoryAgentCapability] = [],
agentSocketPath: String? = nil,
dockerdSocketPath: String? = nil,
shellSocketPath: String? = nil,
@@ -368,6 +372,8 @@ public struct DoryMachineStatus: Sendable, Equatable {
self.lastError = lastError
self.handoffSocketPath = handoffSocketPath
self.agentBuild = agentBuild
+ self.agentProtocolVersion = agentProtocolVersion
+ self.agentCapabilities = agentCapabilities
self.agentSocketPath = agentSocketPath
self.dockerdSocketPath = dockerdSocketPath
self.shellSocketPath = shellSocketPath
@@ -3983,6 +3989,8 @@ public final class MachineManager: @unchecked Sendable {
lastError: entry.lastError,
handoffSocketPath: entry.handoffServer?.path,
agentBuild: entry.handoff?.ready.agentBuild,
+ agentProtocolVersion: entry.handoff?.ready.agentProtocolVersion,
+ agentCapabilities: entry.handoff?.ready.agentCapabilities ?? [],
agentSocketPath: entry.handoff?.ready.agentSocketPath,
dockerdSocketPath: entry.handoff?.ready.dockerdSocketPath,
shellSocketPath: entry.handoff?.ready.shellSocketPath,
diff --git a/dory-core-swift/Sources/DorydKit/VmmHandoff.swift b/dory-core-swift/Sources/DorydKit/VmmHandoff.swift
index 2d8809d8..73e07f07 100644
--- a/dory-core-swift/Sources/DorydKit/VmmHandoff.swift
+++ b/dory-core-swift/Sources/DorydKit/VmmHandoff.swift
@@ -1,9 +1,12 @@
import Darwin
+import DoryCore
import Foundation
public struct VmmReadyMessage: Sendable, Equatable, Codable {
public var machineID: String
public var agentBuild: String?
+ public var agentProtocolVersion: UInt32?
+ public var agentCapabilities: [DoryAgentCapability]
public var agentSocketPath: String?
public var dockerdSocketPath: String?
public var shellSocketPath: String?
@@ -13,6 +16,8 @@ public struct VmmReadyMessage: Sendable, Equatable, Codable {
public init(
machineID: String,
agentBuild: String? = nil,
+ agentProtocolVersion: UInt32? = nil,
+ agentCapabilities: [DoryAgentCapability] = [],
agentSocketPath: String? = nil,
dockerdSocketPath: String? = nil,
shellSocketPath: String? = nil,
@@ -21,6 +26,8 @@ public struct VmmReadyMessage: Sendable, Equatable, Codable {
) {
self.machineID = machineID
self.agentBuild = agentBuild
+ self.agentProtocolVersion = agentProtocolVersion
+ self.agentCapabilities = agentCapabilities
self.agentSocketPath = agentSocketPath
self.dockerdSocketPath = dockerdSocketPath
self.shellSocketPath = shellSocketPath
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index cc5784ba..3c96025c 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -723,6 +723,8 @@ final class DorydServiceTests: XCTestCase {
ready: VmmReadyMessage(
machineID: "dev",
agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)],
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock",
controlSocketPath: "/run/control.sock"
@@ -741,6 +743,20 @@ final class DorydServiceTests: XCTestCase {
pressure: .critical
)))
)
+ let machineStatus = expectation(description: "versioned machine tools status")
+ service.machineList { rows, message in
+ XCTAssertEqual(message, "")
+ let status = (rows as? [NSDictionary])?.first
+ XCTAssertEqual(
+ (status?["agentProtocolVersion"] as? NSNumber)?.uint32Value,
+ DoryCore.protocolVersion()
+ )
+ let capabilities = status?["agentCapabilities"] as? [NSDictionary]
+ XCTAssertEqual(capabilities?.first?["id"] as? String, "exec")
+ XCTAssertEqual((capabilities?.first?["version"] as? NSNumber)?.uint32Value, 1)
+ machineStatus.fulfill()
+ }
+ wait(for: [machineStatus], timeout: 5)
let listener = makeAnonymousListener(service: service)
listener.resume()
defer { listener.invalidate() }
diff --git a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
index 10c4eacb..7cd6cfc2 100644
--- a/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/HealthReporterTests.swift
@@ -434,6 +434,46 @@ final class HealthReporterTests: XCTestCase {
) == true)
}
+ func testMachineToolsHealthRequiresCanonicalVersionedCapabilities() {
+ let capabilities = [
+ "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry",
+ ].map { DoryAgentCapability(id: $0, version: 1) }
+ let ready = HealthReporter.machineToolsCheck(DoryMachineStatus(
+ id: "ready",
+ state: .running,
+ agentBuild: "dory-agent/1.0",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: capabilities
+ ))
+ XCTAssertEqual(ready.status, .pass)
+ XCTAssertEqual(ready.code, "machine.tools.ready")
+ XCTAssertEqual(ready.data["capabilities"], capabilities.map {
+ "\($0.id)@\($0.version)"
+ }.joined(separator: ","))
+
+ let legacy = HealthReporter.machineToolsCheck(DoryMachineStatus(
+ id: "legacy",
+ state: .running,
+ agentBuild: "dory-agent/0.9",
+ agentProtocolVersion: DoryCore.protocolVersion()
+ ))
+ XCTAssertEqual(legacy.status, .warn)
+ XCTAssertEqual(legacy.code, "machine.tools.legacy_handshake")
+
+ let invalid = HealthReporter.machineToolsCheck(DoryMachineStatus(
+ id: "invalid",
+ state: .running,
+ agentBuild: "dory-agent/tampered",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: [
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "exec", version: 2),
+ ]
+ ))
+ XCTAssertEqual(invalid.status, .fail)
+ XCTAssertEqual(invalid.code, "machine.tools.invalid_handshake")
+ }
+
func testInvalidRuntimeIdentityFailsClosedWithoutProjectingUntrustedPlanFields() {
let plan = healthResolvedPlan()
let identity = DoryMachineRuntimeIdentity(
diff --git a/dory-core-swift/Tests/DorydKitTests/VmmHandoffTests.swift b/dory-core-swift/Tests/DorydKitTests/VmmHandoffTests.swift
index 1e2802b9..adee75b3 100644
--- a/dory-core-swift/Tests/DorydKitTests/VmmHandoffTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/VmmHandoffTests.swift
@@ -1,4 +1,5 @@
import Darwin
+import DoryCore
@testable import DorydKit
import XCTest
@@ -26,6 +27,8 @@ final class VmmHandoffTests: XCTestCase {
ready: VmmReadyMessage(
machineID: "dev",
agentBuild: "dory-agent/test",
+ agentProtocolVersion: 1,
+ agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)],
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock",
shellSocketPath: "/run/shell.sock",
@@ -38,6 +41,11 @@ final class VmmHandoffTests: XCTestCase {
let handoff = try resultBox.get()
XCTAssertEqual(handoff.ready.machineID, "dev")
XCTAssertEqual(handoff.ready.agentBuild, "dory-agent/test")
+ XCTAssertEqual(handoff.ready.agentProtocolVersion, 1)
+ XCTAssertEqual(
+ handoff.ready.agentCapabilities,
+ [DoryAgentCapability(id: "exec", version: 1)]
+ )
XCTAssertEqual(handoff.ready.agentSocketPath, "/run/agent.sock")
XCTAssertEqual(handoff.ready.shellSocketPath, "/run/shell.sock")
XCTAssertEqual(handoff.fileDescriptors.count, 1)
From c13f1d2fb40abb7bde94227772b254627f37de91 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 23:09:37 +0000
Subject: [PATCH 059/338] feat(app): show guest tools capability state
---
Dory/Models/AppStore.swift | 2 +
Dory/Models/Models.swift | 68 +++++++++++++++++---
Dory/Runtime/Doryd/DorydClient.swift | 76 +++++++++++++++++++++++
DoryTests/DorydClientTests.swift | 93 ++++++++++++++++++++++++++++
4 files changed, 232 insertions(+), 7 deletions(-)
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index 0ee230e6..12147c08 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -5193,6 +5193,8 @@ final class AppStore {
installerMediaAttached: status.installerMediaAttached,
runtimeIdentity: status.runtimeIdentity,
agentBuild: status.agentBuild,
+ agentProtocolVersion: status.agentProtocolVersion,
+ agentCapabilities: status.agentCapabilities,
mounts: status.shares.map(Self.mountPair(fromDoryd:))
)
}
diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift
index bc50ab9d..0eadc18c 100644
--- a/Dory/Models/Models.swift
+++ b/Dory/Models/Models.swift
@@ -303,6 +303,8 @@ struct Machine: Identifiable, Hashable, Sendable {
var installerMediaAttached: Bool = false
var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
var agentBuild: String? = nil
+ var agentProtocolVersion: UInt32? = nil
+ var agentCapabilities: [DorydAgentCapability] = []
var mounts: [MountPair] = []
var id: String { name }
@@ -369,16 +371,68 @@ struct Machine: Identifiable, Hashable, Sendable {
detail: "Legacy compatibility launch authority"
))
}
- evidence.append(MachineRuntimeEvidence(
- id: "tools",
- label: agentBuild == nil ? "Tools unavailable" : "Tools ready",
- systemImage: agentBuild == nil ? "wrench.and.screwdriver" : "wrench.and.screwdriver.fill",
- tone: agentBuild == nil ? .warning : .positive,
- detail: agentBuild ?? "The guest has not reported a Dory Tools build"
- ))
+ evidence.append(toolsRuntimeEvidence)
return evidence
}
+ private var toolsRuntimeEvidence: MachineRuntimeEvidence {
+ guard let agentBuild, !agentBuild.isEmpty else {
+ return MachineRuntimeEvidence(
+ id: "tools",
+ label: "Tools unavailable",
+ systemImage: "wrench.and.screwdriver",
+ tone: .warning,
+ detail: "The guest has not reported a Dory Tools handshake"
+ )
+ }
+ guard let agentProtocolVersion else {
+ return MachineRuntimeEvidence(
+ id: "tools",
+ label: "Tools unversioned",
+ systemImage: "wrench.and.screwdriver",
+ tone: .warning,
+ detail: "\(agentBuild) does not report a protocol contract"
+ )
+ }
+ guard agentProtocolVersion == 1 else {
+ return MachineRuntimeEvidence(
+ id: "tools",
+ label: "Tools incompatible",
+ systemImage: "exclamationmark.triangle.fill",
+ tone: .warning,
+ detail: "\(agentBuild) uses unsupported protocol \(agentProtocolVersion)"
+ )
+ }
+ guard !agentCapabilities.isEmpty else {
+ return MachineRuntimeEvidence(
+ id: "tools",
+ label: "Tools need an update",
+ systemImage: "arrow.triangle.2.circlepath",
+ tone: .warning,
+ detail: "\(agentBuild) does not report versioned capabilities"
+ )
+ }
+ let versions = Dictionary(uniqueKeysWithValues: agentCapabilities.map { ($0.id, $0.version) })
+ let required = ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"]
+ let missing = required.filter { (versions[$0] ?? 0) < 1 }
+ guard missing.isEmpty else {
+ return MachineRuntimeEvidence(
+ id: "tools",
+ label: "Tools partially ready",
+ systemImage: "wrench.and.screwdriver",
+ tone: .warning,
+ detail: "\(agentBuild) is missing \(missing.joined(separator: ", "))"
+ )
+ }
+ return MachineRuntimeEvidence(
+ id: "tools",
+ label: "Tools ready",
+ systemImage: "wrench.and.screwdriver.fill",
+ tone: .positive,
+ detail: "\(agentBuild) · \(agentCapabilities.count) versioned capabilities"
+ )
+ }
+
private static func backendLabel(_ backend: String) -> String {
switch backend {
case "dory-hypervisor": "Raw HV"
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index 540cd9d9..2dfa94cd 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -747,6 +747,16 @@ nonisolated struct DorydMachineSnapshotArtifactEvidence: Codable, Sendable, Equa
}
}
+nonisolated struct DorydAgentCapability: Sendable, Equatable, Hashable {
+ var id: String
+ var version: UInt32
+
+ var isValid: Bool {
+ version > 0 && id.utf8.count <= 63
+ && id.wholeMatch(of: /[a-z][a-z0-9]*(?:-[a-z0-9]+)*/) != nil
+ }
+}
+
nonisolated struct DorydMachineStatus: Sendable, Equatable {
var id: String
var state: String
@@ -754,6 +764,8 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable {
var lastError: String?
var handoffSocketPath: String?
var agentBuild: String?
+ var agentProtocolVersion: UInt32? = nil
+ var agentCapabilities: [DorydAgentCapability] = []
var agentSocketPath: String?
var dockerdSocketPath: String?
var shellSocketPath: String?
@@ -1850,6 +1862,9 @@ nonisolated final class DorydClient: @unchecked Sendable {
) else {
return nil
}
+ guard let agentHandshake = machineAgentHandshake(from: dictionary) else {
+ return nil
+ }
return DorydMachineStatus(
id: id,
state: state,
@@ -1857,6 +1872,8 @@ nonisolated final class DorydClient: @unchecked Sendable {
lastError: nonEmptyString(dictionary["lastError"]),
handoffSocketPath: nonEmptyString(dictionary["handoffSocketPath"]),
agentBuild: nonEmptyString(dictionary["agentBuild"]),
+ agentProtocolVersion: agentHandshake.protocolVersion,
+ agentCapabilities: agentHandshake.capabilities,
agentSocketPath: nonEmptyString(dictionary["agentSocketPath"]),
dockerdSocketPath: nonEmptyString(dictionary["dockerdSocketPath"]),
shellSocketPath: nonEmptyString(dictionary["shellSocketPath"]),
@@ -1881,6 +1898,65 @@ nonisolated final class DorydClient: @unchecked Sendable {
)
}
+ private struct ParsedMachineAgentHandshake {
+ var protocolVersion: UInt32?
+ var capabilities: [DorydAgentCapability]
+ }
+
+ nonisolated private static func machineAgentHandshake(
+ from dictionary: NSDictionary
+ ) -> ParsedMachineAgentHandshake? {
+ let encodedProtocol = dictionary["agentProtocolVersion"]
+ let encodedCapabilities = dictionary["agentCapabilities"]
+ guard encodedProtocol != nil || encodedCapabilities != nil else {
+ return ParsedMachineAgentHandshake(protocolVersion: nil, capabilities: [])
+ }
+ guard let encodedProtocol,
+ dictionary["agentBuild"] is String,
+ let number = encodedProtocol as? NSNumber,
+ CFGetTypeID(number) != CFBooleanGetTypeID(),
+ number.doubleValue.rounded(.towardZero) == number.doubleValue,
+ number.uint64Value > 0,
+ number.uint64Value <= UInt64(UInt32.max) else {
+ return nil
+ }
+ let protocolVersion = UInt32(number.uint64Value)
+ guard let encodedCapabilities else {
+ return ParsedMachineAgentHandshake(protocolVersion: protocolVersion, capabilities: [])
+ }
+ guard let rawCapabilities = encodedCapabilities as? NSArray else { return nil }
+ var capabilities: [DorydAgentCapability] = []
+ capabilities.reserveCapacity(rawCapabilities.count)
+ for encoded in rawCapabilities {
+ guard let raw = encoded as? NSDictionary,
+ let keys = raw.allKeys as? [String],
+ Set(keys) == ["id", "version"],
+ keys.count == 2,
+ let id = raw["id"] as? String,
+ let versionNumber = raw["version"] as? NSNumber,
+ CFGetTypeID(versionNumber) != CFBooleanGetTypeID(),
+ versionNumber.doubleValue.rounded(.towardZero) == versionNumber.doubleValue,
+ versionNumber.uint64Value > 0,
+ versionNumber.uint64Value <= UInt64(UInt32.max) else {
+ return nil
+ }
+ let capability = DorydAgentCapability(
+ id: id,
+ version: UInt32(versionNumber.uint64Value)
+ )
+ guard capability.isValid else { return nil }
+ capabilities.append(capability)
+ }
+ guard capabilities == capabilities.sorted(by: { $0.id < $1.id }),
+ Set(capabilities.map(\.id)).count == capabilities.count else {
+ return nil
+ }
+ return ParsedMachineAgentHandshake(
+ protocolVersion: protocolVersion,
+ capabilities: capabilities
+ )
+ }
+
private struct ParsedMachineTypedSettings {
var value: DorydMachineTypedSettings?
}
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 4654848e..39263b05 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -545,6 +545,10 @@ struct DorydClientTests {
)
let machine = AppStore.machine(fromDoryd: status)
#expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d")
+ #expect(machine.agentProtocolVersion == 1)
+ #expect(machine.agentCapabilities.map(\.id) == [
+ "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry",
+ ])
#expect(machine.runtimeEvidence.map(\.label) == [
"Supported", "Raw HV", "Qualified 3D", "Tools ready",
])
@@ -559,9 +563,62 @@ struct DorydClientTests {
invalidationReason: "restored-snapshot"
)
replanning.agentBuild = nil
+ replanning.agentProtocolVersion = nil
+ replanning.agentCapabilities = []
#expect(replanning.runtimeEvidence.map(\.label) == [
"Needs planning", "Tools unavailable",
])
+
+ var legacyHandshake = machine
+ legacyHandshake.agentProtocolVersion = nil
+ legacyHandshake.agentCapabilities = []
+ #expect(legacyHandshake.runtimeEvidence.last?.label == "Tools unversioned")
+
+ var partialHandshake = machine
+ partialHandshake.agentCapabilities = [DorydAgentCapability(id: "exec", version: 1)]
+ #expect(partialHandshake.runtimeEvidence.last?.label == "Tools partially ready")
+ #expect(partialHandshake.runtimeEvidence.last?.detail.contains("clock-sync") == true)
+
+ var incompatibleHandshake = machine
+ incompatibleHandshake.agentProtocolVersion = 2
+ #expect(incompatibleHandshake.runtimeEvidence.last?.label == "Tools incompatible")
+ }
+
+ @Test func machineCapabilityHandshakeRejectsMalformedPresentClaims() async throws {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService()
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ defer { listener.invalidate() }
+ let client = DorydClient(endpoint: listener.endpoint)
+
+ let valid = try #require((try await client.machineList()).first)
+ #expect(valid.agentProtocolVersion == 1)
+ #expect(valid.agentCapabilities.count == 6)
+
+ let malformed: [(Any?, Any?)] = [
+ (nil, [["id": "exec", "version": 1] as NSDictionary]),
+ (true, nil),
+ (1, [["id": "exec", "version": 1, "unknown": "claim"] as NSDictionary]),
+ (1, [
+ ["id": "exec", "version": 1] as NSDictionary,
+ ["id": "exec", "version": 1] as NSDictionary,
+ ]),
+ ]
+ for (protocolVersion, capabilities) in malformed {
+ service.setMachineAgentHandshake(
+ "dev",
+ protocolVersion: protocolVersion,
+ capabilities: capabilities
+ )
+ do {
+ _ = try await client.machineList()
+ Issue.record("present malformed capability handshake must fail closed")
+ } catch let error as DorydClientError {
+ #expect(error.description.contains("invalid doryd response"))
+ }
+ }
}
@Test func snapshotArtifactEvidenceIsAbsentOnlyForLegacyAndOtherwiseExact() async throws {
@@ -2475,6 +2532,27 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
current["displayMode"] = "desktop"
machines[machineID] = current.copy() as? NSDictionary
}
+
+ func setMachineAgentHandshake(
+ _ machineID: String,
+ protocolVersion: Any?,
+ capabilities: Any?
+ ) {
+ lock.lock()
+ defer { lock.unlock() }
+ guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else {
+ return
+ }
+ current.removeObject(forKey: "agentProtocolVersion")
+ current.removeObject(forKey: "agentCapabilities")
+ if let protocolVersion {
+ current["agentProtocolVersion"] = protocolVersion
+ }
+ if let capabilities {
+ current["agentCapabilities"] = capabilities
+ }
+ machines[machineID] = current
+ }
var machineStopCount: Int {
lock.lock(); defer { lock.unlock() }
return _machineStopCount
@@ -3391,6 +3469,15 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
state: String,
pid: Int32? = nil,
agentBuild: String? = nil,
+ agentProtocolVersion: UInt32? = 1,
+ agentCapabilities: [NSDictionary] = [
+ ["id": "clock-sync", "version": 1] as NSDictionary,
+ ["id": "exec", "version": 1] as NSDictionary,
+ ["id": "exec-stdin", "version": 1] as NSDictionary,
+ ["id": "ports-watch", "version": 1] as NSDictionary,
+ ["id": "sync-push", "version": 1] as NSDictionary,
+ ["id": "telemetry", "version": 1] as NSDictionary,
+ ],
handoffFDCount: Int = 0,
memoryMB: UInt64 = 2048,
cpuCount: Int = 2,
@@ -3411,6 +3498,12 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
if let pid { row["pid"] = pid }
if let agentBuild {
row["agentBuild"] = agentBuild
+ if let agentProtocolVersion {
+ row["agentProtocolVersion"] = agentProtocolVersion
+ if !agentCapabilities.isEmpty {
+ row["agentCapabilities"] = agentCapabilities
+ }
+ }
row["handoffSocketPath"] = "/tmp/handoff.sock"
row["agentSocketPath"] = "/tmp/agent.sock"
row["dockerdSocketPath"] = "/tmp/dockerd.sock"
From e7e1e47e08c2fddf913d3ea17d5dcbdcfd3e7589 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 23:17:06 +0000
Subject: [PATCH 060/338] feat(guest): enforce declared agent capabilities
---
.../Sources/DorydKit/MachineManager.swift | 56 ++++++++++-
.../DorydKitTests/DorydServiceTests.swift | 14 ++-
.../DorydKitTests/MachineManagerTests.swift | 96 ++++++++++++++++++-
3 files changed, 158 insertions(+), 8 deletions(-)
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 95a016cc..1b656263 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -621,6 +621,7 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert
case unknownSnapshot(String)
case alreadyRunning(String)
case agentUnavailable(String)
+ case agentCapabilityUnavailable(String, String)
case balloonUnavailable(String)
case balloonApplyFailed(String, String)
case invalidAddress(String)
@@ -644,6 +645,8 @@ public enum MachineManagerError: Error, Sendable, Equatable, CustomStringConvert
return "machine is already running: \(id)"
case let .agentUnavailable(id):
return "machine agent is unavailable: \(id)"
+ case let .agentCapabilityUnavailable(id, capability):
+ return "machine agent capability is unavailable for \(id): \(capability)"
case let .balloonUnavailable(id):
return "machine balloon control is unavailable: \(id)"
case let .balloonApplyFailed(id, message):
@@ -4709,7 +4712,7 @@ public final class MachineManager: @unchecked Sendable {
}
public func telemetry(id: String) throws -> DoryTelemetry {
- try withAgentClient(id: id) { client in
+ try withAgentClient(id: id, requiredCapability: "telemetry") { client in
try client.telemetry()
}
}
@@ -4800,7 +4803,7 @@ public final class MachineManager: @unchecked Sendable {
guard !argv.isEmpty else {
throw MachineManagerError.agentUnavailable(id)
}
- return try withAgentClient(id: id) { client in
+ return try withAgentClient(id: id, requiredCapability: "exec") { client in
try client.exec(
argv: argv,
cwd: cwd,
@@ -4813,6 +4816,7 @@ public final class MachineManager: @unchecked Sendable {
private func withAgentClient(
id: String,
+ requiredCapability: String? = nil,
_ operation: (any AgentControlClient) throws -> T
) throws -> T {
guard let status = status(id: id) else {
@@ -4821,6 +4825,10 @@ public final class MachineManager: @unchecked Sendable {
guard status.state == .running, let socketPath = status.agentSocketPath else {
throw MachineManagerError.agentUnavailable(id)
}
+ if let requiredCapability,
+ !status.supportsAgentCapability(requiredCapability) {
+ throw MachineManagerError.agentCapabilityUnavailable(id, requiredCapability)
+ }
let client = try agentConnector(socketPath)
defer { client.close() }
return try operation(client)
@@ -6273,6 +6281,16 @@ public final class MachineManager: @unchecked Sendable {
launchID: UUID,
agentSocketPath: String
) {
+ lock.lock()
+ let mayExecute = machines[machineID].map { entry in
+ entry.launchID == launchID
+ && entry.state == .running
+ && entry.handoff?.ready.agentSocketPath == agentSocketPath
+ && entry.handoff?.ready.supportsAgentCapability("exec") == true
+ } ?? false
+ lock.unlock()
+ guard mayExecute else { return }
+
let address: String
do {
let client = try agentConnector(agentSocketPath)
@@ -9100,11 +9118,11 @@ private struct MachineEntry {
extension MachineManager: WakeClockSyncing {
public func syncAgentClock(now: Date) -> AgentClockSyncResult {
- let runningAgents = list().compactMap { status -> (id: String, socketPath: String)? in
+ let runningAgents = list().compactMap { status -> (id: String, socketPath: String, supported: Bool)? in
guard status.state == .running, let socketPath = status.agentSocketPath else {
return nil
}
- return (status.id, socketPath)
+ return (status.id, socketPath, status.supportsAgentCapability("clock-sync"))
}
guard !runningAgents.isEmpty else {
return AgentClockSyncResult(name: "machines", attempted: false, synced: false)
@@ -9114,6 +9132,10 @@ extension MachineManager: WakeClockSyncing {
var failures: [String] = []
var syncedCount = 0
for agent in runningAgents {
+ guard agent.supported else {
+ failures.append("\(agent.id): clock-sync capability unavailable")
+ continue
+ }
do {
let client = try agentConnector(agent.socketPath)
defer { client.close() }
@@ -9135,3 +9157,29 @@ extension MachineManager: WakeClockSyncing {
)
}
}
+
+private extension DoryMachineStatus {
+ func supportsAgentCapability(_ id: String, minimumVersion: UInt32 = 1) -> Bool {
+ guard agentBuild?.isEmpty == false,
+ agentProtocolVersion == DoryCore.protocolVersion(),
+ agentCapabilities.allSatisfy(\.isValid),
+ agentCapabilities == agentCapabilities.sorted(by: { $0.id < $1.id }),
+ Set(agentCapabilities.map(\.id)).count == agentCapabilities.count else {
+ return false
+ }
+ return agentCapabilities.contains { $0.id == id && $0.version >= minimumVersion }
+ }
+}
+
+private extension VmmReadyMessage {
+ func supportsAgentCapability(_ id: String, minimumVersion: UInt32 = 1) -> Bool {
+ guard agentBuild?.isEmpty == false,
+ agentProtocolVersion == DoryCore.protocolVersion(),
+ agentCapabilities.allSatisfy(\.isValid),
+ agentCapabilities == agentCapabilities.sorted(by: { $0.id < $1.id }),
+ Set(agentCapabilities.map(\.id)).count == agentCapabilities.count else {
+ return false
+ }
+ return agentCapabilities.contains { $0.id == id && $0.version >= minimumVersion }
+ }
+}
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 3c96025c..747fe0ca 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -724,7 +724,10 @@ final class DorydServiceTests: XCTestCase {
machineID: "dev",
agentBuild: "dory-agent/test",
agentProtocolVersion: DoryCore.protocolVersion(),
- agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)],
+ agentCapabilities: [
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "telemetry", version: 1),
+ ],
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock",
controlSocketPath: "/run/control.sock"
@@ -813,6 +816,11 @@ final class DorydServiceTests: XCTestCase {
ready: VmmReadyMessage(
machineID: "dev",
agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: [
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "telemetry", version: 1),
+ ],
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock",
controlSocketPath: "/run/control.sock"
@@ -1628,6 +1636,8 @@ final class DorydServiceTests: XCTestCase {
ready: VmmReadyMessage(
machineID: "dev",
agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)],
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock",
shellSocketPath: "/run/shell.sock"
@@ -1704,6 +1714,8 @@ final class DorydServiceTests: XCTestCase {
ready: VmmReadyMessage(
machineID: "dev",
agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: [DoryAgentCapability(id: "exec", version: 1)],
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock"
),
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index 22091369..ad24d8cc 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -6,6 +6,10 @@ import DoryOperations
import XCTest
final class MachineManagerTests: XCTestCase {
+ private static func agentCapabilities(_ ids: String...) -> [DoryAgentCapability] {
+ ids.sorted().map { DoryAgentCapability(id: $0, version: 1) }
+ }
+
func testShareArgumentsRoundTripDelimiterHeavyPathsAndJSON() throws {
let shares = [
DoryMachineShareConfiguration(
@@ -3167,6 +3171,10 @@ final class MachineManagerTests: XCTestCase {
ready: VmmReadyMessage(
machineID: "dev",
agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: Self.agentCapabilities(
+ "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry"
+ ),
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock",
shellSocketPath: "/run/shell.sock"
@@ -3251,7 +3259,13 @@ final class MachineManagerTests: XCTestCase {
let starting = try manager.start(id: "dev")
try sendVmmHandoff(
path: try XCTUnwrap(starting.handoffSocketPath),
- ready: VmmReadyMessage(machineID: "dev", agentSocketPath: "/run/agent.sock"),
+ ready: VmmReadyMessage(
+ machineID: "dev",
+ agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: Self.agentCapabilities("exec"),
+ agentSocketPath: "/run/agent.sock"
+ ),
fileDescriptors: []
)
@@ -3296,7 +3310,13 @@ final class MachineManagerTests: XCTestCase {
let starting = try manager.start(id: "dev")
try sendVmmHandoff(
path: try XCTUnwrap(starting.handoffSocketPath),
- ready: VmmReadyMessage(machineID: "dev", agentSocketPath: "/run/agent.sock"),
+ ready: VmmReadyMessage(
+ machineID: "dev",
+ agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: Self.agentCapabilities("exec"),
+ agentSocketPath: "/run/agent.sock"
+ ),
fileDescriptors: []
)
@@ -3342,7 +3362,13 @@ final class MachineManagerTests: XCTestCase {
let starting = try manager.start(id: "dev")
try sendVmmHandoff(
path: try XCTUnwrap(starting.handoffSocketPath),
- ready: VmmReadyMessage(machineID: "dev", agentSocketPath: "/run/agent.sock"),
+ ready: VmmReadyMessage(
+ machineID: "dev",
+ agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: Self.agentCapabilities("exec"),
+ agentSocketPath: "/run/agent.sock"
+ ),
fileDescriptors: []
)
_ = try waitForRecordedExecs(connector, count: 1)
@@ -3671,6 +3697,8 @@ final class MachineManagerTests: XCTestCase {
ready: VmmReadyMessage(
machineID: "dev",
agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: Self.agentCapabilities("clock-sync", "exec"),
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock",
controlSocketPath: "/run/control.sock"
@@ -3717,6 +3745,8 @@ final class MachineManagerTests: XCTestCase {
ready: VmmReadyMessage(
machineID: "dev",
agentBuild: "dory-agent/test",
+ agentProtocolVersion: DoryCore.protocolVersion(),
+ agentCapabilities: Self.agentCapabilities("exec"),
agentSocketPath: "/run/agent.sock",
dockerdSocketPath: "/run/docker.sock",
controlSocketPath: "/run/control.sock"
@@ -3755,6 +3785,58 @@ final class MachineManagerTests: XCTestCase {
])
}
+ func testAgentOperationsRequireAdvertisedCapabilitiesBeforeConnecting() throws {
+ let base = "/tmp/dory-machine-capability-gate-\(getpid())-\(UInt32.random(in: 0..
Date: Thu, 20 Aug 2026 23:19:30 +0000
Subject: [PATCH 061/338] feat(agent): negotiate capabilities before control
calls
---
.../Sources/DorydKit/AgentControl.swift | 66 +++++++++++++--
.../DorydKitTests/AgentControlTests.swift | 81 ++++++++++++++++++-
.../DorydKitTests/DorydServiceTests.swift | 11 ++-
3 files changed, 145 insertions(+), 13 deletions(-)
diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift
index 8ff581f9..46bb6efd 100644
--- a/dory-core-swift/Sources/DorydKit/AgentControl.swift
+++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift
@@ -72,6 +72,7 @@ public final class AgentControl: @unchecked Sendable {
private let connector: Connector
private let lock = NSLock()
private var client: (any AgentControlClient)?
+ private var negotiatedInfo: DoryAgentInfo?
public init(
configuration: AgentControlConfiguration,
@@ -97,7 +98,8 @@ public final class AgentControl: @unchecked Sendable {
}
public func info() throws -> DoryAgentInfo {
- try connectedClient().info()
+ let client = try connectedClient()
+ return try cachedInfo(from: client)
}
public func clockSync(now: Date = Date()) throws -> Bool {
@@ -105,15 +107,16 @@ public final class AgentControl: @unchecked Sendable {
}
public func clockSync(hostEpochNs: Int64) throws -> Bool {
- try connectedClient().clockSync(hostEpochNs: hostEpochNs)
+ let client = try client(requiring: "clock-sync")
+ return try client.clockSync(hostEpochNs: hostEpochNs)
}
public func portsWatch() throws -> DoryPortsSnapshot {
- try connectedClient().portsWatch()
+ try client(requiring: "ports-watch").portsWatch()
}
public func telemetry() throws -> DoryTelemetry {
- try connectedClient().telemetry()
+ try client(requiring: "telemetry").telemetry()
}
public func exec(
@@ -123,7 +126,7 @@ public final class AgentControl: @unchecked Sendable {
timeoutMs: UInt64 = 30_000,
outputLimitBytes: UInt64 = 1024 * 1024
) throws -> DoryExecResult {
- try connectedClient().exec(
+ try client(requiring: "exec").exec(
argv: argv,
cwd: cwd,
env: env,
@@ -140,7 +143,9 @@ public final class AgentControl: @unchecked Sendable {
timeoutMs: UInt64 = 30_000,
outputLimitBytes: UInt64 = 1024 * 1024
) throws -> DoryExecResult {
- try connectedClient().execWithInput(
+ let client = try client(requiring: "exec")
+ try requireCapability("exec-stdin", from: client)
+ return try client.execWithInput(
argv: argv,
stdin: stdin,
cwd: cwd,
@@ -154,6 +159,7 @@ public final class AgentControl: @unchecked Sendable {
lock.lock()
let current = client
client = nil
+ negotiatedInfo = nil
lock.unlock()
current?.close()
}
@@ -179,13 +185,59 @@ public final class AgentControl: @unchecked Sendable {
return existing
}
+ private func client(requiring capability: String) throws -> any AgentControlClient {
+ let client = try connectedClient()
+ try requireCapability(capability, from: client)
+ return client
+ }
+
+ private func requireCapability(
+ _ capability: String,
+ from client: any AgentControlClient
+ ) throws {
+ let info = try cachedInfo(from: client)
+ guard info.protocolVersion == DoryCore.protocolVersion() else {
+ throw AgentControlError.incompatibleProtocol(
+ expected: DoryCore.protocolVersion(),
+ actual: info.protocolVersion
+ )
+ }
+ guard info.capabilitiesAreCanonical else {
+ throw AgentControlError.invalidCapabilities
+ }
+ guard info.supports(capability) else {
+ throw AgentControlError.capabilityUnavailable(capability)
+ }
+ }
+
+ private func cachedInfo(from client: any AgentControlClient) throws -> DoryAgentInfo {
+ lock.lock()
+ if let negotiatedInfo {
+ lock.unlock()
+ return negotiatedInfo
+ }
+ lock.unlock()
+
+ let fresh = try client.info()
+ lock.lock()
+ if negotiatedInfo == nil {
+ negotiatedInfo = fresh
+ }
+ let selected = negotiatedInfo!
+ lock.unlock()
+ return selected
+ }
+
deinit {
disconnect()
}
}
-public enum AgentControlError: Error, Sendable {
+public enum AgentControlError: Error, Sendable, Equatable {
case standardInputUnsupported
+ case incompatibleProtocol(expected: UInt32, actual: UInt32)
+ case invalidCapabilities
+ case capabilityUnavailable(String)
}
public enum LocalAgentControlError: Error, Sendable, Equatable, CustomStringConvertible {
diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
index a7bcc293..e36ca6cc 100644
--- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
@@ -17,11 +17,14 @@ final class AgentControlTests: XCTestCase {
XCTAssertEqual(counter.value, 0)
let info = try control.info()
XCTAssertEqual(info.agentBuild, "fake-agent")
- XCTAssertEqual(info.capabilities, [DoryAgentCapability(id: "exec", version: 1)])
+ XCTAssertEqual(info.capabilities.map(\.id), [
+ "clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry",
+ ])
XCTAssertEqual(counter.value, 1)
XCTAssertFalse(try control.clockSync(now: Date(timeIntervalSince1970: 1.5)))
XCTAssertEqual(fake.clockSyncInputs, [1_500_000_000])
+ XCTAssertEqual(try control.portsWatch().ports.first?.port, 8080)
XCTAssertEqual(try control.telemetry().memTotalKB, 1024)
let exec = try control.exec(argv: ["/bin/echo", "ok"], cwd: "/tmp")
XCTAssertEqual(exec.exitCode, 0)
@@ -35,12 +38,73 @@ final class AgentControlTests: XCTestCase {
control.disconnect()
XCTAssertEqual(fake.closeCount, 1)
}
+
+ func testAgentControlRejectsMissingInvalidAndIncompatibleCapabilitiesBeforeCallingOperation() throws {
+ let missing = FakeAgentControlClient(
+ capabilities: [DoryAgentCapability(id: "exec", version: 1)]
+ )
+ let missingControl = AgentControl(
+ configuration: AgentControlConfiguration(directSocketPath: "/tmp/missing.sock"),
+ connector: { _ in missing }
+ )
+ XCTAssertThrowsError(try missingControl.telemetry()) { error in
+ XCTAssertEqual(error as? AgentControlError, .capabilityUnavailable("telemetry"))
+ }
+ XCTAssertEqual(missing.telemetryCalls, 0)
+
+ let invalid = FakeAgentControlClient(capabilities: [
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "exec", version: 1),
+ ])
+ let invalidControl = AgentControl(
+ configuration: AgentControlConfiguration(directSocketPath: "/tmp/invalid.sock"),
+ connector: { _ in invalid }
+ )
+ XCTAssertThrowsError(try invalidControl.exec(argv: ["/bin/true"])) { error in
+ XCTAssertEqual(error as? AgentControlError, .invalidCapabilities)
+ }
+
+ let incompatible = FakeAgentControlClient(
+ protocolVersion: DoryCore.protocolVersion() + 1
+ )
+ let incompatibleControl = AgentControl(
+ configuration: AgentControlConfiguration(directSocketPath: "/tmp/incompatible.sock"),
+ connector: { _ in incompatible }
+ )
+ XCTAssertThrowsError(try incompatibleControl.clockSync()) { error in
+ XCTAssertEqual(
+ error as? AgentControlError,
+ .incompatibleProtocol(
+ expected: DoryCore.protocolVersion(),
+ actual: DoryCore.protocolVersion() + 1
+ )
+ )
+ }
+ XCTAssertTrue(incompatible.clockSyncInputs.isEmpty)
+ }
}
private final class FakeAgentControlClient: AgentControlClient, @unchecked Sendable {
private let lock = NSLock()
+ private let protocolVersion: UInt32
+ private let capabilities: [DoryAgentCapability]
private var inputs: [Int64] = []
private var closes = 0
+ private var telemetryCallCount = 0
+
+ init(
+ protocolVersion: UInt32 = DoryCore.protocolVersion(),
+ capabilities: [DoryAgentCapability] = [
+ DoryAgentCapability(id: "clock-sync", version: 1),
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "exec-stdin", version: 1),
+ DoryAgentCapability(id: "ports-watch", version: 1),
+ DoryAgentCapability(id: "telemetry", version: 1),
+ ]
+ ) {
+ self.protocolVersion = protocolVersion
+ self.capabilities = capabilities
+ }
var clockSyncInputs: [Int64] {
lock.lock()
@@ -54,13 +118,19 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda
return closes
}
+ var telemetryCalls: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return telemetryCallCount
+ }
+
func info() throws -> DoryAgentInfo {
DoryAgentInfo(
- protocolVersion: 1,
+ protocolVersion: protocolVersion,
kernel: "Linux fake",
agentBuild: "fake-agent",
uptimeSeconds: 42,
- capabilities: [DoryAgentCapability(id: "exec", version: 1)]
+ capabilities: capabilities
)
}
@@ -80,7 +150,10 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda
}
func telemetry() throws -> DoryTelemetry {
- DoryTelemetry(
+ lock.lock()
+ telemetryCallCount += 1
+ lock.unlock()
+ return DoryTelemetry(
memTotalKB: 1024,
memAvailableKB: 512,
psiSomeAvg10: 0,
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 747fe0ca..e5a9ac84 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -1941,10 +1941,17 @@ private final class ServiceFakeAgentControlClient: AgentControlClient, @unchecke
func info() throws -> DoryAgentInfo {
DoryAgentInfo(
- protocolVersion: 1,
+ protocolVersion: DoryCore.protocolVersion(),
kernel: "Linux docker",
agentBuild: "docker-agent",
- uptimeSeconds: 9
+ uptimeSeconds: 9,
+ capabilities: [
+ DoryAgentCapability(id: "clock-sync", version: 1),
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "exec-stdin", version: 1),
+ DoryAgentCapability(id: "ports-watch", version: 1),
+ DoryAgentCapability(id: "telemetry", version: 1),
+ ]
)
}
From d4f68f2242cf7c0859cf5a87703773259cfcc505 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 23:21:02 +0000
Subject: [PATCH 062/338] feat(remote): enforce agent capability contracts
---
.../DorydKit/RemoteMachineManager.swift | 37 +++++++-
.../DorydKitTests/DorydServiceTests.swift | 9 +-
.../RemoteMachineManagerTests.swift | 89 ++++++++++++++++++-
3 files changed, 125 insertions(+), 10 deletions(-)
diff --git a/dory-core-swift/Sources/DorydKit/RemoteMachineManager.swift b/dory-core-swift/Sources/DorydKit/RemoteMachineManager.swift
index cf9c9b64..cdaf418a 100644
--- a/dory-core-swift/Sources/DorydKit/RemoteMachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/RemoteMachineManager.swift
@@ -111,6 +111,9 @@ public struct RemoteMachineStatus: Sendable, Equatable {
public enum RemoteMachineError: Error, Sendable, Equatable {
case unknownMachine(String)
case notConnected(String)
+ case incompatibleProtocol(expected: UInt32, actual: UInt32)
+ case invalidCapabilities(String)
+ case capabilityUnavailable(String, String)
}
public final class RemoteMachineManager: @unchecked Sendable {
@@ -157,6 +160,15 @@ public final class RemoteMachineManager: @unchecked Sendable {
// The FFI call carries its own deadline (Rust AgentClient enforces 30s control /
// 2min sync timeouts), so there is no host-side timeout to add here.
info = try agent.info()
+ guard info.protocolVersion == DoryCore.protocolVersion() else {
+ throw RemoteMachineError.incompatibleProtocol(
+ expected: DoryCore.protocolVersion(),
+ actual: info.protocolVersion
+ )
+ }
+ guard info.capabilitiesAreCanonical else {
+ throw RemoteMachineError.invalidCapabilities(configuration.id)
+ }
} catch {
// connector() already handed us a live handle; close it before we drop it so a
// failing info() can't leak an fd/SSH session per retry.
@@ -206,13 +218,21 @@ public final class RemoteMachineManager: @unchecked Sendable {
localRoot: String,
remoteRoot: String? = nil
) throws -> DoryPushStats {
- let (agent, root) = try connectedAgent(id: id, remoteRoot: remoteRoot)
+ let (agent, root) = try connectedAgent(
+ id: id,
+ remoteRoot: remoteRoot,
+ requiredCapability: "sync-push"
+ )
// Per-call deadline is enforced in the Rust AgentClient; no host-side timeout to add.
return try agent.push(localRoot: localRoot, remoteRoot: root)
}
public func telemetry(id: String) throws -> DoryTelemetry {
- let (agent, _) = try connectedAgent(id: id, remoteRoot: nil)
+ let (agent, _) = try connectedAgent(
+ id: id,
+ remoteRoot: nil,
+ requiredCapability: "telemetry"
+ )
// Per-call deadline is enforced in the Rust AgentClient; no host-side timeout to add.
let telemetry = try agent.telemetry()
lock.lock()
@@ -229,7 +249,11 @@ public final class RemoteMachineManager: @unchecked Sendable {
timeoutMs: UInt64 = 30_000,
outputLimitBytes: UInt64 = 1024 * 1024
) throws -> DoryExecResult {
- let (agent, _) = try connectedAgent(id: id, remoteRoot: nil)
+ let (agent, _) = try connectedAgent(
+ id: id,
+ remoteRoot: nil,
+ requiredCapability: "exec"
+ )
return try agent.exec(
argv: argv,
cwd: cwd,
@@ -282,7 +306,8 @@ public final class RemoteMachineManager: @unchecked Sendable {
private func connectedAgent(
id: String,
- remoteRoot: String?
+ remoteRoot: String?,
+ requiredCapability: String
) throws -> (any RemoteAgentClient, String) {
lock.lock()
guard let entry = machines[id] else {
@@ -293,6 +318,10 @@ public final class RemoteMachineManager: @unchecked Sendable {
lock.unlock()
throw RemoteMachineError.notConnected(id)
}
+ guard entry.info?.supports(requiredCapability) == true else {
+ lock.unlock()
+ throw RemoteMachineError.capabilityUnavailable(id, requiredCapability)
+ }
let root = remoteRoot ?? entry.configuration.remoteRoot
lock.unlock()
return (agent, root)
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index e5a9ac84..9dd43469 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -2022,10 +2022,15 @@ private final class ServiceFakeRemoteAgentClient: RemoteAgentClient, @unchecked
func info() throws -> DoryAgentInfo {
DoryAgentInfo(
- protocolVersion: 1,
+ protocolVersion: DoryCore.protocolVersion(),
kernel: "Linux remote",
agentBuild: "remote-agent",
- uptimeSeconds: 7
+ uptimeSeconds: 7,
+ capabilities: [
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "sync-push", version: 1),
+ DoryAgentCapability(id: "telemetry", version: 1),
+ ]
)
}
diff --git a/dory-core-swift/Tests/DorydKitTests/RemoteMachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/RemoteMachineManagerTests.swift
index 519b894c..c2ac30bd 100644
--- a/dory-core-swift/Tests/DorydKitTests/RemoteMachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/RemoteMachineManagerTests.swift
@@ -74,6 +74,64 @@ final class RemoteMachineManagerTests: XCTestCase {
XCTAssertEqual(error as? RemoteMachineError, .unknownMachine("missing"))
}
}
+
+ func testRemoteOperationsRequireAdvertisedCapabilitiesBeforeCallingAgent() throws {
+ let fake = FakeRemoteAgentClient(capabilities: [
+ DoryAgentCapability(id: "exec", version: 1),
+ ])
+ let manager = RemoteMachineManager(
+ keyStore: FakeSSHKeyStore(keys: ["primary": "OPENSSH-PRIVATE-KEY"]),
+ connector: { _ in fake }
+ )
+ _ = try manager.connect(testConfiguration())
+
+ XCTAssertThrowsError(try manager.push(id: "prod", localRoot: "/tmp/local")) { error in
+ XCTAssertEqual(
+ error as? RemoteMachineError,
+ .capabilityUnavailable("prod", "sync-push")
+ )
+ }
+ XCTAssertThrowsError(try manager.telemetry(id: "prod")) { error in
+ XCTAssertEqual(
+ error as? RemoteMachineError,
+ .capabilityUnavailable("prod", "telemetry")
+ )
+ }
+ XCTAssertTrue(fake.pushes.isEmpty)
+ XCTAssertEqual(fake.telemetryCalls, 0)
+ }
+
+ func testRemoteConnectRejectsInvalidOrIncompatibleHandshakeAndClosesAgent() {
+ for fixture in [
+ FakeRemoteAgentClient(
+ capabilities: [
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "exec", version: 1),
+ ]
+ ),
+ FakeRemoteAgentClient(protocolVersion: DoryCore.protocolVersion() + 1),
+ ] {
+ let manager = RemoteMachineManager(
+ keyStore: FakeSSHKeyStore(keys: ["primary": "OPENSSH-PRIVATE-KEY"]),
+ connector: { _ in fixture }
+ )
+ XCTAssertThrowsError(try manager.connect(testConfiguration()))
+ XCTAssertEqual(fixture.closeCount, 1)
+ XCTAssertEqual(manager.status(id: "prod")?.state, .failed)
+ }
+ }
+
+ private func testConfiguration() -> RemoteMachineConfiguration {
+ RemoteMachineConfiguration(
+ id: "prod",
+ host: "vps.example.com",
+ user: "dory",
+ privateKeyID: "primary",
+ hostKey: .pinned(opensshPublicKey: "ssh-ed25519 AAAA fake"),
+ endpoint: .unixSocket(path: "/run/dory/agent.sock"),
+ remoteRoot: "/srv/app"
+ )
+ }
}
private enum FakeConnectError: Error {
@@ -104,10 +162,23 @@ private final class FakeRemoteAgentClient: RemoteAgentClient, @unchecked Sendabl
private let lock = NSLock()
private var storedPushes: [Push] = []
private var closes = 0
+ private var storedTelemetryCalls = 0
private let infoError: Error?
+ private let protocolVersion: UInt32
+ private let capabilities: [DoryAgentCapability]
- init(infoError: Error? = nil) {
+ init(
+ infoError: Error? = nil,
+ protocolVersion: UInt32 = DoryCore.protocolVersion(),
+ capabilities: [DoryAgentCapability] = [
+ DoryAgentCapability(id: "exec", version: 1),
+ DoryAgentCapability(id: "sync-push", version: 1),
+ DoryAgentCapability(id: "telemetry", version: 1),
+ ]
+ ) {
self.infoError = infoError
+ self.protocolVersion = protocolVersion
+ self.capabilities = capabilities
}
var pushes: [Push] {
@@ -122,20 +193,30 @@ private final class FakeRemoteAgentClient: RemoteAgentClient, @unchecked Sendabl
return closes
}
+ var telemetryCalls: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return storedTelemetryCalls
+ }
+
func info() throws -> DoryAgentInfo {
if let infoError {
throw infoError
}
return DoryAgentInfo(
- protocolVersion: 1,
+ protocolVersion: protocolVersion,
kernel: "Linux remote",
agentBuild: "remote-agent",
- uptimeSeconds: 99
+ uptimeSeconds: 99,
+ capabilities: capabilities
)
}
func telemetry() throws -> DoryTelemetry {
- DoryTelemetry(
+ lock.lock()
+ storedTelemetryCalls += 1
+ lock.unlock()
+ return DoryTelemetry(
memTotalKB: 2048,
memAvailableKB: 1024,
psiSomeAvg10: 0.5,
From 37b48bb287c40960df34cedaf493f798d05119d5 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 23:23:48 +0000
Subject: [PATCH 063/338] feat(agent): surface capabilities across xpc
---
Dory/Runtime/Doryd/DorydClient.swift | 26 +++++++++++++++----
DoryTests/DorydClientTests.swift | 16 ++++++++++++
.../Sources/DorydKit/DorydService.swift | 3 +++
.../DorydKitTests/DorydServiceTests.swift | 8 ++++++
4 files changed, 48 insertions(+), 5 deletions(-)
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index 2dfa94cd..36bdaa5f 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -928,6 +928,7 @@ nonisolated struct DorydAgentInfo: Sendable, Equatable {
var kernel: String
var agentBuild: String
var uptimeSeconds: UInt64
+ var capabilities: [DorydAgentCapability] = []
}
nonisolated struct DorydTelemetry: Sendable, Equatable {
@@ -1924,6 +1925,16 @@ nonisolated final class DorydClient: @unchecked Sendable {
guard let encodedCapabilities else {
return ParsedMachineAgentHandshake(protocolVersion: protocolVersion, capabilities: [])
}
+ guard let capabilities = agentCapabilities(from: encodedCapabilities) else { return nil }
+ return ParsedMachineAgentHandshake(
+ protocolVersion: protocolVersion,
+ capabilities: capabilities
+ )
+ }
+
+ nonisolated private static func agentCapabilities(
+ from encodedCapabilities: Any
+ ) -> [DorydAgentCapability]? {
guard let rawCapabilities = encodedCapabilities as? NSArray else { return nil }
var capabilities: [DorydAgentCapability] = []
capabilities.reserveCapacity(rawCapabilities.count)
@@ -1951,10 +1962,7 @@ nonisolated final class DorydClient: @unchecked Sendable {
Set(capabilities.map(\.id)).count == capabilities.count else {
return nil
}
- return ParsedMachineAgentHandshake(
- protocolVersion: protocolVersion,
- capabilities: capabilities
- )
+ return capabilities
}
private struct ParsedMachineTypedSettings {
@@ -2427,11 +2435,19 @@ nonisolated final class DorydClient: @unchecked Sendable {
let uptimeSeconds = uint64(dictionary["uptimeSeconds"]) else {
return nil
}
+ let capabilities: [DorydAgentCapability]
+ if let encodedCapabilities = dictionary["capabilities"] {
+ guard let parsed = agentCapabilities(from: encodedCapabilities) else { return nil }
+ capabilities = parsed
+ } else {
+ capabilities = []
+ }
return DorydAgentInfo(
protocolVersion: protocolVersion,
kernel: kernel,
agentBuild: agentBuild,
- uptimeSeconds: uptimeSeconds
+ uptimeSeconds: uptimeSeconds,
+ capabilities: capabilities
)
}
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 39263b05..406a95e0 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -353,6 +353,9 @@ struct DorydClientTests {
#expect(slept == DorydCommandResult(ok: true, message: ""))
#expect(woke == DorydCommandResult(ok: true, message: ""))
#expect(dockerAgentInfo.agentBuild == "docker-agent")
+ #expect(dockerAgentInfo.capabilities.map(\.id) == [
+ "clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry",
+ ])
#expect(dockerAgentPorts.ports == [DorydListenPort(protocol: "tcp", port: 8080)])
#expect(dockerAgentPorts.added == [DorydListenPort(protocol: "tcp", port: 8080)])
#expect(dockerAgentTelemetry.memTotalKB == 2048)
@@ -411,6 +414,7 @@ struct DorydClientTests {
#expect(machines.map(\.id) == ["dev", "dev-copy"])
#expect(deletedMachine == DorydCommandResult(ok: true, message: ""))
#expect(remoteInfo.agentBuild == "remote-agent")
+ #expect(remoteInfo.capabilities.map(\.id) == ["exec", "sync-push", "telemetry"])
#expect(pushStats == DorydPushStats(filesSent: 2, bytesSent: 30, filesDeleted: 1))
#expect(remoteStatus.telemetry?.memAvailableKB == 512)
#expect(replacedRoutes == DorydCommandResult(ok: true, message: ""))
@@ -3434,6 +3438,13 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
"kernel": "Linux docker",
"agentBuild": "docker-agent",
"uptimeSeconds": 11,
+ "capabilities": [
+ ["id": "clock-sync", "version": 1] as NSDictionary,
+ ["id": "exec", "version": 1] as NSDictionary,
+ ["id": "exec-stdin", "version": 1] as NSDictionary,
+ ["id": "ports-watch", "version": 1] as NSDictionary,
+ ["id": "telemetry", "version": 1] as NSDictionary,
+ ],
]
}
@@ -3452,6 +3463,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
"kernel": "Linux test",
"agentBuild": "remote-agent",
"uptimeSeconds": 9,
+ "capabilities": [
+ ["id": "exec", "version": 1] as NSDictionary,
+ ["id": "sync-push", "version": 1] as NSDictionary,
+ ["id": "telemetry", "version": 1] as NSDictionary,
+ ],
]
}
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index 0e2228b3..2e5097c2 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -1835,6 +1835,9 @@ private extension DoryAgentInfo {
"kernel": kernel,
"agentBuild": agentBuild,
"uptimeSeconds": uptimeSeconds,
+ "capabilities": capabilities.map {
+ ["id": $0.id, "version": $0.version] as NSDictionary
+ },
]
}
}
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 9dd43469..00b3736e 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -444,6 +444,10 @@ final class DorydServiceTests: XCTestCase {
XCTAssertEqual(message, "")
XCTAssertEqual(body["agentBuild"] as? String, "docker-agent")
XCTAssertEqual(body["protocolVersion"] as? UInt32, 1)
+ XCTAssertEqual(
+ (body["capabilities"] as? [NSDictionary])?.compactMap { $0["id"] as? String },
+ ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"]
+ )
infoReply.fulfill()
}
wait(for: [infoReply], timeout: 5)
@@ -537,6 +541,10 @@ final class DorydServiceTests: XCTestCase {
wait(for: [connect], timeout: 5)
XCTAssertTrue(connectOK, connectMessage)
XCTAssertEqual(info["agentBuild"] as? String, "remote-agent")
+ XCTAssertEqual(
+ (info["capabilities"] as? [NSDictionary])?.compactMap { $0["id"] as? String },
+ ["exec", "sync-push", "telemetry"]
+ )
XCTAssertEqual(captured.value?.opensshPrivateKey, "PRIVATE")
let push = expectation(description: "remotePush reply")
From d23846df82da0db88a0c4daa1b34858dda9b408b Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 23:41:39 +0000
Subject: [PATCH 064/338] feat(snapshot): quiesce capable guests
---
.../Sources/DoryCore/DoryCore.swift | 8 ++
.../Sources/DorydKit/AgentControl.swift | 18 +++
.../Sources/DorydKit/MachineManager.swift | 131 ++++++++++++++----
.../DorydKitTests/MachineManagerTests.swift | 126 +++++++++++++++++
dory-core/agent/src/dispatch.rs | 42 +++---
dory-core/agent/src/handler.rs | 4 +
dory-core/agent/src/lib.rs | 1 +
dory-core/agent/src/snapshot_quiesce.rs | 77 ++++++++++
dory-core/ffi/src/agent_forward.rs | 29 ++++
dory-core/pb/proto/agent.proto | 18 +++
dory-core/remote/src/agent_client.rs | 21 ++-
11 files changed, 430 insertions(+), 45 deletions(-)
create mode 100644 dory-core/agent/src/snapshot_quiesce.rs
diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift
index 6e4cd425..f03e37c3 100644
--- a/dory-core-swift/Sources/DoryCore/DoryCore.swift
+++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift
@@ -397,6 +397,14 @@ public final class DoryAgentControlHandle: @unchecked Sendable {
)
}
+ public func snapshotFreeze() throws {
+ try withControl { try $0.snapshotFreeze() }
+ }
+
+ public func snapshotThaw() throws {
+ try withControl { try $0.snapshotThaw() }
+ }
+
public func exec(
argv: [String],
cwd: String = "",
diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift
index 46bb6efd..cbc827f3 100644
--- a/dory-core-swift/Sources/DorydKit/AgentControl.swift
+++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift
@@ -25,6 +25,8 @@ public protocol AgentControlClient: Sendable {
func clockSync(hostEpochNs: Int64) throws -> Bool
func portsWatch() throws -> DoryPortsSnapshot
func telemetry() throws -> DoryTelemetry
+ func snapshotFreeze() throws
+ func snapshotThaw() throws
func exec(
argv: [String],
cwd: String,
@@ -44,6 +46,14 @@ public protocol AgentControlClient: Sendable {
}
public extension AgentControlClient {
+ func snapshotFreeze() throws {
+ throw AgentControlError.capabilityUnavailable("snapshot-quiesce")
+ }
+
+ func snapshotThaw() throws {
+ throw AgentControlError.capabilityUnavailable("snapshot-quiesce")
+ }
+
func execWithInput(
argv: [String],
stdin: Data,
@@ -119,6 +129,14 @@ public final class AgentControl: @unchecked Sendable {
try client(requiring: "telemetry").telemetry()
}
+ public func snapshotFreeze() throws {
+ try client(requiring: "snapshot-quiesce").snapshotFreeze()
+ }
+
+ public func snapshotThaw() throws {
+ try client(requiring: "snapshot-quiesce").snapshotThaw()
+ }
+
public func exec(
argv: [String],
cwd: String = "",
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 1b656263..8cf640f0 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -396,6 +396,11 @@ public struct DoryMachineStatus: Sendable, Equatable {
}
}
+public enum DoryMachineSnapshotConsistency: String, Sendable, Equatable, Hashable, Codable {
+ case coldStopped = "cold-stopped"
+ case guestQuiesced = "guest-quiesced"
+}
+
public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
public var id: String
public var machineID: String
@@ -418,6 +423,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
public var runtimeIdentity: DoryMachineRuntimeIdentity
public var artifactEvidence: DoryMachineSnapshotArtifactEvidence?
public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt?
+ public var consistency: DoryMachineSnapshotConsistency
public init(
id: String,
@@ -443,7 +449,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
DoryVirtualMachineDefinition.currentVirtualHardwareABIVersion
),
artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil,
- installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil
+ installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil,
+ consistency: DoryMachineSnapshotConsistency = .coldStopped
) {
self.id = id
self.machineID = machineID
@@ -466,6 +473,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
self.runtimeIdentity = runtimeIdentity
self.artifactEvidence = artifactEvidence
self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt
+ self.consistency = consistency
}
private enum CodingKeys: String, CodingKey {
@@ -490,6 +498,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
case runtimeIdentity
case artifactEvidence
case installedDesktopPayloadReceipt
+ case consistency
}
public init(from decoder: Decoder) throws {
@@ -530,7 +539,11 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
installedDesktopPayloadReceipt: try container.decodeIfPresent(
DoryInstalledDesktopPayloadReceipt.self,
forKey: .installedDesktopPayloadReceipt
- )
+ ),
+ consistency: try container.decodeIfPresent(
+ DoryMachineSnapshotConsistency.self,
+ forKey: .consistency
+ ) ?? .coldStopped
)
}
}
@@ -3053,6 +3066,7 @@ public final class MachineManager: @unchecked Sendable {
let (machine, sourceMachineState) = try configurationAndPowerState(id: id)
let wasRunning = [.starting, .running].contains(sourceMachineState)
let wasPaused = sourceMachineState == .paused
+ let wasResident = wasRunning || wasPaused
let snapshotRuntimeIdentity = try currentRuntimeIdentity(id: id)
try Self.validateLaunchConfiguration(machine)
try ensurePrivateSnapshotDirectory(machineID: id)
@@ -3067,22 +3081,38 @@ public final class MachineManager: @unchecked Sendable {
!Self.pathEntryExists(nvramPath) else {
throw MachineManagerError.duplicateSnapshot(snapshotID)
}
- // Quiesce under its own durable stop journal. Once stopped, the exact bytes selected for
- // the snapshot can be hashed and bound before any snapshot artifact is published.
- if wasRunning {
- _ = try stopImplementation(
- id: id,
- journalLifecycle: true,
- preserveResolvedAdmissionForRestart: true
- )
+ // Prefer a negotiated guest freeze before the durable cold-stop boundary. A missing tools
+ // capability degrades to the existing cold snapshot; an advertised capability that fails
+ // is not silently ignored because the guest may already be partially frozen.
+ let guestQuiesced = sourceMachineState == .running
+ ? try freezeGuestForSnapshotIfSupported(id: id) : false
+ if wasResident {
+ do {
+ _ = try stopImplementation(
+ id: id,
+ journalLifecycle: true,
+ preserveResolvedAdmissionForRestart: true
+ )
+ } catch {
+ if guestQuiesced {
+ do { try thawGuestAfterFailedSnapshotStop(id: id) }
+ catch let thawError {
+ throw MachineManagerError.persistence(
+ "could not stop \(id) for snapshot: \(error); guest thaw failed: \(thawError)"
+ )
+ }
+ }
+ throw error
+ }
}
var snapshotLifecycleStarted = false
defer {
- if wasRunning, !snapshotLifecycleStarted {
- try? prepareRetainedResolvedAdmissionForRestart(
- plan: snapshotRuntimeIdentity.resolvedPlan
+ if wasResident, !snapshotLifecycleStarted {
+ try? restoreSnapshotSourcePowerState(
+ id: id,
+ wasPaused: wasPaused,
+ resolvedPlan: snapshotRuntimeIdentity.resolvedPlan
)
- _ = try? startImplementation(id: id, journalLifecycle: true)
}
}
let liveMachineIdentifierPath = machine.bootMode == .efi
@@ -3129,7 +3159,8 @@ public final class MachineManager: @unchecked Sendable {
runtimeIdentity: snapshotRuntimeIdentity,
artifactEvidence: artifactEvidence,
installedDesktopPayloadReceipt:
- machine.effectiveInstalledDesktopPayloadReceipt
+ machine.effectiveInstalledDesktopPayloadReceipt,
+ consistency: guestQuiesced ? .guestQuiesced : .coldStopped
)
let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot)
let lifecycle = try beginLifecycleSnapshot(
@@ -3186,11 +3217,12 @@ public final class MachineManager: @unchecked Sendable {
try? FileManager.default.removeItem(atPath: nvramPath)
}
failLifecycle(lifecycle, stepID: "snapshot.failed", rolledBack: true)
- if wasRunning {
- try? prepareRetainedResolvedAdmissionForRestart(
- plan: snapshotRuntimeIdentity.resolvedPlan
+ if wasResident {
+ try? restoreSnapshotSourcePowerState(
+ id: id,
+ wasPaused: wasPaused,
+ resolvedPlan: snapshotRuntimeIdentity.resolvedPlan
)
- _ = try? startImplementation(id: id, journalLifecycle: true)
}
if let error = error as? MachineManagerError {
throw error
@@ -3202,18 +3234,20 @@ public final class MachineManager: @unchecked Sendable {
lifecycle,
diagnostic: "published snapshot has an unfinished snapshot journal"
)
- if wasRunning, journalCompleted {
+ if wasResident, journalCompleted {
do {
- try prepareRetainedResolvedAdmissionForRestart(
- plan: snapshotRuntimeIdentity.resolvedPlan
+ try restoreSnapshotSourcePowerState(
+ id: id,
+ wasPaused: wasPaused,
+ resolvedPlan: snapshotRuntimeIdentity.resolvedPlan
)
- _ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch let firstError {
do {
- try prepareRetainedResolvedAdmissionForRestart(
- plan: snapshotRuntimeIdentity.resolvedPlan
+ try restoreSnapshotSourcePowerState(
+ id: id,
+ wasPaused: wasPaused,
+ resolvedPlan: snapshotRuntimeIdentity.resolvedPlan
)
- _ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
} catch {
throw MachineManagerError.persistence(
"snapshot \(snapshotID) was created, but \(id) could not restart: \(firstError); retry: \(error)"
@@ -3224,6 +3258,51 @@ public final class MachineManager: @unchecked Sendable {
return snapshot
}
+ private func freezeGuestForSnapshotIfSupported(id: String) throws -> Bool {
+ guard status(id: id)?.supportsAgentCapability("snapshot-quiesce") == true else {
+ return false
+ }
+ do {
+ try withAgentClient(id: id, requiredCapability: "snapshot-quiesce") { client in
+ try client.snapshotFreeze()
+ }
+ } catch {
+ let freezeError = error
+ do { try thawGuestAfterFailedSnapshotStop(id: id) }
+ catch let thawError {
+ throw MachineManagerError.persistence(
+ "guest snapshot freeze failed for \(id): \(freezeError); thaw failed: \(thawError)"
+ )
+ }
+ throw MachineManagerError.persistence(
+ "guest snapshot freeze failed for \(id): \(freezeError)"
+ )
+ }
+ return true
+ }
+
+ private func thawGuestAfterFailedSnapshotStop(id: String) throws {
+ try withAgentClient(id: id, requiredCapability: "snapshot-quiesce") { client in
+ try client.snapshotThaw()
+ }
+ }
+
+ private func restoreSnapshotSourcePowerState(
+ id: String,
+ wasPaused: Bool,
+ resolvedPlan: DoryResolvedMachinePlan?
+ ) throws {
+ try prepareRetainedResolvedAdmissionForRestart(plan: resolvedPlan)
+ _ = try startAndWaitUntilReady(id: id, journalLifecycle: true)
+ if wasPaused {
+ // Readiness publishes `.running` before its callback can reacquire operationLock to
+ // terminalize the start journal. snapshot() already owns that recursive lock, so
+ // settle the committed start here before opening the pause lifecycle transaction.
+ completeActiveStartLifecycle(id: id)
+ _ = try pauseImplementation(id: id, journalLifecycle: true)
+ }
+ }
+
/// Applies a signed desktop component to an existing persistent guest. The caller provides
/// only stable active-component generation identifiers; doryd resolves and re-verifies the
/// exact component-store bytes and owns the entire transaction: a last-good disk/kernel
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index ad24d8cc..aa8ea66f 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -3715,6 +3715,57 @@ final class MachineManagerTests: XCTestCase {
XCTAssertEqual(connector.clockSyncs, [1_234_500_000_000])
}
+ func testSnapshotsQuiesceCapableGuestsAndColdStopPausedMachines() throws {
+ let base = "/tmp/dory-machine-snapshot-quiesce-\(getpid())-\(UInt32.random(in: 0.. DoryMachineSnapshot {
+ let result = LockedResult()
+ DispatchQueue.global(qos: .userInitiated).async {
+ result.store(Result {
+ try manager.snapshot(id: machineID, snapshotID: snapshotID)
+ })
+ }
+ let deadline = Date().addingTimeInterval(15)
+ var handedOffPIDs: Set = []
+ while Date() < deadline, result.value == nil {
+ if let status = manager.status(id: machineID),
+ status.state == .starting,
+ let pid = status.pid,
+ !handedOffPIDs.contains(pid),
+ let handoffPath = status.handoffSocketPath {
+ try sendSnapshotQuiesceHandoff(path: handoffPath, machineID: machineID)
+ handedOffPIDs.insert(pid)
+ }
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ return try XCTUnwrap(result.value, "snapshot timed out").get()
+}
+
private func runDesktopUpdate(
manager: MachineManager,
id: String,
@@ -4211,6 +4303,8 @@ private final class RecordingMachineAgentConnector: @unchecked Sendable {
private let lock = NSLock()
private var paths: [String] = []
private var syncs: [Int64] = []
+ private var freezes = 0
+ private var thaws = 0
private var recordedExecs: [Exec] = []
private let telemetry: DoryTelemetry
private let execResult: DoryExecResult
@@ -4259,6 +4353,18 @@ private final class RecordingMachineAgentConnector: @unchecked Sendable {
return recordedExecs
}
+ var snapshotFreezes: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return freezes
+ }
+
+ var snapshotThaws: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return thaws
+ }
+
func connect(socketPath: String) throws -> any AgentControlClient {
lock.lock()
paths.append(socketPath)
@@ -4282,6 +4388,18 @@ private final class RecordingMachineAgentConnector: @unchecked Sendable {
recordedExecs.append(exec)
lock.unlock()
}
+
+ func recordSnapshotFreeze() {
+ lock.lock()
+ freezes += 1
+ lock.unlock()
+ }
+
+ func recordSnapshotThaw() {
+ lock.lock()
+ thaws += 1
+ lock.unlock()
+ }
}
private final class RecordingMachineAgentClient: AgentControlClient, @unchecked Sendable {
@@ -4326,6 +4444,14 @@ private final class RecordingMachineAgentClient: AgentControlClient, @unchecked
telemetryValue
}
+ func snapshotFreeze() throws {
+ recorder.recordSnapshotFreeze()
+ }
+
+ func snapshotThaw() throws {
+ recorder.recordSnapshotThaw()
+ }
+
func exec(
argv: [String],
cwd: String,
diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs
index ca1d43b7..a1881096 100644
--- a/dory-core/agent/src/dispatch.rs
+++ b/dory-core/agent/src/dispatch.rs
@@ -20,20 +20,25 @@ pub fn agent_build() -> String {
}
pub fn agent_capabilities() -> Vec {
- [
+ let mut capabilities = vec![
("clock-sync", 1),
("exec", 1),
("exec-stdin", 1),
("ports-watch", 1),
("sync-push", 1),
("telemetry", 1),
- ]
- .into_iter()
- .map(|(id, version)| agent::AgentCapability {
- id: id.to_string(),
- version,
- })
- .collect()
+ ];
+ if crate::snapshot_quiesce::available() {
+ capabilities.push(("snapshot-quiesce", 1));
+ }
+ capabilities.sort_unstable_by_key(|(id, _)| *id);
+ capabilities
+ .into_iter()
+ .map(|(id, version)| agent::AgentCapability {
+ id: id.to_string(),
+ version,
+ })
+ .collect()
}
pub fn err(code: i32, message: &str) -> agent::AgentResponse {
@@ -190,19 +195,24 @@ mod tests {
assert_eq!(i.proto_version, dory_proto::handshake::PROTO_VERSION);
assert!(i.agent_build.starts_with("dory-agent/"));
assert!(!i.kernel.is_empty());
+ let mut expected_capabilities = vec![
+ ("clock-sync", 1),
+ ("exec", 1),
+ ("exec-stdin", 1),
+ ("ports-watch", 1),
+ ("sync-push", 1),
+ ("telemetry", 1),
+ ];
+ if crate::snapshot_quiesce::available() {
+ expected_capabilities.push(("snapshot-quiesce", 1));
+ expected_capabilities.sort_unstable();
+ }
assert_eq!(
i.capabilities
.iter()
.map(|capability| (capability.id.as_str(), capability.version))
.collect::>(),
- vec![
- ("clock-sync", 1),
- ("exec", 1),
- ("exec-stdin", 1),
- ("ports-watch", 1),
- ("sync-push", 1),
- ("telemetry", 1),
- ]
+ expected_capabilities
);
}
other => panic!("expected Info, got {other:?}"),
diff --git a/dory-core/agent/src/handler.rs b/dory-core/agent/src/handler.rs
index 30907ee5..af71a4ee 100644
--- a/dory-core/agent/src/handler.rs
+++ b/dory-core/agent/src/handler.rs
@@ -10,6 +10,7 @@ use prost::Message;
use crate::dispatch::{err, handle_method};
use crate::exec::{self, ExecError};
+use crate::snapshot_quiesce;
use crate::sync_apply::{self, SyncError};
pub async fn handle(req_bytes: &[u8]) -> Vec {
@@ -26,6 +27,9 @@ pub async fn handle(req_bytes: &[u8]) -> Vec {
Some(Method::SyncPutChunk(r)) => wrap(sync_apply::put_chunk(r).await, Res::SyncPutChunk),
Some(Method::SyncDelete(r)) => wrap(sync_apply::delete(r).await, Res::SyncDelete),
Some(Method::Exec(r)) => wrap_exec(exec::run(r).await),
+ Some(Method::SnapshotQuiesce(r)) => AgentResponse {
+ result: Some(Res::SnapshotQuiesce(snapshot_quiesce::run(r).await)),
+ },
other => handle_method(other),
};
response.encode_to_vec()
diff --git a/dory-core/agent/src/lib.rs b/dory-core/agent/src/lib.rs
index d0bd1bf2..1f20148c 100644
--- a/dory-core/agent/src/lib.rs
+++ b/dory-core/agent/src/lib.rs
@@ -16,6 +16,7 @@ pub mod fsevents;
pub mod handler;
pub mod proc_net;
pub mod reaper;
+pub mod snapshot_quiesce;
pub mod sync_apply;
pub mod telemetry;
diff --git a/dory-core/agent/src/snapshot_quiesce.rs b/dory-core/agent/src/snapshot_quiesce.rs
new file mode 100644
index 00000000..e1b72040
--- /dev/null
+++ b/dory-core/agent/src/snapshot_quiesce.rs
@@ -0,0 +1,77 @@
+use std::path::Path;
+use std::time::Duration;
+
+use dory_pb::agent::{
+ snapshot_quiesce_request::Action, SnapshotQuiesceRequest, SnapshotQuiesceResponse,
+};
+
+const QUIESCE_TIMEOUT: Duration = Duration::from_secs(20);
+
+pub fn available() -> bool {
+ binary().is_some()
+}
+
+pub async fn run(request: SnapshotQuiesceRequest) -> SnapshotQuiesceResponse {
+ let action = match Action::try_from(request.action) {
+ Ok(Action::Freeze) => "-f",
+ Ok(Action::Thaw) => "-u",
+ _ => return response(false, "snapshot quiesce action is invalid"),
+ };
+ let Some(binary) = binary() else {
+ return response(false, "filesystem freeze is unavailable in this guest");
+ };
+
+ let mut command = tokio::process::Command::new(binary);
+ command.args([action, "/"]).kill_on_drop(true);
+ match tokio::time::timeout(QUIESCE_TIMEOUT, command.output()).await {
+ Ok(Ok(output)) if output.status.success() => response(true, ""),
+ Ok(Ok(output)) => {
+ let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ response(
+ false,
+ if detail.is_empty() {
+ "filesystem freeze command failed"
+ } else {
+ detail.as_str()
+ },
+ )
+ }
+ Ok(Err(error)) => response(false, &format!("filesystem freeze command failed: {error}")),
+ Err(_) => response(false, "filesystem freeze command timed out"),
+ }
+}
+
+fn response(completed: bool, detail: &str) -> SnapshotQuiesceResponse {
+ SnapshotQuiesceResponse {
+ completed,
+ detail: detail.chars().take(512).collect(),
+ }
+}
+
+#[cfg(target_os = "linux")]
+fn binary() -> Option<&'static str> {
+ ["/usr/sbin/fsfreeze", "/sbin/fsfreeze"]
+ .into_iter()
+ .find(|candidate| Path::new(candidate).is_file())
+}
+
+#[cfg(not(target_os = "linux"))]
+fn binary() -> Option<&'static str> {
+ let _ = Path::new("/");
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn rejects_unspecified_action_without_touching_the_host() {
+ let result = run(SnapshotQuiesceRequest {
+ action: Action::Unspecified as i32,
+ })
+ .await;
+ assert!(!result.completed);
+ assert!(result.detail.contains("invalid"));
+ }
+}
diff --git a/dory-core/ffi/src/agent_forward.rs b/dory-core/ffi/src/agent_forward.rs
index 262d10e1..fb75783f 100644
--- a/dory-core/ffi/src/agent_forward.rs
+++ b/dory-core/ffi/src/agent_forward.rs
@@ -164,6 +164,17 @@ impl AgentControl {
})
}
+ pub fn snapshot_freeze(&self) -> Result<(), RemoteFfiError> {
+ snapshot_quiesce(
+ self,
+ dory_pb::agent::snapshot_quiesce_request::Action::Freeze,
+ )
+ }
+
+ pub fn snapshot_thaw(&self) -> Result<(), RemoteFfiError> {
+ snapshot_quiesce(self, dory_pb::agent::snapshot_quiesce_request::Action::Thaw)
+ }
+
pub fn exec(
&self,
argv: Vec,
@@ -207,6 +218,24 @@ impl AgentControl {
}
}
+fn snapshot_quiesce(
+ control: &AgentControl,
+ action: dory_pb::agent::snapshot_quiesce_request::Action,
+) -> Result<(), RemoteFfiError> {
+ let guard = control.runtime.lock().unwrap();
+ let runtime = guard.as_ref().ok_or_else(shutdown_error)?;
+ let result = runtime.block_on(control.client.snapshot_quiesce(action))?;
+ if result.completed {
+ Ok(())
+ } else {
+ Err(failed(if result.detail.is_empty() {
+ "guest declined snapshot quiesce"
+ } else {
+ result.detail.as_str()
+ }))
+ }
+}
+
impl Drop for AgentControl {
fn drop(&mut self) {
if let Some(runtime) = self.runtime.lock().unwrap().take() {
diff --git a/dory-core/pb/proto/agent.proto b/dory-core/pb/proto/agent.proto
index 451b2567..0e81fab4 100644
--- a/dory-core/pb/proto/agent.proto
+++ b/dory-core/pb/proto/agent.proto
@@ -58,6 +58,22 @@ message TelemetryResponse {
double psi_full_avg10 = 4; // memory-pressure "full" 10s average, percent (0..100)
}
+// Coordinate application-consistent snapshots. The agent advertises `snapshot-quiesce@1` only
+// when the guest contains a usable filesystem-freeze implementation. Freeze and thaw are one
+// negotiated capability because callers must always be able to undo a failed snapshot attempt.
+message SnapshotQuiesceRequest {
+ enum Action {
+ ACTION_UNSPECIFIED = 0;
+ FREEZE = 1;
+ THAW = 2;
+ }
+ Action action = 1;
+}
+message SnapshotQuiesceResponse {
+ bool completed = 1;
+ string detail = 2;
+}
+
// Run a bounded non-interactive command in the guest. This is the primitive doryd uses for
// VM-machine provisioning and smoke checks; streaming terminals/debug shells can layer on a
// separate channel later.
@@ -147,6 +163,7 @@ message AgentRequest {
SyncDeleteRequest sync_delete = 7;
TelemetryRequest telemetry = 8;
ExecRequest exec = 9;
+ SnapshotQuiesceRequest snapshot_quiesce = 10;
}
}
@@ -162,5 +179,6 @@ message AgentResponse {
SyncDeleteResponse sync_delete = 8;
TelemetryResponse telemetry = 9;
ExecResponse exec = 10;
+ SnapshotQuiesceResponse snapshot_quiesce = 11;
}
}
diff --git a/dory-core/remote/src/agent_client.rs b/dory-core/remote/src/agent_client.rs
index cd269dca..240218ec 100644
--- a/dory-core/remote/src/agent_client.rs
+++ b/dory-core/remote/src/agent_client.rs
@@ -12,9 +12,9 @@ use std::time::Duration;
use dory_pb::agent::{
self, agent_request::Method, agent_response::Result as Res, AgentRequest, AgentResponse,
ClockSyncRequest, ExecEnv, ExecRequest, ExecResponse, InfoRequest, PortsWatchRequest,
- SyncDeleteRequest, SyncDeleteResponse, SyncFileStatusRequest, SyncFileStatusResponse,
- SyncManifestRequest, SyncManifestResponse, SyncPutChunkRequest, SyncPutChunkResponse,
- TelemetryRequest, TelemetryResponse,
+ SnapshotQuiesceRequest, SnapshotQuiesceResponse, SyncDeleteRequest, SyncDeleteResponse,
+ SyncFileStatusRequest, SyncFileStatusResponse, SyncManifestRequest, SyncManifestResponse,
+ SyncPutChunkRequest, SyncPutChunkResponse, TelemetryRequest, TelemetryResponse,
};
use dory_proto::handshake::{handshake, Hello};
use dory_proto::mux::Mux;
@@ -126,6 +126,21 @@ impl AgentClient {
}
}
+ pub async fn snapshot_quiesce(
+ &self,
+ action: agent::snapshot_quiesce_request::Action,
+ ) -> Result {
+ match self
+ .call(Method::SnapshotQuiesce(SnapshotQuiesceRequest {
+ action: action as i32,
+ }))
+ .await?
+ {
+ Res::SnapshotQuiesce(r) => Ok(r),
+ _ => Err(RemoteError::UnexpectedVariant),
+ }
+ }
+
pub async fn exec(
&self,
argv: Vec,
From 7588ce378ff0eeb2da094bc3db061430ed9da359 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 23:44:49 +0000
Subject: [PATCH 065/338] feat(snapshot): surface consistency evidence
---
Dory/Features/Machines/SnapshotsSheet.swift | 6 ++
Dory/Models/AppStore.swift | 3 +-
Dory/Runtime/Doryd/DorydClient.swift | 20 ++++-
Dory/Runtime/Machines/MachineSnapshot.swift | 5 +-
DoryTests/DorydClientTests.swift | 78 +++++++++++++------
.../Sources/DorydKit/DorydService.swift | 1 +
.../DorydKitTests/DorydServiceTests.swift | 5 ++
7 files changed, 91 insertions(+), 27 deletions(-)
diff --git a/Dory/Features/Machines/SnapshotsSheet.swift b/Dory/Features/Machines/SnapshotsSheet.swift
index 1eb34840..abd193cd 100644
--- a/Dory/Features/Machines/SnapshotsSheet.swift
+++ b/Dory/Features/Machines/SnapshotsSheet.swift
@@ -238,6 +238,12 @@ struct SnapshotsSheet: View {
Text(relativeTime(snapshot.createdISO)).font(.system(size: 11)).foregroundStyle(p.text3)
Text("·").font(.system(size: 11)).foregroundStyle(p.text3)
Text(DockerFormat.bytes(snapshot.sizeBytes)).font(.mono(11)).foregroundStyle(p.text3)
+ if let consistency = snapshot.consistency {
+ Text("·").font(.system(size: 11)).foregroundStyle(p.text3)
+ Text(consistency == .guestQuiesced ? "Guest quiesced" : "Cold stopped")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(consistency == .guestQuiesced ? p.green : p.text3)
+ }
}
}
Spacer(minLength: 8)
diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift
index 12147c08..90df43a1 100644
--- a/Dory/Models/AppStore.swift
+++ b/Dory/Models/AppStore.swift
@@ -6148,7 +6148,8 @@ final class AppStore {
boot: "vz",
recipe: "doryd",
runtimeIdentity: snapshot.runtimeIdentity,
- artifactEvidence: snapshot.artifactEvidence
+ artifactEvidence: snapshot.artifactEvidence,
+ consistency: snapshot.consistency
)
}
diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift
index 36bdaa5f..1e2868e4 100644
--- a/Dory/Runtime/Doryd/DorydClient.swift
+++ b/Dory/Runtime/Doryd/DorydClient.swift
@@ -747,6 +747,11 @@ nonisolated struct DorydMachineSnapshotArtifactEvidence: Codable, Sendable, Equa
}
}
+nonisolated enum DorydMachineSnapshotConsistency: String, Sendable, Equatable, Hashable {
+ case coldStopped = "cold-stopped"
+ case guestQuiesced = "guest-quiesced"
+}
+
nonisolated struct DorydAgentCapability: Sendable, Equatable, Hashable {
var id: String
var version: UInt32
@@ -839,6 +844,7 @@ nonisolated struct DorydMachineSnapshot: Sendable, Equatable {
var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility
var artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil
var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil
+ var consistency: DorydMachineSnapshotConsistency = .coldStopped
}
nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable {
@@ -2327,6 +2333,7 @@ nonisolated final class DorydClient: @unchecked Sendable {
let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt(
from: dictionary
),
+ let consistency = machineSnapshotConsistency(from: dictionary),
let artifactEvidence = machineSnapshotArtifactEvidence(
from: dictionary,
runtimeIdentity: runtimeIdentity
@@ -2346,10 +2353,21 @@ nonisolated final class DorydClient: @unchecked Sendable {
cpuCount: cpuCount,
runtimeIdentity: runtimeIdentity,
artifactEvidence: artifactEvidence.value,
- installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value
+ installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value,
+ consistency: consistency
)
}
+ /// Absence preserves compatibility with older daemons. Once the field is present, its type
+ /// and value are closed so invented consistency claims cannot be displayed as trusted facts.
+ nonisolated private static func machineSnapshotConsistency(
+ from dictionary: NSDictionary
+ ) -> DorydMachineSnapshotConsistency? {
+ guard let encoded = dictionary["consistency"] else { return .coldStopped }
+ guard let rawValue = encoded as? String else { return nil }
+ return DorydMachineSnapshotConsistency(rawValue: rawValue)
+ }
+
private struct ParsedMachineSnapshotArtifactEvidence {
var value: DorydMachineSnapshotArtifactEvidence?
}
diff --git a/Dory/Runtime/Machines/MachineSnapshot.swift b/Dory/Runtime/Machines/MachineSnapshot.swift
index aee8238c..c1a8f166 100644
--- a/Dory/Runtime/Machines/MachineSnapshot.swift
+++ b/Dory/Runtime/Machines/MachineSnapshot.swift
@@ -18,13 +18,15 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable {
let loginShell: String
let runtimeIdentity: DorydMachineRuntimeIdentity
let artifactEvidence: DorydMachineSnapshotArtifactEvidence?
+ let consistency: DorydMachineSnapshotConsistency?
nonisolated init(id: String, imageRef: String, machineName: String, note: String, createdISO: String,
sizeBytes: Int64, distro: String, version: String, arch: String, boot: String,
recipe: String, username: String = "root", uid: Int? = nil, homePath: String? = nil,
loginShell: String = "/bin/sh",
runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility,
- artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil) {
+ artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil,
+ consistency: DorydMachineSnapshotConsistency? = nil) {
self.id = id
self.imageRef = imageRef
self.machineName = machineName
@@ -42,6 +44,7 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable {
self.loginShell = loginShell
self.runtimeIdentity = runtimeIdentity
self.artifactEvidence = artifactEvidence
+ self.consistency = consistency
}
}
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 406a95e0..62cf9e43 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -391,6 +391,7 @@ struct DorydClientTests {
#expect(snapshot.id == "s1")
#expect(snapshot.machineID == "dev")
#expect(snapshot.runtimeIdentity == .legacyCompatibility)
+ #expect(snapshot.consistency == .coldStopped)
#expect(snapshots.map(\.id).contains("s1"))
#expect(clonedSnapshot.id == "dev-copy")
#expect(restoredSnapshot.id == "dev")
@@ -671,6 +672,43 @@ struct DorydClientTests {
}
}
+ @Test func snapshotConsistencyDefaultsOnlyWhenAbsentAndRejectsMalformedClaims() async throws {
+ let validService = FakeDorydService(snapshotConsistencyOverride: "guest-quiesced")
+ let validListener = NSXPCListener.anonymous()
+ let validDelegate = FakeDorydListenerDelegate(service: validService)
+ validListener.delegate = validDelegate
+ validListener.resume()
+ defer { validListener.invalidate() }
+ let valid = try await DorydClient(endpoint: validListener.endpoint).machineSnapshot(
+ "dev",
+ note: "consistent",
+ createdISO: "2026-07-07T00:00:00Z",
+ snapshotID: "consistent"
+ )
+ #expect(valid.consistency == .guestQuiesced)
+
+ for malformed in ["crash-consistent" as Any, 1 as Any] {
+ let listener = NSXPCListener.anonymous()
+ let service = FakeDorydService(snapshotConsistencyOverride: malformed)
+ let delegate = FakeDorydListenerDelegate(service: service)
+ listener.delegate = delegate
+ listener.resume()
+ let client = DorydClient(endpoint: listener.endpoint)
+ do {
+ _ = try await client.machineSnapshot(
+ "dev",
+ note: "invalid consistency",
+ createdISO: "2026-07-07T00:00:00Z",
+ snapshotID: "invalid-consistency"
+ )
+ Issue.record("present malformed snapshot consistency must fail closed")
+ } catch let error as DorydClientError {
+ #expect(error.description.contains("invalid doryd response"))
+ }
+ listener.invalidate()
+ }
+ }
+
@Test func installedDesktopPayloadReceiptUsesAbsentOnlyLegacyCompatibilityAndRejectsMalformedClaims() async throws {
let digest = String(repeating: "a", count: 64)
let valid: [String: Any] = [
@@ -1157,6 +1195,7 @@ struct DorydClientTests {
let snapshot = try #require(store.machineSnapshots.first)
#expect(snapshot.note == "before upgrade")
#expect(snapshot.imageRef.hasPrefix("doryd://dev/"))
+ #expect(snapshot.consistency == .coldStopped)
service.setMachineCloneSnapshotDuplicateFailures(1)
store.cloneSnapshot(snapshot)
@@ -2481,6 +2520,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
private var runtimeIdentityOverride: NSDictionary?
private var artifactEvidenceOverride: NSDictionary?
private var installedDesktopPayloadReceiptOverride: NSDictionary?
+ private var snapshotConsistencyOverride: Any?
private var snapshots: [String: [NSDictionary]] = [:]
private var backupStatuses: [String: NSDictionary] = [:]
var engineStartCount: Int {
@@ -2631,13 +2671,15 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
engineShutdownReplyDelay: TimeInterval = 0,
runtimeIdentityOverride: NSDictionary? = nil,
artifactEvidenceOverride: NSDictionary? = nil,
- installedDesktopPayloadReceiptOverride: NSDictionary? = nil
+ installedDesktopPayloadReceiptOverride: NSDictionary? = nil,
+ snapshotConsistencyOverride: Any? = nil
) {
self.socketPath = socketPath
self.engineShutdownReplyDelay = engineShutdownReplyDelay
self.runtimeIdentityOverride = runtimeIdentityOverride
self.artifactEvidenceOverride = artifactEvidenceOverride
self.installedDesktopPayloadReceiptOverride = installedDesktopPayloadReceiptOverride
+ self.snapshotConsistencyOverride = snapshotConsistencyOverride
if let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary {
if let runtimeIdentityOverride {
existing["runtimeIdentity"] = runtimeIdentityOverride
@@ -3031,34 +3073,22 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
createdISO: request["createdISO"] as? String ?? "2026-07-07T00:00:00Z"
)
lock.lock()
- let row: NSDictionary
- if let runtimeIdentityOverride,
- let mutable = baseRow.mutableCopy() as? NSMutableDictionary {
+ let mutable = baseRow.mutableCopy() as? NSMutableDictionary
+ ?? NSMutableDictionary(dictionary: baseRow)
+ if let runtimeIdentityOverride {
mutable["runtimeIdentity"] = runtimeIdentityOverride
- if let artifactEvidenceOverride {
- mutable["artifactEvidence"] = artifactEvidenceOverride
- }
- if let installedDesktopPayloadReceiptOverride {
- mutable["installedDesktopPayloadReceipt"] =
- installedDesktopPayloadReceiptOverride
- }
- row = mutable.copy() as? NSDictionary ?? baseRow
- } else if let artifactEvidenceOverride,
- let mutable = baseRow.mutableCopy() as? NSMutableDictionary {
+ }
+ if let artifactEvidenceOverride {
mutable["artifactEvidence"] = artifactEvidenceOverride
- if let installedDesktopPayloadReceiptOverride {
- mutable["installedDesktopPayloadReceipt"] =
- installedDesktopPayloadReceiptOverride
- }
- row = mutable.copy() as? NSDictionary ?? baseRow
- } else if let installedDesktopPayloadReceiptOverride,
- let mutable = baseRow.mutableCopy() as? NSMutableDictionary {
+ }
+ if let installedDesktopPayloadReceiptOverride {
mutable["installedDesktopPayloadReceipt"] =
installedDesktopPayloadReceiptOverride
- row = mutable.copy() as? NSDictionary ?? baseRow
- } else {
- row = baseRow
}
+ if let snapshotConsistencyOverride {
+ mutable["consistency"] = snapshotConsistencyOverride
+ }
+ let row = mutable.copy() as? NSDictionary ?? baseRow
_machineSnapshotCount += 1
snapshots[machineID, default: []].insert(row, at: 0)
lock.unlock()
diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift
index 2e5097c2..e9f627f9 100644
--- a/dory-core-swift/Sources/DorydKit/DorydService.swift
+++ b/dory-core-swift/Sources/DorydKit/DorydService.swift
@@ -1909,6 +1909,7 @@ private extension DoryMachineSnapshot {
"memoryMB": memoryMB,
"cpuCount": cpuCount,
"displayMode": displayMode.rawValue,
+ "consistency": consistency.rawValue,
"runtimeIdentity": runtimeIdentity.xpcDictionary,
]
if let artifactEvidence {
diff --git a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
index 00b3736e..1a8ef360 100644
--- a/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/DorydServiceTests.swift
@@ -1799,6 +1799,7 @@ final class DorydServiceTests: XCTestCase {
let runtime = body["runtimeIdentity"] as? NSDictionary
XCTAssertEqual(runtime?["mode"] as? String, "legacy-compatibility")
XCTAssertEqual((runtime?["virtualHardwareABIVersion"] as? NSNumber)?.uint16Value, 1)
+ XCTAssertEqual(body["consistency"] as? String, "cold-stopped")
let artifacts = body["artifactEvidence"] as? NSDictionary
XCTAssertEqual(
((artifacts?["rootfs"] as? NSDictionary)?["sha256"] as? String)?.count,
@@ -1812,6 +1813,10 @@ final class DorydServiceTests: XCTestCase {
proxy.machineSnapshots("dev") { rows, message in
XCTAssertEqual(message, "")
XCTAssertEqual((rows as? [NSDictionary])?.first?["id"] as? String, "s1")
+ XCTAssertEqual(
+ (rows as? [NSDictionary])?.first?["consistency"] as? String,
+ "cold-stopped"
+ )
listReply.fulfill()
}
wait(for: [listReply], timeout: 5)
From 247679b0ccae79259e2ff0cfeff86d50282afaab Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Thu, 20 Aug 2026 23:48:01 +0000
Subject: [PATCH 066/338] feat(tools): require snapshot quiesce health
---
Dory/Models/Models.swift | 9 ++++++++-
DoryTests/DorydClientTests.swift | 6 ++++--
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift
index 0eadc18c..7fde2b39 100644
--- a/Dory/Models/Models.swift
+++ b/Dory/Models/Models.swift
@@ -413,7 +413,14 @@ struct Machine: Identifiable, Hashable, Sendable {
)
}
let versions = Dictionary(uniqueKeysWithValues: agentCapabilities.map { ($0.id, $0.version) })
- let required = ["clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry"]
+ let required = [
+ "clock-sync",
+ "exec",
+ "exec-stdin",
+ "ports-watch",
+ "snapshot-quiesce",
+ "telemetry",
+ ]
let missing = required.filter { (versions[$0] ?? 0) < 1 }
guard missing.isEmpty else {
return MachineRuntimeEvidence(
diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift
index 62cf9e43..5809abc1 100644
--- a/DoryTests/DorydClientTests.swift
+++ b/DoryTests/DorydClientTests.swift
@@ -552,7 +552,8 @@ struct DorydClientTests {
#expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d")
#expect(machine.agentProtocolVersion == 1)
#expect(machine.agentCapabilities.map(\.id) == [
- "clock-sync", "exec", "exec-stdin", "ports-watch", "sync-push", "telemetry",
+ "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "sync-push",
+ "telemetry",
])
#expect(machine.runtimeEvidence.map(\.label) == [
"Supported", "Raw HV", "Qualified 3D", "Tools ready",
@@ -600,7 +601,7 @@ struct DorydClientTests {
let valid = try #require((try await client.machineList()).first)
#expect(valid.agentProtocolVersion == 1)
- #expect(valid.agentCapabilities.count == 6)
+ #expect(valid.agentCapabilities.count == 7)
let malformed: [(Any?, Any?)] = [
(nil, [["id": "exec", "version": 1] as NSDictionary]),
@@ -3521,6 +3522,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC {
["id": "exec", "version": 1] as NSDictionary,
["id": "exec-stdin", "version": 1] as NSDictionary,
["id": "ports-watch", "version": 1] as NSDictionary,
+ ["id": "snapshot-quiesce", "version": 1] as NSDictionary,
["id": "sync-push", "version": 1] as NSDictionary,
["id": "telemetry", "version": 1] as NSDictionary,
],
From c63c681c75b4fefbe48d658157f4060cb6f248a5 Mon Sep 17 00:00:00 2001
From: Augustus Otu
Date: Fri, 21 Aug 2026 00:04:30 +0000
Subject: [PATCH 067/338] feat(snapshot): bind guest quiesce receipts
---
.../Sources/DoryCore/DoryCore.swift | 8 +-
.../Sources/DorydKit/AgentControl.swift | 30 ++--
.../Sources/DorydKit/MachineManager.swift | 163 +++++++++++++++---
.../DorydKitTests/AgentControlTests.swift | 52 +++++-
.../DorydKitTests/MachineManagerTests.swift | 38 +++-
dory-core/agent/src/dispatch.rs | 4 +-
dory-core/agent/src/snapshot_quiesce.rs | 156 +++++++++++++++--
dory-core/ffi/src/agent_forward.rs | 19 +-
dory-core/pb/proto/agent.proto | 5 +
dory-core/remote/src/agent_client.rs | 2 +
10 files changed, 410 insertions(+), 67 deletions(-)
diff --git a/dory-core-swift/Sources/DoryCore/DoryCore.swift b/dory-core-swift/Sources/DoryCore/DoryCore.swift
index f03e37c3..bf323546 100644
--- a/dory-core-swift/Sources/DoryCore/DoryCore.swift
+++ b/dory-core-swift/Sources/DoryCore/DoryCore.swift
@@ -397,12 +397,12 @@ public final class DoryAgentControlHandle: @unchecked Sendable {
)
}
- public func snapshotFreeze() throws {
- try withControl { try $0.snapshotFreeze() }
+ public func snapshotFreeze(receiptID: String) throws -> String {
+ try withControl { try $0.snapshotFreeze(receiptId: receiptID) }
}
- public func snapshotThaw() throws {
- try withControl { try $0.snapshotThaw() }
+ public func snapshotThaw(receiptID: String) throws {
+ try withControl { try $0.snapshotThaw(receiptId: receiptID) }
}
public func exec(
diff --git a/dory-core-swift/Sources/DorydKit/AgentControl.swift b/dory-core-swift/Sources/DorydKit/AgentControl.swift
index cbc827f3..46658d1a 100644
--- a/dory-core-swift/Sources/DorydKit/AgentControl.swift
+++ b/dory-core-swift/Sources/DorydKit/AgentControl.swift
@@ -25,8 +25,8 @@ public protocol AgentControlClient: Sendable {
func clockSync(hostEpochNs: Int64) throws -> Bool
func portsWatch() throws -> DoryPortsSnapshot
func telemetry() throws -> DoryTelemetry
- func snapshotFreeze() throws
- func snapshotThaw() throws
+ func snapshotFreeze(receiptID: String) throws -> String
+ func snapshotThaw(receiptID: String) throws
func exec(
argv: [String],
cwd: String,
@@ -46,11 +46,13 @@ public protocol AgentControlClient: Sendable {
}
public extension AgentControlClient {
- func snapshotFreeze() throws {
+ func snapshotFreeze(receiptID: String) throws -> String {
+ _ = receiptID
throw AgentControlError.capabilityUnavailable("snapshot-quiesce")
}
- func snapshotThaw() throws {
+ func snapshotThaw(receiptID: String) throws {
+ _ = receiptID
throw AgentControlError.capabilityUnavailable("snapshot-quiesce")
}
@@ -129,12 +131,14 @@ public final class AgentControl: @unchecked Sendable {
try client(requiring: "telemetry").telemetry()
}
- public func snapshotFreeze() throws {
- try client(requiring: "snapshot-quiesce").snapshotFreeze()
+ public func snapshotFreeze(receiptID: String) throws -> String {
+ try client(requiring: "snapshot-quiesce", minimumVersion: 2)
+ .snapshotFreeze(receiptID: receiptID)
}
- public func snapshotThaw() throws {
- try client(requiring: "snapshot-quiesce").snapshotThaw()
+ public func snapshotThaw(receiptID: String) throws {
+ try client(requiring: "snapshot-quiesce", minimumVersion: 2)
+ .snapshotThaw(receiptID: receiptID)
}
public func exec(
@@ -203,14 +207,18 @@ public final class AgentControl: @unchecked Sendable {
return existing
}
- private func client(requiring capability: String) throws -> any AgentControlClient {
+ private func client(
+ requiring capability: String,
+ minimumVersion: UInt32 = 1
+ ) throws -> any AgentControlClient {
let client = try connectedClient()
- try requireCapability(capability, from: client)
+ try requireCapability(capability, minimumVersion: minimumVersion, from: client)
return client
}
private func requireCapability(
_ capability: String,
+ minimumVersion: UInt32 = 1,
from client: any AgentControlClient
) throws {
let info = try cachedInfo(from: client)
@@ -223,7 +231,7 @@ public final class AgentControl: @unchecked Sendable {
guard info.capabilitiesAreCanonical else {
throw AgentControlError.invalidCapabilities
}
- guard info.supports(capability) else {
+ guard info.supports(capability, minimumVersion: minimumVersion) else {
throw AgentControlError.capabilityUnavailable(capability)
}
}
diff --git a/dory-core-swift/Sources/DorydKit/MachineManager.swift b/dory-core-swift/Sources/DorydKit/MachineManager.swift
index 8cf640f0..eb963d65 100644
--- a/dory-core-swift/Sources/DorydKit/MachineManager.swift
+++ b/dory-core-swift/Sources/DorydKit/MachineManager.swift
@@ -401,6 +401,41 @@ public enum DoryMachineSnapshotConsistency: String, Sendable, Equatable, Hashabl
case guestQuiesced = "guest-quiesced"
}
+public struct DoryMachineSnapshotQuiesceReceipt: Sendable, Equatable, Hashable, Codable {
+ public var schemaVersion: UInt16
+ public var receiptID: String
+ public var agentBuild: String
+ public var agentProtocolVersion: UInt32
+ public var capabilityVersion: UInt32
+
+ public init(
+ schemaVersion: UInt16 = 1,
+ receiptID: String,
+ agentBuild: String,
+ agentProtocolVersion: UInt32,
+ capabilityVersion: UInt32
+ ) {
+ self.schemaVersion = schemaVersion
+ self.receiptID = receiptID
+ self.agentBuild = agentBuild
+ self.agentProtocolVersion = agentProtocolVersion
+ self.capabilityVersion = capabilityVersion
+ }
+
+ public var isValid: Bool {
+ schemaVersion == 1
+ && receiptID.utf8.count == 32
+ && receiptID.utf8.allSatisfy {
+ ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x61 && $0 <= 0x66)
+ }
+ && !agentBuild.isEmpty
+ && agentBuild.utf8.count <= 128
+ && agentBuild.utf8.allSatisfy { $0 >= 0x20 && $0 <= 0x7e }
+ && agentProtocolVersion == DoryCore.protocolVersion()
+ && capabilityVersion >= 2
+ }
+}
+
public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
public var id: String
public var machineID: String
@@ -424,6 +459,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
public var artifactEvidence: DoryMachineSnapshotArtifactEvidence?
public var installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt?
public var consistency: DoryMachineSnapshotConsistency
+ public var guestQuiesceReceipt: DoryMachineSnapshotQuiesceReceipt?
public init(
id: String,
@@ -450,7 +486,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
),
artifactEvidence: DoryMachineSnapshotArtifactEvidence? = nil,
installedDesktopPayloadReceipt: DoryInstalledDesktopPayloadReceipt? = nil,
- consistency: DoryMachineSnapshotConsistency = .coldStopped
+ consistency: DoryMachineSnapshotConsistency = .coldStopped,
+ guestQuiesceReceipt: DoryMachineSnapshotQuiesceReceipt? = nil
) {
self.id = id
self.machineID = machineID
@@ -474,6 +511,7 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
self.artifactEvidence = artifactEvidence
self.installedDesktopPayloadReceipt = installedDesktopPayloadReceipt
self.consistency = consistency
+ self.guestQuiesceReceipt = guestQuiesceReceipt
}
private enum CodingKeys: String, CodingKey {
@@ -499,10 +537,27 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
case artifactEvidence
case installedDesktopPayloadReceipt
case consistency
+ case guestQuiesceReceipt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
+ let consistency = try container.decodeIfPresent(
+ DoryMachineSnapshotConsistency.self,
+ forKey: .consistency
+ ) ?? .coldStopped
+ let guestQuiesceReceipt = try container.decodeIfPresent(
+ DoryMachineSnapshotQuiesceReceipt.self,
+ forKey: .guestQuiesceReceipt
+ )
+ guard guestQuiesceReceipt?.isValid ?? true,
+ (consistency == .guestQuiesced) == (guestQuiesceReceipt != nil) else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .guestQuiesceReceipt,
+ in: container,
+ debugDescription: "snapshot consistency and guest quiesce receipt disagree"
+ )
+ }
self.init(
id: try container.decode(String.self, forKey: .id),
machineID: try container.decode(String.self, forKey: .machineID),
@@ -540,10 +595,8 @@ public struct DoryMachineSnapshot: Sendable, Equatable, Hashable, Codable {
DoryInstalledDesktopPayloadReceipt.self,
forKey: .installedDesktopPayloadReceipt
),
- consistency: try container.decodeIfPresent(
- DoryMachineSnapshotConsistency.self,
- forKey: .consistency
- ) ?? .coldStopped
+ consistency: consistency,
+ guestQuiesceReceipt: guestQuiesceReceipt
)
}
}
@@ -3084,8 +3137,11 @@ public final class MachineManager: @unchecked Sendable {
// Prefer a negotiated guest freeze before the durable cold-stop boundary. A missing tools
// capability degrades to the existing cold snapshot; an advertised capability that fails
// is not silently ignored because the guest may already be partially frozen.
- let guestQuiesced = sourceMachineState == .running
- ? try freezeGuestForSnapshotIfSupported(id: id) : false
+ let guestQuiesceReceipt = sourceMachineState == .running
+ ? try freezeGuestForSnapshotIfSupported(
+ id: id,
+ resolvedPlan: snapshotRuntimeIdentity.resolvedPlan
+ ) : nil
if wasResident {
do {
_ = try stopImplementation(
@@ -3094,8 +3150,13 @@ public final class MachineManager: @unchecked Sendable {
preserveResolvedAdmissionForRestart: true
)
} catch {
- if guestQuiesced {
- do { try thawGuestAfterFailedSnapshotStop(id: id) }
+ if let guestQuiesceReceipt {
+ do {
+ try thawGuestAfterFailedSnapshotStop(
+ id: id,
+ receiptID: guestQuiesceReceipt.receiptID
+ )
+ }
catch let thawError {
throw MachineManagerError.persistence(
"could not stop \(id) for snapshot: \(error); guest thaw failed: \(thawError)"
@@ -3160,7 +3221,8 @@ public final class MachineManager: @unchecked Sendable {
artifactEvidence: artifactEvidence,
installedDesktopPayloadReceipt:
machine.effectiveInstalledDesktopPayloadReceipt,
- consistency: guestQuiesced ? .guestQuiesced : .coldStopped
+ consistency: guestQuiesceReceipt == nil ? .coldStopped : .guestQuiesced,
+ guestQuiesceReceipt: guestQuiesceReceipt
)
let snapshotAuthority = try Self.lifecycleSnapshotAuthority(snapshot)
let lifecycle = try beginLifecycleSnapshot(
@@ -3258,32 +3320,83 @@ public final class MachineManager: @unchecked Sendable {
return snapshot
}
- private func freezeGuestForSnapshotIfSupported(id: String) throws -> Bool {
- guard status(id: id)?.supportsAgentCapability("snapshot-quiesce") == true else {
- return false
+ private func freezeGuestForSnapshotIfSupported(
+ id: String,
+ resolvedPlan: DoryResolvedMachinePlan?
+ ) throws -> DoryMachineSnapshotQuiesceReceipt? {
+ guard let status = status(id: id),
+ status.supportsAgentCapability("snapshot-quiesce", minimumVersion: 2),
+ let agentBuild = status.agentBuild,
+ let protocolVersion = status.agentProtocolVersion,
+ let capabilityVersion = status.agentCapabilities.first(where: {
+ $0.id == "snapshot-quiesce"
+ })?.version else {
+ return nil
}
+ let requestedReceiptID = UUID().uuidString
+ .replacingOccurrences(of: "-", with: "")
+ .lowercased()
do {
- try withAgentClient(id: id, requiredCapability: "snapshot-quiesce") { client in
- try client.snapshotFreeze()
+ let receiptID = try withAgentClient(
+ id: id,
+ requiredCapability: "snapshot-quiesce",
+ minimumCapabilityVersion: 2
+ ) { client in
+ try client.snapshotFreeze(receiptID: requestedReceiptID)
+ }
+ let receipt = DoryMachineSnapshotQuiesceReceipt(
+ receiptID: receiptID,
+ agentBuild: agentBuild,
+ agentProtocolVersion: protocolVersion,
+ capabilityVersion: capabilityVersion
+ )
+ guard receipt.isValid, receiptID == requestedReceiptID else {
+ throw MachineManagerError.persistence(
+ "guest snapshot freeze returned an invalid receipt for \(id)"
+ )
}
+ return receipt
} catch {
let freezeError = error
- do { try thawGuestAfterFailedSnapshotStop(id: id) }
- catch let thawError {
+ do {
+ try thawGuestAfterFailedSnapshotStop(
+ id: id,
+ receiptID: requestedReceiptID
+ )
+ } catch let thawError {
+ do {
+ _ = try stopImplementation(
+ id: id,
+ journalLifecycle: true,
+ preserveResolvedAdmissionForRestart: true
+ )
+ try restoreSnapshotSourcePowerState(
+ id: id,
+ wasPaused: false,
+ resolvedPlan: resolvedPlan
+ )
+ } catch let recoveryError {
+ throw MachineManagerError.persistence(
+ "guest snapshot freeze failed for \(id): \(freezeError); recovery thaw failed: \(thawError); forced restart failed: \(recoveryError)"
+ )
+ }
throw MachineManagerError.persistence(
- "guest snapshot freeze failed for \(id): \(freezeError); thaw failed: \(thawError)"
+ "guest snapshot freeze failed for \(id): \(freezeError); recovery thaw failed: \(thawError); the guest was restarted"
)
}
throw MachineManagerError.persistence(
"guest snapshot freeze failed for \(id): \(freezeError)"
)
}
- return true
}
- private func thawGuestAfterFailedSnapshotStop(id: String) throws {
- try withAgentClient(id: id, requiredCapability: "snapshot-quiesce") { client in
- try client.snapshotThaw()
+ private func thawGuestAfterFailedSnapshotStop(id: String, receiptID: String) throws {
+ try withAgentClient(
+ id: id,
+ requiredCapability: "snapshot-quiesce",
+ minimumCapabilityVersion: 2
+ ) { client in
+ try client.snapshotThaw(receiptID: receiptID)
}
}
@@ -4896,6 +5009,7 @@ public final class MachineManager: @unchecked Sendable {
private func withAgentClient(
id: String,
requiredCapability: String? = nil,
+ minimumCapabilityVersion: UInt32 = 1,
_ operation: (any AgentControlClient) throws -> T
) throws -> T {
guard let status = status(id: id) else {
@@ -4905,7 +5019,10 @@ public final class MachineManager: @unchecked Sendable {
throw MachineManagerError.agentUnavailable(id)
}
if let requiredCapability,
- !status.supportsAgentCapability(requiredCapability) {
+ !status.supportsAgentCapability(
+ requiredCapability,
+ minimumVersion: minimumCapabilityVersion
+ ) {
throw MachineManagerError.agentCapabilityUnavailable(id, requiredCapability)
}
let client = try agentConnector(socketPath)
diff --git a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
index e36ca6cc..70e3a21c 100644
--- a/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/AgentControlTests.swift
@@ -18,7 +18,7 @@ final class AgentControlTests: XCTestCase {
let info = try control.info()
XCTAssertEqual(info.agentBuild, "fake-agent")
XCTAssertEqual(info.capabilities.map(\.id), [
- "clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry",
+ "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "telemetry",
])
XCTAssertEqual(counter.value, 1)
@@ -26,6 +26,11 @@ final class AgentControlTests: XCTestCase {
XCTAssertEqual(fake.clockSyncInputs, [1_500_000_000])
XCTAssertEqual(try control.portsWatch().ports.first?.port, 8080)
XCTAssertEqual(try control.telemetry().memTotalKB, 1024)
+ let receiptID = String(repeating: "a", count: 32)
+ XCTAssertEqual(try control.snapshotFreeze(receiptID: receiptID), receiptID)
+ try control.snapshotThaw(receiptID: receiptID)
+ XCTAssertEqual(fake.snapshotFreezeReceipts, [receiptID])
+ XCTAssertEqual(fake.snapshotThawReceipts, [receiptID])
let exec = try control.exec(argv: ["/bin/echo", "ok"], cwd: "/tmp")
XCTAssertEqual(exec.exitCode, 0)
XCTAssertEqual(String(data: exec.stdout, encoding: .utf8), "ok\n")
@@ -52,6 +57,23 @@ final class AgentControlTests: XCTestCase {
}
XCTAssertEqual(missing.telemetryCalls, 0)
+ let oldSnapshotCapability = FakeAgentControlClient(capabilities: [
+ DoryAgentCapability(id: "snapshot-quiesce", version: 1),
+ ])
+ let oldSnapshotControl = AgentControl(
+ configuration: AgentControlConfiguration(directSocketPath: "/tmp/old-snapshot.sock"),
+ connector: { _ in oldSnapshotCapability }
+ )
+ XCTAssertThrowsError(try oldSnapshotControl.snapshotFreeze(
+ receiptID: String(repeating: "b", count: 32)
+ )) { error in
+ XCTAssertEqual(
+ error as? AgentControlError,
+ .capabilityUnavailable("snapshot-quiesce")
+ )
+ }
+ XCTAssertTrue(oldSnapshotCapability.snapshotFreezeReceipts.isEmpty)
+
let invalid = FakeAgentControlClient(capabilities: [
DoryAgentCapability(id: "exec", version: 1),
DoryAgentCapability(id: "exec", version: 1),
@@ -91,6 +113,8 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda
private var inputs: [Int64] = []
private var closes = 0
private var telemetryCallCount = 0
+ private var freezeReceipts: [String] = []
+ private var thawReceipts: [String] = []
init(
protocolVersion: UInt32 = DoryCore.protocolVersion(),
@@ -99,6 +123,7 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda
DoryAgentCapability(id: "exec", version: 1),
DoryAgentCapability(id: "exec-stdin", version: 1),
DoryAgentCapability(id: "ports-watch", version: 1),
+ DoryAgentCapability(id: "snapshot-quiesce", version: 2),
DoryAgentCapability(id: "telemetry", version: 1),
]
) {
@@ -124,6 +149,18 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda
return telemetryCallCount
}
+ var snapshotFreezeReceipts: [String] {
+ lock.lock()
+ defer { lock.unlock() }
+ return freezeReceipts
+ }
+
+ var snapshotThawReceipts: [String] {
+ lock.lock()
+ defer { lock.unlock() }
+ return thawReceipts
+ }
+
func info() throws -> DoryAgentInfo {
DoryAgentInfo(
protocolVersion: protocolVersion,
@@ -161,6 +198,19 @@ private final class FakeAgentControlClient: AgentControlClient, @unchecked Senda
)
}
+ func snapshotFreeze(receiptID: String) throws -> String {
+ lock.lock()
+ freezeReceipts.append(receiptID)
+ lock.unlock()
+ return receiptID
+ }
+
+ func snapshotThaw(receiptID: String) throws {
+ lock.lock()
+ thawReceipts.append(receiptID)
+ lock.unlock()
+ }
+
func exec(
argv: [String],
cwd: String,
diff --git a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
index aa8ea66f..d6d66363 100644
--- a/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
+++ b/dory-core-swift/Tests/DorydKitTests/MachineManagerTests.swift
@@ -3751,6 +3751,11 @@ final class MachineManagerTests: XCTestCase {
snapshotID: "running"
)
XCTAssertEqual(runningSnapshot.consistency, .guestQuiesced)
+ let receipt = try XCTUnwrap(runningSnapshot.guestQuiesceReceipt)
+ XCTAssertTrue(receipt.isValid)
+ XCTAssertEqual(receipt.agentBuild, "dory-agent/snapshot-test")
+ XCTAssertEqual(receipt.agentProtocolVersion, DoryCore.protocolVersion())
+ XCTAssertEqual(receipt.capabilityVersion, 2)
XCTAssertEqual(connector.snapshotFreezes, 1)
XCTAssertEqual(connector.snapshotThaws, 0)
XCTAssertEqual(manager.status(id: "dev")?.state, .running)
@@ -3762,8 +3767,33 @@ final class MachineManagerTests: XCTestCase {
snapshotID: "paused"
)
XCTAssertEqual(pausedSnapshot.consistency, .coldStopped)
+ XCTAssertNil(pausedSnapshot.guestQuiesceReceipt)
XCTAssertEqual(connector.snapshotFreezes, 1)
XCTAssertEqual(manager.status(id: "dev")?.state, .paused)
+
+ var missingReceipt = try XCTUnwrap(
+ JSONSerialization.jsonObject(with: JSONEncoder().encode(runningSnapshot))
+ as? [String: Any]
+ )
+ missingReceipt.removeValue(forKey: "guestQuiesceReceipt")
+ XCTAssertThrowsError(try JSONDecoder().decode(
+ DoryMachineSnapshot.self,
+ from: JSONSerialization.data(withJSONObject: missingReceipt)
+ ))
+
+ var historicalColdSnapshot = try XCTUnwrap(
+ JSONSerialization.jsonObject(with: JSONEncoder().encode(pausedSnapshot))
+ as? [String: Any]
+ )
+ historicalColdSnapshot.removeValue(forKey: "consistency")
+ historicalColdSnapshot.removeValue(forKey: "guestQuiesceReceipt")
+ XCTAssertEqual(
+ try JSONDecoder().decode(
+ DoryMachineSnapshot.self,
+ from: JSONSerialization.data(withJSONObject: historicalColdSnapshot)
+ ).consistency,
+ .coldStopped
+ )
}
func testExecRunsThroughRunningMachineAgent() throws {
@@ -4109,7 +4139,7 @@ private func sendSnapshotQuiesceHandoff(path: String, machineID: String) throws
machineID: machineID,
agentBuild: "dory-agent/snapshot-test",
agentProtocolVersion: DoryCore.protocolVersion(),
- agentCapabilities: [DoryAgentCapability(id: "snapshot-quiesce", version: 1)],
+ agentCapabilities: [DoryAgentCapability(id: "snapshot-quiesce", version: 2)],
agentSocketPath: "/run/dory-snapshot-agent.sock"
),
fileDescriptors: []
@@ -4444,11 +4474,13 @@ private final class RecordingMachineAgentClient: AgentControlClient, @unchecked
telemetryValue
}
- func snapshotFreeze() throws {
+ func snapshotFreeze(receiptID: String) throws -> String {
recorder.recordSnapshotFreeze()
+ return receiptID
}
- func snapshotThaw() throws {
+ func snapshotThaw(receiptID: String) throws {
+ XCTAssertEqual(receiptID.utf8.count, 32)
recorder.recordSnapshotThaw()
}
diff --git a/dory-core/agent/src/dispatch.rs b/dory-core/agent/src/dispatch.rs
index a1881096..46f92c59 100644
--- a/dory-core/agent/src/dispatch.rs
+++ b/dory-core/agent/src/dispatch.rs
@@ -29,7 +29,7 @@ pub fn agent_capabilities() -> Vec {
("telemetry", 1),
];
if crate::snapshot_quiesce::available() {
- capabilities.push(("snapshot-quiesce", 1));
+ capabilities.push(("snapshot-quiesce", 2));
}
capabilities.sort_unstable_by_key(|(id, _)| *id);
capabilities
@@ -204,7 +204,7 @@ mod tests {
("telemetry", 1),
];
if crate::snapshot_quiesce::available() {
- expected_capabilities.push(("snapshot-quiesce", 1));
+ expected_capabilities.push(("snapshot-quiesce", 2));
expected_capabilities.sort_unstable();
}
assert_eq!(
diff --git a/dory-core/agent/src/snapshot_quiesce.rs b/dory-core/agent/src/snapshot_quiesce.rs
index e1b72040..79c48a52 100644
--- a/dory-core/agent/src/snapshot_quiesce.rs
+++ b/dory-core/agent/src/snapshot_quiesce.rs
@@ -1,4 +1,5 @@
use std::path::Path;
+use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use dory_pb::agent::{
@@ -7,44 +8,131 @@ use dory_pb::agent::{
const QUIESCE_TIMEOUT: Duration = Duration::from_secs(20);
+#[derive(Clone, Debug, Eq, PartialEq)]
+enum QuiesceState {
+ Freezing(String),
+ Frozen(String),
+ Thawing(String),
+}
+
+fn state() -> &'static Mutex