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 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}