diff --git a/Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift b/Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift index adfd5aef..7f8186c8 100644 --- a/Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift +++ b/Sources/NucleicCore/MacVM/MacVMEngine+MDM.swift @@ -67,11 +67,25 @@ extension MacVMEngine { enum UAMDMApprovalAction: Equatable { case openEnrollmentProfile(CGRect) case install(CGRect) - case authenticate(passwordField: CGRect) + /// `sheet` identifies WHICH authorization sheet this is, so each gets its own retry budget. + case authenticate(sheet: String, passwordField: CGRect) case installed case unknown } + /// A stable slug for the authorization sheet on screen. The enroll flow raises several in + /// sequence — System certificate trust, then the MDM enrollment itself — and a single shared retry + /// budget spent on the first leaves the last one unanswered, which is exactly how a live run + /// stalled on "Device Management is trying to enroll you…" after exhausting its attempts upstream. + static func authSheetKey(in joined: String) -> String { + func has(_ s: String) -> Bool { joined.localizedCaseInsensitiveContains(s) } + if has("Certificate Trust") || has("System Certificate") { return "certificate-trust" } + if has("remote management") || has("enroll you in") { return "mdm-enroll" } + if has("modify your system settings") { return "modify-settings" } + if has("configuration profile") || has("install a new") { return "profile-install" } + return "generic" + } + /// Levenshtein distance, bounded use — for tolerating OCR substitutions in short UI labels. static func editDistance(_ a: String, _ b: String) -> Int { let s = Array(a), t = Array(b) @@ -148,7 +162,8 @@ extension MacVMEngine { if contains("Enter your password") || contains("enter password"), let password = Self.passwordFieldBox(in: normalized) { - return .authenticate(passwordField: password) + return .authenticate( + sheet: Self.authSheetKey(in: joined), passwordField: password) } // The profile-review sheet ("Are you sure you want to install this device profile?") confirms @@ -874,8 +889,8 @@ extension MacVMEngine { var lastLines: [(text: String, box: CGRect)] = [] var rowClicks = 0, installClicks = 0 var lastRowClickAt: Date?, lastInstallClickAt: Date? - var authAttempts = 0 - var lastAuthAt: Date? + var authAttempts: [String: Int] = [:] + var lastAuthAt: [String: Date] = [:] var paneIndex = 0 var nextPaneOpen = Date() // surface the pane on the first idle capture var reachedEnrollmentUI = false @@ -925,25 +940,36 @@ extension MacVMEngine { name: target.name, .click(x: point.x, y: point.y, button: .left, count: 1)) } - case .authenticate(let passwordField): - // Answer if attempts remain AND we did not just try (a correct answer dismisses the - // sheet within a second, so a still-present sheet means the last attempt failed — a - // dropped keystroke, say — and only then is a re-type warranted). - let sinceLast = lastAuthAt.map { Date().timeIntervalSince($0) } + case .authenticate(let sheet, let passwordField): + // Budget is PER SHEET: the flow raises several authorization sheets in sequence, and a + // shared counter spent retrying the first leaves the last unanswered. + let attempts = authAttempts[sheet, default: 0] + let sinceLast = lastAuthAt[sheet].map { Date().timeIntervalSince($0) } ?? .greatestFiniteMagnitude - if authAttempts < Self.maxAuthAttempts, sinceLast >= Self.reAuthDelaySeconds { + if attempts < Self.maxAuthAttempts, sinceLast >= Self.reAuthDelaySeconds { acted = true - authAttempts += 1 - lastAuthAt = Date() - // Click the CENTER of the fuzzily-located Password field (the placeholder sits - // inside it), let focus settle, then cmd+a (absorbs a dropped first keystroke and - // clears any residue) → type → Return. Return activates the sheet's default button, - // so no button label needs to be OCR-matched (the labels mis-OCR frequently). + authAttempts[sheet] = attempts + 1 + lastAuthAt[sheet] = Date() let field = Self.clickPoint(forNormalizedBox: passwordField) - recorder?.note("answer password sheet: click field at (\(field.x), \(field.y))") + recorder?.note( + "answer \"\(sheet)\" sheet (attempt \(attempts + 1)): " + + "focus+click field at (\(field.x), \(field.y))") + // TWO clicks, deliberately. A SecurityAgent sheet often appears without becoming + // key, and the first click on a non-key window is consumed activating it — so a + // single click leaves the caret unplaced and the keystrokes go nowhere (observed + // live: the sheet sat untouched until a human clicked it, after which the very same + // automation completed it). The first click foregrounds; the second places the + // caret in the field. Both target the field, so neither can hit another control. await surface.send( name: target.name, .click(x: field.x, y: field.y, button: .left, count: 1)) + try? await Task.sleep(nanoseconds: 600_000_000) + await surface.send( + name: target.name, + .click(x: field.x, y: field.y, button: .left, count: 1)) + // Let focus settle, then cmd+a (absorbs a dropped first keystroke and clears any + // residue) → type → Return. Return activates the sheet's default button, so no + // button label needs OCR-matching (those labels mis-OCR frequently). try? await Task.sleep(nanoseconds: 400_000_000) await surface.send(name: target.name, .key(chord: "cmd+a")) await surface.send(name: target.name, .text(password)) @@ -988,7 +1014,12 @@ extension MacVMEngine { ? "every approval capture recognized no text — the guest framebuffer appears blank to the " + "host surface rather than showing an unrecognized screen" : "expected Device Management approval state was not reached " - + "(password sheets answered: \(authAttempts))" + + "(password sheets answered: " + + (authAttempts.isEmpty + ? "none" + : authAttempts.sorted { $0.key < $1.key } + .map { "\($0.key)×\($0.value)" }.joined(separator: ", ")) + + ")" let diagnostic = await recordMDMApprovalDiagnostic( bundle: bundle, target: target, jpeg: lastJPEG, lines: lastLines, reason: reason, recorder: recorder) @@ -1003,7 +1034,7 @@ extension MacVMEngine { switch action { case .openEnrollmentProfile: return "open-enrollment-profile" case .install: return "install" - case .authenticate: return "authenticate" + case .authenticate(let sheet, _): return "authenticate-\(sheet)" case .installed: return "installed" case .unknown: return "unknown" } diff --git a/Tests/NucleicCoreTests/MacVMTests.swift b/Tests/NucleicCoreTests/MacVMTests.swift index baa022fb..5255b1d2 100644 --- a/Tests/NucleicCoreTests/MacVMTests.swift +++ b/Tests/NucleicCoreTests/MacVMTests.swift @@ -1605,7 +1605,8 @@ import Testing (text: "Password", box: field), (text: "Modify Settings", box: CGRect(x: 0.7, y: 0.25, width: 0.15, height: 0.04)), ] - #expect(MacVMEngine.uamdmApprovalAction(in: install) == .authenticate(passwordField: field)) + #expect(MacVMEngine.uamdmApprovalAction(in: install) + == .authenticate(sheet: "modify-settings", passwordField: field)) // No "Enter your password" phrase → not an actionable sheet. #expect(MacVMEngine.uamdmApprovalAction(in: Array(install.dropFirst(1)).filter { !$0.text.contains("Enter your password") @@ -1627,7 +1628,33 @@ import Testing (text: "Peseword", box: field), (text: "Updale Settings", box: CGRect(x: 0.6, y: 0.3, width: 0.15, height: 0.04)), ] - #expect(MacVMEngine.uamdmApprovalAction(in: mangled) == .authenticate(passwordField: field)) + #expect(MacVMEngine.uamdmApprovalAction(in: mangled) + == .authenticate(sheet: "certificate-trust", passwordField: field)) + } + + @Test func uamdmDistinguishesEachAuthorizationSheetForItsOwnRetryBudget() { + // The flow raises several sheets in sequence; each needs its own budget, or retries spent on + // the first leave the last unanswered — how a live run stalled on the final MDM enrollment + // sheet after exhausting a single shared counter upstream. + let field = CGRect(x: 0.44, y: 0.39, width: 0.03, height: 0.02) + func sheet(_ intent: String) -> String? { + let action = MacVMEngine.uamdmApprovalAction(in: [ + (text: intent, box: .zero), + (text: "Enter your password to continue", box: .zero), + (text: "Password", box: field), + ]) + if case .authenticate(let s, _) = action { return s } + return nil + } + #expect(sheet("You are making changes to the System Certificate Trust Settings.") + == "certificate-trust") + // The exact wording of the final enrollment sheet observed on macOS 27. + #expect(sheet("Device Management is trying to enroll you in a remote management (MDM) service.") + == "mdm-enroll") + #expect(sheet("System Settings is trying to modify your system settings.") == "modify-settings") + // Distinct sheets must not collide onto one budget key. + #expect(sheet("You are making changes to the System Certificate Trust Settings.") + != sheet("Device Management is trying to enroll you in a remote management (MDM) service.")) } @Test func passwordFieldFuzzyMatchAndEditDistanceBounds() { @@ -1750,7 +1777,7 @@ import Testing let labels = [ MacVMEngine.stateLabel(.openEnrollmentProfile(.zero)), MacVMEngine.stateLabel(.install(.zero)), - MacVMEngine.stateLabel(.authenticate(passwordField: .zero)), + MacVMEngine.stateLabel(.authenticate(sheet: "mdm-enroll", passwordField: .zero)), MacVMEngine.stateLabel(.installed), MacVMEngine.stateLabel(.unknown), ] diff --git a/docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md b/docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md index 3ab346a1..72dcd7b5 100644 --- a/docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md +++ b/docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md @@ -648,6 +648,31 @@ granted extra clicks on incidental text noise while starving a genuinely stuck s Build clean; MacVM (142) and NucleicMDM (39) green. +### Tenth live run — sheet focus, and a shared retry budget (2026-07-24) + +The certificate failure is **gone**: the serial fix plus profile signing carried the flow through the +review sheet and the Enroll confirmation. It then stalled at the last step — "Device Management is +trying to enroll you in a remote management (MDM) service" — with the password never submitted. The +operator's step-by-step named both causes precisely. + +**A sheet that isn't key swallows the first click.** The trust sheet "is not consistently foregrounded… +must be manually clicked on to foreground, at which point it is automatically completed" — i.e. the +typing was always correct, but the sheet was not key, and the first click on a non-key window is +consumed activating it, so the caret never landed and the keystrokes went nowhere. The answer sequence +now sends **two** clicks at the password field, 600 ms apart: the first foregrounds the sheet, the +second places the caret. Both target the same field, so neither can stray onto another control. + +**The retry budget was global, not per sheet.** `maxAuthAttempts` was one counter for the whole loop, so +while the unfocused trust sheet was retried every 6 s it consumed all five attempts — leaving nothing +for the final enrollment sheet, which is exactly where the run stalled. Attempts are now tracked per +sheet, keyed by a slug derived from the sheet's own wording (`certificate-trust`, `mdm-enroll`, +`modify-settings`, `profile-install`), so each gets its own budget and the failure summary reports them +individually (`certificate-trust×5, mdm-enroll×0` would have made this obvious at a glance). + +Verified: build clean; MacVM (143) and NucleicMDM (39) green, including a test that pins the exact +macOS 27 wording of each sheet to a distinct budget key and asserts two different sheets cannot collide +onto one. + ## Workstream 1 — enrollment-pass blockers - [x] 1.1 Run the enroll script as the console user with per-step `sudo` (hosts pin, CA trust), so