Merge nucleic/jolly-pebble-marten-kmzg into dev
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user