Skip to main content

macos_agent/
preflight.rs

1use std::process::Command;
2
3use nils_common::{env as shared_env, process as shared_process};
4use screen_record::types::PermissionStatusSchema;
5use serde_json::{Value, json};
6
7pub use screen_record::types::PermissionState;
8
9pub const CLICLICK_INSTALL_HINT: &str = "Install cliclick with Homebrew: brew install cliclick";
10pub const ACCESSIBILITY_HINT: &str = "Open System Settings > Privacy & Security > Accessibility, then enable your terminal app (Terminal, iTerm, or other shell host).";
11pub const AUTOMATION_HINT: &str = "Open System Settings > Privacy & Security > Automation, then allow your terminal app to control System Events.";
12pub const SCREEN_RECORDING_HINT: &str = "Advisory: if screenshot commands fail, open System Settings > Privacy & Security > Screen Recording and enable your terminal app.";
13
14const ACCESSIBILITY_SCRIPT: &str = r#"tell application "System Events" to get UI elements enabled"#;
15const AUTOMATION_SCRIPT: &str = r#"tell application "System Events" to get name of first application process whose frontmost is true"#;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct PermissionSignal {
19    pub state: PermissionState,
20    pub detail: String,
21}
22
23impl PermissionSignal {
24    pub fn ready(detail: impl Into<String>) -> Self {
25        Self {
26            state: PermissionState::Ready,
27            detail: detail.into(),
28        }
29    }
30
31    pub fn blocked(detail: impl Into<String>) -> Self {
32        Self {
33            state: PermissionState::Blocked,
34            detail: detail.into(),
35        }
36    }
37
38    pub fn unknown(detail: impl Into<String>) -> Self {
39        Self {
40            state: PermissionState::Unknown,
41            detail: detail.into(),
42        }
43    }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ProbeSnapshot {
48    pub osascript_path: Option<String>,
49    pub cliclick_path: Option<String>,
50    pub accessibility_signal: PermissionSignal,
51    pub automation_signal: PermissionSignal,
52    pub screen_recording_signal: PermissionSignal,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum CheckStatus {
57    Ok,
58    Fail,
59    Warn,
60}
61
62impl CheckStatus {
63    fn as_str(self) -> &'static str {
64        match self {
65            Self::Ok => "ok",
66            Self::Fail => "fail",
67            Self::Warn => "warn",
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct CheckReport {
74    pub id: &'static str,
75    pub label: &'static str,
76    pub status: CheckStatus,
77    pub blocking: bool,
78    pub message: String,
79    pub hint: Option<String>,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct PreflightSummary {
84    pub ok: bool,
85    pub blocking_failures: usize,
86    pub warnings: usize,
87}
88
89impl PreflightSummary {
90    pub fn status(self) -> &'static str {
91        if self.ok { "ready" } else { "not_ready" }
92    }
93
94    fn title(self) -> &'static str {
95        if self.ok { "ready" } else { "not ready" }
96    }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct PreflightReport {
101    pub strict: bool,
102    pub checks: Vec<CheckReport>,
103    pub permissions: PermissionStatusSchema,
104}
105
106impl PreflightReport {
107    pub fn summary(&self) -> PreflightSummary {
108        let blocking_failures = self
109            .checks
110            .iter()
111            .filter(|check| check.blocking && check.status == CheckStatus::Fail)
112            .count();
113        let warnings = self
114            .checks
115            .iter()
116            .filter(|check| check.status == CheckStatus::Warn)
117            .count();
118        let ok = if self.strict {
119            blocking_failures == 0 && warnings == 0
120        } else {
121            blocking_failures == 0
122        };
123
124        PreflightSummary {
125            ok,
126            blocking_failures,
127            warnings,
128        }
129    }
130
131    #[cfg(test)]
132    #[allow(dead_code)]
133    pub fn check(&self, id: &str) -> Option<&CheckReport> {
134        self.checks.iter().find(|check| check.id == id)
135    }
136}
137
138pub fn collect_system_snapshot() -> ProbeSnapshot {
139    let osascript_path = find_in_path("osascript");
140    let cliclick_path = find_in_path("cliclick");
141    let osascript_available = osascript_path.is_some();
142
143    let accessibility_signal = if osascript_available {
144        probe_accessibility()
145    } else {
146        PermissionSignal::unknown("Skipped because osascript is missing.")
147    };
148    let automation_signal = if osascript_available {
149        probe_automation()
150    } else {
151        PermissionSignal::unknown("Skipped because osascript is missing.")
152    };
153    let screen_recording_signal = PermissionSignal::unknown(
154        "Advisory only. Screen Recording is validated when observe screenshot runs.",
155    );
156
157    ProbeSnapshot {
158        osascript_path,
159        cliclick_path,
160        accessibility_signal,
161        automation_signal,
162        screen_recording_signal,
163    }
164}
165
166pub fn build_report(snapshot: ProbeSnapshot, strict: bool) -> PreflightReport {
167    build_report_with_probes(snapshot, strict, Vec::new())
168}
169
170pub fn build_report_with_probes(
171    snapshot: ProbeSnapshot,
172    strict: bool,
173    mut probe_checks: Vec<CheckReport>,
174) -> PreflightReport {
175    let permissions = permission_status_from_snapshot(&snapshot);
176
177    let checks = vec![
178        tool_check(
179            "osascript",
180            "osascript",
181            snapshot.osascript_path,
182            true,
183            None,
184        ),
185        tool_check(
186            "cliclick",
187            "cliclick",
188            snapshot.cliclick_path,
189            true,
190            Some(CLICLICK_INSTALL_HINT),
191        ),
192        permission_check(
193            "accessibility",
194            "Accessibility",
195            snapshot.accessibility_signal,
196            true,
197            ACCESSIBILITY_HINT,
198        ),
199        permission_check(
200            "automation",
201            "Automation",
202            snapshot.automation_signal,
203            true,
204            AUTOMATION_HINT,
205        ),
206        permission_check(
207            "screen_recording",
208            "Screen Recording",
209            snapshot.screen_recording_signal,
210            false,
211            SCREEN_RECORDING_HINT,
212        ),
213    ];
214
215    let mut checks = checks;
216    checks.append(&mut probe_checks);
217
218    PreflightReport {
219        strict,
220        checks,
221        permissions,
222    }
223}
224
225fn permission_status_from_snapshot(snapshot: &ProbeSnapshot) -> PermissionStatusSchema {
226    let mut hints = Vec::new();
227    push_permission_hint_if_not_ready(
228        &mut hints,
229        snapshot.accessibility_signal.state,
230        ACCESSIBILITY_HINT,
231    );
232    push_permission_hint_if_not_ready(
233        &mut hints,
234        snapshot.automation_signal.state,
235        AUTOMATION_HINT,
236    );
237    push_permission_hint_if_not_ready(
238        &mut hints,
239        snapshot.screen_recording_signal.state,
240        SCREEN_RECORDING_HINT,
241    );
242
243    PermissionStatusSchema::from_components(
244        snapshot.screen_recording_signal.state,
245        snapshot.accessibility_signal.state,
246        snapshot.automation_signal.state,
247        hints,
248    )
249}
250
251fn push_permission_hint_if_not_ready(hints: &mut Vec<String>, state: PermissionState, hint: &str) {
252    if state != PermissionState::Ready {
253        hints.push(hint.to_string());
254    }
255}
256
257pub fn run_live_probes() -> Vec<CheckReport> {
258    vec![probe_activate(), probe_input_hotkey(), probe_screenshot()]
259}
260
261pub fn render_text(report: &PreflightReport) -> String {
262    let summary = report.summary();
263    let mut lines = Vec::with_capacity(2 + report.checks.len() * 2);
264    lines.push(format!(
265        "preflight: {} (strict={})",
266        summary.title(),
267        report.strict
268    ));
269    lines.push(format!(
270        "blocking_failures: {}, warnings: {}",
271        summary.blocking_failures, summary.warnings
272    ));
273
274    for check in &report.checks {
275        lines.push(format!(
276            "- [{}] {}: {}",
277            check.status.as_str(),
278            check.label,
279            check.message
280        ));
281        if let Some(hint) = &check.hint {
282            lines.push(format!("  hint: {hint}"));
283        }
284    }
285
286    lines.join("\n")
287}
288
289pub fn render_json(report: &PreflightReport) -> Value {
290    let summary = report.summary();
291    let checks = report
292        .checks
293        .iter()
294        .map(|check| {
295            json!({
296                "id": check.id,
297                "label": check.label,
298                "status": check.status.as_str(),
299                "blocking": check.blocking,
300                "message": check.message,
301                "hint": check.hint,
302            })
303        })
304        .collect::<Vec<_>>();
305    let permissions = json!({
306        "screen_recording": report.permissions.screen_recording.as_str(),
307        "accessibility": report.permissions.accessibility.as_str(),
308        "automation": report.permissions.automation.as_str(),
309        "ready": report.permissions.ready,
310        "hints": report.permissions.hints.clone(),
311    });
312
313    json!({
314        "schema_version": 1,
315        "ok": summary.ok,
316        "command": "preflight",
317        "result": {
318            "strict": report.strict,
319            "status": summary.status(),
320            "summary": {
321                "blocking_failures": summary.blocking_failures,
322                "warnings": summary.warnings,
323            },
324            "checks": checks,
325            "permissions": permissions,
326        }
327    })
328}
329
330fn tool_check(
331    id: &'static str,
332    label: &'static str,
333    path: Option<String>,
334    blocking: bool,
335    missing_hint: Option<&str>,
336) -> CheckReport {
337    match path {
338        Some(path) => CheckReport {
339            id,
340            label,
341            status: CheckStatus::Ok,
342            blocking,
343            message: format!("found at {path}"),
344            hint: None,
345        },
346        None => CheckReport {
347            id,
348            label,
349            status: CheckStatus::Fail,
350            blocking,
351            message: "not found in PATH".to_string(),
352            hint: missing_hint.map(str::to_string),
353        },
354    }
355}
356
357fn permission_check(
358    id: &'static str,
359    label: &'static str,
360    signal: PermissionSignal,
361    blocking: bool,
362    guidance: &'static str,
363) -> CheckReport {
364    match signal.state {
365        PermissionState::Ready => CheckReport {
366            id,
367            label,
368            status: CheckStatus::Ok,
369            blocking,
370            message: signal.detail,
371            hint: None,
372        },
373        PermissionState::Blocked => CheckReport {
374            id,
375            label,
376            status: CheckStatus::Fail,
377            blocking,
378            message: signal.detail,
379            hint: Some(guidance.to_string()),
380        },
381        PermissionState::Unknown => CheckReport {
382            id,
383            label,
384            status: CheckStatus::Warn,
385            blocking,
386            message: signal.detail,
387            hint: Some(guidance.to_string()),
388        },
389    }
390}
391
392fn probe_accessibility() -> PermissionSignal {
393    let output = run_osascript(ACCESSIBILITY_SCRIPT);
394    if output.success {
395        let value = output.stdout.trim().to_ascii_lowercase();
396        return match value.as_str() {
397            "true" => PermissionSignal::ready("System Events reports UI scripting is enabled."),
398            "false" => PermissionSignal::blocked("System Events reports UI scripting is disabled."),
399            _ => PermissionSignal::unknown(format!(
400                "Accessibility probe returned unexpected value: {}",
401                sanitize_probe_detail(&output.stdout)
402            )),
403        };
404    }
405
406    let normalized = output.normalized_detail();
407    if looks_like_accessibility_blocked(&normalized) {
408        PermissionSignal::blocked("Accessibility access is blocked for this terminal host.")
409    } else if looks_like_automation_blocked(&normalized) {
410        PermissionSignal::unknown(
411            "Could not confirm Accessibility because Automation access to System Events is blocked.",
412        )
413    } else {
414        PermissionSignal::unknown(format!(
415            "Accessibility probe failed: {}",
416            sanitize_probe_detail(&normalized)
417        ))
418    }
419}
420
421fn probe_automation() -> PermissionSignal {
422    let output = run_osascript(AUTOMATION_SCRIPT);
423    if output.success {
424        return PermissionSignal::ready("Apple Events access to System Events is allowed.");
425    }
426
427    let normalized = output.normalized_detail();
428    if looks_like_automation_blocked(&normalized) {
429        PermissionSignal::blocked("Apple Events access to System Events is blocked.")
430    } else {
431        PermissionSignal::unknown(format!(
432            "Automation probe failed: {}",
433            sanitize_probe_detail(&normalized)
434        ))
435    }
436}
437
438fn probe_activate() -> CheckReport {
439    let output = run_osascript(AUTOMATION_SCRIPT);
440    if output.success {
441        return CheckReport {
442            id: "probe_activate",
443            label: "Probe: window activate",
444            status: CheckStatus::Ok,
445            blocking: false,
446            message: "Activation probe succeeded.".to_string(),
447            hint: None,
448        };
449    }
450
451    let detail = sanitize_probe_detail(&output.normalized_detail());
452    CheckReport {
453        id: "probe_activate",
454        label: "Probe: window activate",
455        status: CheckStatus::Warn,
456        blocking: false,
457        message: format!("Activation probe failed: {detail}"),
458        hint: Some(AUTOMATION_HINT.to_string()),
459    }
460}
461
462fn probe_input_hotkey() -> CheckReport {
463    // Use a non-printing modifier key so probe execution does not leak visible
464    // escape/control glyphs into an interactive terminal session.
465    let script = r#"tell application "System Events" to key code 56"#;
466    let output = run_osascript(script);
467    if output.success {
468        return CheckReport {
469            id: "probe_input_hotkey",
470            label: "Probe: input hotkey",
471            status: CheckStatus::Ok,
472            blocking: false,
473            message: "Input hotkey probe succeeded.".to_string(),
474            hint: None,
475        };
476    }
477
478    let detail = sanitize_probe_detail(&output.normalized_detail());
479    CheckReport {
480        id: "probe_input_hotkey",
481        label: "Probe: input hotkey",
482        status: CheckStatus::Warn,
483        blocking: false,
484        message: format!("Input probe failed: {detail}"),
485        hint: Some(ACCESSIBILITY_HINT.to_string()),
486    }
487}
488
489fn probe_screenshot() -> CheckReport {
490    if shared_env::env_truthy("AGENTS_MACOS_AGENT_TEST_MODE") {
491        return CheckReport {
492            id: "probe_screenshot",
493            label: "Probe: observe screenshot",
494            status: CheckStatus::Ok,
495            blocking: false,
496            message: "Screenshot probe succeeded in deterministic test mode.".to_string(),
497            hint: None,
498        };
499    }
500
501    #[cfg(target_os = "macos")]
502    let shareable =
503        screen_record::macos::shareable::fetch_shareable().map_err(|err| err.to_string());
504
505    #[cfg(not(target_os = "macos"))]
506    let shareable: Result<screen_record::types::ShareableContent, String> =
507        Err("macOS shareable probe is unavailable on this platform".to_string());
508
509    match shareable {
510        Ok(content) => {
511            if content.windows.is_empty() {
512                CheckReport {
513                    id: "probe_screenshot",
514                    label: "Probe: observe screenshot",
515                    status: CheckStatus::Warn,
516                    blocking: false,
517                    message: "Screenshot probe found no shareable windows.".to_string(),
518                    hint: Some(SCREEN_RECORDING_HINT.to_string()),
519                }
520            } else {
521                CheckReport {
522                    id: "probe_screenshot",
523                    label: "Probe: observe screenshot",
524                    status: CheckStatus::Ok,
525                    blocking: false,
526                    message: "Screenshot probe validated shareable content access.".to_string(),
527                    hint: None,
528                }
529            }
530        }
531        Err(err) => CheckReport {
532            id: "probe_screenshot",
533            label: "Probe: observe screenshot",
534            status: CheckStatus::Warn,
535            blocking: false,
536            message: format!("Screenshot probe failed: {err}"),
537            hint: Some(SCREEN_RECORDING_HINT.to_string()),
538        },
539    }
540}
541
542fn find_in_path(bin: &str) -> Option<String> {
543    shared_process::find_in_path(bin).map(|path| path.display().to_string())
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
547struct OsaScriptOutput {
548    success: bool,
549    stdout: String,
550    stderr: String,
551}
552
553impl OsaScriptOutput {
554    fn normalized_detail(&self) -> String {
555        let merged = format!("{} {}", self.stdout, self.stderr);
556        sanitize_probe_detail(&merged).to_ascii_lowercase()
557    }
558}
559
560fn run_osascript(script: &str) -> OsaScriptOutput {
561    match Command::new("osascript").args(["-e", script]).output() {
562        Ok(output) => OsaScriptOutput {
563            success: output.status.success(),
564            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
565            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
566        },
567        Err(err) => OsaScriptOutput {
568            success: false,
569            stdout: String::new(),
570            stderr: err.to_string(),
571        },
572    }
573}
574
575fn sanitize_probe_detail(raw: &str) -> String {
576    let collapsed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
577    if collapsed.is_empty() {
578        "no detail available".to_string()
579    } else {
580        collapsed
581    }
582}
583
584fn looks_like_automation_blocked(message: &str) -> bool {
585    message.contains("-1743") || message.contains("not authorized to send apple events")
586}
587
588fn looks_like_accessibility_blocked(message: &str) -> bool {
589    message.contains("-25211")
590        || message.contains("assistive access")
591        || message.contains("ui scripting")
592            && (message.contains("not allowed") || message.contains("permission"))
593}
594
595#[cfg(test)]
596mod tests {
597    use std::path::PathBuf;
598
599    use nils_test_support::{EnvGuard, GlobalStateLock, StubBinDir, prepend_path};
600
601    use super::{
602        ACCESSIBILITY_HINT, CheckStatus, PermissionState, collect_system_snapshot, find_in_path,
603        looks_like_accessibility_blocked, looks_like_automation_blocked, probe_accessibility,
604        probe_automation, probe_input_hotkey, run_osascript, sanitize_probe_detail,
605    };
606
607    fn install_stub_tools(
608        lock: &GlobalStateLock,
609        include_cliclick: bool,
610    ) -> (StubBinDir, EnvGuard) {
611        let stub_dir = StubBinDir::new();
612        stub_dir.write_exe(
613            "osascript",
614            r#"#!/usr/bin/env bash
615set -euo pipefail
616script="${*: -1}"
617if [[ "$script" == *"UI elements enabled"* ]]; then
618  mode="${MACOS_AGENT_TEST_ACCESSIBILITY_MODE:-true}"
619  case "$mode" in
620    true|false)
621      echo "$mode"
622      exit 0
623      ;;
624    block)
625      echo "Assistive access not allowed (-25211)" >&2
626      exit 1
627      ;;
628    automation_block)
629      echo "Not authorized to send apple events to System Events. (-1743)" >&2
630      exit 1
631      ;;
632    other_error)
633      echo "unexpected accessibility failure" >&2
634      exit 1
635      ;;
636    *)
637      echo "$mode"
638      exit 0
639      ;;
640  esac
641fi
642if [[ "$script" == *"frontmost is true"* ]]; then
643  mode="${MACOS_AGENT_TEST_AUTOMATION_MODE:-ok}"
644  case "$mode" in
645    ok)
646      echo "Terminal"
647      exit 0
648      ;;
649    block)
650      echo "Not authorized to send apple events to System Events. (-1743)" >&2
651      exit 1
652      ;;
653    other_error)
654      echo "automation probe exploded" >&2
655      exit 1
656      ;;
657  esac
658fi
659if [[ "$script" == *"key code 56"* ]]; then
660  mode="${MACOS_AGENT_TEST_HOTKEY_MODE:-ok}"
661  case "$mode" in
662    ok)
663      exit 0
664      ;;
665    block)
666      echo "Assistive access not allowed (-25211)" >&2
667      exit 1
668      ;;
669    other_error)
670      echo "hotkey probe exploded" >&2
671      exit 1
672      ;;
673  esac
674fi
675echo "unsupported script" >&2
676exit 1
677"#,
678        );
679
680        if include_cliclick {
681            stub_dir.write_exe("cliclick", "#!/usr/bin/env bash\nexit 0\n");
682        }
683
684        let path_guard = prepend_path(lock, stub_dir.path());
685        (stub_dir, path_guard)
686    }
687
688    #[test]
689    fn collect_snapshot_uses_stubbed_tools() {
690        let lock = GlobalStateLock::new();
691        let (_stubs, _path) = install_stub_tools(&lock, true);
692        let _a11y = EnvGuard::set(&lock, "MACOS_AGENT_TEST_ACCESSIBILITY_MODE", "true");
693        let _automation = EnvGuard::set(&lock, "MACOS_AGENT_TEST_AUTOMATION_MODE", "ok");
694
695        let snapshot = collect_system_snapshot();
696        assert!(snapshot.osascript_path.is_some());
697        assert!(snapshot.cliclick_path.is_some());
698        assert_eq!(snapshot.accessibility_signal.state, PermissionState::Ready);
699        assert_eq!(snapshot.automation_signal.state, PermissionState::Ready);
700    }
701
702    #[test]
703    fn collect_snapshot_without_osascript_marks_permission_unknown() {
704        let lock = GlobalStateLock::new();
705        let empty = StubBinDir::new();
706        let _path = EnvGuard::set(&lock, "PATH", &empty.path_str());
707
708        let snapshot = collect_system_snapshot();
709        assert!(snapshot.osascript_path.is_none());
710        assert_eq!(
711            snapshot.accessibility_signal.state,
712            PermissionState::Unknown
713        );
714        assert_eq!(snapshot.automation_signal.state, PermissionState::Unknown);
715    }
716
717    #[test]
718    fn probe_accessibility_covers_success_and_error_modes() {
719        let lock = GlobalStateLock::new();
720        let (_stubs, _path) = install_stub_tools(&lock, false);
721
722        let _mode_false = EnvGuard::set(&lock, "MACOS_AGENT_TEST_ACCESSIBILITY_MODE", "false");
723        let blocked = probe_accessibility();
724        assert_eq!(blocked.state, PermissionState::Blocked);
725
726        let _mode_weird = EnvGuard::set(
727            &lock,
728            "MACOS_AGENT_TEST_ACCESSIBILITY_MODE",
729            "unexpected-value",
730        );
731        let unknown = probe_accessibility();
732        assert_eq!(unknown.state, PermissionState::Unknown);
733
734        let _mode_block = EnvGuard::set(&lock, "MACOS_AGENT_TEST_ACCESSIBILITY_MODE", "block");
735        let blocked = probe_accessibility();
736        assert_eq!(blocked.state, PermissionState::Blocked);
737
738        let _mode_auto_block = EnvGuard::set(
739            &lock,
740            "MACOS_AGENT_TEST_ACCESSIBILITY_MODE",
741            "automation_block",
742        );
743        let unknown = probe_accessibility();
744        assert_eq!(unknown.state, PermissionState::Unknown);
745
746        let _mode_other =
747            EnvGuard::set(&lock, "MACOS_AGENT_TEST_ACCESSIBILITY_MODE", "other_error");
748        let unknown = probe_accessibility();
749        assert_eq!(unknown.state, PermissionState::Unknown);
750    }
751
752    #[test]
753    fn probe_automation_covers_blocked_and_unknown() {
754        let lock = GlobalStateLock::new();
755        let (_stubs, _path) = install_stub_tools(&lock, false);
756
757        let _mode_ok = EnvGuard::set(&lock, "MACOS_AGENT_TEST_AUTOMATION_MODE", "ok");
758        let ready = probe_automation();
759        assert_eq!(ready.state, PermissionState::Ready);
760
761        let _mode_block = EnvGuard::set(&lock, "MACOS_AGENT_TEST_AUTOMATION_MODE", "block");
762        let blocked = probe_automation();
763        assert_eq!(blocked.state, PermissionState::Blocked);
764
765        let _mode_other = EnvGuard::set(&lock, "MACOS_AGENT_TEST_AUTOMATION_MODE", "other_error");
766        let unknown = probe_automation();
767        assert_eq!(unknown.state, PermissionState::Unknown);
768    }
769
770    #[test]
771    fn probe_input_hotkey_uses_non_esc_key_and_maps_failures() {
772        let lock = GlobalStateLock::new();
773        let (_stubs, _path) = install_stub_tools(&lock, false);
774
775        let _mode_ok = EnvGuard::set(&lock, "MACOS_AGENT_TEST_HOTKEY_MODE", "ok");
776        let ready = probe_input_hotkey();
777        assert_eq!(ready.status, CheckStatus::Ok);
778
779        let _mode_block = EnvGuard::set(&lock, "MACOS_AGENT_TEST_HOTKEY_MODE", "block");
780        let blocked = probe_input_hotkey();
781        assert_eq!(blocked.status, CheckStatus::Warn);
782        assert_eq!(blocked.hint.as_deref(), Some(ACCESSIBILITY_HINT));
783    }
784
785    #[test]
786    fn helpers_cover_path_detection_and_sanitization() {
787        let lock = GlobalStateLock::new();
788        let (stubs, _path) = install_stub_tools(&lock, false);
789
790        let osascript_path = stubs.path().join("osascript");
791        let detected = find_in_path(osascript_path.to_str().unwrap()).expect("explicit path");
792        assert_eq!(PathBuf::from(detected), osascript_path);
793        assert!(find_in_path(stubs.path().join("missing").to_str().unwrap()).is_none());
794
795        assert_eq!(sanitize_probe_detail(" a \n b \t c "), "a b c");
796        assert_eq!(sanitize_probe_detail(""), "no detail available");
797
798        assert!(looks_like_automation_blocked("-1743"));
799        assert!(looks_like_accessibility_blocked(
800            "assistive access not allowed"
801        ));
802    }
803
804    #[test]
805    fn run_osascript_reports_spawn_failures() {
806        let lock = GlobalStateLock::new();
807        let empty = StubBinDir::new();
808        let _path = EnvGuard::set(&lock, "PATH", &empty.path_str());
809
810        let output = run_osascript("return 1");
811        assert!(!output.success);
812        assert!(!output.stderr.is_empty());
813    }
814}