diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index 6b7b6acca..a8c5da6df 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -17,6 +17,7 @@ Commands: console-login LEASE_ID reset-guest-password LEASE_ID secure-console-submit LEASE_ID + console-key LEASE_ID --key PARALLELS_KEY_CODE [--modifier PARALLELS_KEY_CODE] rfb-pointer-probe LEASE_ID --x X --y Y desktop-bootstrap LEASE_ID [--install-tools] nameplate LEASE_ID enable|show|hide|status @@ -157,6 +158,21 @@ EOF require_lease_id "$1" remote secure-console-submit "$1" ;; + console-key) + lease=${1:-} + [[ -n $lease ]] || usage + require_lease_id "$lease" + shift + [[ ${1:-} == "--key" && ${2:-} =~ ^[0-9]+$ ]] || usage + key_code=$2 + shift 2 + modifier=0 + if [[ $# -gt 0 ]]; then + [[ ${1:-} == "--modifier" && ${2:-} =~ ^[0-9]+$ && $# -eq 2 ]] || usage + modifier=$2 + fi + remote console-key "$lease" "$key_code" "$modifier" + ;; rfb-pointer-probe) lease=${1:-} [[ -n $lease ]] || usage diff --git a/Scripts/lab/macos-27-selector-driver b/Scripts/lab/macos-27-selector-driver new file mode 100755 index 000000000..96a806dd8 --- /dev/null +++ b/Scripts/lab/macos-27-selector-driver @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Capture and validate macOS 27 System Settings selector evidence.""" + +import argparse +import json +import os +import pathlib +import subprocess + + +ROOT = pathlib.Path(__file__).resolve().parent +PEEKABOO = pathlib.Path(os.environ.get("KEYPATH_SELECTOR_PEEKABOO", ROOT / "peekaboo-ui")) +RESULT = pathlib.Path(os.environ.get("KEYPATH_SELECTOR_RESULT", ROOT / "scenario-result")) + + +def run(command, output=None): + if output is None: + return subprocess.run(command, check=False, capture_output=True, text=True) + with output.open("w") as handle: + return subprocess.run(command, check=False, stdout=handle, stderr=subprocess.STDOUT, text=True) + + +def macos_version() -> tuple[str, str]: + version = run(["sw_vers", "-productVersion"]) + build = run(["sw_vers", "-buildVersion"]) + if version.returncode != 0 or build.returncode != 0: + raise RuntimeError("could not read macOS version") + return version.stdout.strip(), build.stdout.strip() + + +def contains(value, expected: str) -> bool: + needle = expected.casefold() + if isinstance(value, str): + return needle in value.casefold() + if isinstance(value, dict): + return any(contains(item, expected) for item in value.values()) + if isinstance(value, list): + return any(contains(item, expected) for item in value) + return False + + +def record(output: pathlib.Path, scenario: str, status: str, summary: str, + classification: str, step: str, *extra: str) -> None: + subprocess.run([ + str(RESULT), "record", "--output", str(output / "result.json"), + "--scenario", scenario, "--status", status, "--summary", summary, + "--classification", classification, "--step", step, *extra, + ], check=True) + + +def required_permissions(preflight: object) -> list[str]: + if not isinstance(preflight, dict): + return ["unknown"] + data = preflight.get("data", {}) + permissions = data.get("permissions", []) if isinstance(data, dict) else [] + if not isinstance(permissions, list): + return ["unknown"] + return [ + str(item.get("name", "unknown")) + for item in permissions + if isinstance(item, dict) and item.get("isRequired") is True and item.get("isGranted") is not True + ] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=pathlib.Path) + parser.add_argument("--expect", action="append", required=True, + help="required text in fresh System Settings AX evidence") + parser.add_argument("--scenario", default="macos-27-selector-probe") + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + version, build = macos_version() + if version.split(".")[0] != "27": + record(args.output, args.scenario, "blocked", + "macOS 27 selector driver requires a macOS 27 guest.", + "unsupported-os-selector", "os-admission") + return 4 + run(["sw_vers"], args.output / "sw-vers.txt") + preflight_path = args.output / "peekaboo-preflight.json" + preflight_result = run([str(PEEKABOO), "preflight"], preflight_path) + if preflight_result.returncode != 0: + record(args.output, args.scenario, "failed", + "Peekaboo preflight could not verify the macOS 27 desktop session.", + "environment-precondition-failure", "peekaboo-preflight", + "--evidence", preflight_path.name) + return preflight_result.returncode + try: + preflight = json.loads(preflight_path.read_text()) + except json.JSONDecodeError: + record(args.output, args.scenario, "failed", + "Peekaboo preflight evidence was not valid JSON.", + "harness-transport-failure", "peekaboo-preflight", + "--evidence", preflight_path.name) + return 1 + missing_permissions = required_permissions(preflight) + if missing_permissions: + record(args.output, args.scenario, "blocked", + "Peekaboo lacks required macOS 27 desktop permissions.", + "environment-precondition-failure", "peekaboo-permissions", + "--message", "Missing permissions: " + ", ".join(missing_permissions), + "--evidence", preflight_path.name) + return 4 + + snapshot = args.output / "system-settings-ax.json" + capture = run([str(PEEKABOO), "snapshot", "--app", "System Settings", "--output", str(snapshot)]) + if capture.returncode != 0: + record(args.output, args.scenario, "failed", + "Could not capture current System Settings selector evidence.", + "harness-transport-failure", "ax-snapshot") + return capture.returncode + try: + evidence = json.loads(snapshot.read_text()) + except json.JSONDecodeError: + record(args.output, args.scenario, "failed", + "System Settings selector evidence was not valid JSON.", + "harness-transport-failure", "ax-snapshot") + return 1 + missing = [selector for selector in args.expect if not contains(evidence, selector)] + if missing: + record(args.output, args.scenario, "blocked", + "macOS 27 System Settings surface did not expose the required selector evidence.", + "unsupported-os-selector", "selector-admission", + "--message", "Missing selectors: " + ", ".join(missing), + "--evidence", snapshot.name) + return 4 + (args.output / "selector-contract.json").write_text(json.dumps({ + "schemaVersion": 1, + "macOSMajor": 27, + "macOSVersion": version, + "macOSBuild": build, + "expected": args.expect, + "evidence": snapshot.name, + "preflightEvidence": preflight_path.name, + }, indent=2) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 4877e1b1b..a41ed3d73 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1396,6 +1396,32 @@ for ch in value: print(json.dumps([{"key":codes[ch]}],separators=(",",":")))' "$ print "credential_transport\tparallels-key-events" } +console_key() { + local lease=$1 key_code=$2 modifier_code=${3:-0} manifest macos resource parallels_cli + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + [[ "$macos" == "26" || "$macos" == "27" ]] || die "console key requires a macOS 26 or 27 Parallels lane" + [[ "$(field "$manifest" provider)" == "parallels" ]] || die "console key requires a Parallels lease" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "console key requires a desktop-enabled lease" + [[ "$key_code" == <-> && "$key_code" -ge 1 && "$key_code" -le 255 ]] || die "invalid Parallels key code" + [[ "$modifier_code" == <-> && "$modifier_code" -ge 0 && "$modifier_code" -le 255 ]] || die "invalid Parallels modifier key code" + resource=$(field "$manifest" provider_resource) + [[ "$resource" =~ '^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$' ]] || die "invalid Parallels resource id" + parallels_cli=${KEYPATH_LAB_PRLCTL:-"/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"} + [[ -x "$parallels_cli" ]] || die "Parallels CLI is unavailable" + if (( modifier_code > 0 )); then + "$parallels_cli" send-key-event "$resource" --key "$modifier_code" --event press >/dev/null + fi + "$parallels_cli" send-key-event "$resource" --key "$key_code" >/dev/null + if (( modifier_code > 0 )); then + "$parallels_cli" send-key-event "$resource" --key "$modifier_code" --event release >/dev/null + fi + record_command "$lease" passed console-key --key "$key_code" --modifier "$modifier_code" + print "console_key\tpassed" + print "parallels_key_code\t$key_code" + print "parallels_modifier_code\t$modifier_code" +} + reset_guest_password() { local lease=$1 manifest resource parallels_cli secret_file key known_hosts known_hosts_option guest_ip local fifo account_file guest_command reset_pid fifo_ready attempt stream_exit reset_exit enrollment_account @@ -1632,6 +1658,7 @@ case "$action" in console-login) [[ $# -eq 1 ]] || die "console-login requires lease"; console_login "$1" ;; reset-guest-password) [[ $# -eq 1 ]] || die "reset-guest-password requires lease"; reset_guest_password "$1" ;; secure-console-submit) [[ $# -eq 1 ]] || die "secure-console-submit requires lease"; secure_console_submit "$1" ;; + console-key) [[ $# -eq 3 ]] || die "console-key requires lease, Parallels key code, and modifier code"; console_key "$1" "$2" "$3" ;; rfb-pointer-probe) [[ $# -eq 3 ]] || die "rfb-pointer-probe requires lease, x, and y"; rfb_pointer_probe "$@" ;; nameplate) [[ $# -eq 2 ]] || die "nameplate requires lease and action"; nameplate_control "$@" ;; destroy) [[ $# -eq 1 ]] || die "destroy requires lease"; destroy_lease "$1" ;; diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 9c630a063..3ca3681e0 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -585,6 +585,13 @@ expected=[[{"key":codes[ch]}] for ch in open(sys.argv[2]).read()] assert events == expected' "$TMP/secure-console-key-events.jsonl" "$TMP/secure-input" ! grep -Fq 'fixture-password-that-must-not-leak' "$TMP/secure-console-key-events.jsonl" grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 36' "$CALLS" +console_key=$(run_remote console-key cbx_desktop27 73 37) +assert_contains "$console_key" $'console_key\tpassed' +assert_contains "$console_key" $'parallels_key_code\t73' +assert_contains "$console_key" $'parallels_modifier_code\t37' +grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 37 --event press' "$CALLS" +grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 73' "$CALLS" +grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 37 --event release' "$CALLS" if grep -R -F 'fixture-password-that-must-not-leak' "$ROOT/KeyPathInstallerLab" "$CALLS" "$TMP/guest-ssh-args"; then echo "secure console submit leaked its secret into controller logs or arguments" >&2 exit 1 diff --git a/Scripts/lab/tests/macos-27-selector-driver-tests.py b/Scripts/lab/tests/macos-27-selector-driver-tests.py new file mode 100755 index 000000000..4cb84db9f --- /dev/null +++ b/Scripts/lab/tests/macos-27-selector-driver-tests.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + +TOOL = pathlib.Path(__file__).resolve().parents[1] / "macos-27-selector-driver" + + +class DriverTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tmp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + self.output = self.root / "out" + (self.bin / "sw_vers").write_text( + '#!/bin/sh\nversion=${MACOS_TEST_VERSION:-27.0}\n' + 'if [ "$1" = -productVersion ]; then echo "$version"; ' + 'elif [ "$1" = -buildVersion ]; then echo 26A5378j; ' + 'else echo "ProductVersion: $version"; echo "BuildVersion: 26A5378j"; fi\n' + ) + (self.bin / "sw_vers").chmod(0o755) + self.peekaboo = self.root / "peekaboo-ui" + self.peekaboo.write_text( + '#!/bin/sh\n' + 'if [ "$1" = preflight ]; then\n' + ' if [ -n "${PEEKABOO_TEST_PREFLIGHT:-}" ]; then printf \'%s\\n\' "$PEEKABOO_TEST_PREFLIGHT"; ' + ' else printf \'%s\\n\' \'{"data":{"permissions":[{"name":"Screen Recording","isRequired":true,"isGranted":true},{"name":"Accessibility","isRequired":true,"isGranted":true}]}}\'; fi\n' + ' exit 0\n' + 'fi\n' + 'out=\nwhile [ $# -gt 0 ]; do [ "$1" = --output ] && out=$2; shift; done\n' + 'printf \'{"data":{"ui_elements":[{"identifier":"Privacy_Accessibility","label":"Accessibility"}]}}\' > "$out"\n' + ) + self.peekaboo.chmod(0o755) + self.result = self.root / "scenario-result" + self.result.write_text((TOOL.parent / "scenario-result").read_text()) + self.result.chmod(0o755) + + def tearDown(self): + self.tmp.cleanup() + + def call(self, *extra, version="27.0", preflight=None): + env = os.environ | { + "PATH": str(self.bin) + ":" + os.environ["PATH"], + "MACOS_TEST_VERSION": version, + "KEYPATH_SELECTOR_PEEKABOO": str(self.peekaboo), + "KEYPATH_SELECTOR_RESULT": str(self.result), + } + if preflight is not None: + env["PEEKABOO_TEST_PREFLIGHT"] = json.dumps(preflight, separators=(",", ":")) + return subprocess.run( + [str(TOOL), "--output", str(self.output), *extra], + env=env, + text=True, + capture_output=True, + ) + + def test_accepts_fresh_macos_27_selector_evidence(self): + result = self.call("--expect", "Privacy_Accessibility", "--expect", "Accessibility") + self.assertEqual(result.returncode, 0, result.stderr) + contract = json.loads((self.output / "selector-contract.json").read_text()) + self.assertEqual(contract["macOSMajor"], 27) + self.assertEqual(contract["macOSBuild"], "26A5378j") + + def test_requires_at_least_one_selector(self): + result = self.call() + self.assertEqual(result.returncode, 2) + + def test_rejects_another_macos_version_without_preflight(self): + result = self.call("--expect", "Accessibility", version="26.6") + self.assertEqual(result.returncode, 4) + self.assertEqual(json.loads((self.output / "result.json").read_text())["failure"]["classification"], "unsupported-os-selector") + self.assertFalse((self.output / "peekaboo-preflight.json").exists()) + + def test_missing_permission_is_environment_precondition_failure(self): + result = self.call("--expect", "Accessibility", preflight={ + "data": {"permissions": [ + {"name": "Screen Recording", "isRequired": True, "isGranted": False} + ]} + }) + self.assertEqual(result.returncode, 4) + outcome = json.loads((self.output / "result.json").read_text()) + self.assertEqual(outcome["failure"]["classification"], "environment-precondition-failure") + self.assertIn("Screen Recording", outcome["failure"]["message"]) + + def test_missing_selector_is_explicitly_unsupported(self): + result = self.call("--expect", "Missing Control") + self.assertEqual(result.returncode, 4) + outcome = json.loads((self.output / "result.json").read_text()) + self.assertEqual(outcome["failure"]["classification"], "unsupported-os-selector") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/testing/installer-gui-automation-capabilities.md b/docs/testing/installer-gui-automation-capabilities.md index 3c870425c..11bab6da1 100644 --- a/docs/testing/installer-gui-automation-capabilities.md +++ b/docs/testing/installer-gui-automation-capabilities.md @@ -85,9 +85,13 @@ The remaining core capabilities are: 5. Hardened macOS 26 and macOS 27 selectors before those OS versions can claim the same unattended UI coverage as macOS 15. The macOS 27 console session, secret-safe Remote Management authorization, and RFB event delivery are now - proven. The macOS 26 pointer probe is implemented and fails closed when RFB - authentication is not configured; selector capture, Remote Management base - setup, and composition remain. + proven. The macOS 27 selector driver now fails closed unless fresh + accessibility evidence matches its declared selector contract. The clean + macOS 27 source remains intentionally pristine; a separate reusable Desktop + Automation Base must carry the signed tools, desktop permissions, Remote + Management approval, and an Apple-Account-free console state. The macOS 26 + pointer probe is implemented and fails closed when RFB authentication is not + configured; selector capture, desktop-base setup, and composition remain. Clean install, repair, upgrade, reboot, uninstall, reinstall, cancellation, nightly, and pairwise entries are consumers of those foundations. They are diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 53194e767..4ca3f0996 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -223,6 +223,23 @@ inter-key delay. Parallels can drop characters when a complete credential is sent as one unpaced event batch. The command deliberately reports credential transport only; the RFB probe is the required independent postcondition. +For non-secret navigation of an expected disposable-guest dialog, the +controller can send one bounded Parallels key event: + +```bash +Scripts/lab/keypath-lab console-key cbx_example --key 23 +Scripts/lab/keypath-lab console-key cbx_example --key 36 +``` + +Parallels uses X11-style key codes; `23` is Tab and `36` is Return. An optional +`--modifier CODE` holds that modifier only for the duration of the key event. +The command is admitted only for an owned, desktop-enabled macOS 26 or 27 +Parallels lease. It records transport, not UI success. Use it only when the +expected dialog is already visible, and verify the intended state through a +fresh screenshot, accessibility snapshot, or runtime postcondition. Never use +it for credentials; use `secure-console-submit` for the focused authorization +field. + On July 16, 2026, the supported UI flow enabled Remote Management and VNC control for `keypathqa`, and the probe moved the guest cursor from `684.703125 141.1875` to `80 60`. That proves event posting for this disposable @@ -465,6 +482,23 @@ Scripts/lab/keypath-lab scenario cbx_example macos-27-regression Scripts/lab/keypath-lab artifacts cbx_example ``` +Before an automated macOS 27 System Settings flow relies on selectors, capture +fresh accessibility evidence in the guest: + +```bash +Scripts/lab/macos-27-selector-driver \ + --output artifacts/macos-27-selectors \ + --expect Privacy_Accessibility \ + --expect Accessibility +``` + +The driver admits only macOS 27, requires Peekaboo's desktop permissions, and +writes the exact OS version, build, preflight, accessibility snapshot, and +expected selector contract. Missing desktop permissions are an +`environment-precondition-failure`; a missing expected selector is an +`unsupported-os-selector`. Either result blocks the scenario instead of +guessing coordinates or attributing a harness problem to KeyPath. + The command records the exact OS build, canonical CLI system snapshot, VirtualHID extension state, KeyPath-owned launchd jobs, signatures, Gatekeeper and stapling results, processes, TCP readiness, and relevant logs. It also emits