From 602d8fc6154530b5da0b18c09458420576811f18 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:52:54 -0500 Subject: [PATCH] scripting: provision macOS guests on first boot macOS 27 can create the guest's user account, turn on automatic login and Remote Login without going through Setup Assistant, which lets CI and other automation get a ready-to-use macOS VM. The guest only reads these options on the first boot after macOS is installed and they include a password, so they are passed as a parameter to a single start and are never saved to the configuration or the registry. This is only exposed to AppleScript (`start ... provisioning {...}`) and utmctl (`start --provision-*`) because most users will never need it. utmctl can read the password from standard input to keep it out of the process list. Requests that cannot be honoured (not a macOS guest, host older than macOS 27, install still pending, saved state, recovery mode, invalid account) fail before the VM starts so the first boot is not used up. UTMScripting.swift has only the regenerated `start` declaration. The rest of the current bridge-gen.sh output is unrelated to this change. Assisted-by: Claude:claude-fable-5-1 --- Scripting/UTM.sdef | 20 ++++ Scripting/UTMScripting.swift | 2 +- .../UTMScriptingVirtualMachineImpl.swift | 32 +++++- Services/UTMAppleVirtualMachine.swift | 97 ++++++++++++++++++- utmctl/UTMCtl.swift | 86 +++++++++++++++- 5 files changed, 232 insertions(+), 5 deletions(-) diff --git a/Scripting/UTM.sdef b/Scripting/UTM.sdef index 537a2bb2..aa317f99 100644 --- a/Scripting/UTM.sdef +++ b/Scripting/UTM.sdef @@ -72,8 +72,28 @@ + + + + + + + + + + + + + + + diff --git a/Scripting/UTMScripting.swift b/Scripting/UTMScripting.swift index ab0a2fd5..bd715f73 100644 --- a/Scripting/UTMScripting.swift +++ b/Scripting/UTMScripting.swift @@ -235,7 +235,7 @@ extension SBObject: UTMScriptingWindow {} @objc optional var name: String { get } // The name of the VM. @objc optional var backend: UTMScriptingBackend { get } // Emulation/virtualization engine used. @objc optional var status: UTMScriptingStatus { get } // Current running status. - @objc optional func startSaving(_ saving: Bool, recovery: Bool) // Start a virtual machine or resume a suspended virtual machine. + @objc optional func startSaving(_ saving: Bool, recovery: Bool, provisioning: [AnyHashable : Any]!) // Start a virtual machine or resume a suspended virtual machine. @objc optional func install() // Install macOS on a stopped Apple virtual machine from its IPSW recovery image. All data on the primary drive will be erased, there is no confirmation! Returns once the installation starts, use query install to follow its progress. @objc optional func queryInstall() -> [AnyHashable : Any] // Query the progress of the last installation started with install. Raises an error if the installation failed. @objc optional func suspendSaving(_ saving: Bool) // Suspend a running virtual machine to memory. diff --git a/Scripting/UTMScriptingVirtualMachineImpl.swift b/Scripting/UTMScriptingVirtualMachineImpl.swift index 399102ad..a0e12634 100644 --- a/Scripting/UTMScriptingVirtualMachineImpl.swift +++ b/Scripting/UTMScriptingVirtualMachineImpl.swift @@ -119,6 +119,8 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable { @objc func start(_ command: NSScriptCommand) { let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? true let bootRecoveryMode = command.evaluatedArguments?["bootRecoveryFlag"] as? Bool ?? false + let hasProvisioning = command.evaluatedArguments?["provisioningOptions"] != nil + let provisioning = command.evaluatedArguments?["provisioningOptions"] as? [AnyHashable : Any] withScriptCommand(command) { [self] in var options: UTMVirtualMachineStartOptions = [] @@ -126,6 +128,11 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable { guard !isInstalling else { throw ScriptingError.installInProgress } + // the first boot cannot be repeated so never fall back to a normal start + guard provisioning != nil || !hasProvisioning else { + throw ScriptingError.invalidParameter + } + if !shouldSaveState { guard type(of: vm).capabilities.supportsDisposibleMode else { throw ScriptingError.operationNotSupported @@ -139,8 +146,31 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable { options.insert(.bootRecovery) } + var guestProvisioning: UTMAppleGuestProvisioningOptions? + if let provisioning = provisioning { + guard let vm = vm as? UTMAppleVirtualMachine else { + throw ScriptingError.operationNotSupported + } + guard let username = provisioning["username"] as? String, !username.isEmpty, let password = provisioning["password"] as? String else { + throw ScriptingError.invalidParameter + } + guard vm.state == .stopped else { + throw ScriptingError.operationNotAvailable + } + let account = UTMAppleGuestProvisioningOptions(fullName: provisioning["fullName"] as? String ?? username, + username: username, + password: password, + logsInAutomatically: provisioning["automaticLogin"] as? Bool ?? false, + enablesRemoteLogin: provisioning["remoteLogin"] as? Bool ?? false) + // report mistakes before a window is opened + try await vm.validateGuestProvisioning(account, options: options) + guestProvisioning = account + } + data.run(vm: box, startImmediately: false) - if vm.state == .stopped { + if let guestProvisioning = guestProvisioning, let vm = vm as? UTMAppleVirtualMachine { + try await vm.start(options: options, guestProvisioning: guestProvisioning) + } else if vm.state == .stopped { try await vm.start(options: options) } else if vm.state == .paused { try await vm.resume() diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift index 15ac122c..5cb37d05 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/Services/UTMAppleVirtualMachine.swift @@ -170,7 +170,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { updateConfigFromRegistry() } - private func _start(options: UTMVirtualMachineStartOptions) async throws { + /// - Parameter guestProvisioning: `VZMacGuestProvisioningOptions` on macOS 27, always nil on older versions + private func _start(options: UTMVirtualMachineStartOptions, guestProvisioning: Any? = nil) async throws { let boot = await config.system.boot try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) -> Void in vmQueue.async { @@ -182,6 +183,14 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { if #available(macOS 13, *), boot.operatingSystem == .macOS { let vzoptions = VZMacOSVirtualMachineStartOptions() vzoptions.startUpFromMacOSRecovery = options.contains(.bootRecovery) + if #available(macOS 27, *), let guestProvisioning = guestProvisioning as? VZMacGuestProvisioningOptions { + do { + try vzoptions.setGuestProvisioning(guestProvisioning) + } catch { + continuation.resume(throwing: error) + return + } + } apple.start(options: vzoptions) { result in if let result = result { continuation.resume(with: .failure(result)) @@ -203,7 +212,27 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { } func start(options: UTMVirtualMachineStartOptions = []) async throws { + try await start(options: options, guestProvisioning: nil) + } + + /// Start the VM and optionally set up the macOS guest without user interaction + /// + /// The guest only reads the provisioning options on the first boot after macOS is installed. + /// - Parameters: + /// - options: Options for startup + /// - guestProvisioning: If set, account to create in the guest. This is never saved. + func start(options: UTMVirtualMachineStartOptions, guestProvisioning: UTMAppleGuestProvisioningOptions?) async throws { + var vzGuestProvisioning: Any? + if let guestProvisioning = guestProvisioning { + // this must fail before the error handling below which discards any saved state + vzGuestProvisioning = try await validateGuestProvisioning(guestProvisioning, options: options) + } + // do not suspend between checking and changing the state guard state == .stopped else { + if guestProvisioning != nil { + // the first boot cannot be repeated so the caller must know it was not set up + throw UTMAppleVirtualMachineError.guestProvisioningNotAvailable + } return } state = .starting @@ -222,7 +251,7 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { if isRestoring { try await restoreSnapshot() } else { - try await _start(options: options) + try await _start(options: options, guestProvisioning: vzGuestProvisioning) } if #available(macOS 15, *) { try await attachExternalDrives() @@ -295,6 +324,44 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { } } + /// Check that the guest can be provisioned on this start + /// - Parameters: + /// - guestProvisioning: Account to create in the guest + /// - options: Options for startup + /// - Returns: `VZMacGuestProvisioningOptions` to pass to `_start` + @discardableResult + func validateGuestProvisioning(_ guestProvisioning: UTMAppleGuestProvisioningOptions, options: UTMVirtualMachineStartOptions) async throws -> Any { + #if os(macOS) && arch(arm64) + let boot = await config.system.boot + guard #available(macOS 27, *), boot.operatingSystem == .macOS else { + throw UTMAppleVirtualMachineError.guestProvisioningNotSupported + } + // only the first boot after install sets up the guest: not a pending install, a resumed guest, or recoveryOS + let isSuspended = await registryEntry.isSuspended + guard boot.macRecoveryIpswURL == nil && !isSuspended && !options.contains(.bootRecovery) else { + throw UTMAppleVirtualMachineError.guestProvisioningNotAvailable + } + let vzGuestProvisioning = VZMacGuestProvisioningOptions() + vzGuestProvisioning.fullName = guestProvisioning.fullName + vzGuestProvisioning.username = guestProvisioning.username + vzGuestProvisioning.password = guestProvisioning.password + vzGuestProvisioning.logsInAutomatically = guestProvisioning.logsInAutomatically + vzGuestProvisioning.enablesRemoteLogin = guestProvisioning.enablesRemoteLogin + do { + try vzGuestProvisioning.validate() + } catch let error as VZError where error.code == .guestProvisioningInvalidFullName { + throw UTMAppleVirtualMachineError.guestProvisioningInvalidFullName + } catch let error as VZError where error.code == .guestProvisioningInvalidUsername { + throw UTMAppleVirtualMachineError.guestProvisioningInvalidUsername + } catch let error as VZError where error.code == .guestProvisioningInvalidPassword { + throw UTMAppleVirtualMachineError.guestProvisioningInvalidPassword + } + return vzGuestProvisioning + #else + throw UTMAppleVirtualMachineError.guestProvisioningNotSupported + #endif + } + private func _forceStop() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in vmQueue.async { @@ -1356,6 +1423,22 @@ enum UTMAppleVirtualMachineError: Error { case usbDeviceInUse(String) case usbDeviceNotConnected(String) case cannotSaveInDisposibleMode + case guestProvisioningNotSupported + case guestProvisioningNotAvailable + case guestProvisioningInvalidFullName + case guestProvisioningInvalidUsername + case guestProvisioningInvalidPassword +} + +/// Account to create in a macOS guest on the first boot after install +/// +/// This holds a password so it is intentionally not `Codable` and must not be saved or logged. +struct UTMAppleGuestProvisioningOptions { + var fullName: String + var username: String + var password: String + var logsInAutomatically: Bool = false + var enablesRemoteLogin: Bool = false } extension UTMAppleVirtualMachineError: LocalizedError { @@ -1381,6 +1464,16 @@ extension UTMAppleVirtualMachineError: LocalizedError { return String.localizedStringWithFormat(NSLocalizedString("The USB device '%@' is not connected to this virtual machine.", comment: "UTMAppleVirtualMachine"), name) case .cannotSaveInDisposibleMode: return NSLocalizedString("Suspend state cannot be saved when running without saving changes.", comment: "UTMAppleVirtualMachine") + case .guestProvisioningNotSupported: + return NSLocalizedString("Setting up the guest automatically requires a macOS virtual machine and macOS 27 or later on Apple Silicon.", comment: "UTMAppleVirtualMachine") + case .guestProvisioningNotAvailable: + return NSLocalizedString("The guest can only be set up automatically on the first start after macOS is installed. It cannot be set up before macOS is installed, from a saved state, or in recovery mode.", comment: "UTMAppleVirtualMachine") + case .guestProvisioningInvalidFullName: + return NSLocalizedString("The full name for the guest account is not valid.", comment: "UTMAppleVirtualMachine") + case .guestProvisioningInvalidUsername: + return NSLocalizedString("The username for the guest account is not valid.", comment: "UTMAppleVirtualMachine") + case .guestProvisioningInvalidPassword: + return NSLocalizedString("The password for the guest account is not valid.", comment: "UTMAppleVirtualMachine") } } } diff --git a/utmctl/UTMCtl.swift b/utmctl/UTMCtl.swift index d2539f71..7d6cc34d 100644 --- a/utmctl/UTMCtl.swift +++ b/utmctl/UTMCtl.swift @@ -246,9 +246,93 @@ extension UTMCtl { @Flag(help: "Boot a VM in recovery mode.") var recovery: Bool = false + struct Provisioning: ParsableArguments { + @Option(name: .customLong("provision-username"), help: "Set up a macOS guest with this account on the first start after macOS is installed. Requires macOS 27 or later on both the host and the guest.") + var username: String? + + @Option(name: .customLong("provision-full-name"), help: "Full name of the guest account. Defaults to the username.") + var fullName: String? + + @Option(name: .customLong("provision-password"), help: "Password of the guest account. Other processes can see this, use --provision-password-stdin to avoid that.") + var password: String? + + @Flag(name: .customLong("provision-password-stdin"), help: "Read the password of the guest account from standard input.") + var passwordStdin: Bool = false + + @Flag(name: .customLong("provision-auto-login"), help: "Log in to the guest account automatically when the guest starts up.") + var autoLogin: Bool = false + + @Flag(name: .customLong("provision-ssh"), help: "Turn on Remote Login (SSH) in the guest.") + var ssh: Bool = false + + struct InvalidProvisioningError: LocalizedError { + var errorDescription: String? + } + + func validate() throws { + guard username != nil else { + guard fullName == nil && password == nil && !passwordStdin && !autoLogin && !ssh else { + throw InvalidProvisioningError(errorDescription: "You must specify --provision-username to set up the guest") + } + return + } + guard (password != nil) != passwordStdin else { + throw InvalidProvisioningError(errorDescription: "You must specify one of: --provision-password or --provision-password-stdin") + } + } + } + + @OptionGroup var provisioning: Provisioning + + struct ProvisioningRecoveryError: LocalizedError { + var errorDescription: String? { + "You cannot set up the guest when booting in recovery mode" + } + } + + func validate() throws { + guard !recovery || provisioning.username == nil else { + throw ProvisioningRecoveryError() + } + } + + /// Read a password from standard input without showing it when typed at a terminal + static func readPassword() throws -> String { + if isatty(STDIN_FILENO) != 0 { + var buffer = [CChar](repeating: 0, count: 1024) + guard readpassphrase("Password: ", &buffer, buffer.count, 0) != nil else { + throw Provisioning.InvalidProvisioningError(errorDescription: "Failed to read the password") + } + return String(cString: buffer) + } + let data = try FileHandle.standardInput.readToEnd() ?? Data() + // a lossy conversion would create an account with a password nobody knows + guard var password = String(data: data, encoding: .utf8) else { + throw Provisioning.InvalidProvisioningError(errorDescription: "The password must be valid UTF-8 text") + } + if password.last?.isNewline == true { + password.removeLast() + } + return password + } + func run(with application: UTMScriptingApplication) throws { let vm = try virtualMachine(forIdentifier: identifer, in: application) - vm.startSaving!(!disposable, recovery: recovery) + var provisioningOptions: [AnyHashable : Any]? + if let username = provisioning.username { + var password = provisioning.password ?? "" + if provisioning.passwordStdin { + password = try Self.readPassword() + } + provisioningOptions = ["username": username, + "password": password, + "automaticLogin": provisioning.autoLogin, + "remoteLogin": provisioning.ssh] + if let fullName = provisioning.fullName { + provisioningOptions!["fullName"] = fullName + } + } + vm.startSaving!(!disposable, recovery: recovery, provisioning: provisioningOptions) if attach { print("WARNING: attach command is not implemented yet!") }