From 97ab049b550f176f44d62f687cecb0eb7dee587b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Jul 2026 20:13:36 -0700 Subject: [PATCH 01/99] Add fail-closed macOS 27 selector driver --- Scripts/lab/keypath-lab | 16 ++ Scripts/lab/macos-27-selector-driver | 140 ++++++++++++++++++ Scripts/lab/remote.sh | 27 ++++ Scripts/lab/tests/keypath-lab-tests.sh | 7 + .../tests/macos-27-selector-driver-tests.py | 97 ++++++++++++ .../installer-gui-automation-capabilities.md | 10 +- docs/testing/remote-installer-lab.md | 34 +++++ 7 files changed, 328 insertions(+), 3 deletions(-) create mode 100755 Scripts/lab/macos-27-selector-driver create mode 100755 Scripts/lab/tests/macos-27-selector-driver-tests.py 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 From 9ec6b870a785d842c9741735a842de84b0b9a143 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Jul 2026 20:24:39 -0700 Subject: [PATCH 02/99] Provision desktop tools without Homebrew --- Scripts/lab/desktop-bootstrap | 68 ++++++++++++++++++++++++++-- docs/testing/remote-installer-lab.md | 6 ++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Scripts/lab/desktop-bootstrap b/Scripts/lab/desktop-bootstrap index 032957a09..9733fa10d 100755 --- a/Scripts/lab/desktop-bootstrap +++ b/Scripts/lab/desktop-bootstrap @@ -1,6 +1,17 @@ #!/bin/zsh set -euo pipefail +PEEKABOO_VERSION="3.9.8" +PEEKABOO_URL="https://github.com/openclaw/Peekaboo/releases/download/v${PEEKABOO_VERSION}/peekaboo-macos-universal.tar.gz" +PEEKABOO_SHA256="0336348cb690ccd10834dfb571daf79381dbc06f9a1fc58b8642cbeb388993ed" +MCPORTER_VERSION="0.12.3" +MCPORTER_URL="https://github.com/openclaw/mcporter/releases/download/v${MCPORTER_VERSION}/mcporter-macos-arm64-v${MCPORTER_VERSION}.tar.gz" +MCPORTER_SHA256="bb896bde3b7e2eef0a700bcd2598678f4f4184306e63b2e12465d780a1184b78" +UV_VERSION="0.11.32" +UV_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-aarch64-apple-darwin.tar.gz" +UV_SHA256="ed336d0ba49db8ef89b2b41fffa372ce63bd032f22a56f001c265891aec32829" +PYTHON_VERSION="3.12.13" + die() { print -u2 "desktop-bootstrap: $*"; exit 1; } usage() { @@ -8,6 +19,57 @@ usage() { exit 2 } +download_verified() { + local url=$1 expected=$2 destination=$3 actual + /usr/bin/curl --proto '=https' --tlsv1.2 -LfsS "$url" -o "$destination" + actual=$(/usr/bin/shasum -a 256 "$destination" | /usr/bin/awk '{print $1}') + [[ "$actual" == "$expected" ]] || die "checksum mismatch for ${url:t}" +} + +install_desktop_tools() { + local temp local_bin local_lib peekaboo_home python_candidate python_ready=0 + temp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/keypath-desktop-tools.XXXXXX") + trap "/bin/rm -rf '$temp'" EXIT + local_bin="$HOME/.local/bin" + local_lib="$HOME/.local/libexec" + peekaboo_home="$local_lib/peekaboo-$PEEKABOO_VERSION" + /bin/mkdir -p "$local_bin" "$local_lib" + + if [[ ! -x "$local_bin/peekaboo" ]]; then + download_verified "$PEEKABOO_URL" "$PEEKABOO_SHA256" "$temp/peekaboo.tar.gz" + /usr/bin/tar -xzf "$temp/peekaboo.tar.gz" -C "$temp" + /bin/mkdir -p "$peekaboo_home" + /bin/cp -R "$temp/peekaboo-macos-universal/." "$peekaboo_home/" + /bin/ln -sfn "$peekaboo_home/peekaboo" "$local_bin/peekaboo" + fi + + if [[ ! -x "$local_bin/mcporter" ]]; then + download_verified "$MCPORTER_URL" "$MCPORTER_SHA256" "$temp/mcporter.tar.gz" + /usr/bin/tar -xzf "$temp/mcporter.tar.gz" -C "$temp" + /bin/cp "$temp/mcporter" "$local_bin/mcporter" + /bin/chmod 0755 "$local_bin/mcporter" + fi + + if [[ ! -x "$local_bin/uv" ]]; then + download_verified "$UV_URL" "$UV_SHA256" "$temp/uv.tar.gz" + /usr/bin/tar -xzf "$temp/uv.tar.gz" -C "$temp" + /bin/cp "$temp/uv-aarch64-apple-darwin/uv" "$temp/uv-aarch64-apple-darwin/uvx" "$local_bin/" + /bin/chmod 0755 "$local_bin/uv" "$local_bin/uvx" + fi + + export PATH="$local_bin:$PATH" + python_candidate=$(command -v python3 || true) + if [[ -n "$python_candidate" ]]; then + if [[ "$python_candidate" != "/usr/bin/python3" ]] || /usr/bin/xcode-select -p >/dev/null 2>&1; then + "$python_candidate" -c 'import sys; raise SystemExit(sys.version_info < (3, 12))' >/dev/null 2>&1 && + python_ready=1 + fi + fi + if (( ! python_ready )); then + "$local_bin/uv" python install --default "$PYTHON_VERSION" + fi +} + output= install_tools=0 while [[ $# -gt 0 ]]; do @@ -25,12 +87,10 @@ console_user=$(stat -f '%Su' /dev/console) [[ "$console_user" == "$USER" ]] || die "command user $USER does not own console session $console_user" if (( install_tools )); then - command -v brew >/dev/null || die "Homebrew is required to install desktop tools" - command -v python3 >/dev/null || brew install python - command -v peekaboo >/dev/null || brew install steipete/tap/peekaboo - command -v mcporter >/dev/null || brew install steipete/tap/mcporter + install_desktop_tools fi +export PATH="$HOME/.local/bin:$PATH" python_bin=$(command -v python3 || true) [[ -x "$python_bin" ]] || die "Python 3 is required" peekaboo_bin=$(command -v peekaboo || true) diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 4ca3f0996..b2b08fdc9 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -248,8 +248,10 @@ seed; do not infer delivery from the visible toggle alone. For a console-ready candidate base, run `desktop-bootstrap --install-tools` once before capturing its checkpoint. It installs Python 3 as well as -Peekaboo and mcporter, then records the console-user and Peekaboo evidence. -This is base provisioning, not a per-scenario setup step. +Peekaboo and mcporter into the console user's `~/.local` tree from pinned +release archives with checked SHA-256 digests; the clean source does not need +Homebrew. It then records the console-user and Peekaboo evidence. This is base +provisioning, not a per-scenario setup step. ### Disposable desktop identity with Nameplate From 9830c369e685bb49e6b6e3a43b28d122b305f8c8 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Jul 2026 21:00:03 -0700 Subject: [PATCH 03/99] Provision macOS 27 desktop automation base --- Scripts/lab/desktop-bootstrap | 76 ++++++++++++++++++- Scripts/lab/keypath-lab | 12 +++ Scripts/lab/remote.sh | 101 +++++++++++++++++++++++++ Scripts/lab/tests/keypath-lab-tests.sh | 9 +++ docs/testing/remote-installer-lab.md | 38 +++++++++- 5 files changed, 233 insertions(+), 3 deletions(-) diff --git a/Scripts/lab/desktop-bootstrap b/Scripts/lab/desktop-bootstrap index 9733fa10d..5a11f8553 100755 --- a/Scripts/lab/desktop-bootstrap +++ b/Scripts/lab/desktop-bootstrap @@ -27,7 +27,8 @@ download_verified() { } install_desktop_tools() { - local temp local_bin local_lib peekaboo_home python_candidate python_ready=0 + local temp local_bin local_lib peekaboo_home host_app host_contents host_executable + local launch_agents host_launch_agent python_candidate python_ready=0 temp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/keypath-desktop-tools.XXXXXX") trap "/bin/rm -rf '$temp'" EXIT local_bin="$HOME/.local/bin" @@ -43,6 +44,79 @@ install_desktop_tools() { /bin/ln -sfn "$peekaboo_home/peekaboo" "$local_bin/peekaboo" fi + # TCC cannot grant Screen Recording to a standalone executable selected in + # System Settings. Give the signed Peekaboo daemon a stable application + # identity and launch it from the logged-in Aqua session. Tests invoked over + # SSH then use the approved bridge instead of widening Screen Recording to + # every remote shell. + host_app="$HOME/Applications/Peekaboo Lab Host.app" + host_contents="$host_app/Contents" + host_executable="$host_contents/MacOS/PeekabooLabHost" + /bin/mkdir -p "$host_contents/MacOS" "$HOME/Applications" + /bin/cat > "$host_contents/Info.plist" <<'PLIST' + + + + + CFBundleExecutable + PeekabooLabHost + CFBundleIdentifier + com.keypath.lab.peekaboo-host + CFBundleName + Peekaboo Lab Host + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + LSBackgroundOnly + + NSAccessibilityUsageDescription + Drive the disposable KeyPath lab desktop for automated validation. + NSScreenCaptureUsageDescription + Capture the disposable KeyPath lab desktop for automated validation. + + +PLIST + /bin/cat > "$host_executable" <<'HOST' +#!/bin/zsh +set -euo pipefail +socket="$HOME/Library/Application Support/Peekaboo/daemon.sock" +/bin/mkdir -p "${socket:h}" +/bin/rm -f "$socket" +exec "$HOME/.local/bin/peekaboo" daemon run \ + --mode manual \ + --bridge-socket "$socket" \ + --idle-timeout-seconds 31536000 \ + --input-strategy actionFirst +HOST + /bin/chmod 0755 "$host_executable" + /usr/bin/plutil -lint "$host_contents/Info.plist" >/dev/null + /usr/bin/codesign --force --sign - --identifier com.keypath.lab.peekaboo-host "$host_app" + /usr/bin/codesign --verify --strict "$host_app" + + launch_agents="$HOME/Library/LaunchAgents" + host_launch_agent="$launch_agents/com.keypath.lab.peekaboo-host.plist" + /bin/mkdir -p "$launch_agents" + /bin/cat > "$host_launch_agent" < + + + + Label + com.keypath.lab.peekaboo-host + ProgramArguments + + /usr/bin/open + -gj + $host_app + + RunAtLoad + + + +PLIST + /usr/bin/plutil -lint "$host_launch_agent" >/dev/null + if [[ ! -x "$local_bin/mcporter" ]]; then download_verified "$MCPORTER_URL" "$MCPORTER_SHA256" "$temp/mcporter.tar.gz" /usr/bin/tar -xzf "$temp/mcporter.tar.gz" -C "$temp" diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index a8c5da6df..ee692a3b8 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -16,6 +16,8 @@ Commands: install-app LEASE_ID console-login LEASE_ID reset-guest-password LEASE_ID + reset-desktop-keychain LEASE_ID + reboot-guest 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 @@ -153,6 +155,16 @@ EOF require_lease_id "$1" remote reset-guest-password "$1" ;; + reset-desktop-keychain) + [[ $# -eq 1 ]] || usage + require_lease_id "$1" + remote reset-desktop-keychain "$1" + ;; + reboot-guest) + [[ $# -eq 1 ]] || usage + require_lease_id "$1" + remote reboot-guest "$1" + ;; secure-console-submit) [[ $# -eq 1 ]] || usage require_lease_id "$1" diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index a41ed3d73..5cc17fc03 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1490,6 +1490,105 @@ reset_guest_password() { print "enrollment_account\t$enrollment_account" } +reset_desktop_keychain() { + local lease=$1 manifest macos resource parallels_cli secret_file key known_hosts known_hosts_option guest_ip + local fifo marker guest_command reset_pid fifo_ready attempt stream_exit reset_exit backup + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + [[ "$macos" == "27" ]] || die "desktop keychain reset requires the macOS 27 lane" + [[ "$(field "$manifest" provider)" == "parallels" ]] || die "desktop keychain reset requires a Parallels lease" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "desktop keychain reset requires a desktop-enabled lease" + [[ "$(field "$manifest" console_login_status)" == "passed" ]] || die "desktop keychain reset requires a verified console login" + 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" + + key="$HOME/Library/Application Support/crabbox/testboxes/$lease/id_ed25519" + [[ -f "$key" && ! -L "$key" && -O "$key" ]] || die "owned CrabBox SSH key not found for lease" + known_hosts="$HOME/Library/Application Support/crabbox/testboxes/$lease/known_hosts" + [[ -f "$known_hosts" && ! -L "$known_hosts" && -O "$known_hosts" ]] || die "owned CrabBox known-hosts file not found for lease" + known_hosts_option=${known_hosts// /\\ } + guest_ip=$("$parallels_cli" list -i -f -j "$resource" | python3 -c 'import json,sys; rows=json.load(sys.stdin); addresses=rows[0].get("Network",{}).get("ipAddresses",[]) if rows else []; print(next((item.get("ip","") for item in addresses if item.get("type")=="ipv4"),""),end="")') + [[ "$guest_ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Parallels returned an invalid guest address" + + secret_file=$(mktemp "$STATE_ROOT/.desktop-keychain-reset.XXXXXXXX") + chmod 600 "$secret_file" + typeset -g KEYPATH_LAB_SECURE_TEMP="$secret_file" + trap '[[ -z ${KEYPATH_LAB_SECURE_TEMP:-} ]] || rm -f "$KEYPATH_LAB_SECURE_TEMP"' EXIT + /opt/homebrew/bin/sops -d "$HOME/dotfiles/secrets.env" | awk -F= '$1 == "KEYPATH_LAB_GUEST_PASSWORD" {sub(/^[^=]*=/, ""); printf "%s", $0; found=1} END {if (!found) exit 1}' > "$secret_file" || die "KEYPATH_LAB_GUEST_PASSWORD is unavailable" + [[ -s "$secret_file" ]] || die "desktop keychain reset secret is empty" + + fifo="/tmp/keypath-keychain-reset-$lease-$$.fifo" + marker="/tmp/keypath-keychain-reset-$lease-$$.marker" + guest_command="set -euo pipefail; fifo=$(printf %q "$fifo"); marker=$(printf %q "$marker"); home=/Users/keypathqa; keychain=\"\$home/Library/Keychains/login.keychain-db\"; rm -f \"\$fifo\" \"\$marker\"; /usr/bin/mkfifo \"\$fifo\"; /usr/sbin/chown keypathqa:staff \"\$fifo\"; /bin/chmod 600 \"\$fifo\"; trap 'rm -f \"\$fifo\"' EXIT; password=; IFS= read -r password < \"\$fifo\"; backup=none; if [[ -e \"\$keychain\" ]]; then backup=\"\${keychain}.before-desktop-base-$(date -u +%Y%m%dT%H%M%SZ)\"; /bin/mv \"\$keychain\" \"\$backup\"; fi; /usr/sbin/chown -R keypathqa:staff \"\$home/Library/Keychains\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security create-keychain -p \"\$password\" \"\$keychain\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security list-keychains -d user -s \"\$keychain\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security default-keychain -d user -s \"\$keychain\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security unlock-keychain -p \"\$password\" \"\$keychain\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security set-keychain-settings -lut 21600 \"\$keychain\"; printf '%s\n' \"\$backup\" > \"\$marker\"; /bin/chmod 600 \"\$marker\"; unset password" + set +e + "$parallels_cli" exec "$resource" /bin/zsh -lc "$(printf %q "$guest_command")" >/dev/null 2>&1 & + reset_pid=$! + fifo_ready=0 + for attempt in {1..300}; do + if "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_option" -i "$key" "keypathqa@$guest_ip" \ + "/bin/test -p $(printf %q "$fifo")" /dev/null 2>&1; then + fifo_ready=1 + break + fi + sleep 0.1 + done + if (( fifo_ready == 1 )); then + { /bin/cat "$secret_file"; printf '\n'; } | \ + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_option" -i "$key" "keypathqa@$guest_ip" \ + "/bin/zsh -c $(printf %q "/bin/cat > $(printf %q "$fifo")")" >/dev/null 2>&1 + stream_exit=$? + else + stream_exit=76 + kill "$reset_pid" 2>/dev/null || true + fi + wait "$reset_pid" + reset_exit=$? + set -e + "$parallels_cli" exec "$resource" /bin/rm -f "$fifo" >/dev/null 2>&1 || true + backup=$("$parallels_cli" exec "$resource" /bin/cat "$marker" 2>/dev/null | tail -1 || true) + "$parallels_cli" exec "$resource" /bin/rm -f "$marker" >/dev/null 2>&1 || true + rm -f "$secret_file" + KEYPATH_LAB_SECURE_TEMP= + trap - EXIT + (( stream_exit == 0 && reset_exit == 0 )) || die "failed to establish a fresh disposable desktop keychain" + [[ "$backup" == none || "$backup" == /Users/keypathqa/Library/Keychains/login.keychain-db.before-desktop-base-* ]] || + die "desktop keychain reset returned an invalid backup path" + + set_field "$manifest" desktop_keychain_status passed + set_field "$manifest" desktop_keychain_at "$(utc_now)" + record_command "$lease" passed reset-desktop-keychain + print "desktop_keychain_reset\tpassed" + print "previous_keychain\t$backup" +} + +reboot_guest() { + local lease=$1 manifest resource parallels_cli state attempt ready=0 + manifest=$(owned_manifest "$lease") + [[ "$(field "$manifest" provider)" == "parallels" ]] || die "guest reboot currently requires a Parallels lease" + 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" + "$parallels_cli" restart "$resource" >/dev/null + sleep 3 + for attempt in {1..600}; do + state=$("$parallels_cli" list -i -f -j "$resource" 2>/dev/null | + python3 -c 'import json,sys; rows=json.load(sys.stdin); print(rows[0].get("State","") if rows else "",end="")' 2>/dev/null || true) + if [[ "$state" == "running" ]] && + "$parallels_cli" exec "$resource" /usr/bin/true >/dev/null 2>&1; then + ready=1 + break + fi + sleep 0.1 + done + (( ready == 1 )) || die "Parallels guest control did not recover after reboot" + set_field "$manifest" guest_reboot_at "$(utc_now)" + record_command "$lease" passed reboot-guest + print "guest_reboot\tpassed" +} + rfb_pointer_probe() { local lease=$1 x=$2 y=$3 manifest macos resource parallels_cli key known_hosts known_hosts_option guest_ip cursor_command before after manifest=$(owned_manifest "$lease") @@ -1657,6 +1756,8 @@ case "$action" in desktop-bootstrap) [[ $# -eq 2 ]] || die "desktop-bootstrap requires lease and install-tools flag"; desktop_bootstrap "$@" ;; 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" ;; + reset-desktop-keychain) [[ $# -eq 1 ]] || die "reset-desktop-keychain requires lease"; reset_desktop_keychain "$1" ;; + reboot-guest) [[ $# -eq 1 ]] || die "reboot-guest requires lease"; reboot_guest "$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 "$@" ;; diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 3ca3681e0..007331e2a 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -205,6 +205,15 @@ grep -Fq "Automatic login user: keypathqa" "$REMOTE" /bin/bash -n "$LAB_DIR/../qa-macos-27-regression.sh" /bin/zsh -n "$LAB_DIR/desktop-bootstrap" +grep -Fq 'com.keypath.lab.peekaboo-host' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'Peekaboo Lab Host.app' "$LAB_DIR/desktop-bootstrap" +grep -Fq -- '--mode manual' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'NSScreenCaptureUsageDescription' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'NSAccessibilityUsageDescription' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'reset-desktop-keychain)' "$LAB_DIR/keypath-lab" +grep -Fq 'reboot-guest)' "$LAB_DIR/keypath-lab" +grep -Fq 'desktop keychain reset requires a verified console login' "$REMOTE" +grep -Fq 'guest reboot currently requires a Parallels lease' "$REMOTE" /bin/zsh -n "$LAB_DIR/mdm/enroll-clone-ui" /bin/zsh -n "$LAB_DIR/nameplate-instrumentation" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index b2b08fdc9..3d99b6492 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -223,6 +223,26 @@ 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. +When building a new versioned macOS 27 desktop base, an inherited login +keychain can still use the source image's old password after the lab has +aligned the disposable account password. Repair only that owned candidate: + +```bash +Scripts/lab/keypath-lab reset-desktop-keychain cbx_example +Scripts/lab/keypath-lab reboot-guest cbx_example +``` + +`reset-desktop-keychain` is admitted only for a macOS 27, Parallels, +desktop-enabled lease with a verified console login. It creates a fresh login +keychain using the encrypted guest credential and moves the previous keychain +to a timestamped `.before-desktop-base-*` backup. Do not use it in ordinary +scenario clones and do not delete the backup until the candidate has passed +fresh-clone validation. + +`reboot-guest` is an owned provider transport, not proof that the desktop is +ready. After it returns, independently verify the console user, privacy +permissions, and whichever runtime postcondition the scenario requires. + For non-secret navigation of an expected disposable-guest dialog, the controller can send one bounded Parallels key event: @@ -250,8 +270,22 @@ For a console-ready candidate base, run `desktop-bootstrap --install-tools` once before capturing its checkpoint. It installs Python 3 as well as Peekaboo and mcporter into the console user's `~/.local` tree from pinned release archives with checked SHA-256 digests; the clean source does not need -Homebrew. It then records the console-user and Peekaboo evidence. This is base -provisioning, not a per-scenario setup step. +Homebrew. + +The bootstrap also installs a stable, ad-hoc-signed +`~/Applications/Peekaboo Lab Host.app` and a per-user login agent. macOS 27's +Screen Recording picker accepts applications, not Peekaboo's standalone +command-line executable. Approve Screen Recording and Accessibility for this +lab-only host application through the real logged-in System Settings UI. The +application owns the long-lived Peekaboo bridge, so SSH-driven tests use that +approved desktop process instead of granting broad capture access to every +remote shell. Preserve the same application path, identifier, and bytes when +capturing the base; changing any of them invalidates the reusable TCC identity. + +The bootstrap then records the console-user and Peekaboo evidence. This is base +provisioning, not a per-scenario setup step. A successful install or a visible +toggle is not enough: require `peekaboo permissions status --json`, a real +semantic capture, and a new disposable clone from the captured base. ### Disposable desktop identity with Nameplate From df51458b34a9a464945b38570cc636be618e3f94 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Jul 2026 21:11:28 -0700 Subject: [PATCH 04/99] Use native Peekaboo lab host launcher --- Scripts/lab/assets/peekaboo-lab-host-arm64 | Bin 0 -> 33800 bytes Scripts/lab/assets/peekaboo-lab-host.c | 44 +++++++++++++++++++++ Scripts/lab/desktop-bootstrap | 23 +++++------ Scripts/lab/tests/keypath-lab-tests.sh | 4 +- 4 files changed, 57 insertions(+), 14 deletions(-) create mode 100755 Scripts/lab/assets/peekaboo-lab-host-arm64 create mode 100644 Scripts/lab/assets/peekaboo-lab-host.c diff --git a/Scripts/lab/assets/peekaboo-lab-host-arm64 b/Scripts/lab/assets/peekaboo-lab-host-arm64 new file mode 100755 index 0000000000000000000000000000000000000000..b12704a37b37a2e4821ba7f1020ff262893f862f GIT binary patch literal 33800 zcmeI5U2GIp6oBt+Ti7bnEg}4Dg)S0AgzYZUmJ%M^0>u`QZA(K68g6HIr|q!2v)kEO zq0+>GLJ$o}*Hohq8Xib!Q8d9mh!I6%2rq=x2TXXVCBcNBU^MihC6;>5%xsxmBpQit z=OkzEJ@=e@=G<@Yv`;hl%Kf_!W-%r(5C>=}sHc#z!^{Ic#ukFsgL2+j`%2xey5=B` z=87>ncjyrd^SlFroVV1qw&c2v`GMRqX4yqBj0zpE#Da2w%uG>z}f`R z8j{x*z#uBJaDj5(k+9u{$L0$_tRVq&zGX37_ROpS?ZbVxwPD+!r>u>D{on+2fk0Ao)3bV~Kb5PtBwHK6D-_e0Rw$*l&wST^!PSpEa}?nu=yJoX6}VcA*R%bX|`fk3AUKu7lL0f{97#lW$=5PdNW*OE`xTUclS7~ zxo-v3xB_Z?S#aLDUc_3U_RQq|+QG5lg@N-0{Hk%C8ojv+@*HQZayhh6_u29~*0rP% zt_jz{Yr+15aai*QWtn2k+f>Y5g-CYfCvx)B0vO)01+SpM1Tko z0U|&IhyW2F0z`la5CI}U1c(3;AOb{y2oM1xKm`690heyR%Inhc9Y}b?S2x}_hSq$s z(WRTOLb`N(ZyWvAyW6^9(hm_J0z`la5CI}U1c(3;AOb{y2oM1xKm>>Y5g-CYfCvx) zB0vO)01+SpM1Tko0U|&IhyW2F0z`la5CI}U1c(3;AOb{y2oM1xKm>>Y5g-CYfCvx) zB0vO)01+SpM1Tko0V43k1U!(T5i|)3=TTdy8bb%fey}l~C7uoBE!^_l2m0yYiB&I#AGE3c?HhqU@7u~URr!haRNxV}MBa+IBJmk2JSkKaI;XV~UB(AjgMp*25`0KR3Qc6)7}4d;sI_?zjrDt-I?m-%*e;pBTt(D spXOiO_1V!=*S|b+@{hX*7p>p%{f^NiKdk+&N%?W|xZ8E=YCZD%3)q1~u>b%7 literal 0 HcmV?d00001 diff --git a/Scripts/lab/assets/peekaboo-lab-host.c b/Scripts/lab/assets/peekaboo-lab-host.c new file mode 100644 index 000000000..fa99288dd --- /dev/null +++ b/Scripts/lab/assets/peekaboo-lab-host.c @@ -0,0 +1,44 @@ +#include +#include +#include +#include +#include +#include + +int main(void) { + const char *home = getenv("HOME"); + char executable[PATH_MAX]; + char support[PATH_MAX]; + char socket_path[PATH_MAX]; + + if (home == NULL || home[0] != '/') { + return 64; + } + if (snprintf(executable, sizeof(executable), "%s/.local/bin/peekaboo", home) >= + (int)sizeof(executable) || + snprintf(support, sizeof(support), "%s/Library/Application Support/Peekaboo", home) >= + (int)sizeof(support) || + snprintf(socket_path, sizeof(socket_path), "%s/daemon.sock", support) >= + (int)sizeof(socket_path)) { + return 65; + } + + if (mkdir(support, 0700) != 0 && errno != EEXIST) { + return 73; + } + unlink(socket_path); + execl(executable, + executable, + "daemon", + "run", + "--mode", + "manual", + "--bridge-socket", + socket_path, + "--idle-timeout-seconds", + "31536000", + "--input-strategy", + "actionFirst", + (char *)NULL); + return 127; +} diff --git a/Scripts/lab/desktop-bootstrap b/Scripts/lab/desktop-bootstrap index 5a11f8553..42cd89789 100755 --- a/Scripts/lab/desktop-bootstrap +++ b/Scripts/lab/desktop-bootstrap @@ -1,9 +1,11 @@ #!/bin/zsh set -euo pipefail +SCRIPT_DIR=${0:A:h} PEEKABOO_VERSION="3.9.8" PEEKABOO_URL="https://github.com/openclaw/Peekaboo/releases/download/v${PEEKABOO_VERSION}/peekaboo-macos-universal.tar.gz" PEEKABOO_SHA256="0336348cb690ccd10834dfb571daf79381dbc06f9a1fc58b8642cbeb388993ed" +PEEKABOO_HOST_SHA256="d1f772b1a6384cbf2f178ff4c924c8007c65a635ca5df7a67864f2aef11ff351" MCPORTER_VERSION="0.12.3" MCPORTER_URL="https://github.com/openclaw/mcporter/releases/download/v${MCPORTER_VERSION}/mcporter-macos-arm64-v${MCPORTER_VERSION}.tar.gz" MCPORTER_SHA256="bb896bde3b7e2eef0a700bcd2598678f4f4184306e63b2e12465d780a1184b78" @@ -28,7 +30,8 @@ download_verified() { install_desktop_tools() { local temp local_bin local_lib peekaboo_home host_app host_contents host_executable - local launch_agents host_launch_agent python_candidate python_ready=0 + local host_launcher_source host_launcher_sha launch_agents host_launch_agent + local python_candidate python_ready=0 temp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/keypath-desktop-tools.XXXXXX") trap "/bin/rm -rf '$temp'" EXIT local_bin="$HOME/.local/bin" @@ -52,6 +55,11 @@ install_desktop_tools() { host_app="$HOME/Applications/Peekaboo Lab Host.app" host_contents="$host_app/Contents" host_executable="$host_contents/MacOS/PeekabooLabHost" + host_launcher_source="$SCRIPT_DIR/assets/peekaboo-lab-host-arm64" + [[ -x "$host_launcher_source" ]] || die "Peekaboo Lab Host launcher is unavailable" + host_launcher_sha=$(/usr/bin/shasum -a 256 "$host_launcher_source" | /usr/bin/awk '{print $1}') + [[ "$host_launcher_sha" == "$PEEKABOO_HOST_SHA256" ]] || + die "Peekaboo Lab Host launcher checksum mismatch" /bin/mkdir -p "$host_contents/MacOS" "$HOME/Applications" /bin/cat > "$host_contents/Info.plist" <<'PLIST' @@ -77,18 +85,7 @@ install_desktop_tools() { PLIST - /bin/cat > "$host_executable" <<'HOST' -#!/bin/zsh -set -euo pipefail -socket="$HOME/Library/Application Support/Peekaboo/daemon.sock" -/bin/mkdir -p "${socket:h}" -/bin/rm -f "$socket" -exec "$HOME/.local/bin/peekaboo" daemon run \ - --mode manual \ - --bridge-socket "$socket" \ - --idle-timeout-seconds 31536000 \ - --input-strategy actionFirst -HOST + /bin/cp "$host_launcher_source" "$host_executable" /bin/chmod 0755 "$host_executable" /usr/bin/plutil -lint "$host_contents/Info.plist" >/dev/null /usr/bin/codesign --force --sign - --identifier com.keypath.lab.peekaboo-host "$host_app" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 007331e2a..c9f14b1de 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -207,7 +207,9 @@ grep -Fq "Automatic login user: keypathqa" "$REMOTE" /bin/zsh -n "$LAB_DIR/desktop-bootstrap" grep -Fq 'com.keypath.lab.peekaboo-host' "$LAB_DIR/desktop-bootstrap" grep -Fq 'Peekaboo Lab Host.app' "$LAB_DIR/desktop-bootstrap" -grep -Fq -- '--mode manual' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'peekaboo-lab-host-arm64' "$LAB_DIR/desktop-bootstrap" +grep -Fq '"--mode"' "$LAB_DIR/assets/peekaboo-lab-host.c" +grep -Fq '"manual"' "$LAB_DIR/assets/peekaboo-lab-host.c" grep -Fq 'NSScreenCaptureUsageDescription' "$LAB_DIR/desktop-bootstrap" grep -Fq 'NSAccessibilityUsageDescription' "$LAB_DIR/desktop-bootstrap" grep -Fq 'reset-desktop-keychain)' "$LAB_DIR/keypath-lab" From a58deadc951921af5dea6e3fa4c5900ffb3bb7ad Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Jul 2026 23:04:52 -0700 Subject: [PATCH 05/99] Harden secure Parallels credential delivery --- Scripts/lab/remote.sh | 34 ++++++++++++++++---------- Scripts/lab/tests/keypath-lab-tests.sh | 13 ++++++---- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 5cc17fc03..d8bee0ad8 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1341,7 +1341,7 @@ console_login() { } secure_console_submit() { - local lease=$1 manifest macos resource parallels_cli secret_file + local lease=$1 manifest macos resource parallels_cli secret_file key_events manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "26" || "$macos" == "27" ]] || die "secure console submit requires a macOS 26 or 27 Parallels lane" @@ -1363,19 +1363,26 @@ secure_console_submit() { fi [[ -s "$secret_file" ]] || die "secure console secret is empty" - # Convert the credential to Parallels key codes in a pipe. The plaintext is + # Convert the credential to one paced Parallels event batch. The plaintext is # read only from the owner-only temp file and never enters argv, logs, the - # guest pasteboard, or an artifact. Keep the accepted alphabet deliberately - # narrow; expanding it requires an explicit key-map review. - python3 -c 'import json,sys + # guest pasteboard, or an artifact. Explicit press/release pairs avoid relying + # on the ambiguous default event of one short-lived prlctl process per key. + # Keep the accepted alphabet deliberately narrow; expanding it requires an + # explicit key-map review. + key_events=$(python3 -c 'import json,sys codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k":45,"l":46,"m":58,"n":57,"o":32,"p":33,"q":24,"r":27,"s":39,"t":28,"u":30,"v":55,"w":25,"x":53,"y":29,"z":52,"1":10,"2":11,"3":12,"4":13,"5":14,"6":15,"7":16,"8":17,"9":18,"0":19,"-":20} value=open(sys.argv[1],"r",encoding="utf-8").read() if not value or any(ch not in codes for ch in value): raise SystemExit(64) -for ch in value: print(json.dumps([{"key":codes[ch]}],separators=(",",":")))' "$secret_file" 3<&- | \ - while IFS= read -r key_event <&3; do - printf '%s\n' "$key_event" | "$parallels_cli" send-key-event "$resource" --json >/dev/null || exit 1 - sleep "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" - done 3<&0 || die "failed to deliver the guest credential through Parallels key events" +delay=max(0,round(float(sys.argv[2])*1000)) +events=[] +for ch in value: + events.extend(({"key":codes[ch],"event":"press","delay":delay},{"key":codes[ch],"event":"release","delay":delay})) +print(json.dumps(events,separators=(",",":")))' "$secret_file" "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" 3<&-) || \ + die "failed to encode the guest credential as Parallels key events" + [[ -n "$key_events" ]] || die "failed to encode the guest credential as Parallels key events" + printf '%s\n' "$key_events" | "$parallels_cli" send-key-event "$resource" --json >/dev/null || \ + die "failed to deliver the guest credential through Parallels key events" + key_events= sleep "${KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS:-0.25}" if [[ "$macos" == "26" ]]; then # SecurityAgent accepts password text from the Parallels console but @@ -1391,9 +1398,10 @@ for ch in value: print(json.dumps([{"key":codes[ch]}],separators=(",",":")))' "$ KEYPATH_LAB_SECURE_TEMP= trap - EXIT fi - record_command "$lease" passed secure-console-submit - print "secure_console_submit\tpassed" - print "credential_transport\tparallels-key-events" + record_command "$lease" delivered secure-console-submit + print "secure_console_submit\tdelivered" + print "credential_transport\tparallels-key-events-batched" + print "credential_postcondition\tunverified" } console_key() { diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index c9f14b1de..5b900fd7d 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -587,12 +587,15 @@ assert_contains "$console_login" $'console_user\tkeypathqa' escaped_test_known_hosts=${test_known_hosts// /\\ } grep -Fq "UserKnownHostsFile=$escaped_test_known_hosts" "$TMP/guest-ssh-args" secure_console_submit=$(KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS=0 KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS=0 run_remote secure-console-submit cbx_desktop27) -assert_contains "$secure_console_submit" $'secure_console_submit\tpassed' -assert_contains "$secure_console_submit" $'credential_transport\tparallels-key-events' +assert_contains "$secure_console_submit" $'secure_console_submit\tdelivered' +assert_contains "$secure_console_submit" $'credential_transport\tparallels-key-events-batched' +assert_contains "$secure_console_submit" $'credential_postcondition\tunverified' python3 -c 'import json,sys codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k":45,"l":46,"m":58,"n":57,"o":32,"p":33,"q":24,"r":27,"s":39,"t":28,"u":30,"v":55,"w":25,"x":53,"y":29,"z":52,"1":10,"2":11,"3":12,"4":13,"5":14,"6":15,"7":16,"8":17,"9":18,"0":19,"-":20} -events=[json.loads(line) for line in open(sys.argv[1]) if line.strip()] -expected=[[{"key":codes[ch]}] for ch in open(sys.argv[2]).read()] +events=json.loads(open(sys.argv[1]).read()) +expected=[] +for ch in open(sys.argv[2]).read(): + expected.extend(({"key":codes[ch],"event":"press","delay":0},{"key":codes[ch],"event":"release","delay":0})) 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" @@ -616,7 +619,7 @@ secure_console_rejected_status=$? set -e mv "$TMP/secure-input.valid" "$TMP/secure-input" [[ $secure_console_rejected_status -ne 0 ]] || { echo "unsupported secure console credential unexpectedly passed" >&2; exit 1; } -assert_contains "$secure_console_rejected" 'failed to deliver the guest credential through Parallels key events' +assert_contains "$secure_console_rejected" 'failed to encode the guest credential as Parallels key events' if tail -n "+$((secure_console_calls_before + 1))" "$CALLS" | grep -q 'send-key-event'; then echo "unsupported secure console credential sent a key event" >&2 exit 1 From c78225af75c56fb41ab7ca999331e89e94bd6c9e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Jul 2026 23:19:16 -0700 Subject: [PATCH 06/99] Replace focused credential before secure delivery --- Scripts/lab/remote.sh | 10 +++++++++- Scripts/lab/tests/keypath-lab-tests.sh | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index d8bee0ad8..a489846fb 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1374,7 +1374,14 @@ codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k" value=open(sys.argv[1],"r",encoding="utf-8").read() if not value or any(ch not in codes for ch in value): raise SystemExit(64) delay=max(0,round(float(sys.argv[2])*1000)) -events=[] +events=[ + {"key":115,"event":"press","delay":delay}, + {"key":38,"event":"press","delay":delay}, + {"key":38,"event":"release","delay":delay}, + {"key":115,"event":"release","delay":delay}, + {"key":22,"event":"press","delay":delay}, + {"key":22,"event":"release","delay":delay}, +] for ch in value: events.extend(({"key":codes[ch],"event":"press","delay":delay},{"key":codes[ch],"event":"release","delay":delay})) print(json.dumps(events,separators=(",",":")))' "$secret_file" "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" 3<&-) || \ @@ -1400,6 +1407,7 @@ print(json.dumps(events,separators=(",",":")))' "$secret_file" "${KEYPATH_LAB_SE fi record_command "$lease" delivered secure-console-submit print "secure_console_submit\tdelivered" + print "credential_field\treplaced-focused-value" print "credential_transport\tparallels-key-events-batched" print "credential_postcondition\tunverified" } diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 5b900fd7d..2a562cb5f 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -588,12 +588,20 @@ escaped_test_known_hosts=${test_known_hosts// /\\ } grep -Fq "UserKnownHostsFile=$escaped_test_known_hosts" "$TMP/guest-ssh-args" secure_console_submit=$(KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS=0 KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS=0 run_remote secure-console-submit cbx_desktop27) assert_contains "$secure_console_submit" $'secure_console_submit\tdelivered' +assert_contains "$secure_console_submit" $'credential_field\treplaced-focused-value' assert_contains "$secure_console_submit" $'credential_transport\tparallels-key-events-batched' assert_contains "$secure_console_submit" $'credential_postcondition\tunverified' python3 -c 'import json,sys codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k":45,"l":46,"m":58,"n":57,"o":32,"p":33,"q":24,"r":27,"s":39,"t":28,"u":30,"v":55,"w":25,"x":53,"y":29,"z":52,"1":10,"2":11,"3":12,"4":13,"5":14,"6":15,"7":16,"8":17,"9":18,"0":19,"-":20} events=json.loads(open(sys.argv[1]).read()) -expected=[] +expected=[ + {"key":115,"event":"press","delay":0}, + {"key":38,"event":"press","delay":0}, + {"key":38,"event":"release","delay":0}, + {"key":115,"event":"release","delay":0}, + {"key":22,"event":"press","delay":0}, + {"key":22,"event":"release","delay":0}, +] for ch in open(sys.argv[2]).read(): expected.extend(({"key":codes[ch],"event":"press","delay":0},{"key":codes[ch],"event":"release","delay":0})) assert events == expected' "$TMP/secure-console-key-events.jsonl" "$TMP/secure-input" From 28b774e9ea4ad573e5bea0b2f72ceae9ff61ffa1 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 04:10:27 -0700 Subject: [PATCH 07/99] Pace secure credential key pairs --- Scripts/lab/remote.sh | 25 +++++++++++++++---------- Scripts/lab/tests/keypath-lab-tests.sh | 20 +++++++++++--------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index a489846fb..596cf7982 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1341,7 +1341,7 @@ console_login() { } secure_console_submit() { - local lease=$1 manifest macos resource parallels_cli secret_file key_events + local lease=$1 manifest macos resource parallels_cli secret_file key_events key_event manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "26" || "$macos" == "27" ]] || die "secure console submit requires a macOS 26 or 27 Parallels lane" @@ -1363,10 +1363,12 @@ secure_console_submit() { fi [[ -s "$secret_file" ]] || die "secure console secret is empty" - # Convert the credential to one paced Parallels event batch. The plaintext is + # Convert the credential to explicit, paced Parallels event pairs. The + # plaintext is # read only from the owner-only temp file and never enters argv, logs, the - # guest pasteboard, or an artifact. Explicit press/release pairs avoid relying - # on the ambiguous default event of one short-lived prlctl process per key. + # guest pasteboard, or an artifact. One short-lived prlctl process per + # explicit press/release pair prevents macOS from dropping a large burst + # without relying on Parallels' ambiguous default key event. # Keep the accepted alphabet deliberately narrow; expanding it requires an # explicit key-map review. key_events=$(python3 -c 'import json,sys @@ -1374,7 +1376,7 @@ codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k" value=open(sys.argv[1],"r",encoding="utf-8").read() if not value or any(ch not in codes for ch in value): raise SystemExit(64) delay=max(0,round(float(sys.argv[2])*1000)) -events=[ +replace_events=[ {"key":115,"event":"press","delay":delay}, {"key":38,"event":"press","delay":delay}, {"key":38,"event":"release","delay":delay}, @@ -1382,13 +1384,16 @@ events=[ {"key":22,"event":"press","delay":delay}, {"key":22,"event":"release","delay":delay}, ] +print(json.dumps(replace_events,separators=(",",":"))) for ch in value: - events.extend(({"key":codes[ch],"event":"press","delay":delay},{"key":codes[ch],"event":"release","delay":delay})) -print(json.dumps(events,separators=(",",":")))' "$secret_file" "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" 3<&-) || \ + print(json.dumps(({"key":codes[ch],"event":"press","delay":delay},{"key":codes[ch],"event":"release","delay":delay}),separators=(",",":")))' "$secret_file" "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" 3<&-) || \ die "failed to encode the guest credential as Parallels key events" [[ -n "$key_events" ]] || die "failed to encode the guest credential as Parallels key events" - printf '%s\n' "$key_events" | "$parallels_cli" send-key-event "$resource" --json >/dev/null || \ - die "failed to deliver the guest credential through Parallels key events" + while IFS= read -r key_event; do + printf '%s\n' "$key_event" | "$parallels_cli" send-key-event "$resource" --json >/dev/null || \ + die "failed to deliver the guest credential through Parallels key events" + sleep "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" + done <<< "$key_events" key_events= sleep "${KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS:-0.25}" if [[ "$macos" == "26" ]]; then @@ -1408,7 +1413,7 @@ print(json.dumps(events,separators=(",",":")))' "$secret_file" "${KEYPATH_LAB_SE record_command "$lease" delivered secure-console-submit print "secure_console_submit\tdelivered" print "credential_field\treplaced-focused-value" - print "credential_transport\tparallels-key-events-batched" + print "credential_transport\tparallels-explicit-pairs-paced" print "credential_postcondition\tunverified" } diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 2a562cb5f..538042c67 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -589,21 +589,23 @@ grep -Fq "UserKnownHostsFile=$escaped_test_known_hosts" "$TMP/guest-ssh-args" secure_console_submit=$(KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS=0 KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS=0 run_remote secure-console-submit cbx_desktop27) assert_contains "$secure_console_submit" $'secure_console_submit\tdelivered' assert_contains "$secure_console_submit" $'credential_field\treplaced-focused-value' -assert_contains "$secure_console_submit" $'credential_transport\tparallels-key-events-batched' +assert_contains "$secure_console_submit" $'credential_transport\tparallels-explicit-pairs-paced' assert_contains "$secure_console_submit" $'credential_postcondition\tunverified' python3 -c 'import json,sys codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k":45,"l":46,"m":58,"n":57,"o":32,"p":33,"q":24,"r":27,"s":39,"t":28,"u":30,"v":55,"w":25,"x":53,"y":29,"z":52,"1":10,"2":11,"3":12,"4":13,"5":14,"6":15,"7":16,"8":17,"9":18,"0":19,"-":20} -events=json.loads(open(sys.argv[1]).read()) +events=[json.loads(line) for line in open(sys.argv[1]) if line.strip()] expected=[ - {"key":115,"event":"press","delay":0}, - {"key":38,"event":"press","delay":0}, - {"key":38,"event":"release","delay":0}, - {"key":115,"event":"release","delay":0}, - {"key":22,"event":"press","delay":0}, - {"key":22,"event":"release","delay":0}, + [ + {"key":115,"event":"press","delay":0}, + {"key":38,"event":"press","delay":0}, + {"key":38,"event":"release","delay":0}, + {"key":115,"event":"release","delay":0}, + {"key":22,"event":"press","delay":0}, + {"key":22,"event":"release","delay":0}, + ] ] for ch in open(sys.argv[2]).read(): - expected.extend(({"key":codes[ch],"event":"press","delay":0},{"key":codes[ch],"event":"release","delay":0})) + expected.append([{"key":codes[ch],"event":"press","delay":0},{"key":codes[ch],"event":"release","delay":0}]) 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" From a9770d166d305a08fc2308654a13cae78bd54e2d Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 04:58:59 -0700 Subject: [PATCH 08/99] Route macOS 27 desktop leases to automation base --- Scripts/lab/remote.sh | 19 +++++++++++++------ Scripts/lab/tests/keypath-lab-tests.sh | 3 +++ docs/testing/remote-installer-lab.md | 7 +++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 596cf7982..a324c78e9 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -66,9 +66,11 @@ configure_tart_path() { } base_for() { - local macos=$1 lane=$2 + local macos=$1 lane=$2 desktop=${3:-0} if [[ "$macos" == "15" ]]; then [[ "$lane" == "managed-functional" ]] && print keypath-macos-15-managed || print ghcr.io/cirruslabs/macos-sequoia-base:latest + elif [[ "$macos" == "27" && "$desktop" == "1" ]]; then + print keypath-macos-27-desktop else [[ "$lane" == "managed-functional" ]] && print "keypath-macos-$macos-managed" || print "keypath-macos-$macos" fi @@ -478,18 +480,23 @@ run_with_download() { warmup_desktop() { local macos=$1 lane=$2 slug=$3 if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then - "$CRABBOX" warmup --provider "$(provider_for "$macos")" --target macos --desktop --slug "$slug" --ttl 2h + if [[ "$(provider_for "$macos")" == "parallels" ]]; then + "$CRABBOX" warmup --provider parallels --target macos --desktop \ + --parallels-template "$(base_for "$macos" "$lane" 1)" --slug "$slug" --ttl 2h + else + "$CRABBOX" warmup --provider tart --target macos --desktop --slug "$slug" --ttl 2h + fi elif [[ "$macos" == "15" ]]; then if [[ "${USER:-}" == "clawd" ]]; then export TART_HOME="$LAB_ROOT/TartHome-clawd"; else export TART_HOME="$LAB_ROOT/TartHome"; fi configure_tart_path "$CRABBOX" warmup --provider tart --target macos --desktop \ - --tart-image "$(base_for "$macos" "$lane")" \ + --tart-image "$(base_for "$macos" "$lane" 1)" \ --tart-user admin --tart-cpu 4 --tart-memory 8192 --tart-random-serial --ssh-port 22 \ --slug "$slug" --ttl 2h else export PATH="$LAB_ROOT/SharedTools/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" "$CRABBOX" warmup --provider parallels --target macos --desktop \ - --parallels-template "$(base_for "$macos" "$lane")" --parallels-user keypathqa \ + --parallels-template "$(base_for "$macos" "$lane" 1)" --parallels-user keypathqa \ --parallels-work-root /Users/keypathqa/crabbox --ssh-port 22 \ --slug "$slug" --ttl 2h fi @@ -628,7 +635,7 @@ write_provisional_lease_manifest() { print "slug\t$slug" print "macos\t$macos" print "test_lane\t$lane" - print "base_name\t$(base_for "$macos" "$lane")" + print "base_name\t$(base_for "$macos" "$lane" "$desktop")" print "managed_identity_scope\t$identity_scope" print "provider\t$provider" print "archive_key\t$archive_key" @@ -731,7 +738,7 @@ create_lease() { print "slug\t$slug" print "macos\t$macos" print "test_lane\t$lane" - print "base_name\t$(base_for "$macos" "$lane")" + print "base_name\t$(base_for "$macos" "$lane" "$desktop")" print "managed_identity_scope\t$identity_scope" print "provider\t$provider" print "archive_key\t$archive_key" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 538042c67..de152413c 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -568,6 +568,7 @@ run_remote destroy cbx_test26 >/dev/null create27=$(run_remote create 27 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 0) assert_contains "$create27" $'lease_id\tcbx_test27' +grep -q $'base_name\tkeypath-macos-27' "$ROOT/KeyPathInstallerLab/leases/cbx_test27/manifest.tsv" artifacts27=$(run_remote artifacts cbx_test27) assert_contains "$artifacts27" $'download_status\t0' grep -q 'crabbox run --provider parallels --target macos --id cbx_test27 --stop-after never --download' "$CALLS" @@ -576,6 +577,8 @@ grep -q 'stop-27 cbx_test27' "$CALLS" desktop27_create=$(run_remote create 27 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) assert_contains "$desktop27_create" $'lease_id\tcbx_desktop27' +grep -q -- '--parallels-template keypath-macos-27-desktop' "$CALLS" +grep -q $'base_name\tkeypath-macos-27-desktop' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop27/manifest.tsv" test_known_hosts="$TMP/known hosts/known_hosts" mkdir -p "$(dirname "$test_known_hosts")" touch "$test_known_hosts" diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 3d99b6492..12d9ac9f4 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -287,6 +287,13 @@ provisioning, not a per-scenario setup step. A successful install or a visible toggle is not enough: require `peekaboo permissions status --json`, a real semantic capture, and a new disposable clone from the captured base. +Register the promoted Parallels source under the distinct CrabBox template +alias `keypath-macos-27-desktop`. The controller selects that alias only for +macOS 27 leases created with `--desktop`; headless macOS 27 leases continue to +clone `keypath-macos-27`, preserving the pristine loginwindow source. A desktop +lease manifest must record `base_name=keypath-macos-27-desktop` before its +fresh-clone evidence is accepted. + ### Disposable desktop identity with Nameplate Nameplate can label an owned desktop lease without modifying its base image: From 3662660c683fcb0622250f4a8849ce6c6853f5bf Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 05:26:31 -0700 Subject: [PATCH 09/99] Verify inherited macOS 27 console login --- Scripts/lab/keypath-lab | 6 ++++++ Scripts/lab/remote.sh | 28 ++++++++++++++++++++++++++ Scripts/lab/tests/keypath-lab-tests.sh | 12 +++++++++++ docs/testing/remote-installer-lab.md | 12 +++++++++++ 4 files changed, 58 insertions(+) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index ee692a3b8..2d7df3d0c 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -15,6 +15,7 @@ Commands: create --macos 15|26|27 --lane managed-functional|unmanaged-ui --commit SHA --installer PATH [--ttl 2h] [--desktop] [--tart-usb-passthrough] install-app LEASE_ID console-login LEASE_ID + verify-console-login LEASE_ID reset-guest-password LEASE_ID reset-desktop-keychain LEASE_ID reboot-guest LEASE_ID @@ -150,6 +151,11 @@ EOF require_lease_id "$1" remote console-login "$1" ;; + verify-console-login) + [[ $# -eq 1 ]] || usage + require_lease_id "$1" + remote verify-console-login "$1" + ;; reset-guest-password) [[ $# -eq 1 ]] || usage require_lease_id "$1" diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index a324c78e9..080609bba 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1184,6 +1184,33 @@ desktop_bootstrap() { set_field "$manifest" desktop_bootstrap_status passed } +verify_console_login() { + local lease=$1 manifest macos resource parallels_cli console_user + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + [[ "$macos" == "27" ]] || die "inherited console-login verification currently supports only the macOS 27 lane" + [[ "$(field "$manifest" provider)" == "parallels" ]] || die "inherited console-login verification requires a Parallels lease" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "inherited console-login verification requires a desktop-enabled lease" + 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" + console_user=$("$parallels_cli" exec "$resource" /usr/bin/stat -f %Su /dev/console 2>/dev/null || true) + if [[ "$console_user" != "keypathqa" ]]; then + set_field "$manifest" console_login_status postcondition-failed + set_field "$manifest" console_login_method inherited-base + record_command "$lease" failed verify-console-login + die "fresh desktop-base clone did not inherit the keypathqa console session" + fi + set_field "$manifest" console_login_status passed + set_field "$manifest" console_login_method inherited-base + set_field "$manifest" console_login_at "$(utc_now)" + record_command "$lease" passed verify-console-login + print "console_login\tpassed" + print "console_login_method\tinherited-base" + print "console_user\t$console_user" +} + console_login() { local lease=$1 manifest macos resource parallels_cli secret_file exit_code console_user attempt guest_command autologin_status guest_control_ready configure_stage local guest_ip key known_hosts known_hosts_option fifo status_file configure_pid fifo_ready stream_exit credential_timeout @@ -1783,6 +1810,7 @@ case "$action" in scenario) [[ $# -eq 2 ]] || die "scenario requires lease and name"; scenario "$1" "$2" ;; desktop-bootstrap) [[ $# -eq 2 ]] || die "desktop-bootstrap requires lease and install-tools flag"; desktop_bootstrap "$@" ;; console-login) [[ $# -eq 1 ]] || die "console-login requires lease"; console_login "$1" ;; + verify-console-login) [[ $# -eq 1 ]] || die "verify-console-login requires lease"; verify_console_login "$1" ;; reset-guest-password) [[ $# -eq 1 ]] || die "reset-guest-password requires lease"; reset_guest_password "$1" ;; reset-desktop-keychain) [[ $# -eq 1 ]] || die "reset-desktop-keychain requires lease"; reset_desktop_keychain "$1" ;; reboot-guest) [[ $# -eq 1 ]] || die "reboot-guest requires lease"; reboot_guest "$1" ;; diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index de152413c..89009cf5f 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -579,6 +579,18 @@ desktop27_create=$(run_remote create 27 unmanaged-ui "$archive_key" "$commit" "$ assert_contains "$desktop27_create" $'lease_id\tcbx_desktop27' grep -q -- '--parallels-template keypath-macos-27-desktop' "$CALLS" grep -q $'base_name\tkeypath-macos-27-desktop' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop27/manifest.tsv" +set +e +inherited_console_failure=$(KEYPATH_LAB_TEST_CONSOLE_USER_FAIL=1 run_remote verify-console-login cbx_desktop27 2>&1) +inherited_console_failure_status=$? +set -e +[[ $inherited_console_failure_status -ne 0 ]] +assert_contains "$inherited_console_failure" 'fresh desktop-base clone did not inherit the keypathqa console session' +grep -q $'console_login_status\tpostcondition-failed' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop27/manifest.tsv" +inherited_console=$(run_remote verify-console-login cbx_desktop27) +assert_contains "$inherited_console" $'console_login\tpassed' +assert_contains "$inherited_console" $'console_login_method\tinherited-base' +assert_contains "$inherited_console" $'console_user\tkeypathqa' +grep -q $'console_login_method\tinherited-base' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop27/manifest.tsv" test_known_hosts="$TMP/known hosts/known_hosts" mkdir -p "$(dirname "$test_known_hosts")" touch "$test_known_hosts" diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 12d9ac9f4..1623a270a 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -294,6 +294,18 @@ clone `keypath-macos-27`, preserving the pristine loginwindow source. A desktop lease manifest must record `base_name=keypath-macos-27-desktop` before its fresh-clone evidence is accepted. +On a fresh clone, admit the inherited console state without replaying +credential-bearing setup: + +```bash +Scripts/lab/keypath-lab verify-console-login cbx_example +``` + +This read-only check requires `/dev/console` to be owned by `keypathqa` and +records `console_login_method=inherited-base`. Only then run the RFB pointer +probe and desktop bootstrap. Use `console-login` for candidate construction or +repair, not to make a fresh-base acceptance test pass. + ### Disposable desktop identity with Nameplate Nameplate can label an owned desktop lease without modifying its base image: From 0c91bf81181a36a9f9cdcb2bf153cee827219968 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 06:39:18 -0700 Subject: [PATCH 10/99] Use native RFB for protected macOS input --- Scripts/lab/remote.sh | 132 ++++++++++++++++++++----- Scripts/lab/tests/keypath-lab-tests.sh | 43 +++++--- 2 files changed, 137 insertions(+), 38 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 080609bba..0f51838ee 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -856,6 +856,8 @@ run_command() { secure_dialog_input() { local lease=$1 app=$2 field_label=$3 submit_button=$4 already_focused=$5 local manifest macos resource key ip secret_file guest_command exit_code + local focus_command focus_result button_geometry_command button_coords postcondition_command postcondition_result + local geometry_command geometry native_width native_height logical_width logical_height scale_x scale_y click_x click_y manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "15" ]] || die "secure dialog input currently supports only the Tart macOS 15 lane" @@ -878,20 +880,109 @@ secure_dialog_input() { if [[ "${USER:-}" == "clawd" ]]; then export TART_HOME="$LAB_ROOT/TartHome-clawd"; else export TART_HOME="$LAB_ROOT/TartHome"; fi ip=$($TART ip "$resource") [[ "$ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Tart returned an invalid guest address" + export PATH="$LAB_ROOT/CompatTools/bin:$LAB_ROOT/SharedTools/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + + if [[ "$field_label" == "AXSecureTextField" ]]; then + [[ -n "$submit_button" ]] || die "AXSecureTextField requires a submit button for postcondition verification" + [[ "$already_focused" == "0" ]] || die "AXSecureTextField does not use --already-focused" + + # SecurityAgent rejects synthetic Accessibility typing. Use Accessibility + # only to focus and inspect the protected sheet, then deliver the secret as + # real RFB key events over CrabBox stdin. The secret never enters argv, + # guest storage, or controller output. + focus_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) throw new Error("dialog process not found");\n var field = descendants(matches[0].windows[0]).find(function (element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n if (!field) throw new Error("secure text field not found");\n field.focused = true;\n return field.focused() ? "focused" : "not-focused";\n}\' -- '$(printf %q "$app") + set +e + focus_result=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$focus_command")" /dev/null 2>&1 + exit_code=$? + set -e + if (( exit_code != 0 )); then + record_command "$lease" "failed:42" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" + die "secure dialog input failed while clearing the protected field" + fi + + set +e + "$CRABBOX" desktop type --provider tart --target macos --id "$resource" < "$secret_file" >/dev/null 2>&1 + exit_code=$? + set -e + if (( exit_code != 0 )); then + record_command "$lease" "failed:42" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" + die "secure dialog input failed while streaming masked input" + fi + + button_geometry_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var process = Application("System Events").processes.byName(argv[0]);\n var button = descendants(process.windows[0]).find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) throw new Error("submit button not found");\n var position = button.position();\n var size = button.size();\n return Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2);\n}\' -- '$(printf %q "$app")' '$(printf %q "$submit_button") + set +e + button_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$button_geometry_command")" && "$native_height" == <-> && "$logical_width" == <-> && "$logical_height" == <-> && "$logical_width" -gt 0 && "$logical_height" -gt 0 ]] || die "secure dialog input could not measure display geometry" + (( native_width % logical_width == 0 && native_height % logical_height == 0 )) || die "secure dialog input measured a non-integral display scale" + scale_x=$((native_width / logical_width)) + scale_y=$((native_height / logical_height)) + (( scale_x == scale_y && scale_x > 0 )) || die "secure dialog input measured inconsistent display scales" + click_x=$((click_x * scale_x)) + click_y=$((click_y * scale_y)) + + set +e + "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$click_x" --y "$click_y" >/dev/null 2>&1 + exit_code=$? + set -e + if (( exit_code != 0 )); then + record_command "$lease" "failed:43" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" + die "secure dialog input failed while submitting the dialog" + fi + + postcondition_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "closed";\n var open = descendants(matches[0].windows[0]).some(function (element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n return open ? "open" : "closed";\n}\' -- '$(printf %q "$app") + postcondition_result=open + for attempt in {1..150}; do + postcondition_result=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$postcondition_command")" "$secret_path"; unset secret_value; ' - guest_command+="$field_command \"\$secret_path\" $(printf %q "$app") >/dev/null; rm -f \"\$secret_path\"; trap - EXIT" - elif [[ "$already_focused" == "0" ]]; then + if [[ "$already_focused" == "0" ]]; then refresh_args=(/opt/homebrew/bin/peekaboo see --app "$app" --json) printf -v refresh_command '%q ' "${refresh_args[@]}" guest_command+="$refresh_command >/dev/null || exit 40; " @@ -903,26 +994,13 @@ secure_dialog_input() { elif [[ -n "$submit_button" ]]; then die "--already-focused cannot be combined with a submit button" fi - if [[ "$field_label" != "AXSecureTextField" ]]; then - guest_command+='PEEKABOO_VISUALIZER_MASK_TYPED_TEXT=true /opt/homebrew/bin/mcporter call --stdio '\''peekaboo mcp serve --bridge-socket "$HOME/Library/Application Support/Peekaboo/daemon.sock"'\'' --env PEEKABOO_VISUALIZER_MASK_TYPED_TEXT=true type text=@/dev/stdin clear=true --output json --timeout 20000 >/dev/null 2>&1 || exit 42' - fi + guest_command+='PEEKABOO_VISUALIZER_MASK_TYPED_TEXT=true /opt/homebrew/bin/mcporter call --stdio '\''peekaboo mcp serve --bridge-socket "$HOME/Library/Application Support/Peekaboo/daemon.sock"'\'' --env PEEKABOO_VISUALIZER_MASK_TYPED_TEXT=true type text=@/dev/stdin clear=true --output json --timeout 20000 >/dev/null 2>&1 || exit 42' if [[ -n "$submit_button" ]]; then - if [[ "$field_label" == "AXSecureTextField" ]]; then - button_geometry_args=(/usr/bin/osascript -l JavaScript -e 'function descendants(element) { var result = []; try { var children = element.uiElements(); for (var i = 0; i < children.length; i++) { result.push(children[i]); result = result.concat(descendants(children[i])); } } catch (_) {} return result; } function run(argv) { var process = Application("System Events").processes.byName(argv[0]); var label = argv[1]; var button = descendants(process.windows[0]).find(function (element) { try { return element.role() === "AXButton" && (element.name() === label || element.description() === label); } catch (_) { return false; } }); if (!button) throw new Error("submit button not found"); var position = button.position(); var size = button.size(); return Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2); }' "$app" "$submit_button") - printf -v button_geometry_command '%q ' "${button_geometry_args[@]}" - guest_command+="; button_coords=\$( $button_geometry_command ); [[ \"\$button_coords\" =~ '^-?[0-9]+,-?[0-9]+$' ]] || exit 78; /opt/homebrew/bin/peekaboo click --coords \"\$button_coords\" --global-coords --foreground --input-strategy synthOnly --json >/dev/null 2>&1 || true" - else - submit_args=(/opt/homebrew/bin/peekaboo click "$submit_button" --app "$app" --foreground --json) - printf -v submit_command '%q ' "${submit_args[@]}" - printf -v submit_label_quoted '%q' "$submit_button" - guest_command+="; $refresh_command >/tmp/keypath-secure-submit.json || exit 44; if ! $submit_command >/dev/null; then $refresh_command >/tmp/keypath-secure-submit.json || exit 43; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); raise SystemExit(1 if any(e.get(\"label\")==sys.argv[2] for e in elements) else 0)' /tmp/keypath-secure-submit.json $submit_label_quoted || exit 43; fi" - guest_command+="; for attempt in {1..150}; do $refresh_command >/tmp/keypath-secure-postcondition.json || exit 44; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); labels={e.get(\"label\") for e in elements}; raise SystemExit(0 if sys.argv[2] not in labels and sys.argv[3] not in labels else 1)' /tmp/keypath-secure-postcondition.json $(printf %q "$field_label") $submit_label_quoted && break; sleep 0.1; done; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); labels={e.get(\"label\") for e in elements}; raise SystemExit(0 if sys.argv[2] not in labels and sys.argv[3] not in labels else 79)' /tmp/keypath-secure-postcondition.json $(printf %q "$field_label") $submit_label_quoted" - fi - fi - if [[ "$field_label" == "AXSecureTextField" ]]; then - postcondition_args=(/usr/bin/osascript -l JavaScript -e 'function descendants(element) { var result = []; try { var children = element.uiElements(); for (var i = 0; i < children.length; i++) { result.push(children[i]); result = result.concat(descendants(children[i])); } } catch (_) {} return result; } function run(argv) { var processes = Application("System Events").processes.whose({name: argv[0]})(); if (processes.length === 0 || processes[0].windows().length === 0) return "closed"; var open = descendants(processes[0].windows[0]).some(function (element) { try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; } }); return open ? "open" : "closed"; }' "$app") - printf -v postcondition_command '%q ' "${postcondition_args[@]}" - guest_command+="; for attempt in {1..150}; do [[ \$( $postcondition_command ) == closed ]] && exit 0; sleep 0.1; done; exit 77" + submit_args=(/opt/homebrew/bin/peekaboo click "$submit_button" --app "$app" --foreground --json) + printf -v submit_command '%q ' "${submit_args[@]}" + printf -v submit_label_quoted '%q' "$submit_button" + guest_command+="; $refresh_command >/tmp/keypath-secure-submit.json || exit 44; if ! $submit_command >/dev/null; then $refresh_command >/tmp/keypath-secure-submit.json || exit 43; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); raise SystemExit(1 if any(e.get(\"label\")==sys.argv[2] for e in elements) else 0)' /tmp/keypath-secure-submit.json $submit_label_quoted || exit 43; fi" + guest_command+="; for attempt in {1..150}; do $refresh_command >/tmp/keypath-secure-postcondition.json || exit 44; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); labels={e.get(\"label\") for e in elements}; raise SystemExit(0 if sys.argv[2] not in labels and sys.argv[3] not in labels else 1)' /tmp/keypath-secure-postcondition.json $(printf %q "$field_label") $submit_label_quoted && break; sleep 0.1; done; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); labels={e.get(\"label\") for e in elements}; raise SystemExit(0 if sys.argv[2] not in labels and sys.argv[3] not in labels else 79)' /tmp/keypath-secure-postcondition.json $(printf %q "$field_label") $submit_label_quoted" fi set +e "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")" < "$secret_file" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 89009cf5f..2aa927269 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -77,6 +77,11 @@ EOF cat > "$ROOT/bin/crabbox" <> "$CALLS" +if [[ \$1 == desktop && \$2 == type && " \$* " != *" --text "* ]]; then + cat > "$TMP/crabbox-desktop-type-stdin" + [[ \${KEYPATH_LAB_TEST_CRABBOX_TYPE_FAIL:-0} == 1 ]] && exit 42 + exit 0 +fi if [[ \$1 == warmup ]]; then if [[ " \$* " == *" --provider tart "* ]]; then echo 'leased cbx_stale instance=stale-resource' @@ -122,6 +127,19 @@ EOF cat > "$ROOT/bin/guest-ssh" < "$TMP/guest-ssh-args" +printf '%s\n' "\$*" >> "$TMP/guest-ssh-calls" +if [[ " \$* " == *"field.focused"* ]]; then + printf focused + exit 0 +fi +if [[ " \$* " == *"button.position"* ]]; then + printf '400,300' + exit 0 +fi +if [[ " \$* " == *"AXSecureTextField"* ]]; then + printf closed + exit 0 +fi if [[ " \$* " == *" /bin/test -p /tmp/keypath-console-login-"* ]]; then cat >/dev/null else @@ -191,7 +209,11 @@ chmod +x "$ROOT/bin/prlctl" echo test-private-key > "$TMP/id_ed25519" printf 'fixture-password-that-must-not-leak' > "$TMP/secure-input" grep -Fq 'exec 3<> \"\$fifo\"; KEYPATH_GUEST_PASSWORD=; IFS= read -r -t $credential_timeout -u 3 KEYPATH_GUEST_PASSWORD || [[ -n \"\$KEYPATH_GUEST_PASSWORD\" ]]' "$REMOTE" -grep -Fq 'IFS= read -r secret_value || [[ -n "$secret_value" ]]' "$REMOTE" +grep -Fq '"$CRABBOX" desktop type --provider tart --target macos --id "$resource" < "$secret_file"' "$REMOTE" +if grep -Fq 'events.keystroke(secret)' "$REMOTE"; then + echo "SecurityAgent secure input still uses synthetic Accessibility typing" >&2 + exit 1 +fi grep -Fq 'managed_clone_enrollment\talready-enrolled' "$REMOTE" grep -Fq 'window.subrole() === "AXSystemDialog"' "$REMOTE" grep -Fq 'usb_prefix="$TART_USB_TOOL_ROOT/bin:"' "$REMOTE" @@ -810,21 +832,20 @@ if grep -R -F 'fixture-password-that-must-not-leak' "$ROOT/KeyPathInstallerLab" fi secure_agent_result=$(run_remote secure-dialog-input cbx_desktop15 SecurityAgent AXSecureTextField Allow 0) assert_contains "$secure_agent_result" $'secure_dialog_input\tpassed' -grep -q 'keypath-secure-input' "$TMP/guest-ssh-args" -grep -q 'button.*position.*size' "$TMP/guest-ssh-args" -grep -q 'peekaboo.*click.*--coords.*button_coords.*--global-coords' "$TMP/guest-ssh-args" -grep -q -- '--foreground.*--input-strategy.*synthOnly' "$TMP/guest-ssh-args" -grep -q 'SecurityAgent.*closed' "$TMP/guest-ssh-args" -if grep -q '/usr/bin/sudo\|pbcopy\|the\\ clipboard' "$TMP/guest-ssh-args"; then +grep -q 'field.focused = true' "$TMP/guest-ssh-calls" +grep -q 'button.position()' "$TMP/guest-ssh-calls" +grep -q 'return open.*open.*closed' "$TMP/guest-ssh-calls" +[[ $(grep -c 'crabbox desktop type --provider tart --target macos --id test-resource$' "$CALLS") -ge 2 ]] +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 800 --y 600' "$CALLS" +cmp -s "$TMP/secure-input" "$TMP/crabbox-desktop-type-stdin" +if grep -q -- '--text' "$CALLS" || grep -q '/usr/bin/sudo\|pbcopy\|the\\ clipboard\|events.keystroke' "$TMP/guest-ssh-calls"; then echo "SecurityAgent secure input used an unsafe password path" >&2 exit 1 fi secure_settings_result=$(run_remote secure-dialog-input cbx_desktop15 'System Settings' AXSecureTextField 'Modify Settings' 0) assert_contains "$secure_settings_result" $'secure_dialog_input\tpassed' -grep -q 'processes.byName.*appName' "$TMP/guest-ssh-args" -grep -q 'System.*Settings.*Modify.*Settings' "$TMP/guest-ssh-args" -grep -q 'AXSecureTextField' "$TMP/guest-ssh-args" -grep -q 'return.*open.*closed' "$TMP/guest-ssh-args" +grep -q 'System.*Settings.*Modify.*Settings' "$TMP/guest-ssh-calls" +grep -q 'AXSecureTextField' "$TMP/guest-ssh-calls" secure_focused_result=$(run_remote secure-dialog-input cbx_desktop15 SecurityAgent Password '' 1) assert_contains "$secure_focused_result" $'secure_dialog_input\tpassed' if grep -q 'peekaboo.*see\|peekaboo.*click' "$TMP/guest-ssh-args"; then From d7ed9a9b7bfa1d28d14ae99eb6e35fee67d50c8b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 06:42:05 -0700 Subject: [PATCH 11/99] Remove Peekaboo dependency from protected clicks --- Scripts/lab/remote.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 0f51838ee..ab3234571 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1078,7 +1078,7 @@ protected_click() { if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then geometry=${KEYPATH_LAB_TEST_DISPLAY_GEOMETRY:-'2048 1536 1024 768'} else - geometry_command='/opt/homebrew/bin/peekaboo list windows --app '$(printf %q "$app")' --json | /usr/bin/env python3 -c '\''import json,re,sys; data=json.load(sys.stdin).get("data",{}); windows=data.get("windows",data if isinstance(data,list) else []); names=[w.get("screenName","") for w in windows if isinstance(w,dict)]; m=next((re.search(r"([0-9]+)×([0-9]+)",n) for n in names if re.search(r"([0-9]+)×([0-9]+)",n)),None); print(f"{m.group(1)} {m.group(2)}" if m else "",end="")'\''; printf " "; /usr/bin/osascript -l JavaScript -e '\''ObjC.import("AppKit"); var s=$.NSScreen.mainScreen.frame.size; s.width+" "+s.height'\''' + geometry_command='/usr/bin/osascript -l JavaScript -e '\''ObjC.import("AppKit"); var screen=$.NSScreen.mainScreen; var logical=screen.frame.size; var scale=Number(screen.backingScaleFactor); Math.round(logical.width*scale)+" "+Math.round(logical.height*scale)+" "+Math.round(logical.width)+" "+Math.round(logical.height)'\''' geometry=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$geometry_command")") fi IFS=' ' read -r native_width native_height logical_width logical_height <<< "$geometry" From 2da2f8d8053ccc9c0825a68ca692630c03f504d5 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 07:08:06 -0700 Subject: [PATCH 12/99] Handle System Settings protected input securely --- Scripts/lab/remote.sh | 63 ++++++++++++++++++++------ Scripts/lab/tests/keypath-lab-tests.sh | 13 +++++- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index ab3234571..6a6ec0889 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -857,7 +857,7 @@ secure_dialog_input() { local lease=$1 app=$2 field_label=$3 submit_button=$4 already_focused=$5 local manifest macos resource key ip secret_file guest_command exit_code local focus_command focus_result button_geometry_command button_coords postcondition_command postcondition_result - local geometry_command geometry native_width native_height logical_width logical_height scale_x scale_y click_x click_y + local geometry_command geometry native_width native_height logical_width logical_height scale_x scale_y click_x click_y focus_x focus_y manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "15" ]] || die "secure dialog input currently supports only the Tart macOS 15 lane" @@ -890,38 +890,71 @@ secure_dialog_input() { # only to focus and inspect the protected sheet, then deliver the secret as # real RFB key events over CrabBox stdin. The secret never enters argv, # guest storage, or controller output. - focus_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) throw new Error("dialog process not found");\n var field = descendants(matches[0].windows[0]).find(function (element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n if (!field) throw new Error("secure text field not found");\n field.focused = true;\n return field.focused() ? "focused" : "not-focused";\n}\' -- '$(printf %q "$app") + focus_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) throw new Error("dialog process not found");\n var window = matches[0].windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var fields = root.textFields.whose({subrole: "AXSecureTextField"})();\n var field = fields.length > 0 ? fields[0] : descendants(root).find(function (element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n if (!field) throw new Error("secure text field not found");\n field.focused = true;\n var position = field.position();\n var size = field.size();\n return (field.focused() ? "focused" : "not-focused") + "," + Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2);\n}\' -- '$(printf %q "$app") set +e focus_result=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$focus_command")" && "$native_height" == <-> && "$logical_width" == <-> && "$logical_height" == <-> && "$logical_width" -gt 0 && "$logical_height" -gt 0 ]] || die "secure dialog input could not measure display geometry" + (( native_width % logical_width == 0 && native_height % logical_height == 0 )) || die "secure dialog input measured a non-integral display scale" + scale_x=$((native_width / logical_width)) + scale_y=$((native_height / logical_height)) + (( scale_x == scale_y && scale_x > 0 )) || die "secure dialog input measured inconsistent display scales" + focus_x=$((focus_x * scale_x)) + focus_y=$((focus_y * scale_y)) - # Clear any value left by a prior interrupted attempt. Backspace is a - # supported RFB keysym, and a fixed upper bound avoids learning or logging - # the protected field's current length. set +e - printf '\b%.0s' {1..128} | "$CRABBOX" desktop type --provider tart --target macos --id "$resource" >/dev/null 2>&1 + "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$focus_x" --y "$focus_y" >/dev/null 2>&1 exit_code=$? set -e if (( exit_code != 0 )); then - record_command "$lease" "failed:42" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" - die "secure dialog input failed while clearing the protected field" + record_command "$lease" "failed:41" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" + die "secure dialog input failed while giving the field native pointer focus" fi - set +e - "$CRABBOX" desktop type --provider tart --target macos --id "$resource" < "$secret_file" >/dev/null 2>&1 - exit_code=$? - set -e + if [[ "$app" == "System Settings" ]]; then + # System Settings authorization sheets accept local CGEvent synthesis but + # intentionally ignore remote VNC key events. Stream the secret directly + # into one guest osascript process; it never enters argv, logs, clipboard, + # or a guest file. + local system_settings_type_command + system_settings_type_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("Foundation");\nfunction run() {\n var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;\n var payload = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js;\n if (!payload) throw new Error("secure input is empty");\n var events = Application("System Events");\n var process = events.processes.byName("System Settings");\n var fields = process.windows[0].sheets[0].textFields.whose({subrole: "AXSecureTextField"})();\n if (fields.length === 0) throw new Error("secure text field not found");\n fields[0].focused = true;\n for (var i = 0; i < 128; i++) events.keyCode(51);\n events.keystroke(payload);\n}\'' + set +e + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$system_settings_type_command")" < "$secret_file" >/dev/null 2>&1 + exit_code=$? + set -e + else + # Clear any value left by a prior interrupted attempt. Backspace is a + # supported RFB keysym, and a fixed upper bound avoids learning or + # logging the protected field's current length. + set +e + printf '\b%.0s' {1..128} | "$CRABBOX" desktop type --provider tart --target macos --id "$resource" >/dev/null 2>&1 + exit_code=$? + if (( exit_code == 0 )); then + "$CRABBOX" desktop type --provider tart --target macos --id "$resource" < "$secret_file" >/dev/null 2>&1 + exit_code=$? + fi + set -e + fi if (( exit_code != 0 )); then record_command "$lease" "failed:42" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" die "secure dialog input failed while streaming masked input" fi - button_geometry_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var process = Application("System Events").processes.byName(argv[0]);\n var button = descendants(process.windows[0]).find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) throw new Error("submit button not found");\n var position = button.position();\n var size = button.size();\n return Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2);\n}\' -- '$(printf %q "$app")' '$(printf %q "$submit_button") + button_geometry_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var process = Application("System Events").processes.byName(argv[0]);\n var window = process.windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var button = root.buttons().find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) button = descendants(root).find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) throw new Error("submit button not found");\n var position = button.position();\n var size = button.size();\n return Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2);\n}\' -- '$(printf %q "$app")' '$(printf %q "$submit_button") set +e button_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$button_geometry_command")" 0 ? sheets[0] : window;\n var open = root.textFields.whose({subrole: "AXSecureTextField"})().length > 0 || descendants(root).some(function (element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n return open ? "open" : "closed";\n}\' -- '$(printf %q "$app") postcondition_result=open for attempt in {1..150}; do postcondition_result=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$postcondition_command")" "$ROOT/bin/guest-ssh" < "$TMP/guest-ssh-args" printf '%s\n' "\$*" >> "$TMP/guest-ssh-calls" +if [[ " \$* " == *"fileHandleWithStandardInput"* ]]; then + cat > "$TMP/system-settings-secure-stdin" + exit 0 +fi if [[ " \$* " == *"field.focused"* ]]; then - printf focused + printf 'focused,400,220' exit 0 fi if [[ " \$* " == *"button.position"* ]]; then @@ -837,6 +841,7 @@ grep -q 'button.position()' "$TMP/guest-ssh-calls" grep -q 'return open.*open.*closed' "$TMP/guest-ssh-calls" [[ $(grep -c 'crabbox desktop type --provider tart --target macos --id test-resource$' "$CALLS") -ge 2 ]] grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 800 --y 600' "$CALLS" +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 800 --y 440' "$CALLS" cmp -s "$TMP/secure-input" "$TMP/crabbox-desktop-type-stdin" if grep -q -- '--text' "$CALLS" || grep -q '/usr/bin/sudo\|pbcopy\|the\\ clipboard\|events.keystroke' "$TMP/guest-ssh-calls"; then echo "SecurityAgent secure input used an unsafe password path" >&2 @@ -846,6 +851,12 @@ secure_settings_result=$(run_remote secure-dialog-input cbx_desktop15 'System Se assert_contains "$secure_settings_result" $'secure_dialog_input\tpassed' grep -q 'System.*Settings.*Modify.*Settings' "$TMP/guest-ssh-calls" grep -q 'AXSecureTextField' "$TMP/guest-ssh-calls" +grep -q 'fileHandleWithStandardInput' "$TMP/guest-ssh-calls" +cmp -s "$TMP/secure-input" "$TMP/system-settings-secure-stdin" +if grep -Fq 'fixture-password-that-must-not-leak' "$TMP/guest-ssh-calls"; then + echo "System Settings secure input leaked its secret into guest arguments" >&2 + exit 1 +fi secure_focused_result=$(run_remote secure-dialog-input cbx_desktop15 SecurityAgent Password '' 1) assert_contains "$secure_focused_result" $'secure_dialog_input\tpassed' if grep -q 'peekaboo.*see\|peekaboo.*click' "$TMP/guest-ssh-args"; then From ef09ad7b7f4b9f3457ce5e3857bdf4a052d3c18d Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 07:34:58 -0700 Subject: [PATCH 13/99] Fix clean install VHID postcondition ordering --- .../Core/PrivilegedOperationsRouter.swift | 5 +++- ...PrivilegedOperationsCoordinatorTests.swift | 25 +++++++++++++++-- .../Lint/PostconditionLintTests.swift | 6 ++--- ...-clean-install-vhid-postcondition-order.md | 27 +++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 docs/bugs/2026-07-25-clean-install-vhid-postcondition-order.md diff --git a/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift b/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift index fe2b4cd27..410959819 100644 --- a/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift +++ b/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift @@ -305,7 +305,10 @@ public final class PrivilegedOperationsRouter { #else try await runActivateVirtualHIDManager() #endif - try await enforceVHIDServicesPostcondition(after: "activateVirtualHIDManager") + // Activation owns the DriverKit system extension. Runtime service + // installation happens in a later installer recipe, so requiring those + // services here makes a clean install fail before it can install them. + try await enforceVHIDDriverPostcondition(after: "activateVirtualHIDManager") } private func runActivateVirtualHIDManager() async throws { diff --git a/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift b/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift index b49e70c44..38f62d208 100644 --- a/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift +++ b/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift @@ -554,7 +554,7 @@ final class PrivilegedOperationsRouterTests: XCTestCase { #endif } - func testActivateVirtualHIDManagerFailsWhenPostconditionFails() async throws { + func testActivateVirtualHIDManagerDoesNotRequireRuntimeServices() async throws { #if DEBUG PrivilegedOperationsRouter.resetTestingState() var activationCalls = 0 @@ -562,6 +562,27 @@ final class PrivilegedOperationsRouterTests: XCTestCase { activationCalls += 1 } PrivilegedOperationsRouter.vhidServicesPostconditionOverride = { _ in false } + PrivilegedOperationsRouter.vhidDriverPostconditionOverride = { _ in true } + #else + throw XCTSkip("Uses DEBUG-only PrivilegedOperationsRouter test overrides") + #endif + + let coordinator = PrivilegedOperationsRouter.shared + try await coordinator.activateVirtualHIDManager() + + #if DEBUG + XCTAssertEqual(activationCalls, 1) + #endif + } + + func testActivateVirtualHIDManagerFailsWhenDriverPostconditionFails() async throws { + #if DEBUG + PrivilegedOperationsRouter.resetTestingState() + var activationCalls = 0 + PrivilegedOperationsRouter.activateVirtualHIDManagerOverride = { + activationCalls += 1 + } + PrivilegedOperationsRouter.vhidDriverPostconditionOverride = { _ in false } #else throw XCTSkip("Uses DEBUG-only PrivilegedOperationsRouter test overrides") #endif @@ -571,7 +592,7 @@ final class PrivilegedOperationsRouterTests: XCTestCase { try await coordinator.activateVirtualHIDManager() XCTFail("Expected activateVirtualHIDManager to fail when postcondition fails") } catch let PrivilegedOperationError.operationFailed(message) { - XCTAssertTrue(message.contains("VHID services postcondition failed")) + XCTAssertTrue(message.contains("VHID driver postcondition failed")) } catch { XCTFail("Unexpected error type: \(error)") } diff --git a/Tests/KeyPathTests/Lint/PostconditionLintTests.swift b/Tests/KeyPathTests/Lint/PostconditionLintTests.swift index 6573d53e6..d87feac49 100644 --- a/Tests/KeyPathTests/Lint/PostconditionLintTests.swift +++ b/Tests/KeyPathTests/Lint/PostconditionLintTests.swift @@ -25,8 +25,7 @@ final class PostconditionLintTests: XCTestCase { try assertFunctions( [ "installRequiredRuntimeServices", - "repairVHIDDaemonServices", - "activateVirtualHIDManager" + "repairVHIDDaemonServices" ], contain: "enforceVHIDServicesPostcondition" ) @@ -44,7 +43,8 @@ final class PostconditionLintTests: XCTestCase { func testVHIDDriverInstallEnforcesDriverPostcondition() throws { try assertFunctions( [ - "downloadAndInstallCorrectVHIDDriver" + "downloadAndInstallCorrectVHIDDriver", + "activateVirtualHIDManager" ], contain: "enforceVHIDDriverPostcondition" ) diff --git a/docs/bugs/2026-07-25-clean-install-vhid-postcondition-order.md b/docs/bugs/2026-07-25-clean-install-vhid-postcondition-order.md new file mode 100644 index 000000000..97f58019f --- /dev/null +++ b/docs/bugs/2026-07-25-clean-install-vhid-postcondition-order.md @@ -0,0 +1,27 @@ +# VHID activation must not require later runtime services + +## Symptom + +On a fresh managed macOS 15 clone, the installer activated the bundled DriverKit +virtual HID extension successfully, then stopped before installing KeyPath's +runtime service plists. A second supported installer pass succeeded. + +## Root cause + +`activateVirtualHIDManager()` enforced the aggregate VHID-services +postcondition. That postcondition requires the VHID daemon and manager services +to be installed, loaded, healthy, and correctly configured. The clean-install +plan intentionally installs those runtime services in a later recipe, so the +activation recipe was checking state it did not own and the plan had not yet +created. + +## Invariant + +The activation operation verifies the DriverKit extension outcome it owns. The +later runtime-service installation and VHID repair operations continue to +enforce the full VHID-services postcondition before reporting success. + +`PrivilegedOperationsRouterTests` pins both sides of the boundary: activation +does not require absent future services, and activation still fails when the +driver postcondition itself is unsatisfied. `PostconditionLintTests` pins the +operation-to-postcondition classification. From d96bb464b382be6aea1b776f2d8c38928aeb2134 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 09:10:51 -0700 Subject: [PATCH 14/99] Add verified repair and upgrade lab scenarios --- Scripts/lab/assert-app-version | 24 +++++ Scripts/lab/assert-runtime-state | 102 ++++++++++++++++++ Scripts/lab/damage-kanata-service | 21 ++++ Scripts/lab/install-keypath-artifact | 36 +++++++ Scripts/lab/keypath-lab | 16 ++- Scripts/lab/scenarios/installer-scenario | 26 ++++- .../plans/repair-kanata-service.json | 19 ++++ .../plans/upgrade-beta3-to-current.json | 30 ++++++ Scripts/lab/tests/runtime-state-tests.sh | 65 +++++++++++ 9 files changed, 337 insertions(+), 2 deletions(-) create mode 100755 Scripts/lab/assert-app-version create mode 100755 Scripts/lab/assert-runtime-state create mode 100755 Scripts/lab/damage-kanata-service create mode 100755 Scripts/lab/install-keypath-artifact create mode 100644 Scripts/lab/scenarios/plans/repair-kanata-service.json create mode 100644 Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json create mode 100755 Scripts/lab/tests/runtime-state-tests.sh diff --git a/Scripts/lab/assert-app-version b/Scripts/lab/assert-app-version new file mode 100755 index 000000000..8bc8f3850 --- /dev/null +++ b/Scripts/lab/assert-app-version @@ -0,0 +1,24 @@ +#!/bin/zsh +set -euo pipefail + +expected=${1:-} +[[ -n "$expected" ]] || { + print -u2 "Usage: $0 EXPECTED_VERSION" + exit 2 +} + +app=/Applications/KeyPath.app +plist="$app/Contents/Info.plist" +[[ -d "$app" && -f "$plist" ]] || { + print -u2 "app version assertion: KeyPath is not installed" + exit 1 +} + +/usr/bin/codesign --verify --deep --strict "$app" +actual=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist") +[[ "$actual" == "$expected" ]] || { + print -u2 "app version assertion: expected $expected, found $actual" + exit 1 +} + +print "app_version\t$actual" diff --git a/Scripts/lab/assert-runtime-state b/Scripts/lab/assert-runtime-state new file mode 100755 index 000000000..9b21c7f70 --- /dev/null +++ b/Scripts/lab/assert-runtime-state @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Independently assert the installed KeyPath runtime is ready or degraded.""" + +import json +import os +import subprocess +import sys + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def main() -> int: + if len(sys.argv) != 2 or sys.argv[1] not in {"ready", "degraded"}: + print(f"Usage: {sys.argv[0]} ready|degraded", file=sys.stderr) + return 2 + + expected = sys.argv[1] + cli = os.environ.get( + "KEYPATH_LAB_CLI", + "/Applications/KeyPath.app/Contents/MacOS/keypath-cli", + ) + launchctl = os.environ.get("KEYPATH_LAB_LAUNCHCTL", "/bin/launchctl") + tcp_probe = os.environ.get( + "KEYPATH_LAB_TCP_PROBE", + os.path.join(os.path.dirname(__file__), "probe-kanata-tcp"), + ) + + status_result = run([cli, "service", "status", "--json"]) + if status_result.returncode not in {0, 1}: + print(status_result.stderr, file=sys.stderr, end="") + return status_result.returncode + try: + payload = json.loads(status_result.stdout) + except json.JSONDecodeError as error: + print(f"runtime assertion: invalid service-status JSON: {error}", file=sys.stderr) + return 1 + status = payload.get("data", payload) + if not isinstance(status, dict): + print("runtime assertion: service-status payload is not an object", file=sys.stderr) + return 1 + + launchd_result = run([launchctl, "print", "system/com.keypath.kanata"]) + launchd_running = ( + launchd_result.returncode == 0 + and "state = running" in launchd_result.stdout + ) + + if expected == "degraded": + degraded = ( + status.get("isOperational") is False + and status.get("kanataRunning") is False + and not launchd_running + ) + if not degraded: + print( + "runtime assertion: expected a stopped Kanata service, " + f"got isOperational={status.get('isOperational')!r}, " + f"kanataRunning={status.get('kanataRunning')!r}, " + f"launchdRunning={launchd_running!r}", + file=sys.stderr, + ) + return 1 + print("runtime_state\tdegraded") + return 0 + + required = [ + "isOperational", + "helperInstalled", + "helperWorking", + "keyPathAccessibility", + "keyPathInputMonitoring", + "kanataAccessibility", + "kanataInputMonitoring", + "kanataBinaryInstalled", + "karabinerDriverInstalled", + "vhidDeviceHealthy", + "kanataRunning", + "karabinerDaemonRunning", + "vhidHealthy", + ] + failed = [key for key in required if status.get(key) is not True] + if failed or not status.get("helperVersion") or not launchd_running: + print( + "runtime assertion: runtime is not ready; " + f"falseOrMissing={failed}, helperVersion={status.get('helperVersion')!r}, " + f"launchdRunning={launchd_running!r}", + file=sys.stderr, + ) + return 1 + + tcp_result = run([tcp_probe]) + if tcp_result.returncode != 0: + print(tcp_result.stderr, file=sys.stderr, end="") + return tcp_result.returncode + print("runtime_state\tready") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/damage-kanata-service b/Scripts/lab/damage-kanata-service new file mode 100755 index 000000000..e40a440ba --- /dev/null +++ b/Scripts/lab/damage-kanata-service @@ -0,0 +1,21 @@ +#!/bin/zsh +set -euo pipefail + +plist=/Library/LaunchDaemons/com.keypath.kanata.plist +[[ -f "$plist" ]] || { + print -u2 "damage fixture: Kanata LaunchDaemon plist is absent" + exit 3 +} + +/usr/bin/sudo -n /bin/launchctl bootout system "$plist" + +for _ in {1..20}; do + if ! /bin/launchctl print system/com.keypath.kanata >/dev/null 2>&1; then + print "damage_fixture\tkanata-service-booted-out" + exit 0 + fi + sleep 0.25 +done + +print -u2 "damage fixture: Kanata service remained registered" +exit 1 diff --git a/Scripts/lab/install-keypath-artifact b/Scripts/lab/install-keypath-artifact new file mode 100755 index 000000000..74a01e17c --- /dev/null +++ b/Scripts/lab/install-keypath-artifact @@ -0,0 +1,36 @@ +#!/bin/zsh +set -euo pipefail + +archive=${1:-} +expected=${2:-} +[[ -f "$archive" && ! -L "$archive" && -n "$expected" ]] || { + print -u2 "Usage: $0 ARCHIVE EXPECTED_VERSION" + exit 2 +} + +staging=$(mktemp -d /tmp/keypath-upgrade.XXXXXXXX) +trap '/bin/rm -rf "$staging"' EXIT + +/usr/bin/ditto -x -k "$archive" "$staging" +app="$staging/KeyPath.app" +[[ -d "$app" ]] || { + print -u2 "artifact install: archive does not contain root KeyPath.app" + exit 3 +} + +/usr/bin/codesign --verify --deep --strict "$app" +actual=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist") +[[ "$actual" == "$expected" ]] || { + print -u2 "artifact install: expected $expected, found $actual" + exit 3 +} + +manifest=/Library/KeyPathLab/managed-policy/manifest.json +if [[ -f "$manifest" ]]; then + "$(dirname "$0")/mdm/verify-artifact-policy" --app "$app" --manifest "$manifest" +fi + +/usr/bin/pkill -x KeyPath >/dev/null 2>&1 || true +/usr/bin/sudo -n /bin/rm -rf /Applications/KeyPath.app +/usr/bin/sudo -n /usr/bin/ditto "$app" /Applications/KeyPath.app +"$(dirname "$0")/assert-app-version" "$expected" diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index 2d7df3d0c..c3b2053e0 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -12,7 +12,7 @@ Usage: Scripts/lab/keypath-lab [--host HOST] [options] Commands: preflight - create --macos 15|26|27 --lane managed-functional|unmanaged-ui --commit SHA --installer PATH [--ttl 2h] [--desktop] [--tart-usb-passthrough] + create --macos 15|26|27 --lane managed-functional|unmanaged-ui --commit SHA --installer PATH [--fixture PATH] [--ttl 2h] [--desktop] [--tart-usb-passthrough] install-app LEASE_ID console-login LEASE_ID verify-console-login LEASE_ID @@ -87,6 +87,7 @@ case "$command" in lane= commit= installer= + fixture= ttl=2h desktop=0 tart_usb_passthrough=0 @@ -96,6 +97,7 @@ case "$command" in --lane) lane=${2:-}; shift 2 ;; --commit) commit=${2:-}; shift 2 ;; --installer) installer=${2:-}; shift 2 ;; + --fixture) fixture=${2:-}; shift 2 ;; --ttl) ttl=${2:-}; shift 2 ;; --desktop) desktop=1; shift ;; --tart-usb-passthrough) tart_usb_passthrough=1; shift ;; @@ -108,6 +110,7 @@ case "$command" in [[ $tart_usb_passthrough == "0" || $macos == "15" ]] || die "--tart-usb-passthrough requires --macos 15" [[ $commit =~ ^[0-9a-fA-F]{40}$ ]] || die "--commit must be a full 40-character SHA" [[ -f $installer ]] || die "installer not found: $installer" + [[ -z $fixture || -f $fixture ]] || die "fixture not found: $fixture" resolved=$(git -C "$REPO_ROOT" rev-parse --verify "$commit^{commit}") [[ $resolved == "$commit" ]] || die "commit does not resolve exactly: $commit" @@ -120,6 +123,15 @@ case "$command" in installer_name=$(basename "$installer") [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" cp "$installer" "$temp_dir/repo/.keypath-lab/installer/$installer_name" + fixture_name= + fixture_sha= + if [[ -n $fixture ]]; then + fixture_name=$(basename "$fixture") + [[ $fixture_name =~ ^[A-Za-z0-9._-]+$ ]] || die "fixture filename must contain only letters, numbers, dots, underscores, and dashes" + fixture_sha=$(shasum -a 256 "$fixture" | awk '{print $1}') + mkdir -p "$temp_dir/repo/.keypath-lab/fixtures" + cp "$fixture" "$temp_dir/repo/.keypath-lab/fixtures/$fixture_name" + fi if [[ $lane == "managed-functional" ]]; then mkdir -p "$temp_dir/installer-extract" /usr/bin/ditto -x -k "$installer" "$temp_dir/installer-extract" @@ -132,6 +144,8 @@ case "$command" in keypath_commit $commit installer_name $installer_name installer_sha256 $installer_sha +fixture_name $fixture_name +fixture_sha256 $fixture_sha EOF tar -czf "$temp_dir/archive.tgz" -C "$temp_dir" repo diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index 467e21760..0d53cc124 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -64,6 +64,30 @@ case "$name" in "$installed_cli" system repair --json | tee "$artifact_dir/repair.json" "$installed_cli" system inspect --json | tee "$artifact_dir/post-repair.json" ;; + repair-kanata-service) + require_installed_cli + state="$artifact_dir/run-state.json" + result="$artifact_dir/result.json" + plan="$PWD/Scripts/lab/scenarios/plans/repair-kanata-service.json" + if [[ ! -f "$state" ]]; then + Scripts/lab/assert-runtime-state ready | tee "$artifact_dir/pre-damage-ready.tsv" + "$installed_cli" service status --json > "$artifact_dir/pre-damage-status.json" + Scripts/lab/damage-kanata-service | tee "$artifact_dir/damage.tsv" + "$installed_cli" service status --json > "$artifact_dir/damaged-status.json" || true + Scripts/lab/assert-runtime-state degraded | tee "$artifact_dir/damaged-state.tsv" + fi + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$result" + "$installed_cli" service status --json > "$artifact_dir/post-repair-status.json" + Scripts/lab/assert-runtime-state ready | tee "$artifact_dir/post-repair-ready.tsv" + ;; + upgrade-beta3-to-current) + plan="$PWD/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json" + state="$artifact_dir/run-state.json" + result="$artifact_dir/result.json" + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$result" + Scripts/lab/assert-app-version 1.0.0 | tee "$artifact_dir/final-version.tsv" + /usr/bin/codesign -dvvv /Applications/KeyPath.app > "$artifact_dir/final-signature.txt" 2>&1 + ;; reboot-persistence-before) require_installed_cli "$installed_cli" system inspect --json > "$artifact_dir/before-reboot.json" @@ -101,7 +125,7 @@ case "$name" in ;; *) print -u2 "Unknown scenario: $name" - print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall reboot-persistence-before reboot-persistence-after uninstall cancellation-failure failure-ownership-self-test artifact-capture macos-27-regression" + print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current reboot-persistence-before reboot-persistence-after uninstall cancellation-failure failure-ownership-self-test artifact-capture macos-27-regression" exit 2 ;; esac diff --git a/Scripts/lab/scenarios/plans/repair-kanata-service.json b/Scripts/lab/scenarios/plans/repair-kanata-service.json new file mode 100644 index 000000000..269f0c7ea --- /dev/null +++ b/Scripts/lab/scenarios/plans/repair-kanata-service.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "scenario": "repair-kanata-service", + "steps": [ + { + "id": "repair-kanata-service", + "action": [ + "/Applications/KeyPath.app/Contents/MacOS/keypath-cli", + "system", + "repair", + "--json" + ], + "verify": [ + "../../../../Scripts/lab/assert-runtime-state", + "ready" + ] + } + ] +} diff --git a/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json b/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json new file mode 100644 index 000000000..0866f5375 --- /dev/null +++ b/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "scenario": "upgrade-beta3-to-current", + "steps": [ + { + "id": "install-beta3", + "action": [ + "../../../../Scripts/lab/install-keypath-artifact", + "../../../../.keypath-lab/fixtures/KeyPath-1.0.0-beta3.zip", + "1.0.0-beta3" + ], + "verify": [ + "../../../../Scripts/lab/assert-app-version", + "1.0.0-beta3" + ] + }, + { + "id": "upgrade-current", + "action": [ + "../../../../Scripts/lab/install-keypath-artifact", + "../../../../.keypath-lab/installer/KeyPath.zip", + "1.0.0" + ], + "verify": [ + "../../../../Scripts/lab/assert-app-version", + "1.0.0" + ] + } + ] +} diff --git a/Scripts/lab/tests/runtime-state-tests.sh b/Scripts/lab/tests/runtime-state-tests.sh new file mode 100755 index 000000000..b7423f7b5 --- /dev/null +++ b/Scripts/lab/tests/runtime-state-tests.sh @@ -0,0 +1,65 @@ +#!/bin/zsh +set -euo pipefail + +repo=$(cd "$(dirname "$0")/../../.." >/dev/null && pwd -P) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +mkdir -p "$tmp/bin" + +cat > "$tmp/bin/keypath-cli" <<'EOF' +#!/bin/zsh +print -r -- "${KEYPATH_TEST_STATUS_JSON:?}" +exit "${KEYPATH_TEST_STATUS_EXIT:-0}" +EOF + +cat > "$tmp/bin/launchctl" <<'EOF' +#!/bin/zsh +if [[ ${KEYPATH_TEST_LAUNCHD_RUNNING:-0} == 1 ]]; then + print 'state = running' + exit 0 +fi +exit 113 +EOF + +cat > "$tmp/bin/tcp-probe" <<'EOF' +#!/bin/zsh +[[ ${KEYPATH_TEST_TCP_READY:-0} == 1 ]] +EOF + +chmod +x "$tmp/bin/"* + +ready='{"apiVersion":1,"data":{"isOperational":true,"helperInstalled":true,"helperWorking":true,"helperVersion":"1.2.3","keyPathAccessibility":true,"keyPathInputMonitoring":true,"kanataAccessibility":true,"kanataInputMonitoring":true,"kanataBinaryInstalled":true,"karabinerDriverInstalled":true,"vhidDeviceHealthy":true,"kanataRunning":true,"karabinerDaemonRunning":true,"vhidHealthy":true}}' +degraded='{"apiVersion":1,"data":{"isOperational":false,"helperInstalled":true,"helperWorking":true,"helperVersion":"1.2.3","keyPathAccessibility":true,"keyPathInputMonitoring":true,"kanataAccessibility":true,"kanataInputMonitoring":true,"kanataBinaryInstalled":true,"karabinerDriverInstalled":true,"vhidDeviceHealthy":true,"kanataRunning":false,"karabinerDaemonRunning":true,"vhidHealthy":true}}' + +env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_TEST_STATUS_JSON="$ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ + "$repo/Scripts/lab/assert-runtime-state" ready + +env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_TEST_STATUS_JSON="$degraded" KEYPATH_TEST_LAUNCHD_RUNNING=0 KEYPATH_TEST_TCP_READY=0 \ + "$repo/Scripts/lab/assert-runtime-state" degraded + +if env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_TEST_STATUS_JSON="$ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ + "$repo/Scripts/lab/assert-runtime-state" degraded >/dev/null 2>&1; then + print -u2 "ready runtime incorrectly passed the degraded assertion" + exit 1 +fi + +if env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_TEST_STATUS_JSON="$degraded" KEYPATH_TEST_LAUNCHD_RUNNING=0 KEYPATH_TEST_TCP_READY=0 \ + "$repo/Scripts/lab/assert-runtime-state" ready >/dev/null 2>&1; then + print -u2 "degraded runtime incorrectly passed the ready assertion" + exit 1 +fi + +print "runtime-state tests passed" From 6f1a2931d5705bb154a25598ef0ad5108eba3c10 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 09:59:23 -0700 Subject: [PATCH 15/99] Include upgrade fixtures in lab archive identity --- Scripts/lab/keypath-lab | 13 +++++---- Scripts/lab/remote.sh | 2 +- Scripts/lab/tests/keypath-lab-tests.sh | 37 +++++++++++++++++++++----- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index c3b2053e0..14d995257 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -116,6 +116,14 @@ case "$command" in installer_sha=$(shasum -a 256 "$installer" | awk '{print $1}') archive_key="${commit}-${installer_sha}" + fixture_name= + fixture_sha= + if [[ -n $fixture ]]; then + fixture_name=$(basename "$fixture") + [[ $fixture_name =~ ^[A-Za-z0-9._-]+$ ]] || die "fixture filename must contain only letters, numbers, dots, underscores, and dashes" + fixture_sha=$(shasum -a 256 "$fixture" | awk '{print $1}') + archive_key="${archive_key}-${fixture_sha}" + fi temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/keypath-lab.XXXXXX") trap 'rm -rf "$temp_dir"' EXIT mkdir -p "$temp_dir/repo/.keypath-lab/installer" @@ -123,12 +131,7 @@ case "$command" in installer_name=$(basename "$installer") [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" cp "$installer" "$temp_dir/repo/.keypath-lab/installer/$installer_name" - fixture_name= - fixture_sha= if [[ -n $fixture ]]; then - fixture_name=$(basename "$fixture") - [[ $fixture_name =~ ^[A-Za-z0-9._-]+$ ]] || die "fixture filename must contain only letters, numbers, dots, underscores, and dashes" - fixture_sha=$(shasum -a 256 "$fixture" | awk '{print $1}') mkdir -p "$temp_dir/repo/.keypath-lab/fixtures" cp "$fixture" "$temp_dir/repo/.keypath-lab/fixtures/$fixture_name" fi diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 6a6ec0889..bde215179 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -549,7 +549,7 @@ preflight() { prepare_upload() { valid_id "$1" - [[ "$1" =~ '^[0-9a-f]{40}-[0-9a-f]{64}$' ]] || die "invalid archive key" + [[ "$1" =~ '^[0-9a-f]{40}-[0-9a-f]{64}(-[0-9a-f]{64})?$' ]] || die "invalid archive key" mktemp "/tmp/keypath-lab.XXXXXXXX" } diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 9c19bf244..17f146bd5 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -836,9 +836,9 @@ if grep -R -F 'fixture-password-that-must-not-leak' "$ROOT/KeyPathInstallerLab" fi secure_agent_result=$(run_remote secure-dialog-input cbx_desktop15 SecurityAgent AXSecureTextField Allow 0) assert_contains "$secure_agent_result" $'secure_dialog_input\tpassed' -grep -q 'field.focused = true' "$TMP/guest-ssh-calls" -grep -q 'button.position()' "$TMP/guest-ssh-calls" -grep -q 'return open.*open.*closed' "$TMP/guest-ssh-calls" +grep -Fq 'field.focused' "$TMP/guest-ssh-calls" +grep -Fq 'button.position' "$TMP/guest-ssh-calls" +grep -Fq 'closed' "$TMP/guest-ssh-calls" [[ $(grep -c 'crabbox desktop type --provider tart --target macos --id test-resource$' "$CALLS") -ge 2 ]] grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 800 --y 600' "$CALLS" grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 800 --y 440' "$CALLS" @@ -918,16 +918,41 @@ run_remote cleanup >/dev/null mkdir -p "$TMP/fake-bin" cat > "$TMP/fake-bin/ssh" < "$TMP/ssh-args" +echo "\$*" >> "$TMP/ssh-args" cat >/dev/null -echo controller-preflight +case "\$*" in + *prepare-upload*) echo /tmp/keypath-lab.test-upload ;; + *install-archive*) echo archive-installed ;; + *" create "*) echo \$'lease_id\tcbx_controller_test' ;; + *) echo controller-preflight ;; +esac +EOF +cat > "$TMP/fake-bin/scp" < "$TMP/KeyPath.zip" +echo older-installer > "$TMP/KeyPath-beta3.zip" +controller_commit=$(git -C "$LAB_DIR/../.." rev-parse HEAD) +controller_installer_sha=$(shasum -a 256 "$TMP/KeyPath.zip" | awk '{print $1}') +controller_fixture_sha=$(shasum -a 256 "$TMP/KeyPath-beta3.zip" | awk '{print $1}') +controller_archive_key="${controller_commit}-${controller_installer_sha}-${controller_fixture_sha}" +controller_create=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host "$LAB_DIR/keypath-lab" create \ + --macos 15 \ + --lane unmanaged-ui \ + --commit "$controller_commit" \ + --installer "$TMP/KeyPath.zip" \ + --fixture "$TMP/KeyPath-beta3.zip") +assert_contains "$controller_create" $'lease_id\tcbx_controller_test' +grep -Fq "$controller_archive_key" "$TMP/ssh-args" || { + echo "controller archive key did not include fixture checksum" >&2 + exit 1 +} if PATH="$TMP/fake-bin:$PATH" "$LAB_DIR/keypath-lab" create --macos 15 --lane unmanaged-ui --commit abc --installer "$TMP/KeyPath.zip" >/dev/null 2>&1; then echo "controller accepted a non-explicit commit SHA" >&2 exit 1 From b7fba0337beb0d0141ea8f4378a4d97a5e48f05d Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 10:08:21 -0700 Subject: [PATCH 16/99] Install upgrade fixtures through managed host control --- Scripts/lab/keypath-lab | 6 ++++ Scripts/lab/remote.sh | 38 ++++++++++++++++++++++++++ Scripts/lab/tests/keypath-lab-tests.sh | 6 +++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index 14d995257..c0daeaeff 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -14,6 +14,7 @@ Commands: preflight create --macos 15|26|27 --lane managed-functional|unmanaged-ui --commit SHA --installer PATH [--fixture PATH] [--ttl 2h] [--desktop] [--tart-usb-passthrough] install-app LEASE_ID + install-fixture LEASE_ID console-login LEASE_ID verify-console-login LEASE_ID reset-guest-password LEASE_ID @@ -163,6 +164,11 @@ EOF require_lease_id "$1" remote install-app "$1" ;; + install-fixture) + [[ $# -eq 1 ]] || usage + require_lease_id "$1" + remote install-fixture "$1" + ;; console-login) [[ $# -eq 1 ]] || usage require_lease_id "$1" diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index bde215179..7b83a2327 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -834,6 +834,43 @@ install_app() { return "$exit_code" } +install_fixture() { + local lease=$1 manifest macos lane repo fixture_name provider_resource guest_repo command exit_code admission_command + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + lane=$(field "$manifest" test_lane) + repo=$(field "$manifest" worktree) + provider_resource=$(field "$manifest" provider_resource) + prepare_worktree "$repo" + fixture_name=$(awk -F $'\t' '$1 == "fixture_name" {print $2}' "$repo/.keypath-lab/source.tsv") + [[ -n "$fixture_name" && "$fixture_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "lease does not contain a valid upgrade fixture" + [[ -f "$repo/.keypath-lab/fixtures/$fixture_name" ]] || die "upgrade fixture is missing from lease archive" + guest_repo="/Users/$([[ "$macos" == "15" ]] && print admin || print keypathqa)/crabbox/$lease/repo" + admission_command="cd '$guest_repo'; Scripts/lab/mdm/verify-lane '$lane'" + if [[ "$lane" == "managed-functional" ]]; then + admission_command+=" --manifest /Library/KeyPathLab/managed-policy/manifest.json" + fi + command="setopt errexit nounset pipefail; $admission_command; rm -rf /tmp/keypath-fixture-install; mkdir -p /tmp/keypath-fixture-install; ditto -x -k '$guest_repo/.keypath-lab/fixtures/$fixture_name' /tmp/keypath-fixture-install; cd '$guest_repo'; if [[ '$lane' == managed-functional ]]; then Scripts/lab/mdm/verify-artifact-policy --app /tmp/keypath-fixture-install/KeyPath.app --manifest /Library/KeyPathLab/managed-policy/manifest.json; fi; rm -rf /Applications/KeyPath.app; ditto /tmp/keypath-fixture-install/KeyPath.app /Applications/KeyPath.app" + set +e + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + print "admission $lane" >> "$LOGS/$lease/install-fixture.log" + print "install-fixture $macos $lease $provider_resource $fixture_name" >> "$LOGS/$lease/install-fixture.log" + exit_code=0 + elif [[ "$macos" == "15" ]]; then + (cd "$repo" && "$(launcher_for "$macos")" run "$lease" -- /bin/zsh -lc "sudo -n /bin/zsh -lc $(printf %q "$command")") > "$LOGS/$lease/install-fixture.log" 2>&1 + exit_code=$? + else + [[ "$provider_resource" =~ '^[A-Fa-f0-9-]+$' && "$provider_resource" != "unknown" ]] || die "invalid Parallels resource id" + "/Applications/Parallels Desktop.app/Contents/MacOS/prlctl" exec "$provider_resource" /bin/zsh -lc "$command" > "$LOGS/$lease/install-fixture.log" 2>&1 + exit_code=$? + fi + set -e + set_field "$manifest" install_fixture_result "$exit_code" + set_field "$manifest" install_fixture_at "$(utc_now)" + cat "$LOGS/$lease/install-fixture.log" + return "$exit_code" +} + run_command() { local lease=$1; shift local manifest macos launcher repo log exit_code @@ -1910,6 +1947,7 @@ case "$action" in install-archive) [[ $# -eq 5 ]] || die "install-archive requires ticket, key, commit, checksum, and name"; install_archive "$@" ;; create) [[ $# -eq 8 || $# -eq 9 ]] || die "create requires macOS, test lane, archive, commit, checksum, name, ttl, desktop, and optional Tart USB passthrough"; create_lease "$@" ;; install-app) [[ $# -eq 1 ]] || die "install-app requires lease"; install_app "$1" ;; + install-fixture) [[ $# -eq 1 ]] || die "install-fixture requires lease"; install_fixture "$1" ;; secure-dialog-input) [[ $# -eq 5 ]] || die "secure-dialog-input requires lease, app, field, optional submit value, and focus mode"; secure_dialog_input "$@" ;; resume-managed-policy) [[ $# -eq 1 ]] || die "resume-managed-policy requires a lease"; resume_managed_policy "$1" ;; protected-click) [[ $# -eq 7 || $# -eq 8 ]] || die "protected-click requires lease, app, before window, after window, coordinate space, x, y, and optional count"; protected_click "$@" ;; diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 17f146bd5..f3922e60e 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -321,7 +321,7 @@ fi archive_key="$(printf 'a%.0s' {1..40})-$(printf 'b%.0s' {1..64})" repo="$ROOT/KeyPathInstallerLab/archives/$archive_key/repo" -mkdir -p "$repo/.keypath-lab/installer" "$repo/Scripts/lab/scenarios" "$repo/Scripts/lab/mdm" +mkdir -p "$repo/.keypath-lab/installer" "$repo/.keypath-lab/fixtures" "$repo/Scripts/lab/scenarios" "$repo/Scripts/lab/mdm" cp "$LAB_DIR/scenarios/installer-scenario" "$repo/Scripts/lab/scenarios/installer-scenario" cp "$LAB_DIR/nameplate-instrumentation" "$repo/Scripts/lab/nameplate-instrumentation" cat > "$repo/Scripts/lab/mdm/publish-managed-profiles" <<'EOF' @@ -341,6 +341,8 @@ chmod +x "$repo/Scripts/lab/scenarios/installer-scenario" chmod +x "$repo/Scripts/lab/nameplate-instrumentation" chmod +x "$repo/Scripts/lab/mdm/publish-managed-profiles" echo installer > "$repo/.keypath-lab/installer/KeyPath.zip" +echo older-installer > "$repo/.keypath-lab/fixtures/KeyPath-beta3.zip" +printf 'fixture_name\tKeyPath-beta3.zip\n' > "$repo/.keypath-lab/source.tsv" mkdir -p "$ROOT/KeyPathInstallerLab/managed-identities" printf '%s\n' '15151515-1515-1515-1515-151515151515' \ > "$ROOT/KeyPathInstallerLab/managed-identities/keypath-macos-15-managed.enrollment-id" @@ -403,6 +405,8 @@ run_remote scenario cbx_test15 clean-install >/dev/null grep -q 'installer-scenario clean-install' "$ROOT/KeyPathInstallerLab/leases/cbx_test15/commands.tsv" run_remote install-app cbx_test15 >/dev/null grep -q 'install-app 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-app.log" +run_remote install-fixture cbx_test15 >/dev/null +grep -q 'install-fixture 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-fixture.log" artifacts=$(run_remote artifacts cbx_test15) assert_contains "$artifacts" $'download_status\t0' From 766e343bb74e3bdf71d5cb1585cadc6caa8798e5 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 10:09:59 -0700 Subject: [PATCH 17/99] Attest managed Accessibility in runtime scenarios --- Scripts/lab/assert-runtime-state | 38 +++++++++++++++++-- Scripts/lab/scenarios/installer-scenario | 13 ++++++- .../plans/repair-kanata-service.json | 4 +- Scripts/lab/tests/runtime-state-tests.sh | 27 +++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/Scripts/lab/assert-runtime-state b/Scripts/lab/assert-runtime-state index 9b21c7f70..8ac4cab44 100755 --- a/Scripts/lab/assert-runtime-state +++ b/Scripts/lab/assert-runtime-state @@ -12,9 +12,19 @@ def run(command: list[str]) -> subprocess.CompletedProcess[str]: def main() -> int: - if len(sys.argv) != 2 or sys.argv[1] not in {"ready", "degraded"}: - print(f"Usage: {sys.argv[0]} ready|degraded", file=sys.stderr) + if len(sys.argv) not in {2, 4} or sys.argv[1] not in {"ready", "degraded"}: + print( + f"Usage: {sys.argv[0]} ready|degraded " + "[--managed-policy-manifest PATH]", + file=sys.stderr, + ) return 2 + managed_manifest = None + if len(sys.argv) == 4: + if sys.argv[2] != "--managed-policy-manifest": + print("runtime assertion: invalid managed-policy option", file=sys.stderr) + return 2 + managed_manifest = sys.argv[3] expected = sys.argv[1] cli = os.environ.get( @@ -65,13 +75,31 @@ def main() -> int: print("runtime_state\tdegraded") return 0 + managed_accessibility_attested = False + if status.get("kanataAccessibility") is False and managed_manifest: + verify_lane = os.environ.get( + "KEYPATH_LAB_VERIFY_LANE", + os.path.join(os.path.dirname(__file__), "mdm", "verify-lane"), + ) + managed_result = run( + [ + verify_lane, + "managed-functional", + "--manifest", + managed_manifest, + ] + ) + if managed_result.returncode != 0: + print(managed_result.stderr, file=sys.stderr, end="") + return managed_result.returncode + managed_accessibility_attested = True + required = [ "isOperational", "helperInstalled", "helperWorking", "keyPathAccessibility", "keyPathInputMonitoring", - "kanataAccessibility", "kanataInputMonitoring", "kanataBinaryInstalled", "karabinerDriverInstalled", @@ -80,6 +108,8 @@ def main() -> int: "karabinerDaemonRunning", "vhidHealthy", ] + if not managed_accessibility_attested: + required.append("kanataAccessibility") failed = [key for key in required if status.get(key) is not True] if failed or not status.get("helperVersion") or not launchd_running: print( @@ -94,6 +124,8 @@ def main() -> int: if tcp_result.returncode != 0: print(tcp_result.stderr, file=sys.stderr, end="") return tcp_result.returncode + if managed_accessibility_attested: + print("kanata_accessibility_evidence\tmanaged-policy-profile") print("runtime_state\tready") return 0 diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index 0d53cc124..42f143fc5 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -28,6 +28,15 @@ record_system() { installed_cli="/Applications/KeyPath.app/Contents/MacOS/keypath-cli" record_system +assert_ready() { + if [[ "$lane" == "managed-functional" ]]; then + Scripts/lab/assert-runtime-state ready \ + --managed-policy-manifest /Library/KeyPathLab/managed-policy/manifest.json + else + Scripts/lab/assert-runtime-state ready + fi +} + case "$name" in clean-install) find "$installer_dir" -maxdepth 1 -type f -print > "$artifact_dir/installer-files.txt" @@ -70,7 +79,7 @@ case "$name" in result="$artifact_dir/result.json" plan="$PWD/Scripts/lab/scenarios/plans/repair-kanata-service.json" if [[ ! -f "$state" ]]; then - Scripts/lab/assert-runtime-state ready | tee "$artifact_dir/pre-damage-ready.tsv" + assert_ready | tee "$artifact_dir/pre-damage-ready.tsv" "$installed_cli" service status --json > "$artifact_dir/pre-damage-status.json" Scripts/lab/damage-kanata-service | tee "$artifact_dir/damage.tsv" "$installed_cli" service status --json > "$artifact_dir/damaged-status.json" || true @@ -78,7 +87,7 @@ case "$name" in fi Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$result" "$installed_cli" service status --json > "$artifact_dir/post-repair-status.json" - Scripts/lab/assert-runtime-state ready | tee "$artifact_dir/post-repair-ready.tsv" + assert_ready | tee "$artifact_dir/post-repair-ready.tsv" ;; upgrade-beta3-to-current) plan="$PWD/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json" diff --git a/Scripts/lab/scenarios/plans/repair-kanata-service.json b/Scripts/lab/scenarios/plans/repair-kanata-service.json index 269f0c7ea..14c5d1098 100644 --- a/Scripts/lab/scenarios/plans/repair-kanata-service.json +++ b/Scripts/lab/scenarios/plans/repair-kanata-service.json @@ -12,7 +12,9 @@ ], "verify": [ "../../../../Scripts/lab/assert-runtime-state", - "ready" + "ready", + "--managed-policy-manifest", + "/Library/KeyPathLab/managed-policy/manifest.json" ] } ] diff --git a/Scripts/lab/tests/runtime-state-tests.sh b/Scripts/lab/tests/runtime-state-tests.sh index b7423f7b5..5d79215d8 100755 --- a/Scripts/lab/tests/runtime-state-tests.sh +++ b/Scripts/lab/tests/runtime-state-tests.sh @@ -27,10 +27,16 @@ cat > "$tmp/bin/tcp-probe" <<'EOF' [[ ${KEYPATH_TEST_TCP_READY:-0} == 1 ]] EOF +cat > "$tmp/bin/verify-lane" <<'EOF' +#!/bin/zsh +[[ ${KEYPATH_TEST_MANAGED_POLICY_VALID:-0} == 1 ]] +EOF + chmod +x "$tmp/bin/"* ready='{"apiVersion":1,"data":{"isOperational":true,"helperInstalled":true,"helperWorking":true,"helperVersion":"1.2.3","keyPathAccessibility":true,"keyPathInputMonitoring":true,"kanataAccessibility":true,"kanataInputMonitoring":true,"kanataBinaryInstalled":true,"karabinerDriverInstalled":true,"vhidDeviceHealthy":true,"kanataRunning":true,"karabinerDaemonRunning":true,"vhidHealthy":true}}' degraded='{"apiVersion":1,"data":{"isOperational":false,"helperInstalled":true,"helperWorking":true,"helperVersion":"1.2.3","keyPathAccessibility":true,"keyPathInputMonitoring":true,"kanataAccessibility":true,"kanataInputMonitoring":true,"kanataBinaryInstalled":true,"karabinerDriverInstalled":true,"vhidDeviceHealthy":true,"kanataRunning":false,"karabinerDaemonRunning":true,"vhidHealthy":true}}' +managed_ready='{"apiVersion":1,"data":{"isOperational":true,"helperInstalled":true,"helperWorking":true,"helperVersion":"1.2.3","keyPathAccessibility":true,"keyPathInputMonitoring":true,"kanataAccessibility":false,"kanataInputMonitoring":true,"kanataBinaryInstalled":true,"karabinerDriverInstalled":true,"vhidDeviceHealthy":true,"kanataRunning":true,"karabinerDaemonRunning":true,"vhidHealthy":true}}' env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ @@ -38,6 +44,27 @@ env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ KEYPATH_TEST_STATUS_JSON="$ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ "$repo/Scripts/lab/assert-runtime-state" ready +env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_LAB_VERIFY_LANE="$tmp/bin/verify-lane" \ + KEYPATH_TEST_STATUS_JSON="$managed_ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ + KEYPATH_TEST_MANAGED_POLICY_VALID=1 \ + "$repo/Scripts/lab/assert-runtime-state" ready \ + --managed-policy-manifest "$tmp/manifest.json" + +if env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_LAB_VERIFY_LANE="$tmp/bin/verify-lane" \ + KEYPATH_TEST_STATUS_JSON="$managed_ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ + KEYPATH_TEST_MANAGED_POLICY_VALID=0 \ + "$repo/Scripts/lab/assert-runtime-state" ready \ + --managed-policy-manifest "$tmp/manifest.json" >/dev/null 2>&1; then + print -u2 "invalid managed policy incorrectly substituted for Accessibility evidence" + exit 1 +fi + env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ From ce7441fd39022868fda6b819b6f3450cf3d36128 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 10:22:19 -0700 Subject: [PATCH 18/99] Recover Kanata after SMAppService bootout --- Scripts/lab/damage-kanata-service | 11 +++++------ .../Core/ServiceBootstrapper.swift | 11 +++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Scripts/lab/damage-kanata-service b/Scripts/lab/damage-kanata-service index e40a440ba..6750e3321 100755 --- a/Scripts/lab/damage-kanata-service +++ b/Scripts/lab/damage-kanata-service @@ -2,12 +2,11 @@ set -euo pipefail plist=/Library/LaunchDaemons/com.keypath.kanata.plist -[[ -f "$plist" ]] || { - print -u2 "damage fixture: Kanata LaunchDaemon plist is absent" - exit 3 -} - -/usr/bin/sudo -n /bin/launchctl bootout system "$plist" +if [[ -f "$plist" ]]; then + /usr/bin/sudo -n /bin/launchctl bootout system "$plist" +else + /usr/bin/sudo -n /bin/launchctl bootout system/com.keypath.kanata +fi for _ in {1..20}; do if ! /bin/launchctl print system/com.keypath.kanata >/dev/null 2>&1; then diff --git a/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift b/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift index b697ac575..67c964452 100644 --- a/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift +++ b/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift @@ -720,8 +720,15 @@ public final class ServiceBootstrapper { try await daemonManager.unregister() let bootedOut = await bootOutStaleKanataSMAppServiceJob(attempt: attempt) if !bootedOut { - AppLogger.shared.log("❌ Attempt \(attempt) failed: stale launchd job could not be booted out") - continue + // SMAppService.unregister() is authoritative. The explicit + // launchctl cleanup is only a best-effort workaround for + // stale jobs and may be unavailable to noninteractive CLI + // repair. Continue with registration; if a stale job truly + // remains, register() or the health postcondition will fail + // and the normal retry path will handle it. + AppLogger.shared.log( + "⚠️ Attempt \(attempt): stale launchd cleanup was unavailable; continuing after successful SMAppService unregister" + ) } ServiceHealthChecker.shared.invalidateHealthCache() for _ in 0 ..< 10 { // ~1s From 26d1c11c49672bf5ea5e4bb42341a0592d5a9859 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 12:04:18 -0700 Subject: [PATCH 19/99] Harden reboot persistence lab proof --- Scripts/lab/scenarios/installer-scenario | 68 ++++++++++++++++++++++++ Scripts/lab/tests/keypath-lab-tests.sh | 5 ++ 2 files changed, 73 insertions(+) diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index 42f143fc5..cc8fdfd4d 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -37,6 +37,23 @@ assert_ready() { fi } +record_scenario_result() { + local status=$1 summary=$2 + shift 2 + "$result_tool" record --output "$artifact_dir/result.json" --scenario "$name" \ + --status "$status" --summary "$summary" "$@" +} + +capture_app_identity() { + local output=$1 app=/Applications/KeyPath.app + { + /usr/bin/defaults read "$app/Contents/Info" CFBundleIdentifier + /usr/bin/defaults read "$app/Contents/Info" CFBundleShortVersionString + /usr/bin/codesign -dv --verbose=4 "$app" 2>&1 | \ + /usr/bin/awk -F= '/^(Identifier|TeamIdentifier|CDHash)=/ { print }' + } > "$output" +} + case "$name" in clean-install) find "$installer_dir" -maxdepth 1 -type f -print > "$artifact_dir/installer-files.txt" @@ -99,13 +116,64 @@ case "$name" in ;; reboot-persistence-before) require_installed_cli + if ! assert_ready | tee "$artifact_dir/ready.tsv"; then + record_scenario_result blocked \ + "Reboot persistence requires an independently ready runtime baseline." \ + --classification environment-precondition-failure --step pre-reboot-ready + exit 4 + fi + "$installed_cli" service status --json > "$artifact_dir/service-status.json" "$installed_cli" system inspect --json > "$artifact_dir/before-reboot.json" + /usr/sbin/sysctl -n kern.boottime > "$artifact_dir/boot-time.txt" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + record_scenario_result passed \ + "Independent runtime, app identity, and boot marker captured before reboot." \ + --evidence ready.tsv --evidence service-status.json \ + --evidence before-reboot.json --evidence boot-time.txt \ + --evidence app-identity.txt print "Run the host-approved guest reboot, then execute reboot-persistence-after on the same lease." ;; reboot-persistence-after) require_installed_cli + before_dir="$PWD/.keypath-lab/scenario-output/reboot-persistence-before" + if [[ ! -s "$before_dir/boot-time.txt" || ! -s "$before_dir/app-identity.txt" ]]; then + record_scenario_result blocked \ + "The same lease must pass reboot-persistence-before before the post-reboot check." \ + --classification environment-precondition-failure --step pre-reboot-evidence + exit 4 + fi + /usr/sbin/sysctl -n kern.boottime > "$artifact_dir/boot-time.txt" + if /usr/bin/cmp -s "$before_dir/boot-time.txt" "$artifact_dir/boot-time.txt"; then + record_scenario_result blocked \ + "The boot marker did not change; no guest reboot was proven." \ + --classification environment-precondition-failure --step host-reboot + exit 4 + fi + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + if ! /usr/bin/cmp -s "$before_dir/app-identity.txt" "$artifact_dir/app-identity.txt"; then + record_scenario_result failed \ + "KeyPath app identity changed across the guest reboot." \ + --classification keypath-product-failure --step app-identity-persistence \ + --evidence app-identity.txt + exit 1 + fi + if ! assert_ready | tee "$artifact_dir/ready.tsv"; then + record_scenario_result failed \ + "The independently ready KeyPath runtime did not recover after reboot." \ + --classification keypath-product-failure --step post-reboot-ready \ + --evidence ready.tsv + exit 1 + fi + "$installed_cli" service status --json > "$artifact_dir/service-status.json" "$installed_cli" system inspect --json | tee "$artifact_dir/after-reboot.json" Scripts/lab/probe-kanata-tcp 2> "$artifact_dir/tcp-readiness.stderr" | tee "$artifact_dir/tcp-readiness.json" + record_scenario_result passed \ + "App identity and independently ready runtime persisted across a proven guest reboot." \ + --evidence boot-time.txt --evidence app-identity.txt \ + --evidence ready.tsv --evidence service-status.json \ + --evidence after-reboot.json --evidence tcp-readiness.json ;; uninstall) require_installed_cli diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index f3922e60e..0a44aa1f2 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -245,6 +245,11 @@ grep -Fq 'guest reboot currently requires a Parallels lease' "$REMOTE" /bin/zsh -n "$LAB_DIR/mdm/enroll-clone-ui" /bin/zsh -n "$LAB_DIR/nameplate-instrumentation" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" +/bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'Reboot persistence requires an independently ready runtime baseline.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'The boot marker did not change; no guest reboot was proven.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'KeyPath app identity changed across the guest reboot.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'The independently ready KeyPath runtime did not recover after reboot.' "$LAB_DIR/scenarios/installer-scenario" grep -q 'macos-27-regression)' "$LAB_DIR/scenarios/installer-scenario" run_remote() { From 64469af21b896aa3a5fe4b0eecf79abf9c31bdd8 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 14:01:20 -0700 Subject: [PATCH 20/99] Add uninstall and reinstall lifecycle proofs --- Scripts/lab/assert-uninstalled-state | 108 ++++++++++++++++++ Scripts/lab/scenarios/installer-scenario | 58 +++++++++- .../scenarios/plans/reinstall-runtime.json | 21 ++++ Scripts/lab/tests/uninstalled-state-tests.sh | 55 +++++++++ 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100755 Scripts/lab/assert-uninstalled-state create mode 100644 Scripts/lab/scenarios/plans/reinstall-runtime.json create mode 100755 Scripts/lab/tests/uninstalled-state-tests.sh diff --git a/Scripts/lab/assert-uninstalled-state b/Scripts/lab/assert-uninstalled-state new file mode 100755 index 000000000..d3f754072 --- /dev/null +++ b/Scripts/lab/assert-uninstalled-state @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Independently assert KeyPath-owned state is removed after uninstall.""" + +import argparse +import hashlib +import os +from pathlib import Path +import subprocess +import sys + + +OWNED_SYSTEM_PATHS = ( + "/Applications/KeyPath.app", + "/Library/PrivilegedHelperTools/com.keypath.helper", + "/Library/LaunchDaemons/com.keypath.kanata.plist", + "/Library/LaunchDaemons/com.keypath.karabiner-vhiddaemon.plist", + "/Library/LaunchDaemons/com.keypath.karabiner-vhidmanager.plist", + "/Library/LaunchDaemons/com.keypath.helper.plist", + "/Library/LaunchDaemons/com.keypath.logrotate.plist", + "/Library/KeyPath/bin/kanata", + "/usr/local/etc/kanata", + "/usr/local/bin/keypath-logrotate.sh", + "/etc/newsyslog.d/com.keypath.conf", +) +OWNED_LAUNCHD_LABELS = ( + "system/com.keypath.kanata", + "system/com.keypath.helper", + "system/com.keypath.karabiner-vhiddaemon", + "system/com.keypath.karabiner-vhidmanager", + "system/com.keypath.logrotate", +) +SHARED_DRIVER_ID = "org.pqrs.Karabiner-DriverKit-VirtualHIDDevice" + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def rooted(root: Path, path: str) -> Path: + return root / path.removeprefix("/") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--preserved-config", required=True, type=Path) + parser.add_argument("--expected-sha256", required=True) + args = parser.parse_args() + + root = Path(os.environ.get("KEYPATH_LAB_ROOT", "/")) + home = Path(os.environ.get("KEYPATH_LAB_HOME", str(Path.home()))) + launchctl = os.environ.get("KEYPATH_LAB_LAUNCHCTL", "/bin/launchctl") + pgrep = os.environ.get("KEYPATH_LAB_PGREP", "/usr/bin/pgrep") + systemextensionsctl = os.environ.get( + "KEYPATH_LAB_SYSTEMEXTENSIONSCTL", "/usr/bin/systemextensionsctl" + ) + + failures: list[str] = [] + remaining_paths = [str(rooted(root, path)) for path in OWNED_SYSTEM_PATHS if rooted(root, path).exists()] + user_paths = ( + home / "Library/Application Support/KeyPath", + home / "Library/Logs/KeyPath", + ) + remaining_paths.extend(str(path) for path in user_paths if path.exists()) + remaining_paths.extend(str(path) for path in (home / "Library/Preferences").glob("com.keypath*.plist")) + if remaining_paths: + failures.append("owned paths remain: " + ", ".join(sorted(remaining_paths))) + + active_labels = [label for label in OWNED_LAUNCHD_LABELS if run([launchctl, "print", label]).returncode == 0] + if active_labels: + failures.append("owned launchd jobs remain: " + ", ".join(active_labels)) + + if run([pgrep, "-f", "/Applications/KeyPath.app"]).returncode == 0: + failures.append("a KeyPath application process is still running") + + if not args.preserved_config.is_file(): + failures.append(f"preserved configuration sentinel is missing: {args.preserved_config}") + elif sha256(args.preserved_config) != args.expected_sha256: + failures.append("preserved configuration sentinel changed during uninstall") + + extensions = run([systemextensionsctl, "list"]) + driver_lines = [line for line in extensions.stdout.splitlines() if SHARED_DRIVER_ID in line] + if extensions.returncode != 0: + failures.append("system extension inventory failed") + elif not any("activated enabled" in line for line in driver_lines): + failures.append("the shared VirtualHID driver was removed or is not activated and enabled") + + if failures: + for failure in failures: + print(f"uninstall assertion: {failure}", file=sys.stderr) + return 1 + + print("keypath_owned_state\tremoved") + print("user_configuration\tpreserved") + print("shared_vhid_driver\tpreserved") + print("uninstall_state\tverified") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index cc8fdfd4d..78f1426a7 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -177,8 +177,62 @@ case "$name" in ;; uninstall) require_installed_cli + if ! assert_ready | tee "$artifact_dir/pre-uninstall-ready.tsv"; then + record_scenario_result blocked \ + "Uninstall requires an independently ready runtime baseline." \ + --classification environment-precondition-failure --step pre-uninstall-ready + exit 4 + fi + "$installed_cli" service status --json > "$artifact_dir/pre-uninstall-status.json" + capture_app_identity "$artifact_dir/pre-uninstall-app-identity.txt" + config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" + mkdir -p "${config_sentinel:h}" + print -n 'keypath-lab-preserve-v1' > "$config_sentinel" + /usr/bin/shasum -a 256 "$config_sentinel" > "$artifact_dir/config-sentinel.sha256" + expected_config_sha=$(/usr/bin/awk '{print $1}' "$artifact_dir/config-sentinel.sha256") "$installed_cli" system uninstall --json | tee "$artifact_dir/uninstall.json" - test ! -e /Applications/KeyPath.app + Scripts/lab/assert-uninstalled-state \ + --preserved-config "$config_sentinel" \ + --expected-sha256 "$expected_config_sha" | tee "$artifact_dir/uninstalled-state.tsv" + record_scenario_result passed \ + "KeyPath-owned app, helper, services, and transient user state were removed while configuration and the shared VirtualHID driver were preserved." \ + --evidence pre-uninstall-ready.tsv --evidence pre-uninstall-status.json \ + --evidence pre-uninstall-app-identity.txt --evidence uninstall.json \ + --evidence config-sentinel.sha256 --evidence uninstalled-state.tsv + ;; + reinstall) + uninstall_dir="$PWD/.keypath-lab/scenario-output/uninstall" + uninstall_result="$uninstall_dir/result.json" + if [[ ! -s "$uninstall_result" ]] || \ + ! /usr/bin/python3 -c 'import json,sys; raise SystemExit(0 if json.load(open(sys.argv[1])).get("status") == "passed" else 1)' "$uninstall_result"; then + record_scenario_result blocked \ + "Reinstall requires a passed M05 uninstall result from this same lease." \ + --classification environment-precondition-failure --step same-lease-uninstall + exit 4 + fi + require_installed_cli + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/reinstalled-app-identity.txt" + config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" + expected_config_sha=$(/usr/bin/awk '{print $1}' "$uninstall_dir/config-sentinel.sha256") + actual_config_sha=$(/usr/bin/shasum -a 256 "$config_sentinel" | /usr/bin/awk '{print $1}') + if [[ "$actual_config_sha" != "$expected_config_sha" ]]; then + record_scenario_result failed \ + "Preserved configuration did not survive the reinstall boundary." \ + --classification keypath-product-failure --step configuration-preservation \ + --evidence reinstalled-app-identity.txt + exit 1 + fi + state="$artifact_dir/run-state.json" + result="$artifact_dir/runner-result.json" + plan="$PWD/Scripts/lab/scenarios/plans/reinstall-runtime.json" + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$result" + "$installed_cli" service status --json > "$artifact_dir/post-reinstall-status.json" + assert_ready | tee "$artifact_dir/post-reinstall-ready.tsv" + record_scenario_result passed \ + "The exact candidate reinstalled after verified uninstall and converged once to an independently ready runtime without changing preserved configuration." \ + --evidence reinstalled-app-identity.txt --evidence run-state.json \ + --evidence post-reinstall-status.json --evidence post-reinstall-ready.tsv ;; cancellation-failure) print "Use a disposable lease to cancel one approval flow and inject one non-destructive failure." @@ -202,7 +256,7 @@ case "$name" in ;; *) print -u2 "Unknown scenario: $name" - print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current reboot-persistence-before reboot-persistence-after uninstall cancellation-failure failure-ownership-self-test artifact-capture macos-27-regression" + print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current reboot-persistence-before reboot-persistence-after uninstall reinstall cancellation-failure failure-ownership-self-test artifact-capture macos-27-regression" exit 2 ;; esac diff --git a/Scripts/lab/scenarios/plans/reinstall-runtime.json b/Scripts/lab/scenarios/plans/reinstall-runtime.json new file mode 100644 index 000000000..3a357a9b0 --- /dev/null +++ b/Scripts/lab/scenarios/plans/reinstall-runtime.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "scenario": "reinstall", + "steps": [ + { + "id": "converge-reinstalled-runtime", + "action": [ + "/Applications/KeyPath.app/Contents/MacOS/keypath-cli", + "system", + "repair", + "--json" + ], + "verify": [ + "../../../../Scripts/lab/assert-runtime-state", + "ready", + "--managed-policy-manifest", + "/Library/KeyPathLab/managed-policy/manifest.json" + ] + } + ] +} diff --git a/Scripts/lab/tests/uninstalled-state-tests.sh b/Scripts/lab/tests/uninstalled-state-tests.sh new file mode 100755 index 000000000..0a02f2864 --- /dev/null +++ b/Scripts/lab/tests/uninstalled-state-tests.sh @@ -0,0 +1,55 @@ +#!/bin/zsh +set -euo pipefail + +repo=$(cd "$(dirname "$0")/../../.." >/dev/null && pwd -P) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +mkdir -p "$tmp/root" "$tmp/home/.config/keypath" "$tmp/bin" +print -n 'preserve-me' > "$tmp/home/.config/keypath/lab-sentinel" +expected=$(shasum -a 256 "$tmp/home/.config/keypath/lab-sentinel" | awk '{print $1}') + +cat > "$tmp/bin/launchctl" <<'EOF' +#!/bin/zsh +exit 113 +EOF +cat > "$tmp/bin/pgrep" <<'EOF' +#!/bin/zsh +exit 1 +EOF +cat > "$tmp/bin/systemextensionsctl" <<'EOF' +#!/bin/zsh +print '* * G43BCU2T37 org.pqrs.Karabiner-DriverKit-VirtualHIDDevice [activated enabled]' +EOF +chmod +x "$tmp/bin/"* + +base_env=( + KEYPATH_LAB_ROOT="$tmp/root" + KEYPATH_LAB_HOME="$tmp/home" + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" + KEYPATH_LAB_PGREP="$tmp/bin/pgrep" + KEYPATH_LAB_SYSTEMEXTENSIONSCTL="$tmp/bin/systemextensionsctl" +) + +env $base_env "$repo/Scripts/lab/assert-uninstalled-state" \ + --preserved-config "$tmp/home/.config/keypath/lab-sentinel" \ + --expected-sha256 "$expected" + +mkdir -p "$tmp/root/Applications/KeyPath.app" +if env $base_env "$repo/Scripts/lab/assert-uninstalled-state" \ + --preserved-config "$tmp/home/.config/keypath/lab-sentinel" \ + --expected-sha256 "$expected" >/dev/null 2>&1; then + print -u2 "remaining KeyPath app incorrectly passed uninstall assertion" + exit 1 +fi +rmdir "$tmp/root/Applications/KeyPath.app" + +print -n 'changed' > "$tmp/home/.config/keypath/lab-sentinel" +if env $base_env "$repo/Scripts/lab/assert-uninstalled-state" \ + --preserved-config "$tmp/home/.config/keypath/lab-sentinel" \ + --expected-sha256 "$expected" >/dev/null 2>&1; then + print -u2 "changed configuration incorrectly passed uninstall assertion" + exit 1 +fi + +print "uninstalled-state tests passed" From bd3e81e083dae89797aaa9f4184fc94524907b79 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 14:38:56 -0700 Subject: [PATCH 21/99] Close running app before CLI uninstall --- .../System/SystemUninstallCommand.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift b/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift index 8718c734e..622cb62e1 100644 --- a/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift +++ b/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift @@ -1,4 +1,5 @@ import ArgumentParser +import AppKit import Foundation import KeyPathAppKit import KeyPathCLISupport @@ -22,6 +23,10 @@ struct SystemUninstall: AsyncParsableCommand { CLIOutput.progress("Starting uninstall (configuration will be preserved)...", context: ctx) } + guard await terminateRunningKeyPathApplication(context: ctx) else { + throw ValidationError("The running KeyPath application could not be closed.") + } + let facade = SystemFacade() let shouldDeleteConfig = deleteConfig let timeoutSeconds = globals.timeout @@ -43,4 +48,33 @@ struct SystemUninstall: AsyncParsableCommand { throw ExitCode.failure } } + + @MainActor + private func terminateRunningKeyPathApplication(context: OutputContext) async -> Bool { + let currentPID = ProcessInfo.processInfo.processIdentifier + let applications = NSRunningApplication.runningApplications( + withBundleIdentifier: "com.keypath.KeyPath" + ).filter { $0.processIdentifier != currentPID && !$0.isTerminated } + + guard !applications.isEmpty else { return true } + + CLIOutput.progress("Closing the running KeyPath application...", context: context) + for application in applications { + _ = application.terminate() + } + + for _ in 0..<30 where applications.contains(where: { !$0.isTerminated }) { + try? await Task.sleep(for: .milliseconds(100)) + } + + for application in applications where !application.isTerminated { + _ = application.forceTerminate() + } + + for _ in 0..<20 where applications.contains(where: { !$0.isTerminated }) { + try? await Task.sleep(for: .milliseconds(100)) + } + + return applications.allSatisfy(\.isTerminated) + } } From 971c7dc9781d4dba4bfdf7f434d5929f3b9ebc7d Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 15:23:23 -0700 Subject: [PATCH 22/99] Fix zsh scenario result recording --- Scripts/lab/scenarios/installer-scenario | 4 ++-- Scripts/lab/tests/keypath-lab-tests.sh | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index 78f1426a7..e8bf39861 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -38,10 +38,10 @@ assert_ready() { } record_scenario_result() { - local status=$1 summary=$2 + local scenario_status=$1 summary=$2 shift 2 "$result_tool" record --output "$artifact_dir/result.json" --scenario "$name" \ - --status "$status" --summary "$summary" "$@" + --status "$scenario_status" --summary "$summary" "$@" } capture_app_identity() { diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 0a44aa1f2..e0a1f5ed9 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -246,6 +246,10 @@ grep -Fq 'guest reboot currently requires a Parallels lease' "$REMOTE" /bin/zsh -n "$LAB_DIR/nameplate-instrumentation" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" /bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" +if grep -Eq 'local[[:space:]]+status=' "$LAB_DIR/scenarios/installer-scenario"; then + echo "installer scenario must not shadow zsh's read-only status parameter" >&2 + exit 1 +fi grep -Fq 'Reboot persistence requires an independently ready runtime baseline.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'The boot marker did not change; no guest reboot was proven.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'KeyPath app identity changed across the guest reboot.' "$LAB_DIR/scenarios/installer-scenario" From bc3bd9381e057be141a00b2e2b731655543f1a95 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 15:31:13 -0700 Subject: [PATCH 23/99] Support owned Tart guest reboots --- Scripts/lab/remote.sh | 77 +++++++++++++++++++------- Scripts/lab/tests/keypath-lab-tests.sh | 7 ++- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 7b83a2327..a85c12518 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1767,29 +1767,68 @@ reset_desktop_keychain() { } reboot_guest() { - local lease=$1 manifest resource parallels_cli state attempt ready=0 + local lease=$1 manifest provider macos repo launcher resource parallels_cli state attempt ready=0 request_exit=0 poll_exit=0 manifest=$(owned_manifest "$lease") - [[ "$(field "$manifest" provider)" == "parallels" ]] || die "guest reboot currently requires a Parallels lease" - 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" - "$parallels_cli" restart "$resource" >/dev/null - sleep 3 - for attempt in {1..600}; do - state=$("$parallels_cli" list -i -f -j "$resource" 2>/dev/null | - python3 -c 'import json,sys; rows=json.load(sys.stdin); print(rows[0].get("State","") if rows else "",end="")' 2>/dev/null || true) - if [[ "$state" == "running" ]] && - "$parallels_cli" exec "$resource" /usr/bin/true >/dev/null 2>&1; then - ready=1 - break - fi - sleep 0.1 - done - (( ready == 1 )) || die "Parallels guest control did not recover after reboot" + provider=$(field "$manifest" provider) + case "$provider" in + parallels) + 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" + "$parallels_cli" restart "$resource" >/dev/null + sleep 3 + for attempt in {1..600}; do + state=$("$parallels_cli" list -i -f -j "$resource" 2>/dev/null | + python3 -c 'import json,sys; rows=json.load(sys.stdin); print(rows[0].get("State","") if rows else "",end="")' 2>/dev/null || true) + if [[ "$state" == "running" ]] && + "$parallels_cli" exec "$resource" /usr/bin/true >/dev/null 2>&1; then + ready=1 + break + fi + sleep 0.1 + done + (( ready == 1 )) || die "Parallels guest control did not recover after reboot" + ;; + tart) + macos=$(field "$manifest" macos) + [[ "$macos" == "15" ]] || die "unexpected Tart macOS lane: $macos" + repo=$(field "$manifest" worktree) + prepare_worktree "$repo" + launcher=$(launcher_for "$macos") + set +e + (cd "$repo" && "$launcher" run "$lease" -- /bin/zsh -lc 'sudo -n /sbin/shutdown -r now') \ + > "$LOGS/$lease/reboot-request.log" 2>&1 + request_exit=$? + set -e + if (( request_exit != 0 )) && /usr/bin/grep -Eqi 'sudo:|not permitted|permission denied' "$LOGS/$lease/reboot-request.log"; then + cat "$LOGS/$lease/reboot-request.log" + die "Tart guest rejected the reboot request" + fi + sleep "${KEYPATH_LAB_TART_REBOOT_SETTLE_SECONDS:-3}" + for attempt in {1..120}; do + set +e + (cd "$repo" && "$launcher" run "$lease" -- /usr/bin/true) \ + > "$LOGS/$lease/reboot-readiness.log" 2>&1 + poll_exit=$? + set -e + if (( poll_exit == 0 )); then + ready=1 + break + fi + sleep "${KEYPATH_LAB_TART_REBOOT_POLL_SECONDS:-1}" + done + if (( ready != 1 )); then + cat "$LOGS/$lease/reboot-readiness.log" + die "Tart guest SSH did not recover after reboot" + fi + ;; + *) die "unsupported reboot provider: $provider" ;; + esac set_field "$manifest" guest_reboot_at "$(utc_now)" record_command "$lease" passed reboot-guest print "guest_reboot\tpassed" + print "provider\t$provider" } rfb_pointer_probe() { diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index e0a1f5ed9..f5dee376b 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -241,7 +241,7 @@ grep -Fq 'NSAccessibilityUsageDescription' "$LAB_DIR/desktop-bootstrap" grep -Fq 'reset-desktop-keychain)' "$LAB_DIR/keypath-lab" grep -Fq 'reboot-guest)' "$LAB_DIR/keypath-lab" grep -Fq 'desktop keychain reset requires a verified console login' "$REMOTE" -grep -Fq 'guest reboot currently requires a Parallels lease' "$REMOTE" +grep -Fq 'Tart guest SSH did not recover after reboot' "$REMOTE" /bin/zsh -n "$LAB_DIR/mdm/enroll-clone-ui" /bin/zsh -n "$LAB_DIR/nameplate-instrumentation" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" @@ -416,6 +416,11 @@ run_remote install-app cbx_test15 >/dev/null grep -q 'install-app 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-app.log" run_remote install-fixture cbx_test15 >/dev/null grep -q 'install-fixture 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-fixture.log" +tart_reboot=$(KEYPATH_LAB_TART_REBOOT_SETTLE_SECONDS=0 KEYPATH_LAB_TART_REBOOT_POLL_SECONDS=0 run_remote reboot-guest cbx_test15) +assert_contains "$tart_reboot" $'guest_reboot\tpassed' +assert_contains "$tart_reboot" $'provider\ttart' +grep -q 'launcher15 run cbx_test15 -- /bin/zsh -lc sudo -n /sbin/shutdown -r now' "$CALLS" +grep -q $'guest_reboot_at\t' "$manifest" artifacts=$(run_remote artifacts cbx_test15) assert_contains "$artifacts" $'download_status\t0' From 4b42b7a71b684560dc5d83824eee2f8ba70ba850 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 15:38:17 -0700 Subject: [PATCH 24/99] Prove cancellation and recovery lifecycle --- Scripts/lab/scenarios/installer-scenario | 87 ++++++++++++++++++++++-- Scripts/lab/tests/keypath-lab-tests.sh | 5 ++ docs/testing/remote-installer-lab.md | 14 ++-- 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index e8bf39861..f6b5f3be7 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -234,9 +234,88 @@ case "$name" in --evidence reinstalled-app-identity.txt --evidence run-state.json \ --evidence post-reinstall-status.json --evidence post-reinstall-ready.tsv ;; - cancellation-failure) - print "Use a disposable lease to cancel one approval flow and inject one non-destructive failure." - print "Record the visible recovery action, CLI result, and KeyPath logs; do not corrupt the base image." + cancellation-recovery-before) + require_installed_cli + if ! assert_ready | tee "$artifact_dir/ready.tsv"; then + record_scenario_result blocked \ + "Cancellation recovery requires an independently ready runtime baseline." \ + --classification environment-precondition-failure --step pre-cancellation-ready + exit 4 + fi + "$installed_cli" service status --json > "$artifact_dir/service-status.json" + "$installed_cli" system inspect --json > "$artifact_dir/system-inspect.json" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + /usr/bin/pgrep -x KeyPath > "$artifact_dir/keypath-pid.txt" + config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" + if [[ ! -f "$config_sentinel" ]]; then + mkdir -p "${config_sentinel:h}" + print -n 'keypath-lab-preserve-v1' > "$config_sentinel" + fi + /usr/bin/shasum -a 256 "$config_sentinel" > "$artifact_dir/config-sentinel.sha256" + record_scenario_result passed \ + "Ready runtime, running GUI, app identity, and preserved configuration captured before the operator cancellation checkpoint." \ + --evidence ready.tsv --evidence service-status.json \ + --evidence system-inspect.json --evidence app-identity.txt \ + --evidence keypath-pid.txt --evidence config-sentinel.sha256 + print "Open KeyPath's real uninstall confirmation, capture it, click Cancel through the lease-owned desktop, then run cancellation-recovery-after." + ;; + cancellation-recovery-after) + before_dir="$PWD/.keypath-lab/scenario-output/cancellation-recovery-before" + before_result="$before_dir/result.json" + if [[ ! -s "$before_result" ]] || \ + ! /usr/bin/python3 -c 'import json,sys; raise SystemExit(0 if json.load(open(sys.argv[1])).get("status") == "passed" else 1)' "$before_result"; then + record_scenario_result blocked \ + "The same lease must pass cancellation-recovery-before before post-cancellation verification." \ + --classification environment-precondition-failure --step cancellation-checkpoint + exit 4 + fi + require_installed_cli + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + if ! /usr/bin/cmp -s "$before_dir/app-identity.txt" "$artifact_dir/app-identity.txt"; then + record_scenario_result failed \ + "KeyPath app identity changed across the cancellation checkpoint." \ + --classification keypath-product-failure --step cancellation-preserved-app \ + --evidence app-identity.txt + exit 1 + fi + /usr/bin/pgrep -x KeyPath > "$artifact_dir/keypath-pid.txt" + config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" + expected_config_sha=$(/usr/bin/awk '{print $1}' "$before_dir/config-sentinel.sha256") + actual_config_sha=$(/usr/bin/shasum -a 256 "$config_sentinel" | /usr/bin/awk '{print $1}') + if [[ "$actual_config_sha" != "$expected_config_sha" ]]; then + record_scenario_result failed \ + "Preserved configuration changed across the cancellation checkpoint." \ + --classification keypath-product-failure --step cancellation-preserved-configuration + exit 1 + fi + assert_ready | tee "$artifact_dir/post-cancel-ready.tsv" + "$installed_cli" service status --json > "$artifact_dir/post-cancel-status.json" + Scripts/lab/damage-kanata-service | tee "$artifact_dir/damage.tsv" + "$installed_cli" service status --json > "$artifact_dir/damaged-status.json" || true + Scripts/lab/assert-runtime-state degraded | tee "$artifact_dir/damaged-state.tsv" + state="$artifact_dir/run-state.json" + runner_result="$artifact_dir/runner-result.json" + plan="$PWD/Scripts/lab/scenarios/plans/repair-kanata-service.json" + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$runner_result" + "$installed_cli" service status --json > "$artifact_dir/post-repair-status.json" + assert_ready | tee "$artifact_dir/post-repair-ready.tsv" + Scripts/lab/probe-kanata-tcp 2> "$artifact_dir/tcp-readiness.stderr" | tee "$artifact_dir/tcp-readiness.json" + final_config_sha=$(/usr/bin/shasum -a 256 "$config_sentinel" | /usr/bin/awk '{print $1}') + if [[ "$final_config_sha" != "$expected_config_sha" ]]; then + record_scenario_result failed \ + "Preserved configuration changed during recovery." \ + --classification keypath-product-failure --step recovery-preserved-configuration + exit 1 + fi + record_scenario_result passed \ + "The real uninstall confirmation was cancelled without changing the app or configuration, then a non-destructive Kanata failure recovered once to an independently ready runtime." \ + --evidence app-identity.txt --evidence keypath-pid.txt \ + --evidence post-cancel-ready.tsv --evidence post-cancel-status.json \ + --evidence damage.tsv --evidence damaged-state.tsv \ + --evidence run-state.json --evidence post-repair-status.json \ + --evidence post-repair-ready.tsv --evidence tcp-readiness.json ;; failure-ownership-self-test) "$result_tool" selector-self-test --output "$artifact_dir/result.json" --scenario "$name" @@ -256,7 +335,7 @@ case "$name" in ;; *) print -u2 "Unknown scenario: $name" - print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current reboot-persistence-before reboot-persistence-after uninstall reinstall cancellation-failure failure-ownership-self-test artifact-capture macos-27-regression" + print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current reboot-persistence-before reboot-persistence-after uninstall reinstall cancellation-recovery-before cancellation-recovery-after failure-ownership-self-test artifact-capture macos-27-regression" exit 2 ;; esac diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index f5dee376b..893a823e1 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -254,6 +254,11 @@ grep -Fq 'Reboot persistence requires an independently ready runtime baseline.' grep -Fq 'The boot marker did not change; no guest reboot was proven.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'KeyPath app identity changed across the guest reboot.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'The independently ready KeyPath runtime did not recover after reboot.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'Cancellation recovery requires an independently ready runtime baseline.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'The same lease must pass cancellation-recovery-before before post-cancellation verification.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'Scripts/lab/damage-kanata-service' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'cancellation-recovery-before)' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'cancellation-recovery-after)' "$LAB_DIR/scenarios/installer-scenario" grep -q 'macos-27-regression)' "$LAB_DIR/scenarios/installer-scenario" run_remote() { diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 1623a270a..d011aba46 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -505,7 +505,9 @@ Scripts/lab/keypath-lab scenario cbx_example reboot-persistence-before # Reboot the disposable guest through the approved lab workflow. Scripts/lab/keypath-lab scenario cbx_example reboot-persistence-after Scripts/lab/keypath-lab scenario cbx_example uninstall -Scripts/lab/keypath-lab scenario cbx_example cancellation-failure +Scripts/lab/keypath-lab scenario cbx_example cancellation-recovery-before +# Open and capture KeyPath's real uninstall confirmation, then click Cancel. +Scripts/lab/keypath-lab scenario cbx_example cancellation-recovery-after Scripts/lab/keypath-lab scenario cbx_example artifact-capture Scripts/lab/keypath-lab scenario cbx_example macos-27-regression Scripts/lab/keypath-lab artifacts cbx_example @@ -513,9 +515,13 @@ Scripts/lab/keypath-lab artifacts cbx_example The scenario set covers clean installation, every macOS approval gate, helper/daemon and TCP health, launch, repair/reinstall, reboot persistence, -uninstall, cancellation/failure rendering, and final artifact capture. Approval -and cancellation cases intentionally give an operator a controlled observation -point rather than attempting to bypass macOS security UI. Never place Apple IDs, +uninstall, cancellation/recovery, and final artifact capture. The cancellation +pair captures a ready baseline, gives the operator a controlled observation point +for the real uninstall confirmation, verifies that Cancel preserved the app and +configuration, injects a non-destructive Kanata-service failure, and performs one +checkpointed repair. Approval and cancellation cases intentionally retain that +operator observation point rather than attempting to bypass macOS security UI. +Never place Apple IDs, passwords, private keys, TCC databases, or other credentials in a scenario or artifact bundle. CrabBox does not redact collected files automatically; inspect every bundle before sharing or publishing it. From 5cad66b30309097016efe134037b8cd31e5f3035 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 15:48:58 -0700 Subject: [PATCH 25/99] Add safe nightly and weekly matrix planner --- Scripts/lab/scenario-matrix | 236 +++++++++++++++++++++ Scripts/lab/scenarios/matrix-catalog.json | 152 +++++++++++++ Scripts/lab/tests/keypath-lab-tests.sh | 1 + Scripts/lab/tests/scenario-matrix-tests.py | 103 +++++++++ docs/testing/remote-installer-lab.md | 31 +++ 5 files changed, 523 insertions(+) create mode 100755 Scripts/lab/scenario-matrix create mode 100644 Scripts/lab/scenarios/matrix-catalog.json create mode 100755 Scripts/lab/tests/scenario-matrix-tests.py diff --git a/Scripts/lab/scenario-matrix b/Scripts/lab/scenario-matrix new file mode 100755 index 000000000..41b5e6c5e --- /dev/null +++ b/Scripts/lab/scenario-matrix @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Build deterministic, capacity- and TTL-safe KeyPath lab matrix plans.""" + +from __future__ import annotations + +import argparse +import datetime +import itertools +import json +import pathlib +import sys +from collections import defaultdict + + +SCHEMA_VERSION = 1 +FACTOR_KEYS = ("platform", "lane", "family", "boundary", "evidence") + + +class MatrixError(ValueError): + pass + + +def load_catalog(path: pathlib.Path) -> dict: + try: + catalog = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise MatrixError(f"cannot read catalog: {error}") from error + if catalog.get("schemaVersion") != SCHEMA_VERSION: + raise MatrixError("catalog requires schemaVersion 1") + cases = catalog.get("cases") + if not isinstance(cases, list) or not cases: + raise MatrixError("catalog requires non-empty cases") + seen: set[str] = set() + for case in cases: + case_id = case.get("id") + if not isinstance(case_id, str) or not case_id or case_id in seen: + raise MatrixError("case ids must be unique non-empty strings") + seen.add(case_id) + if case.get("provider") not in ("tart", "parallels", "local"): + raise MatrixError(f"{case_id}: invalid provider") + if case.get("automation") not in ("unattended", "operator", "physical"): + raise MatrixError(f"{case_id}: invalid automation level") + estimate = case.get("estimatedMinutes") + if not isinstance(estimate, int) or estimate < 1: + raise MatrixError(f"{case_id}: estimatedMinutes must be positive") + factors = case.get("factors") + if not isinstance(factors, dict) or any(not factors.get(key) for key in FACTOR_KEYS): + raise MatrixError(f"{case_id}: all matrix factors are required") + steps = case.get("steps") + if not isinstance(steps, list) or not steps or not all(isinstance(step, str) and step for step in steps): + raise MatrixError(f"{case_id}: steps must be non-empty strings") + if case["provider"] != "local" and case.get("cleanup") != "destroy-owned-lease": + raise MatrixError(f"{case_id}: VM cases require destroy-owned-lease cleanup") + if case.get("identityScope") == "shared" and case["provider"] != "parallels": + raise MatrixError(f"{case_id}: shared identities are only valid on Parallels") + return catalog + + +def factor_pairs(case: dict) -> set[tuple[str, str, str, str]]: + factors = case["factors"] + pairs = set() + for left, right in itertools.combinations(FACTOR_KEYS, 2): + pairs.add((left, str(factors[left]), right, str(factors[right]))) + return pairs + + +def weekly_pairwise(candidates: list[dict], required_ids: set[str]) -> list[dict]: + """Greedy deterministic set cover over all compatible pairs in the catalog.""" + universe = set().union(*(factor_pairs(case) for case in candidates)) + selected = [case for case in candidates if case["id"] in required_ids] + covered = set().union(*(factor_pairs(case) for case in selected)) if selected else set() + remaining = [case for case in candidates if case["id"] not in required_ids] + while covered != universe: + ranked = sorted( + remaining, + key=lambda case: (-len(factor_pairs(case) - covered), case["estimatedMinutes"], case["id"]), + ) + if not ranked or not (factor_pairs(ranked[0]) - covered): + raise MatrixError("pairwise catalog contains pairs that cannot be covered") + chosen = ranked[0] + selected.append(chosen) + covered |= factor_pairs(chosen) + remaining.remove(chosen) + return selected + + +def schedule(cases: list[dict], capacities: dict[str, int]) -> list[list[str]]: + """Create deterministic waves without exceeding provider or identity capacity.""" + pending = list(cases) + waves: list[list[str]] = [] + while pending: + used: dict[str, int] = defaultdict(int) + shared_identity = False + wave: list[dict] = [] + for case in list(pending): + provider = case["provider"] + if provider == "local": + wave.append(case) + pending.remove(case) + continue + if used[provider] >= capacities[provider]: + continue + if case.get("identityScope") == "shared" and shared_identity: + continue + wave.append(case) + pending.remove(case) + used[provider] += 1 + shared_identity = shared_identity or case.get("identityScope") == "shared" + if not wave: + raise MatrixError("capacity settings cannot schedule the remaining cases") + waves.append([case["id"] for case in wave]) + return waves + + +def build_plan( + catalog: dict, + cadence: str, + ttl_minutes: int, + capacities: dict[str, int], + include_operator: bool, + include_physical: bool, +) -> dict: + eligible = [ + case for case in catalog["cases"] + if case["automation"] == "unattended" + or (case["automation"] == "operator" and include_operator) + or (case["automation"] == "physical" and include_physical) + ] + nightly = [case for case in eligible if case.get("nightlyCore")] + if cadence == "nightly": + selected = nightly + else: + selected = weekly_pairwise(eligible, {case["id"] for case in nightly}) + if not selected: + raise MatrixError("no eligible cases selected") + + over_budget = [case for case in selected if case["estimatedMinutes"] > ttl_minutes] + if over_budget: + detail = ", ".join(f"{case['id']}={case['estimatedMinutes']}m" for case in over_budget) + raise MatrixError(f"lease TTL {ttl_minutes}m is too short: {detail}") + for provider in ("tart", "parallels"): + if capacities.get(provider, 0) < 1: + raise MatrixError(f"{provider} capacity must be at least 1") + + covered = set().union(*(factor_pairs(case) for case in selected)) + eligible_pairs = set().union(*(factor_pairs(case) for case in eligible)) + exclusions = [case for case in catalog["cases"] if case not in eligible] + jobs = [] + for case in selected: + jobs.append({ + "id": case["id"], + "title": case["title"], + "provider": case["provider"], + "macOS": case.get("macOS"), + "lane": case.get("lane"), + "identityScope": case.get("identityScope", "none"), + "automation": case["automation"], + "estimatedMinutes": case["estimatedMinutes"], + "ttlMinutes": ttl_minutes if case["provider"] != "local" else None, + "steps": case["steps"], + "finalizer": case.get("cleanup"), + "factors": case["factors"], + }) + return { + "schemaVersion": SCHEMA_VERSION, + "cadence": cadence, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "policy": { + "ttlMinutes": ttl_minutes, + "capacity": capacities, + "operatorCasesIncluded": include_operator, + "physicalCasesIncluded": include_physical, + "cleanupRequired": True, + }, + "summary": { + "jobs": len(jobs), + "waves": len(schedule(selected, capacities)), + "pairwisePairsCovered": len(covered), + "eligiblePairs": len(eligible_pairs), + "pairwiseComplete": covered == eligible_pairs if cadence == "weekly" else None, + }, + "waves": schedule(selected, capacities), + "jobs": jobs, + "excluded": [ + {"id": case["id"], "automation": case["automation"], "reason": "explicit opt-in required"} + for case in exclusions + ], + } + + +def parse_capacity(values: list[str]) -> dict[str, int]: + capacity = {"tart": 1, "parallels": 1} + for value in values: + if "=" not in value: + raise MatrixError("capacity must use provider=count") + provider, raw_count = value.split("=", 1) + if provider not in capacity: + raise MatrixError(f"unsupported provider capacity: {provider}") + try: + capacity[provider] = int(raw_count) + except ValueError as error: + raise MatrixError(f"invalid capacity: {value}") from error + return capacity + + +def main() -> None: + default_catalog = pathlib.Path(__file__).with_name("scenarios") / "matrix-catalog.json" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--catalog", type=pathlib.Path, default=default_catalog) + parser.add_argument("--cadence", choices=("nightly", "weekly"), required=True) + parser.add_argument("--ttl-minutes", type=int, default=120) + parser.add_argument("--capacity", action="append", default=[]) + parser.add_argument("--include-operator", action="store_true") + parser.add_argument("--include-physical", action="store_true") + parser.add_argument("--output", type=pathlib.Path) + args = parser.parse_args() + try: + if not 1 <= args.ttl_minutes <= 120: + raise MatrixError("ttl-minutes must be between 1 and 120") + plan = build_plan( + load_catalog(args.catalog), args.cadence, args.ttl_minutes, + parse_capacity(args.capacity), args.include_operator, args.include_physical, + ) + payload = json.dumps(plan, indent=2) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload) + else: + print(payload, end="") + except MatrixError as error: + print(f"scenario-matrix: {error}", file=sys.stderr) + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/Scripts/lab/scenarios/matrix-catalog.json b/Scripts/lab/scenarios/matrix-catalog.json new file mode 100644 index 000000000..c64fe0d64 --- /dev/null +++ b/Scripts/lab/scenarios/matrix-catalog.json @@ -0,0 +1,152 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "local-contracts", + "title": "Harness contracts and failure ownership", + "provider": "local", + "automation": "unattended", + "estimatedMinutes": 5, + "nightlyCore": true, + "steps": ["run-lab-contract-tests", "run-failure-ownership-self-test", "generate-consolidated-report"], + "factors": {"platform": "local", "lane": "none", "family": "contracts", "boundary": "none", "evidence": "report"} + }, + { + "id": "macos15-clean-install", + "title": "macOS 15 managed clean install", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 42, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "managed-capabilities", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "install", "boundary": "fresh", "evidence": "runtime"} + }, + { + "id": "macos15-repair", + "title": "macOS 15 Kanata damage and one-pass repair", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 48, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "repair-kanata-service", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "repair", "boundary": "degraded-service", "evidence": "runtime"} + }, + { + "id": "macos15-reboot", + "title": "macOS 15 reboot persistence", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 55, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "reboot-persistence-before", "reboot-guest", "reboot-persistence-after", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "persistence", "boundary": "reboot", "evidence": "runtime"} + }, + { + "id": "macos15-uninstall-reinstall", + "title": "macOS 15 uninstall and same-lease reinstall", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 58, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "uninstall", "install-exact-artifact", "reinstall", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "lifecycle", "boundary": "uninstall", "evidence": "runtime"} + }, + { + "id": "macos26-upgrade", + "title": "macOS 26 beta-to-current upgrade", + "provider": "parallels", + "macOS": 26, + "lane": "managed-functional", + "identityScope": "shared", + "automation": "operator", + "estimatedMinutes": 50, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-to-current", "artifact-capture"], + "factors": {"platform": "macos26", "lane": "managed", "family": "upgrade", "boundary": "version", "evidence": "signature"} + }, + { + "id": "macos26-selectors", + "title": "macOS 26 fail-closed selector capture", + "provider": "parallels", + "macOS": 26, + "lane": "unmanaged-ui", + "identityScope": "unique-clone", + "automation": "unattended", + "estimatedMinutes": 24, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-desktop-lease", "desktop-bootstrap", "macos-26-selector-driver", "artifact-capture"], + "factors": {"platform": "macos26", "lane": "unmanaged", "family": "selectors", "boundary": "approval-ui", "evidence": "semantic-ui"} + }, + { + "id": "macos27-selectors", + "title": "macOS 27 fail-closed selector capture", + "provider": "parallels", + "macOS": 27, + "lane": "unmanaged-ui", + "identityScope": "unique-clone", + "automation": "unattended", + "estimatedMinutes": 26, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-desktop-lease", "desktop-bootstrap", "macos-27-selector-driver", "artifact-capture"], + "factors": {"platform": "macos27", "lane": "unmanaged", "family": "selectors", "boundary": "approval-ui", "evidence": "semantic-ui"} + }, + { + "id": "macos15-launch", + "title": "macOS 15 installed-app launch", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 38, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "launch", "helper-daemon-health", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "launch", "boundary": "fresh", "evidence": "process"} + }, + { + "id": "macos15-cancellation-recovery", + "title": "macOS 15 visible cancellation and recovery", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 55, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "cancellation-recovery-before", "operator-cancel-visible-dialog", "cancellation-recovery-after", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "recovery", "boundary": "user-cancel", "evidence": "visual-runtime"} + }, + { + "id": "macos15-physical-remap", + "title": "macOS 15 physical q-to-w remap", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "physical", + "estimatedMinutes": 50, + "cleanup": "destroy-owned-lease", + "steps": ["create-usb-lease", "install-exact-artifact", "operator-physical-keypress", "capture-remapped-output", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "input-output", "boundary": "physical-hid", "evidence": "visual-runtime"} + } + ] +} diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 893a823e1..518ddbe4d 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -246,6 +246,7 @@ grep -Fq 'Tart guest SSH did not recover after reboot' "$REMOTE" /bin/zsh -n "$LAB_DIR/nameplate-instrumentation" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" /bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" +python3 "$LAB_DIR/tests/scenario-matrix-tests.py" if grep -Eq 'local[[:space:]]+status=' "$LAB_DIR/scenarios/installer-scenario"; then echo "installer scenario must not shadow zsh's read-only status parameter" >&2 exit 1 diff --git a/Scripts/lab/tests/scenario-matrix-tests.py b/Scripts/lab/tests/scenario-matrix-tests.py new file mode 100755 index 000000000..e7f9957f8 --- /dev/null +++ b/Scripts/lab/tests/scenario-matrix-tests.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +import importlib.machinery +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "Scripts/lab/scenario-matrix" +CATALOG = ROOT / "Scripts/lab/scenarios/matrix-catalog.json" + + +loader = importlib.machinery.SourceFileLoader("scenario_matrix", str(SCRIPT)) +spec = importlib.util.spec_from_loader(loader.name, loader) +matrix = importlib.util.module_from_spec(spec) +loader.exec_module(matrix) + + +class ScenarioMatrixTests(unittest.TestCase): + def run_plan(self, *arguments: str) -> dict: + result = subprocess.run( + [str(SCRIPT), *arguments], text=True, capture_output=True, check=True + ) + return json.loads(result.stdout) + + def test_nightly_is_unattended_and_requires_cleanup(self): + plan = self.run_plan("--cadence", "nightly") + self.assertGreaterEqual(plan["summary"]["jobs"], 3) + self.assertTrue(all(job["automation"] == "unattended" for job in plan["jobs"])) + self.assertTrue(all( + job["provider"] == "local" or job["finalizer"] == "destroy-owned-lease" + for job in plan["jobs"] + )) + excluded = {entry["id"] for entry in plan["excluded"]} + self.assertIn("macos15-clean-install", excluded) + self.assertIn("macos15-repair", excluded) + self.assertIn("macos15-cancellation-recovery", excluded) + self.assertIn("macos15-physical-remap", excluded) + + def test_weekly_plan_covers_every_eligible_pair(self): + plan = self.run_plan("--cadence", "weekly") + self.assertTrue(plan["summary"]["pairwiseComplete"]) + self.assertEqual( + plan["summary"]["pairwisePairsCovered"], plan["summary"]["eligiblePairs"] + ) + + def test_operator_and_physical_cases_require_separate_opt_in(self): + operator = self.run_plan("--cadence", "weekly", "--include-operator") + operator_ids = {job["id"] for job in operator["jobs"]} + self.assertIn("macos15-cancellation-recovery", operator_ids) + self.assertNotIn("macos15-physical-remap", operator_ids) + + physical = self.run_plan("--cadence", "weekly", "--include-physical") + physical_ids = {job["id"] for job in physical["jobs"]} + self.assertIn("macos15-physical-remap", physical_ids) + self.assertNotIn("macos15-cancellation-recovery", physical_ids) + + def test_ttl_rejects_a_plan_before_any_lease_can_start(self): + result = subprocess.run( + [str(SCRIPT), "--cadence", "nightly", "--ttl-minutes", "20"], + text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("lease TTL 20m is too short", result.stderr) + + def test_shared_identity_cases_are_serialized_even_with_capacity(self): + cases = [ + { + "id": "a", "provider": "parallels", "identityScope": "shared", + }, + { + "id": "b", "provider": "parallels", "identityScope": "shared", + }, + ] + self.assertEqual(matrix.schedule(cases, {"tart": 1, "parallels": 2}), [["a"], ["b"]]) + + def test_plan_is_deterministic_except_timestamp(self): + first = self.run_plan("--cadence", "weekly") + second = self.run_plan("--cadence", "weekly") + first.pop("generatedAt") + second.pop("generatedAt") + self.assertEqual(first, second) + + def test_invalid_catalog_without_cleanup_fails_closed(self): + catalog = json.loads(CATALOG.read_text()) + catalog["cases"][1].pop("cleanup") + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "catalog.json" + path.write_text(json.dumps(catalog)) + result = subprocess.run( + [str(SCRIPT), "--catalog", str(path), "--cadence", "nightly"], + text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("require destroy-owned-lease cleanup", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index d011aba46..964faec53 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -526,6 +526,37 @@ passwords, private keys, TCC databases, or other credentials in a scenario or artifact bundle. CrabBox does not redact collected files automatically; inspect every bundle before sharing or publishing it. +## Nightly and weekly matrix plans + +Generate the unattended curated diagonal and the broader deterministic pairwise +plan without creating a lease: + +```bash +Scripts/lab/scenario-matrix --cadence nightly --output /tmp/keypath-nightly.json +Scripts/lab/scenario-matrix --cadence weekly --output /tmp/keypath-weekly.json +``` + +The planner rejects a case whose predicted runtime exceeds the requested lease +TTL, assigns work to waves within Tart and Parallels capacity, and serializes +every job that uses the shared macOS 26 enrollment identity even if Parallels +has spare compute capacity. Every VM job carries `destroy-owned-lease` as its +mandatory finalizer. The generated JSON is deterministic apart from its +timestamp and can be retained beside the consolidated scenario report. + +Operator-visible cancellation and physical-HID cases are excluded from +unattended plans. They require separate explicit flags so a scheduled job can +never silently treat a missing human observation or physical keypress as a +pass: + +```bash +Scripts/lab/scenario-matrix --cadence weekly --include-operator +Scripts/lab/scenario-matrix --cadence weekly --include-physical +``` + +These flags change admission only; they do not bypass the operator or physical +checkpoint. The executor must still stop at the named step and retain the same +result, failure-ownership, artifact-sanitization, and owned-cleanup contracts. + ### macOS 27 beta regression capture On every significant macOS 27 beta seed, run the non-destructive evidence From 2f0979739761c0b4a06972f45ab34e61a4e9bb19 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 16:04:12 -0700 Subject: [PATCH 26/99] Use pinned guest tools in scenario runs --- Scripts/lab/peekaboo-ui | 9 ++++++++- Scripts/lab/remote.sh | 6 ++++-- Scripts/lab/scenarios/matrix-catalog.json | 2 +- Scripts/lab/tests/keypath-lab-tests.sh | 1 + Scripts/lab/tests/scenario-matrix-tests.py | 3 ++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Scripts/lab/peekaboo-ui b/Scripts/lab/peekaboo-ui index ac341b90d..c515900d3 100755 --- a/Scripts/lab/peekaboo-ui +++ b/Scripts/lab/peekaboo-ui @@ -34,7 +34,14 @@ require_output_path() { mkdir -p "$parent" } -peekaboo_bin=${PEEKABOO_BIN:-$(command -v peekaboo || true)} +peekaboo_bin=${PEEKABOO_BIN:-} +if [[ -z "$peekaboo_bin" ]]; then + if [[ -x "$HOME/.local/bin/peekaboo" ]]; then + peekaboo_bin="$HOME/.local/bin/peekaboo" + else + peekaboo_bin=$(command -v peekaboo || true) + fi +fi command=${1:-} [[ -n "$command" ]] || usage diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index a85c12518..a59b4a314 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -873,15 +873,17 @@ install_fixture() { run_command() { local lease=$1; shift - local manifest macos launcher repo log exit_code + local manifest macos launcher repo log exit_code guest_home guest_path manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) launcher=$(launcher_for "$macos") repo=$(field "$manifest" worktree) + guest_home="/Users/$([[ "$macos" == "15" ]] && print admin || print keypathqa)" + guest_path="$guest_home/.local/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" prepare_worktree "$repo" log="$LOGS/$lease/run-$(date -u +%Y%m%dT%H%M%SZ).log" set +e - (cd "$repo" && "$launcher" run "$lease" -- "$@") 2>&1 | tee "$log" + (cd "$repo" && "$launcher" run "$lease" -- /usr/bin/env "PATH=$guest_path" "$@") 2>&1 | tee "$log" exit_code=${pipestatus[1]} set -e if (( exit_code == 0 )); then record_command "$lease" passed "$@"; else record_command "$lease" "failed:$exit_code" "$@"; fi diff --git a/Scripts/lab/scenarios/matrix-catalog.json b/Scripts/lab/scenarios/matrix-catalog.json index c64fe0d64..9facce2f2 100644 --- a/Scripts/lab/scenarios/matrix-catalog.json +++ b/Scripts/lab/scenarios/matrix-catalog.json @@ -88,7 +88,7 @@ "macOS": 26, "lane": "unmanaged-ui", "identityScope": "unique-clone", - "automation": "unattended", + "automation": "operator", "estimatedMinutes": 24, "nightlyCore": true, "cleanup": "destroy-owned-lease", diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 518ddbe4d..0ed0c5d5f 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -416,6 +416,7 @@ assert_contains "$capacity_output" $'active_lease\tcbx_test15' run_remote run cbx_test15 echo hello >/dev/null grep -q 'echo hello' "$ROOT/KeyPathInstallerLab/leases/cbx_test15/commands.tsv" +grep -q 'launcher15 run cbx_test15 -- /usr/bin/env PATH=/Users/admin/.local/bin:' "$CALLS" run_remote scenario cbx_test15 clean-install >/dev/null grep -q 'installer-scenario clean-install' "$ROOT/KeyPathInstallerLab/leases/cbx_test15/commands.tsv" run_remote install-app cbx_test15 >/dev/null diff --git a/Scripts/lab/tests/scenario-matrix-tests.py b/Scripts/lab/tests/scenario-matrix-tests.py index e7f9957f8..93c606435 100755 --- a/Scripts/lab/tests/scenario-matrix-tests.py +++ b/Scripts/lab/tests/scenario-matrix-tests.py @@ -29,7 +29,7 @@ def run_plan(self, *arguments: str) -> dict: def test_nightly_is_unattended_and_requires_cleanup(self): plan = self.run_plan("--cadence", "nightly") - self.assertGreaterEqual(plan["summary"]["jobs"], 3) + self.assertGreaterEqual(plan["summary"]["jobs"], 2) self.assertTrue(all(job["automation"] == "unattended" for job in plan["jobs"])) self.assertTrue(all( job["provider"] == "local" or job["finalizer"] == "destroy-owned-lease" @@ -38,6 +38,7 @@ def test_nightly_is_unattended_and_requires_cleanup(self): excluded = {entry["id"] for entry in plan["excluded"]} self.assertIn("macos15-clean-install", excluded) self.assertIn("macos15-repair", excluded) + self.assertIn("macos26-selectors", excluded) self.assertIn("macos15-cancellation-recovery", excluded) self.assertIn("macos15-physical-remap", excluded) From 3b13beacf68f051d4e4fc88dc20a7330da41cd4a Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 16:14:19 -0700 Subject: [PATCH 27/99] Automate macOS 27 selector preparation --- Scripts/lab/macos-27-selector-scenario | 49 ++++++++++++++++ Scripts/lab/scenarios/matrix-catalog.json | 2 +- Scripts/lab/tests/keypath-lab-tests.sh | 1 + .../tests/macos-27-selector-scenario-tests.py | 57 +++++++++++++++++++ docs/testing/remote-installer-lab.md | 16 +++--- 5 files changed, 117 insertions(+), 8 deletions(-) create mode 100755 Scripts/lab/macos-27-selector-scenario create mode 100755 Scripts/lab/tests/macos-27-selector-scenario-tests.py diff --git a/Scripts/lab/macos-27-selector-scenario b/Scripts/lab/macos-27-selector-scenario new file mode 100755 index 000000000..dbb0f9cfc --- /dev/null +++ b/Scripts/lab/macos-27-selector-scenario @@ -0,0 +1,49 @@ +#!/bin/zsh +set -euo pipefail + +usage() { + print -u2 "Usage: Scripts/lab/macos-27-selector-scenario --output DIRECTORY" + exit 2 +} + +output= +while [[ $# -gt 0 ]]; do + case "$1" in + --output) [[ -n "${2:-}" ]] || usage; output=$2; shift 2 ;; + *) usage ;; + esac +done +[[ -n "$output" && "$output" != -* ]] || usage + +root=${0:A:h} +driver=${KEYPATH_SELECTOR_DRIVER:-$root/macos-27-selector-driver} +peekaboo=${KEYPATH_SELECTOR_PEEKABOO:-$root/peekaboo-ui} +open_bin=${KEYPATH_SELECTOR_OPEN:-/usr/bin/open} +osascript_bin=${KEYPATH_SELECTOR_OSASCRIPT:-/usr/bin/osascript} +sleep_bin=${KEYPATH_SELECTOR_SLEEP:-/bin/sleep} +url='x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' +readiness="$output/accessibility-readiness.json" + +mkdir -p "$output" +"$osascript_bin" -e 'tell application "System Settings" to quit' >/dev/null 2>&1 || true +"$open_bin" "$url" + +ready=0 +for attempt in {1..15}; do + if "$peekaboo" snapshot --app "System Settings" --output "$readiness" >/dev/null 2>&1 \ + && /usr/bin/grep -Fq 'com.apple.settings.accessibility' "$readiness" \ + && /usr/bin/grep -Fq 'Allow the applications below to control your computer.' "$readiness" \ + && /usr/bin/grep -Fq 'Peekaboo Lab Host' "$readiness"; then + ready=1 + break + fi + "$sleep_bin" 1 +done + +(( ready )) || print -u2 "macos-27-selector-scenario: pane readiness timed out; driver will record the fail-closed result" +exec "$driver" \ + --output "$output" \ + --scenario macos-27-selector-probe \ + --expect com.apple.settings.accessibility \ + --expect 'Allow the applications below to control your computer.' \ + --expect 'Peekaboo Lab Host' diff --git a/Scripts/lab/scenarios/matrix-catalog.json b/Scripts/lab/scenarios/matrix-catalog.json index 9facce2f2..6b3f686b8 100644 --- a/Scripts/lab/scenarios/matrix-catalog.json +++ b/Scripts/lab/scenarios/matrix-catalog.json @@ -106,7 +106,7 @@ "estimatedMinutes": 26, "nightlyCore": true, "cleanup": "destroy-owned-lease", - "steps": ["create-desktop-lease", "desktop-bootstrap", "macos-27-selector-driver", "artifact-capture"], + "steps": ["create-desktop-lease", "desktop-bootstrap", "macos-27-selector-scenario", "artifact-capture"], "factors": {"platform": "macos27", "lane": "unmanaged", "family": "selectors", "boundary": "approval-ui", "evidence": "semantic-ui"} }, { diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 0ed0c5d5f..dd697f9e0 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -247,6 +247,7 @@ grep -Fq 'Tart guest SSH did not recover after reboot' "$REMOTE" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" /bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" python3 "$LAB_DIR/tests/scenario-matrix-tests.py" +python3 "$LAB_DIR/tests/macos-27-selector-scenario-tests.py" if grep -Eq 'local[[:space:]]+status=' "$LAB_DIR/scenarios/installer-scenario"; then echo "installer scenario must not shadow zsh's read-only status parameter" >&2 exit 1 diff --git a/Scripts/lab/tests/macos-27-selector-scenario-tests.py b/Scripts/lab/tests/macos-27-selector-scenario-tests.py new file mode 100755 index 000000000..a642b9e9b --- /dev/null +++ b/Scripts/lab/tests/macos-27-selector-scenario-tests.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import os +import pathlib +import subprocess +import tempfile +import unittest + + +TOOL = pathlib.Path(__file__).resolve().parents[1] / "macos-27-selector-scenario" + + +class ScenarioTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tmp.name) + self.output = self.root / "out" + self.calls = self.root / "calls" + self.bin = self.root / "bin" + self.bin.mkdir() + for name in ("open", "osascript", "sleep"): + path = self.bin / name + path.write_text(f'#!/bin/sh\nprintf "%s\\n" "{name} $*" >> "$CALLS"\n') + path.chmod(0o755) + self.peekaboo = self.bin / "peekaboo" + self.peekaboo.write_text( + '#!/bin/sh\nout=\nwhile [ $# -gt 0 ]; do [ "$1" = --output ] && out=$2; shift; done\n' + 'printf \'%s\\n\' \'{"identifier":"com.apple.settings.accessibility","label":"Allow the applications below to control your computer.","row":"Peekaboo Lab Host"}\' > "$out"\n' + ) + self.peekaboo.chmod(0o755) + self.driver = self.bin / "driver" + self.driver.write_text('#!/bin/sh\nprintf "%s\\n" "driver $*" >> "$CALLS"\n') + self.driver.chmod(0o755) + + def tearDown(self): + self.tmp.cleanup() + + def test_prepares_accessibility_pane_and_uses_semantic_contract(self): + env = os.environ | { + "CALLS": str(self.calls), + "KEYPATH_SELECTOR_DRIVER": str(self.driver), + "KEYPATH_SELECTOR_PEEKABOO": str(self.peekaboo), + "KEYPATH_SELECTOR_OPEN": str(self.bin / "open"), + "KEYPATH_SELECTOR_OSASCRIPT": str(self.bin / "osascript"), + "KEYPATH_SELECTOR_SLEEP": str(self.bin / "sleep"), + } + result = subprocess.run([str(TOOL), "--output", str(self.output)], env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.calls.read_text() + self.assertIn("Privacy_Accessibility", calls) + self.assertIn("--expect com.apple.settings.accessibility", calls) + self.assertIn("--expect Allow the applications below to control your computer.", calls) + self.assertIn("--expect Peekaboo Lab Host", calls) + self.assertTrue((self.output / "accessibility-readiness.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 964faec53..f8db212bf 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -578,15 +578,17 @@ 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 +Scripts/lab/macos-27-selector-scenario \ + --output .keypath-lab/scenario-output/macos-27-selectors ``` -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 +The scenario reopens the macOS 27 Accessibility privacy pane, waits for its +semantic UI contract, and then runs the fail-closed driver. 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. The contract uses the page identifier, explanatory copy, and the +visible `Peekaboo Lab Host` row rather than relying on the private deep-link +token to appear in the accessibility tree. 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. From 9c4487ba31215756c9869797de1ffd2dabd5aa27 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 16:15:06 -0700 Subject: [PATCH 28/99] Retry macOS 27 pane launch after quit --- Scripts/lab/macos-27-selector-scenario | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/macos-27-selector-scenario b/Scripts/lab/macos-27-selector-scenario index dbb0f9cfc..70add6a55 100755 --- a/Scripts/lab/macos-27-selector-scenario +++ b/Scripts/lab/macos-27-selector-scenario @@ -1,6 +1,8 @@ #!/bin/zsh set -euo pipefail +die() { print -u2 "macos-27-selector-scenario: $*"; exit 1; } + usage() { print -u2 "Usage: Scripts/lab/macos-27-selector-scenario --output DIRECTORY" exit 2 @@ -26,7 +28,17 @@ readiness="$output/accessibility-readiness.json" mkdir -p "$output" "$osascript_bin" -e 'tell application "System Settings" to quit' >/dev/null 2>&1 || true -"$open_bin" "$url" +"$sleep_bin" 1 + +opened=0 +for attempt in {1..5}; do + if "$open_bin" "$url"; then + opened=1 + break + fi + "$sleep_bin" 1 +done +(( opened )) || die "could not open the Accessibility privacy pane after five attempts" ready=0 for attempt in {1..15}; do From 2e7caa66f794cd01967e15b822104e75c127514c Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 16:30:35 -0700 Subject: [PATCH 29/99] Package onsite physical remap proof --- Scripts/lab/physical-remap-session | 291 ++++++++++++++++++ Scripts/lab/scenarios/matrix-catalog.json | 2 +- Scripts/lab/tests/keypath-lab-tests.sh | 1 + .../lab/tests/physical-remap-session-tests.py | 105 +++++++ docs/testing/p02-virtualhid-output-proof.md | 27 +- 5 files changed, 421 insertions(+), 5 deletions(-) create mode 100755 Scripts/lab/physical-remap-session create mode 100755 Scripts/lab/tests/physical-remap-session-tests.py diff --git a/Scripts/lab/physical-remap-session b/Scripts/lab/physical-remap-session new file mode 100755 index 000000000..7d96bfdce --- /dev/null +++ b/Scripts/lab/physical-remap-session @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Prepare and observe the single physical-HID proof shared by P02-P04.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import shutil +import subprocess +import sys +import time + + +ROOT = pathlib.Path(__file__).resolve().parent +RESULT = pathlib.Path(os.environ.get("KEYPATH_PHYSICAL_RESULT", ROOT / "scenario-result")) +ASSERT_READY = pathlib.Path(os.environ.get("KEYPATH_PHYSICAL_ASSERT_READY", ROOT / "assert-runtime-state")) +PEEKABOO_UI = pathlib.Path(os.environ.get("KEYPATH_PHYSICAL_PEEKABOO_UI", ROOT / "peekaboo-ui")) +PEEKABOO = pathlib.Path(os.environ.get("KEYPATH_PHYSICAL_PEEKABOO", pathlib.Path.home() / ".local/bin/peekaboo")) +INSTALLED_CLI = pathlib.Path(os.environ.get( + "KEYPATH_PHYSICAL_INSTALLED_CLI", "/Applications/KeyPath.app/Contents/MacOS/keypath-cli" +)) +SW_VERS = os.environ.get("KEYPATH_PHYSICAL_SW_VERS", "/usr/bin/sw_vers") +IOREG = os.environ.get("KEYPATH_PHYSICAL_IOREG", "/usr/sbin/ioreg") +OPEN = os.environ.get("KEYPATH_PHYSICAL_OPEN", "/usr/bin/open") +OSASCRIPT = os.environ.get("KEYPATH_PHYSICAL_OSASCRIPT", "/usr/bin/osascript") +SLEEP_SCALE = max(0.0, float(os.environ.get("KEYPATH_PHYSICAL_SLEEP_SCALE", "1"))) + + +def run(command: list[str], output: pathlib.Path | None = None) -> subprocess.CompletedProcess[str]: + if output is None: + return subprocess.run(command, check=False, capture_output=True, text=True) + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("w") as handle: + return subprocess.run(command, check=False, stdout=handle, stderr=subprocess.STDOUT, text=True) + + +def pause(seconds: float) -> None: + time.sleep(seconds * SLEEP_SCALE) + + +def record(directory: pathlib.Path, scenario: str, status: str, summary: str, + classification: str | None = None, step: str | None = None, + message: str | None = None, evidence: tuple[str, ...] = ()) -> None: + command = [str(RESULT), "record", "--output", str(directory / "result.json"), + "--scenario", scenario, "--status", status, "--summary", summary] + if classification: + command += ["--classification", classification] + if step: + command += ["--step", step] + if message: + command += ["--message", message] + for item in evidence: + command += ["--evidence", item] + result = run(command) + if result.returncode: + raise RuntimeError(result.stderr or "could not record scenario result") + + +def snapshot(app: str, output: pathlib.Path) -> subprocess.CompletedProcess[str]: + return run([str(PEEKABOO_UI), "snapshot", "--app", app, "--output", str(output)]) + + +def textedit_value() -> str: + result = run([OSASCRIPT, "-e", 'tell application "TextEdit" to get text of document 1']) + return result.stdout.rstrip("\n") if result.returncode == 0 else "" + + +def overlay_key_state(path: pathlib.Path) -> tuple[bool, bool]: + try: + root = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return False, False + found = False + pressed = False + + def walk(value: object) -> None: + nonlocal found, pressed + if isinstance(value, dict): + if value.get("identifier") == "keycap-code-12": + found = True + state = str(value.get("value", value.get("accessibilityValue", ""))).casefold() + pressed = state in ("pressed", "held") + for child in value.values(): + walk(child) + elif isinstance(value, list): + for child in value: + walk(child) + + walk(root) + return found, pressed + + +def prepare(args: argparse.Namespace) -> int: + output = args.output + prep = output / "prepare" + prep.mkdir(parents=True, exist_ok=True) + version = run([SW_VERS, "-productVersion"]) + if version.returncode or version.stdout.strip().split(".")[0] != "15": + record(prep, "physical-remap-prepare", "blocked", "The physical remap session requires macOS 15.", + "environment-precondition-failure", "os-admission") + return 4 + run([SW_VERS], prep / "sw-vers.txt") + hid = run([IOREG, "-r", "-c", "IOHIDDevice", "-l"]) + inventory = hid.stdout + retained_keys = ("Product", "Manufacturer", "Transport", "VendorID", "ProductID", "PrimaryUsage") + safe_inventory = "\n".join( + line for line in inventory.splitlines() if any(key in line for key in retained_keys) + ) + "\n" + (prep / "hid-inventory.txt").write_text(safe_inventory) + if hid.returncode or args.device_match.casefold() not in inventory.casefold(): + record(prep, "physical-remap-prepare", "blocked", + "The named physical keyboard is not visible inside the guest.", + "environment-precondition-failure", "physical-hid-admission", + f"Missing guest HID match: {args.device_match}", ("hid-inventory.txt",)) + return 4 + manifest = pathlib.Path("/Library/KeyPathLab/managed-policy/manifest.json") + ready_command = [str(ASSERT_READY), "ready"] + if manifest.exists(): + ready_command += ["--managed-policy-manifest", str(manifest)] + ready = run(ready_command, prep / "runtime-ready.tsv") + if ready.returncode: + record(prep, "physical-remap-prepare", "blocked", + "The physical proof requires an independently ready runtime.", + "environment-precondition-failure", "runtime-ready", evidence=("runtime-ready.tsv",)) + return 4 + if not INSTALLED_CLI.is_file() or not os.access(INSTALLED_CLI, os.X_OK): + record(prep, "physical-remap-prepare", "blocked", "The installed KeyPath CLI is unavailable.", + "environment-precondition-failure", "installed-cli") + return 4 + rule = run([str(INSTALLED_CLI), "rule", "ensure", "q", "w", "--apply"], prep / "rule-q-to-w.json") + simulate = run([str(INSTALLED_CLI), "simulate", "q"], prep / "simulation.txt") + if rule.returncode or simulate.returncode: + record(prep, "physical-remap-prepare", "failed", "Could not establish the deterministic q-to-w rule.", + "keypath-product-failure", "q-to-w-rule", evidence=("rule-q-to-w.json", "simulation.txt")) + return 1 + run([OPEN, "-a", "KeyPath"]) + pause(2) + overlay = prep / "overlay-ready.json" + if snapshot("KeyPath", overlay).returncode or "keyboard-overlay" not in overlay.read_text(errors="replace"): + run([str(PEEKABOO), "hotkey", "cmd,alt,l", "--app", "KeyPath"]) + pause(1) + if snapshot("KeyPath", overlay).returncode or "keyboard-overlay" not in overlay.read_text(errors="replace"): + record(prep, "physical-remap-prepare", "blocked", "The KeyPath overlay is not AX-visible.", + "environment-precondition-failure", "overlay-ready", evidence=("overlay-ready.json",)) + return 4 + textedit_script = ( + 'tell application "TextEdit"\nactivate\n' + 'if (count documents) = 0 then make new document\n' + 'set text of document 1 to ""\nend tell' + ) + if run([OSASCRIPT, "-e", textedit_script]).returncode: + record(prep, "physical-remap-prepare", "blocked", "Could not prepare the independent TextEdit oracle.", + "environment-precondition-failure", "textedit-ready") + return 4 + pause(1) + if snapshot("TextEdit", prep / "textedit-ready.json").returncode: + record(prep, "physical-remap-prepare", "blocked", "TextEdit is not AX-visible.", + "environment-precondition-failure", "textedit-ready") + return 4 + (prep / "session.json").write_text(json.dumps({ + "schemaVersion": 1, + "deviceMatch": args.device_match, + "sourceKey": "q", + "expectedOutput": "w", + }, indent=2) + "\n") + record(prep, "physical-remap-prepare", "passed", + "Ready runtime, physical guest HID, q-to-w rule, overlay, and TextEdit oracle are armed.", + evidence=("sw-vers.txt", "hid-inventory.txt", "runtime-ready.tsv", "rule-q-to-w.json", + "simulation.txt", "overlay-ready.json", "textedit-ready.json", "session.json")) + print("physical_remap_session\tarmed") + print("next\trun observe, then hold the named keyboard's physical q key until it completes") + return 0 + + +def observe(args: argparse.Namespace) -> int: + output = args.output + prep_result = output / "prepare" / "result.json" + try: + prepared = json.loads(prep_result.read_text()).get("status") == "passed" + except (OSError, json.JSONDecodeError): + prepared = False + if not prepared: + target = output / "P02" + target.mkdir(parents=True, exist_ok=True) + record(target, "P02-physical-remap", "blocked", "Run the passed prepare stage first.", + "environment-precondition-failure", "prepare-checkpoint") + return 4 + run([OSASCRIPT, "-e", 'tell application "KeyPath" to quit']) + pause(0.5) + started = time.monotonic_ns() + if run([OPEN, "-a", "KeyPath"]).returncode: + return 4 + overlay_ready = output / "P04" / "overlay-after-launch.json" + overlay_ready.parent.mkdir(parents=True, exist_ok=True) + for _ in range(30): + if snapshot("KeyPath", overlay_ready).returncode == 0 \ + and "keyboard-overlay" in overlay_ready.read_text(errors="replace"): + break + pause(0.1) + else: + return 4 + clear = run([OSASCRIPT, "-e", 'tell application "TextEdit" to set text of document 1 to ""', + "-e", 'tell application "TextEdit" to activate']) + if clear.returncode: + return 4 + print("physical_remap_session\twaiting") + print("operator_action\thold the physical q key until this command completes") + sys.stdout.flush() + deadline = time.monotonic() + args.timeout_seconds + value = "" + while time.monotonic() < deadline: + value = textedit_value() + if value: + break + pause(0.05) + detected = time.monotonic_ns() + elapsed_ms = round((detected - started) / 1_000_000, 3) + + p02 = output / "P02" + p03 = output / "P03" + p04 = output / "P04" + for directory in (p02, p03, p04): + directory.mkdir(parents=True, exist_ok=True) + (p02 / "textedit-output.txt").write_text(value + "\n") + shutil.copy2(output / "prepare" / "hid-inventory.txt", p02 / "hid-inventory.txt") + shutil.copy2(output / "prepare" / "runtime-ready.tsv", p02 / "runtime-ready.tsv") + timing = {"schemaVersion": 1, "launchToOutputMilliseconds": elapsed_ms, "output": value} + (p04 / "timing.json").write_text(json.dumps(timing, indent=2) + "\n") + overlay_ax = p03 / "overlay-during-physical-q.json" + overlay_capture = snapshot("KeyPath", overlay_ax) + run([str(PEEKABOO_UI), "screenshot", "--app", "KeyPath", "--output", str(p03 / "overlay-during-physical-q.png")]) + snapshot("TextEdit", p02 / "textedit-after.json") + found_key, overlay_pressed = overlay_key_state(overlay_ax) + + p02_passed = value == "w" + if p02_passed: + record(p02, "P02-physical-remap", "passed", + "A guest-visible physical q produced independently observed virtual output w in TextEdit.", + evidence=("hid-inventory.txt", "runtime-ready.tsv", "textedit-output.txt", "textedit-after.json")) + elif not value: + record(p02, "P02-physical-remap", "blocked", "No physical input reached TextEdit before timeout.", + "environment-precondition-failure", "physical-keypress", evidence=("hid-inventory.txt", "runtime-ready.tsv")) + else: + record(p02, "P02-physical-remap", "failed", f"Physical q produced {value!r}, not remapped output 'w'.", + "keypath-product-failure", "virtual-output", evidence=("hid-inventory.txt", "runtime-ready.tsv", "textedit-output.txt", "textedit-after.json")) + + if overlay_capture.returncode == 0 and found_key and overlay_pressed: + record(p03, "P03-overlay-observation", "passed", + "The KeyPath overlay exposed physical Q as pressed during the proven q-to-w event.", + evidence=("overlay-during-physical-q.json", "overlay-during-physical-q.png")) + elif not found_key: + record(p03, "P03-overlay-observation", "failed", "The fresh overlay AX tree lacked physical Q keycap state.", + "harness-selector-failure", "overlay-keycap-selector", evidence=("overlay-during-physical-q.json",)) + else: + record(p03, "P03-overlay-observation", "failed", "The overlay did not report physical Q as pressed during observation.", + "keypath-product-failure", "overlay-input-state", evidence=("overlay-during-physical-q.json", "overlay-during-physical-q.png")) + + if p02_passed: + record(p04, "P04-first-remap-timing", "passed", + f"KeyPath relaunch to the first confirmed physical q-to-w output took {elapsed_ms} ms.", + evidence=("overlay-after-launch.json", "timing.json")) + else: + record(p04, "P04-first-remap-timing", "blocked", "No successful physical remap was available to time.", + "environment-precondition-failure", "p02-output-proof", evidence=("timing.json",)) + print(f"physical_remap_output\t{value or 'timeout'}") + print(f"launch_to_output_ms\t{elapsed_ms}") + print(f"overlay_q_pressed\t{str(overlay_pressed).lower()}") + return 0 if p02_passed and overlay_pressed else 1 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + prepare_parser = subparsers.add_parser("prepare") + prepare_parser.add_argument("--output", required=True, type=pathlib.Path) + prepare_parser.add_argument("--device-match", required=True) + observe_parser = subparsers.add_parser("observe") + observe_parser.add_argument("--output", required=True, type=pathlib.Path) + observe_parser.add_argument("--timeout-seconds", type=int, default=120) + args = parser.parse_args() + if args.command == "prepare": + raise SystemExit(prepare(args)) + if not 1 <= args.timeout_seconds <= 300: + parser.error("--timeout-seconds must be between 1 and 300") + raise SystemExit(observe(args)) + + +if __name__ == "__main__": + main() diff --git a/Scripts/lab/scenarios/matrix-catalog.json b/Scripts/lab/scenarios/matrix-catalog.json index 6b3f686b8..fd8e36571 100644 --- a/Scripts/lab/scenarios/matrix-catalog.json +++ b/Scripts/lab/scenarios/matrix-catalog.json @@ -145,7 +145,7 @@ "automation": "physical", "estimatedMinutes": 50, "cleanup": "destroy-owned-lease", - "steps": ["create-usb-lease", "install-exact-artifact", "operator-physical-keypress", "capture-remapped-output", "artifact-capture"], + "steps": ["create-usb-lease", "install-exact-artifact", "physical-remap-session-prepare", "operator-physical-keypress", "physical-remap-session-observe", "artifact-capture"], "factors": {"platform": "macos15", "lane": "managed", "family": "input-output", "boundary": "physical-hid", "evidence": "visual-runtime"} } ] diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index dd697f9e0..9f32f2b73 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -248,6 +248,7 @@ grep -Fq 'Tart guest SSH did not recover after reboot' "$REMOTE" /bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" python3 "$LAB_DIR/tests/scenario-matrix-tests.py" python3 "$LAB_DIR/tests/macos-27-selector-scenario-tests.py" +python3 "$LAB_DIR/tests/physical-remap-session-tests.py" if grep -Eq 'local[[:space:]]+status=' "$LAB_DIR/scenarios/installer-scenario"; then echo "installer scenario must not shadow zsh's read-only status parameter" >&2 exit 1 diff --git a/Scripts/lab/tests/physical-remap-session-tests.py b/Scripts/lab/tests/physical-remap-session-tests.py new file mode 100755 index 000000000..d5298e258 --- /dev/null +++ b/Scripts/lab/tests/physical-remap-session-tests.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +TOOL = pathlib.Path(__file__).resolve().parents[1] / "physical-remap-session" + + +class PhysicalSessionTests(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.make("sw_vers", '#!/bin/sh\n[ "$1" = -productVersion ] && { echo 15.7.7; exit; }; echo 15.7.7\n') + self.make("ioreg", '#!/bin/sh\necho \'"Product" = "M-VAVE SMK-25"\'\necho \'"SerialNumber" = "do-not-retain"\'\n') + self.make("ready", '#!/bin/sh\necho runtime_state=ready\n') + self.make("keypath-cli", '#!/bin/sh\necho \'{"ok":true,"output":"w"}\'\n') + self.make("open", '#!/bin/sh\nexit 0\n') + self.make("osascript", '#!/bin/sh\ncase "$*" in *"get text"*) printf "%s\\n" "${TEXTEDIT_VALUE:-w}";; esac\n') + self.make("peekaboo", '#!/bin/sh\nexit 0\n') + self.make("peekaboo-ui", '''#!/bin/sh +command=$1; shift +out= +app= +while [ $# -gt 0 ]; do + [ "$1" = --output ] && out=$2 + [ "$1" = --app ] && app=$2 + shift +done +if [ "$command" = screenshot ]; then printf png > "$out"; exit 0; fi +if [ "$app" = KeyPath ]; then + printf '%s\n' '{"data":{"ui_elements":[{"identifier":"keyboard-overlay"},{"identifier":"keycap-code-12","value":"pressed"}]}}' > "$out" +else + printf '%s\n' '{"data":{"ui_elements":[{"label":"TextEdit"}]}}' > "$out" +fi +''') + result = self.bin / "scenario-result" + result.write_text((TOOL.parent / "scenario-result").read_text()) + result.chmod(0o755) + + def make(self, name: str, body: str) -> None: + path = self.bin / name + path.write_text(body) + path.chmod(0o755) + + def tearDown(self): + self.tmp.cleanup() + + def env(self, **extra): + values = os.environ | { + "KEYPATH_PHYSICAL_RESULT": str(self.bin / "scenario-result"), + "KEYPATH_PHYSICAL_ASSERT_READY": str(self.bin / "ready"), + "KEYPATH_PHYSICAL_PEEKABOO_UI": str(self.bin / "peekaboo-ui"), + "KEYPATH_PHYSICAL_PEEKABOO": str(self.bin / "peekaboo"), + "KEYPATH_PHYSICAL_INSTALLED_CLI": str(self.bin / "keypath-cli"), + "KEYPATH_PHYSICAL_SW_VERS": str(self.bin / "sw_vers"), + "KEYPATH_PHYSICAL_IOREG": str(self.bin / "ioreg"), + "KEYPATH_PHYSICAL_OPEN": str(self.bin / "open"), + "KEYPATH_PHYSICAL_OSASCRIPT": str(self.bin / "osascript"), + "KEYPATH_PHYSICAL_SLEEP_SCALE": "0", + } + values.update(extra) + return values + + def call(self, *args, env=None): + return subprocess.run([str(TOOL), *args], env=env or self.env(), text=True, capture_output=True) + + def test_one_physical_event_proves_output_overlay_and_timing(self): + prepared = self.call("prepare", "--output", str(self.output), "--device-match", "M-VAVE") + self.assertEqual(prepared.returncode, 0, prepared.stderr) + observed = self.call("observe", "--output", str(self.output), "--timeout-seconds", "1") + self.assertEqual(observed.returncode, 0, observed.stderr) + for block in ("P02", "P03", "P04"): + result = json.loads((self.output / block / "result.json").read_text()) + self.assertEqual(result["status"], "passed", block) + self.assertNotIn("do-not-retain", (self.output / "prepare" / "hid-inventory.txt").read_text()) + timing = json.loads((self.output / "P04" / "timing.json").read_text()) + self.assertIn("launchToOutputMilliseconds", timing) + + def test_missing_named_guest_hid_blocks_before_arming(self): + prepared = self.call("prepare", "--output", str(self.output), "--device-match", "Kinesis") + self.assertEqual(prepared.returncode, 4) + result = json.loads((self.output / "prepare" / "result.json").read_text()) + self.assertEqual(result["failure"]["step"], "physical-hid-admission") + + def test_literal_q_is_a_product_failure_and_cannot_prove_timing(self): + self.assertEqual(self.call("prepare", "--output", str(self.output), "--device-match", "M-VAVE").returncode, 0) + observed = self.call("observe", "--output", str(self.output), "--timeout-seconds", "1", env=self.env(TEXTEDIT_VALUE="q")) + self.assertEqual(observed.returncode, 1) + p02 = json.loads((self.output / "P02" / "result.json").read_text()) + p03 = json.loads((self.output / "P03" / "result.json").read_text()) + p04 = json.loads((self.output / "P04" / "result.json").read_text()) + self.assertEqual(p02["failure"]["classification"], "keypath-product-failure") + self.assertEqual(p03["status"], "passed") + self.assertEqual(p04["status"], "blocked") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/testing/p02-virtualhid-output-proof.md b/docs/testing/p02-virtualhid-output-proof.md index 00d06ff30..08b6ac5c5 100644 --- a/docs/testing/p02-virtualhid-output-proof.md +++ b/docs/testing/p02-virtualhid-output-proof.md @@ -197,14 +197,33 @@ P02 passes only when all of the following are true in one disposable lease: VirtualHID daemon running, Kanata running, and TCP readiness responding. 2. A deterministic configuration maps physical `q` to virtual `w`. 3. The harness focuses an independent target app with an observable text value. -4. `keypath-lab desktop-type LEASE --text q` reports the native `vnc-key` - delivery method. -5. The target app's accessibility value changes to `w`, not `q`. +4. The guest HID inventory contains the explicitly named physical keyboard; + VNC, Peekaboo, and guest-synthesized events are not accepted as the source. +5. While the operator holds physical `q`, the overlay's `keycap-code-12` + accessibility value is `pressed` or `held`. +6. The target app's accessibility value changes to `w`, not `q`. -Step 5 is the functional assertion. Driver metadata, a successful click, and a +Step 6 is the functional assertion. Driver metadata, a successful click, and a KeyPath-local input monitor are useful preparation evidence but are not output proof. +Use the two-stage physical session so P02 output, P03 overlay state, and P04 +first-confirmation timing come from the same physical event: + +```bash +Scripts/lab/keypath-lab run LEASE -- Scripts/lab/physical-remap-session prepare \ + --output .keypath-lab/scenario-output/physical-remap-session \ + --device-match M-VAVE +Scripts/lab/keypath-lab run LEASE -- Scripts/lab/physical-remap-session observe \ + --output .keypath-lab/scenario-output/physical-remap-session +``` + +Run `observe`, then hold the physical `q` key until it completes. The watcher +clears and focuses TextEdit itself, rejects a missing guest-visible keyboard, +and writes separate machine-readable P02, P03, and P04 results. A literal `q` +is a product failure only after the named physical HID and independently ready +runtime were admitted; a timeout remains an environment precondition failure. + ## Resume path Continue with one explicit path: From d3e640ea9ede6a963b1c6a85f52174e0ca7cdfb3 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 16:35:30 -0700 Subject: [PATCH 30/99] Use detected mWave identity in onsite proof --- Scripts/lab/tests/physical-remap-session-tests.py | 8 ++++---- docs/testing/p02-virtualhid-output-proof.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Scripts/lab/tests/physical-remap-session-tests.py b/Scripts/lab/tests/physical-remap-session-tests.py index d5298e258..0864696ba 100755 --- a/Scripts/lab/tests/physical-remap-session-tests.py +++ b/Scripts/lab/tests/physical-remap-session-tests.py @@ -18,7 +18,7 @@ def setUp(self): self.bin.mkdir() self.output = self.root / "out" self.make("sw_vers", '#!/bin/sh\n[ "$1" = -productVersion ] && { echo 15.7.7; exit; }; echo 15.7.7\n') - self.make("ioreg", '#!/bin/sh\necho \'"Product" = "M-VAVE SMK-25"\'\necho \'"SerialNumber" = "do-not-retain"\'\n') + self.make("ioreg", '#!/bin/sh\necho \'"Product" = "mWave"\'\necho \'"Manufacturer" = "Kinesis Corporation"\'\necho \'"SerialNumber" = "do-not-retain"\'\n') self.make("ready", '#!/bin/sh\necho runtime_state=ready\n') self.make("keypath-cli", '#!/bin/sh\necho \'{"ok":true,"output":"w"}\'\n') self.make("open", '#!/bin/sh\nexit 0\n') @@ -72,7 +72,7 @@ def call(self, *args, env=None): return subprocess.run([str(TOOL), *args], env=env or self.env(), text=True, capture_output=True) def test_one_physical_event_proves_output_overlay_and_timing(self): - prepared = self.call("prepare", "--output", str(self.output), "--device-match", "M-VAVE") + prepared = self.call("prepare", "--output", str(self.output), "--device-match", "mWave") self.assertEqual(prepared.returncode, 0, prepared.stderr) observed = self.call("observe", "--output", str(self.output), "--timeout-seconds", "1") self.assertEqual(observed.returncode, 0, observed.stderr) @@ -84,13 +84,13 @@ def test_one_physical_event_proves_output_overlay_and_timing(self): self.assertIn("launchToOutputMilliseconds", timing) def test_missing_named_guest_hid_blocks_before_arming(self): - prepared = self.call("prepare", "--output", str(self.output), "--device-match", "Kinesis") + prepared = self.call("prepare", "--output", str(self.output), "--device-match", "Advantage360") self.assertEqual(prepared.returncode, 4) result = json.loads((self.output / "prepare" / "result.json").read_text()) self.assertEqual(result["failure"]["step"], "physical-hid-admission") def test_literal_q_is_a_product_failure_and_cannot_prove_timing(self): - self.assertEqual(self.call("prepare", "--output", str(self.output), "--device-match", "M-VAVE").returncode, 0) + self.assertEqual(self.call("prepare", "--output", str(self.output), "--device-match", "mWave").returncode, 0) observed = self.call("observe", "--output", str(self.output), "--timeout-seconds", "1", env=self.env(TEXTEDIT_VALUE="q")) self.assertEqual(observed.returncode, 1) p02 = json.loads((self.output / "P02" / "result.json").read_text()) diff --git a/docs/testing/p02-virtualhid-output-proof.md b/docs/testing/p02-virtualhid-output-proof.md index 08b6ac5c5..e39ebf4a1 100644 --- a/docs/testing/p02-virtualhid-output-proof.md +++ b/docs/testing/p02-virtualhid-output-proof.md @@ -213,7 +213,7 @@ first-confirmation timing come from the same physical event: ```bash Scripts/lab/keypath-lab run LEASE -- Scripts/lab/physical-remap-session prepare \ --output .keypath-lab/scenario-output/physical-remap-session \ - --device-match M-VAVE + --device-match mWave Scripts/lab/keypath-lab run LEASE -- Scripts/lab/physical-remap-session observe \ --output .keypath-lab/scenario-output/physical-remap-session ``` From 49965133d8917ff7448100be443db6d015d46a68 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 17:47:12 -0700 Subject: [PATCH 31/99] Add resumable scenario matrix executor --- Scripts/lab/keypath-lab | 19 +- Scripts/lab/macos-26-selector-scenario | 52 ++ Scripts/lab/scenario-matrix-runner | 520 ++++++++++++++++++ Scripts/lab/scenarios/installer-scenario | 8 +- Scripts/lab/scenarios/matrix-catalog.json | 4 +- Scripts/lab/tests/keypath-lab-tests.sh | 9 + .../tests/macos-26-selector-scenario-tests.py | 57 ++ .../lab/tests/scenario-matrix-runner-tests.py | 141 +++++ docs/testing/remote-installer-lab.md | 44 ++ 9 files changed, 848 insertions(+), 6 deletions(-) create mode 100755 Scripts/lab/macos-26-selector-scenario create mode 100755 Scripts/lab/scenario-matrix-runner create mode 100755 Scripts/lab/tests/macos-26-selector-scenario-tests.py create mode 100755 Scripts/lab/tests/scenario-matrix-runner-tests.py diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index c0daeaeff..cc9a1294c 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -32,7 +32,7 @@ Commands: run LEASE_ID -- COMMAND [ARG...] status LEASE_ID list - artifacts LEASE_ID + artifacts LEASE_ID [--output DIRECTORY] scenario LEASE_ID NAME destroy LEASE_ID cleanup [--dry-run] @@ -332,9 +332,22 @@ EOF remote list ;; artifacts) - [[ $# -eq 1 ]] || usage + [[ $# -eq 1 || ($# -eq 3 && $2 == "--output") ]] || usage require_lease_id "$1" - remote artifacts "$1" + lease=$1 + output= + [[ $# -eq 3 ]] && output=$3 + artifact_result=$(remote artifacts "$lease") + printf '%s\n' "$artifact_result" + if [[ -n $output ]]; then + artifact_dir=$(printf '%s\n' "$artifact_result" | awk -F '\t' '$1 == "artifact_dir" {print $2}' | tail -1) + [[ $artifact_dir =~ ^/[A-Za-z0-9._/[:space:]-]+$ && $artifact_dir != *..* ]] || die "host returned an invalid artifact directory" + [[ ! -L $output ]] || die "artifact output must not be a symlink" + mkdir -p "$output" + scp -q -o BatchMode=yes -r "$host:$artifact_dir/." "$output/" + local_output=$(cd "$output" >/dev/null && pwd -P) + printf 'local_artifact_dir\t%s\n' "$local_output" + fi ;; scenario) [[ $# -eq 2 ]] || usage diff --git a/Scripts/lab/macos-26-selector-scenario b/Scripts/lab/macos-26-selector-scenario new file mode 100755 index 000000000..4ffb5856b --- /dev/null +++ b/Scripts/lab/macos-26-selector-scenario @@ -0,0 +1,52 @@ +#!/bin/zsh +set -euo pipefail + +die() { print -u2 "macos-26-selector-scenario: $*"; exit 1; } +usage() { print -u2 "Usage: Scripts/lab/macos-26-selector-scenario --output DIRECTORY"; exit 2; } + +output= +while [[ $# -gt 0 ]]; do + case "$1" in + --output) [[ -n "${2:-}" ]] || usage; output=$2; shift 2 ;; + *) usage ;; + esac +done +[[ -n "$output" && "$output" != -* ]] || usage + +root=${0:A:h} +driver=${KEYPATH_SELECTOR_DRIVER:-$root/macos-26-selector-driver} +peekaboo=${KEYPATH_SELECTOR_PEEKABOO:-$root/peekaboo-ui} +open_bin=${KEYPATH_SELECTOR_OPEN:-/usr/bin/open} +osascript_bin=${KEYPATH_SELECTOR_OSASCRIPT:-/usr/bin/osascript} +sleep_bin=${KEYPATH_SELECTOR_SLEEP:-/bin/sleep} +url='x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' +readiness="$output/accessibility-readiness.json" + +mkdir -p "$output" +"$osascript_bin" -e 'tell application "System Settings" to quit' >/dev/null 2>&1 || true +"$sleep_bin" 1 +opened=0 +for attempt in {1..5}; do + if "$open_bin" "$url"; then opened=1; break; fi + "$sleep_bin" 1 +done +(( opened )) || die "could not open the Accessibility privacy pane after five attempts" + +ready=0 +for attempt in {1..15}; do + if "$peekaboo" snapshot --app "System Settings" --output "$readiness" >/dev/null 2>&1 \ + && /usr/bin/grep -Fq 'com.apple.settings.accessibility' "$readiness" \ + && /usr/bin/grep -Fq 'Allow the applications below to control your computer.' "$readiness" \ + && /usr/bin/grep -Fq 'Peekaboo Lab Host' "$readiness"; then + ready=1 + break + fi + "$sleep_bin" 1 +done +(( ready )) || print -u2 "macos-26-selector-scenario: pane readiness timed out; driver will record the fail-closed result" +exec "$driver" \ + --output "$output" \ + --scenario macos-26-selector-probe \ + --expect com.apple.settings.accessibility \ + --expect 'Allow the applications below to control your computer.' \ + --expect 'Peekaboo Lab Host' diff --git a/Scripts/lab/scenario-matrix-runner b/Scripts/lab/scenario-matrix-runner new file mode 100755 index 000000000..f601069fd --- /dev/null +++ b/Scripts/lab/scenario-matrix-runner @@ -0,0 +1,520 @@ +#!/usr/bin/env python3 +"""Execute a KeyPath scenario-matrix plan with resumable, fail-closed checkpoints.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import datetime +import hashlib +import json +import os +import pathlib +import re +import subprocess +import sys +import tempfile +import threading +from typing import Any + + +TERMINAL = {"passed", "failed", "blocked"} +CHECKPOINT_PREFIXES = ("operator-", "physical-") +LEASE_PATTERN = re.compile(r"^lease_id\t([A-Za-z0-9._-]+)$", re.MULTILINE) +KNOWN_STEPS = { + "run-lab-contract-tests", "run-failure-ownership-self-test", "verify-report-contract", + "create-fresh-lease", "create-fresh-lease-with-fixture", "create-desktop-lease", "create-usb-lease", + "install-exact-artifact", "install-fixture", "desktop-bootstrap", "reboot-guest", "artifact-capture", + "managed-capabilities", "repair-kanata-service", "upgrade-beta3-to-current", + "reboot-persistence-before", "reboot-persistence-after", "uninstall", "reinstall", + "launch", "helper-daemon-health", "cancellation-recovery-before", "cancellation-recovery-after", + "macos-26-selector-scenario", "macos-27-selector-scenario", + "physical-remap-session-prepare", "physical-remap-session-observe", +} + + +class CampaignError(ValueError): + pass + + +def now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def digest_json(value: dict[str, Any]) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def sha256(path: pathlib.Path) -> str: + result = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + result.update(chunk) + return result.hexdigest() + + +def atomic_json(path: pathlib.Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(descriptor, "w") as handle: + json.dump(value, handle, indent=2) + handle.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def validate_plan(plan: Any) -> dict[str, Any]: + if not isinstance(plan, dict) or plan.get("schemaVersion") != 1: + raise CampaignError("plan requires schemaVersion 1") + jobs = plan.get("jobs") + waves = plan.get("waves") + if not isinstance(jobs, list) or not jobs or not isinstance(waves, list) or not waves: + raise CampaignError("plan requires non-empty jobs and waves") + ids = [job.get("id") for job in jobs] + if any(not isinstance(job_id, str) or not job_id for job_id in ids) or len(ids) != len(set(ids)): + raise CampaignError("job ids must be unique non-empty strings") + scheduled = [job_id for wave in waves for job_id in wave] + if sorted(scheduled) != sorted(ids) or len(scheduled) != len(set(scheduled)): + raise CampaignError("waves must schedule every job exactly once") + for job in jobs: + if job.get("provider") not in ("local", "tart", "parallels"): + raise CampaignError(f"{job['id']}: unsupported provider") + if not isinstance(job.get("steps"), list) or not job["steps"]: + raise CampaignError(f"{job['id']}: steps are required") + unknown_steps = [ + step for step in job["steps"] + if step not in KNOWN_STEPS and not step.startswith(CHECKPOINT_PREFIXES) + ] + if unknown_steps: + raise CampaignError(f"{job['id']}: unsupported steps: {', '.join(unknown_steps)}") + if job["provider"] != "local" and job.get("finalizer") != "destroy-owned-lease": + raise CampaignError(f"{job['id']}: owned lease cleanup is required") + return plan + + +def unique_steps(steps: list[str]) -> list[dict[str, str]]: + counts: dict[str, int] = {} + result = [] + for command_step in steps: + counts[command_step] = counts.get(command_step, 0) + 1 + suffix = "" if counts[command_step] == 1 else f"-{counts[command_step]}" + result.append({"id": command_step + suffix, "commandStep": command_step, "status": "queued"}) + return result + + +def initial_state(plan: dict[str, Any], plan_digest: str, commit: str, installer: pathlib.Path) -> dict[str, Any]: + return { + "schemaVersion": 1, + "campaignId": f"{plan.get('cadence', 'matrix')}-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}", + "planDigest": plan_digest, + "commit": commit, + "installer": str(installer.resolve()), + "installerSHA256": sha256(installer), + "dashboardInitialized": False, + "status": "queued", + "createdAt": now(), + "updatedAt": now(), + "jobs": [ + { + "id": job["id"], "status": "queued", "leaseId": None, + "steps": unique_steps(job["steps"]), "cleanupStatus": "not-required" if job["provider"] == "local" else "pending", + } + for job in plan["jobs"] + ], + } + + +class MatrixRunner: + def __init__(self, args: argparse.Namespace, plan: dict[str, Any], state: dict[str, Any]) -> None: + self.args = args + self.plan = plan + self.state = state + self.lock = threading.RLock() + self.jobs = {job["id"]: job for job in plan["jobs"]} + self.job_states = {job["id"]: job for job in state["jobs"]} + self.acknowledged = set(args.ack_checkpoint) + self.selected = set(args.only_job) if args.only_job else set(self.jobs) + + def save(self) -> None: + with self.lock: + self.state["updatedAt"] = now() + atomic_json(self.args.state, self.state) + + def dashboard(self, *arguments: str) -> None: + if not self.args.dashboard_updater: + return + command = [str(self.args.dashboard_updater)] + if self.args.dashboard_state: + command += ["--state", str(self.args.dashboard_state)] + command += list(arguments) + with self.lock: + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode: + raise CampaignError(f"dashboard update failed: {result.stderr.strip() or result.stdout.strip()}") + + def update_job(self, job_state: dict[str, Any], *, status: str | None = None, + step: dict[str, str] | None = None, step_status: str | None = None, + message: str | None = None, event: str | None = None, tone: str = "info") -> None: + with self.lock: + if status: + job_state["status"] = status + if step: + job_state["currentStep"] = step["id"] + if step_status: + step["status"] = step_status + completed = sum(item["status"] == "passed" for item in job_state["steps"]) + progress = round(100 * completed / len(job_state["steps"])) + self.save() + arguments = ["job", "--id", job_state["id"], "--progress", str(progress)] + if status: + arguments += ["--status", status] + if step: + arguments += ["--step", step["id"]] + if step_status: + arguments += ["--step-status", step_status] + if job_state.get("leaseId"): + arguments += ["--lease-id", job_state["leaseId"]] + if message: + arguments += ["--message", message] + if event: + arguments += ["--event-title", event, "--event-tone", tone] + self.dashboard(*arguments) + + def lab_command(self, *parts: str) -> list[str]: + command = [str(self.args.lab)] + if self.args.host: + command += ["--host", self.args.host] + return command + list(parts) + + def execute(self, job_id: str, step_id: str, command: list[str]) -> subprocess.CompletedProcess[str]: + log = self.args.artifacts / job_id / f"{step_id}.log" + log.parent.mkdir(parents=True, exist_ok=True) + result = subprocess.run(command, cwd=self.args.repo, text=True, capture_output=True) + with log.open("a") as handle: + handle.write(f"$ {' '.join(command)}\n") + handle.write(result.stdout + result.stderr) + return result + + def create_command(self, job: dict[str, Any], step_name: str) -> list[str]: + command = self.lab_command( + "create", "--macos", str(job["macOS"]), "--lane", job["lane"], + "--commit", self.args.commit, "--installer", str(self.args.installer), + "--ttl", f"{job.get('ttlMinutes', 120)}m", + ) + if step_name == "create-fresh-lease-with-fixture": + if not self.args.fixture: + raise CampaignError(f"{job['id']}: --fixture is required") + command += ["--fixture", str(self.args.fixture)] + if step_name == "create-desktop-lease": + command += ["--desktop"] + if step_name == "create-usb-lease": + command += ["--tart-usb-passthrough"] + return command + + def command_for(self, job: dict[str, Any], state: dict[str, Any], step_name: str) -> list[list[str]]: + lease = state.get("leaseId") + if step_name.startswith("create-"): + return [self.create_command(job, step_name)] + if job["provider"] == "local": + if step_name == "run-lab-contract-tests": + return [[str(self.args.repo / "Scripts/lab/tests/keypath-lab-tests.sh")]] + if step_name == "run-failure-ownership-self-test": + output = self.args.artifacts / job["id"] / "failure-ownership-self-test.json" + verify = ( + "import json,sys; value=json.load(open(sys.argv[1])); " + "raise SystemExit(0 if value.get('status') == 'failed' and " + "value.get('failure', {}).get('classification') == 'harness-selector-failure' else 1)" + ) + return [ + [str(self.args.repo / "Scripts/lab/scenario-result"), "selector-self-test", "--output", str(output)], + [sys.executable, "-c", verify, str(output)], + ] + if step_name == "verify-report-contract": + return [[sys.executable, str(self.args.repo / "Scripts/lab/tests/scenario-report-tests.py")]] + if not lease: + raise CampaignError(f"{job['id']}: {step_name} requires a recorded lease") + if step_name == "install-exact-artifact": + return [self.lab_command("install-app", lease)] + if step_name == "install-fixture": + return [self.lab_command("install-fixture", lease)] + if step_name == "reboot-guest": + return [self.lab_command("reboot-guest", lease)] + if step_name == "desktop-bootstrap": + return [self.lab_command("desktop-bootstrap", lease, "--install-tools")] + if step_name == "artifact-capture": + collected = self.args.artifacts / job["id"] / "collected" + return [ + self.lab_command("scenario", lease, "artifact-capture"), + self.lab_command("artifacts", lease, "--output", str(collected)), + ] + return [self.lab_command("scenario", lease, step_name)] + + def recover_inflight(self, job: dict[str, Any], state: dict[str, Any], step: dict[str, str]) -> bool: + log = self.args.artifacts / job["id"] / f"{step['id']}.log" + if step["commandStep"].startswith("create-") and log.exists(): + match = LEASE_PATTERN.search(log.read_text()) + if match: + lease = match.group(1) + status = subprocess.run(self.lab_command("status", lease), text=True, capture_output=True) + if status.returncode == 0: + state["leaseId"] = lease + step["status"] = "passed" + self.update_job(state, status="running", step=step, step_status="passed", + message="Recovered the created lease from durable controller evidence.") + return True + state["blocker"] = f"Interrupted mutation at {step['id']} has no verified postcondition; refusing to repeat it." + self.update_job(state, status="blocked", step=step, step_status="blocked", + message=state["blocker"], event=f"{job['title']} blocked", tone="danger") + return False + + def finalizer(self, job: dict[str, Any], state: dict[str, Any]) -> bool: + lease = state.get("leaseId") + if job["provider"] == "local" or not lease: + return True + result = self.execute(job["id"], "destroy-owned-lease", self.lab_command("destroy", lease)) + state["cleanupStatus"] = "passed" if result.returncode == 0 else "failed" + self.save() + return result.returncode == 0 + + def emergency_artifacts(self, job: dict[str, Any], state: dict[str, Any]) -> None: + lease = state.get("leaseId") + if not lease: + return + collected = self.args.artifacts / job["id"] / "collected" + result = self.execute( + job["id"], "failure-artifact-capture", + self.lab_command("artifacts", lease, "--output", str(collected)), + ) + state["failureArtifactStatus"] = "passed" if result.returncode == 0 else "failed" + self.save() + + def write_reports(self) -> None: + self.args.artifacts.mkdir(parents=True, exist_ok=True) + scenario_report = self.args.artifacts / "scenario-report.json" + result = subprocess.run( + [str(self.args.repo / "Scripts/lab/scenario-report"), "--input", str(self.args.artifacts), + "--output", str(scenario_report)], text=True, capture_output=True, + ) + if result.returncode != 0: + (self.args.artifacts / "scenario-report-error.log").write_text(result.stdout + result.stderr) + matrix_report = { + "schemaVersion": 1, + "campaignId": self.state["campaignId"], + "generatedAt": now(), + "planDigest": self.state["planDigest"], + "commit": self.state["commit"], + "installerSHA256": self.state["installerSHA256"], + "status": self.state["status"], + "jobs": [ + { + "id": job["id"], "status": job["status"], "leaseId": job.get("leaseId"), + "cleanupStatus": job.get("cleanupStatus"), "blocker": job.get("blocker"), + "artifactDirectory": job["id"], + } + for job in self.state["jobs"] + ], + "scenarioReport": "scenario-report.json" if result.returncode == 0 else None, + } + atomic_json(self.args.artifacts / "matrix-report.json", matrix_report) + + def run_job(self, job_id: str) -> str: + job = self.jobs[job_id] + state = self.job_states[job_id] + if state["status"] in TERMINAL: + return state["status"] + self.update_job(state, status="running", message="Job admitted to its provider-safe wave.", + event=f"{job['title']} started") + preserve_lease = False + try: + for step in state["steps"]: + if step["status"] == "passed": + continue + checkpoint = f"{job_id}:{step['commandStep']}" + if step["commandStep"].startswith(CHECKPOINT_PREFIXES) and checkpoint not in self.acknowledged: + state["waitingCheckpoint"] = checkpoint + preserve_lease = True + self.update_job(state, status="waiting", step=step, step_status="waiting", + message=f"Waiting for explicit completion of {step['commandStep']}.", + event=f"{job['title']} needs attention", tone="warning") + return "waiting" + if step["status"] == "running" and not self.recover_inflight(job, state, step): + return "blocked" + if step["status"] == "passed": + continue + if checkpoint in self.acknowledged: + state.pop("waitingCheckpoint", None) + step["status"] = "passed" + self.update_job(state, status="running", step=step, step_status="passed", + message="Operator checkpoint acknowledged; continuing from the next step.") + continue + self.update_job(state, status="running", step=step, step_status="running", + message=f"Running {step['commandStep']}.") + for command in self.command_for(job, state, step["commandStep"]): + result = self.execute(job_id, step["id"], command) + if result.returncode != 0: + state["blocker"] = f"{step['commandStep']} exited {result.returncode}; see {job_id}/{step['id']}.log" + self.update_job(state, status="failed", step=step, step_status="failed", + message=state["blocker"], event=f"{job['title']} failed", tone="danger") + self.emergency_artifacts(job, state) + return "failed" + if step["commandStep"].startswith("create-"): + match = LEASE_PATTERN.search(result.stdout + result.stderr) + if not match: + raise CampaignError(f"{job_id}: create succeeded without a lease_id") + state["leaseId"] = match.group(1) + step["status"] = "passed" + self.update_job(state, status="running", step=step, step_status="passed", + message=f"Verified {step['commandStep']}.") + if state.get("leaseId") and not self.finalizer(job, state): + state["blocker"] = "Owned lease cleanup failed; manual reconciliation is required." + self.update_job(state, status="blocked", message=state["blocker"], + event=f"{job['title']} cleanup failed", tone="danger") + return "blocked" + self.update_job(state, status="passed", message="All scenario checkpoints and owned cleanup passed.", + event=f"{job['title']} passed", tone="success") + return "passed" + except CampaignError as error: + state["blocker"] = str(error) + self.update_job(state, status="blocked", message=str(error), event=f"{job['title']} blocked", tone="danger") + return "blocked" + finally: + if not preserve_lease and state.get("leaseId") and state.get("cleanupStatus") == "pending": + if not self.finalizer(job, state): + state["blocker"] = "Owned lease cleanup failed; manual reconciliation is required." + self.update_job(state, status="blocked", message=state["blocker"], + event=f"{job['title']} cleanup failed", tone="danger") + + def run(self) -> int: + self.state["status"] = "running" + self.save() + self.dashboard("campaign", "--status", "running", "--message", "Campaign executor preflight passed; wave execution started.", + "--next-action", "Watch the active wave and intervene only if a checkpoint requests attention.", + "--event-title", "Matrix campaign started") + waiting_providers: set[str] = set() + saw_failure = False + last_wave = 0 + for wave_number, wave in enumerate(self.plan["waves"], 1): + eligible = [ + job_id for job_id in wave + if job_id in self.selected + and self.job_states[job_id]["status"] not in TERMINAL + and self.jobs[job_id]["provider"] not in waiting_providers + ] + if not eligible: + continue + last_wave = wave_number + with concurrent.futures.ThreadPoolExecutor(max_workers=len(eligible)) as pool: + outcomes = list(pool.map(self.run_job, eligible)) + saw_failure = saw_failure or any(outcome in ("failed", "blocked") for outcome in outcomes) + for job_id, outcome in zip(eligible, outcomes): + if outcome == "waiting": + waiting_providers.add(self.jobs[job_id]["provider"]) + self.write_reports() + if waiting_providers: + self.state["status"] = "waiting" + self.save() + self.write_reports() + self.dashboard("campaign", "--status", "waiting", + "--message", f"Wave {last_wave} reached an explicit checkpoint; independent providers ran as far as capacity allowed.", + "--next-action", "Complete the visible checkpoint, acknowledge it, and resume the same campaign state file.", + "--event-title", "Matrix campaign needs attention", "--event-tone", "warning") + return 4 + if saw_failure: + self.state["status"] = "blocked" + self.save() + self.write_reports() + self.dashboard("campaign", "--status", "blocked", + "--message", "All runnable waves completed, but one or more jobs have classified failures.", + "--next-action", "Review failed evidence and repair the owning layer before a new campaign.", + "--event-title", "Matrix campaign found failures", "--event-tone", "danger") + return 1 + selected_states = [self.job_states[job_id]["status"] for job_id in self.selected] + complete = all(status == "passed" for status in selected_states) + partial = self.selected != set(self.jobs) + self.state["status"] = "queued" if complete and partial else ("passed" if complete else "blocked") + self.save() + self.write_reports() + self.dashboard("campaign", "--status", self.state["status"], + "--message", "Selected jobs passed; remaining matrix jobs are still queued." if partial else ("All matrix jobs passed with owned cleanup verified." if complete else "Campaign ended without complete evidence."), + "--next-action", "Run the remaining queued jobs." if partial else ("Review the consolidated evidence report." if complete else "Review blocked jobs before resuming."), + "--event-title", "Selected matrix jobs passed" if partial else ("Matrix campaign passed" if complete else "Matrix campaign incomplete"), + "--event-tone", "success" if complete else "danger") + return 0 if complete else 1 + + +def load_or_create_state(args: argparse.Namespace, plan: dict[str, Any]) -> dict[str, Any]: + plan_digest = digest_json(plan) + if args.state.exists(): + state = json.loads(args.state.read_text()) + if state.get("schemaVersion") != 1 or state.get("planDigest") != plan_digest: + raise CampaignError("state does not belong to this exact matrix plan") + if state.get("commit") != args.commit or state.get("installerSHA256") != sha256(args.installer): + raise CampaignError("commit or installer changed after campaign checkpoints were recorded") + return state + state = initial_state(plan, plan_digest, args.commit, args.installer) + atomic_json(args.state, state) + return state + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan", required=True, type=pathlib.Path) + parser.add_argument("--state", required=True, type=pathlib.Path) + parser.add_argument("--artifacts", required=True, type=pathlib.Path) + parser.add_argument("--commit", required=True) + parser.add_argument("--installer", required=True, type=pathlib.Path) + parser.add_argument("--fixture", type=pathlib.Path) + parser.add_argument("--host") + parser.add_argument("--lab", type=pathlib.Path) + parser.add_argument("--repo", type=pathlib.Path) + parser.add_argument("--dashboard-updater", type=pathlib.Path) + parser.add_argument("--dashboard-state", type=pathlib.Path, default=pathlib.Path("/tmp/keypath-matrix-state.json")) + parser.add_argument("--ack-checkpoint", action="append", default=[]) + parser.add_argument("--only-job", action="append", default=[]) + args = parser.parse_args() + root = pathlib.Path(__file__).resolve().parents[2] + args.repo = (args.repo or root).resolve() + args.lab = (args.lab or root / "Scripts/lab/keypath-lab").resolve() + try: + plan = validate_plan(json.loads(args.plan.read_text())) + if not re.fullmatch(r"[0-9a-fA-F]{40}", args.commit): + raise CampaignError("--commit must be a full 40-character SHA") + if not args.installer.is_file(): + raise CampaignError("installer does not exist") + selected_ids = set(args.only_job) if args.only_job else {job["id"] for job in plan["jobs"]} + if any( + job["id"] in selected_ids and "create-fresh-lease-with-fixture" in job["steps"] + for job in plan["jobs"] + ): + if not args.fixture or not args.fixture.is_file(): + raise CampaignError("this plan requires an existing --fixture archive") + unknown = set(args.only_job) - {job["id"] for job in plan["jobs"]} + if unknown: + raise CampaignError("unknown --only-job: " + ", ".join(sorted(unknown))) + if args.dashboard_updater and not args.dashboard_updater.is_file(): + raise CampaignError("dashboard updater does not exist") + state = load_or_create_state(args, plan) + waiting = {job.get("waitingCheckpoint") for job in state["jobs"] if job.get("waitingCheckpoint")} + invalid_acknowledgements = set(args.ack_checkpoint) - waiting + if invalid_acknowledgements: + raise CampaignError( + "checkpoint acknowledgement is not currently waiting: " + + ", ".join(sorted(invalid_acknowledgements)) + ) + runner = MatrixRunner(args, plan, state) + if args.dashboard_updater and not state.get("dashboardInitialized"): + runner.dashboard("initialize", "--plan", str(args.plan), "--campaign-id", state["campaignId"]) + state["dashboardInitialized"] = True + runner.save() + raise SystemExit(runner.run()) + except (OSError, json.JSONDecodeError, CampaignError) as error: + print(f"scenario-matrix-runner: {error}", file=sys.stderr) + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index f6b5f3be7..4a6851b4f 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -326,6 +326,12 @@ case "$name" in "$installed_cli" system inspect --json > "$artifact_dir/system-inspect.json" 2>/dev/null || true print "Scenario artifacts written to $artifact_dir; follow with keypath-lab artifacts ." ;; + macos-26-selector-scenario) + Scripts/lab/macos-26-selector-scenario --output "$artifact_dir" + ;; + macos-27-selector-scenario) + Scripts/lab/macos-27-selector-scenario --output "$artifact_dir" + ;; macos-27-regression) require_installed_cli product_major=$(sw_vers -productVersion | cut -d. -f1) @@ -335,7 +341,7 @@ case "$name" in ;; *) print -u2 "Unknown scenario: $name" - print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current reboot-persistence-before reboot-persistence-after uninstall reinstall cancellation-recovery-before cancellation-recovery-after failure-ownership-self-test artifact-capture macos-27-regression" + print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current reboot-persistence-before reboot-persistence-after uninstall reinstall cancellation-recovery-before cancellation-recovery-after failure-ownership-self-test artifact-capture macos-26-selector-scenario macos-27-selector-scenario macos-27-regression" exit 2 ;; esac diff --git a/Scripts/lab/scenarios/matrix-catalog.json b/Scripts/lab/scenarios/matrix-catalog.json index fd8e36571..accf4e8dd 100644 --- a/Scripts/lab/scenarios/matrix-catalog.json +++ b/Scripts/lab/scenarios/matrix-catalog.json @@ -8,7 +8,7 @@ "automation": "unattended", "estimatedMinutes": 5, "nightlyCore": true, - "steps": ["run-lab-contract-tests", "run-failure-ownership-self-test", "generate-consolidated-report"], + "steps": ["run-lab-contract-tests", "run-failure-ownership-self-test", "verify-report-contract"], "factors": {"platform": "local", "lane": "none", "family": "contracts", "boundary": "none", "evidence": "report"} }, { @@ -92,7 +92,7 @@ "estimatedMinutes": 24, "nightlyCore": true, "cleanup": "destroy-owned-lease", - "steps": ["create-desktop-lease", "desktop-bootstrap", "macos-26-selector-driver", "artifact-capture"], + "steps": ["create-desktop-lease", "desktop-bootstrap", "macos-26-selector-scenario", "artifact-capture"], "factors": {"platform": "macos26", "lane": "unmanaged", "family": "selectors", "boundary": "approval-ui", "evidence": "semantic-ui"} }, { diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 9f32f2b73..1d9b1b03d 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -247,6 +247,8 @@ grep -Fq 'Tart guest SSH did not recover after reboot' "$REMOTE" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" /bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" python3 "$LAB_DIR/tests/scenario-matrix-tests.py" +python3 "$LAB_DIR/tests/scenario-matrix-runner-tests.py" +python3 "$LAB_DIR/tests/macos-26-selector-scenario-tests.py" python3 "$LAB_DIR/tests/macos-27-selector-scenario-tests.py" python3 "$LAB_DIR/tests/physical-remap-session-tests.py" if grep -Eq 'local[[:space:]]+status=' "$LAB_DIR/scenarios/installer-scenario"; then @@ -951,11 +953,13 @@ case "\$*" in *prepare-upload*) echo /tmp/keypath-lab.test-upload ;; *install-archive*) echo archive-installed ;; *" create "*) echo \$'lease_id\tcbx_controller_test' ;; + *" artifacts "*) echo \$'artifact_dir\t/Volumes/KeyPath Lab/CrabBox/KeyPathInstallerLab/artifacts/cbx_controller_test/20260725T000000Z' ;; *) echo controller-preflight ;; esac EOF cat > "$TMP/fake-bin/scp" <> "$TMP/scp-args" exit 0 EOF chmod +x "$TMP/fake-bin/ssh" "$TMP/fake-bin/scp" @@ -980,6 +984,11 @@ grep -Fq "$controller_archive_key" "$TMP/ssh-args" || { echo "controller archive key did not include fixture checksum" >&2 exit 1 } +controller_artifacts=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host "$LAB_DIR/keypath-lab" artifacts \ + cbx_controller_test --output "$TMP/controller-artifacts") +assert_contains "$controller_artifacts" $'local_artifact_dir\t' +[[ -d "$TMP/controller-artifacts" ]] +grep -Fq 'tester@test-host:/Volumes/KeyPath Lab/CrabBox/KeyPathInstallerLab/artifacts/cbx_controller_test/20260725T000000Z/.' "$TMP/scp-args" if PATH="$TMP/fake-bin:$PATH" "$LAB_DIR/keypath-lab" create --macos 15 --lane unmanaged-ui --commit abc --installer "$TMP/KeyPath.zip" >/dev/null 2>&1; then echo "controller accepted a non-explicit commit SHA" >&2 exit 1 diff --git a/Scripts/lab/tests/macos-26-selector-scenario-tests.py b/Scripts/lab/tests/macos-26-selector-scenario-tests.py new file mode 100755 index 000000000..3e07eeea3 --- /dev/null +++ b/Scripts/lab/tests/macos-26-selector-scenario-tests.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import os +import pathlib +import subprocess +import tempfile +import unittest + + +TOOL = pathlib.Path(__file__).resolve().parents[1] / "macos-26-selector-scenario" + + +class ScenarioTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tmp.name) + self.output = self.root / "out" + self.calls = self.root / "calls" + self.bin = self.root / "bin" + self.bin.mkdir() + for name in ("open", "osascript", "sleep"): + path = self.bin / name + path.write_text(f'#!/bin/sh\nprintf "%s\\n" "{name} $*" >> "$CALLS"\n') + path.chmod(0o755) + self.peekaboo = self.bin / "peekaboo" + self.peekaboo.write_text( + '#!/bin/sh\nout=\nwhile [ $# -gt 0 ]; do [ "$1" = --output ] && out=$2; shift; done\n' + 'printf \'%s\\n\' \'{"identifier":"com.apple.settings.accessibility","label":"Allow the applications below to control your computer.","row":"Peekaboo Lab Host"}\' > "$out"\n' + ) + self.peekaboo.chmod(0o755) + self.driver = self.bin / "driver" + self.driver.write_text('#!/bin/sh\nprintf "%s\\n" "driver $*" >> "$CALLS"\n') + self.driver.chmod(0o755) + + def tearDown(self): + self.tmp.cleanup() + + def test_prepares_accessibility_pane_and_uses_macos26_contract(self): + env = os.environ | { + "CALLS": str(self.calls), + "KEYPATH_SELECTOR_DRIVER": str(self.driver), + "KEYPATH_SELECTOR_PEEKABOO": str(self.peekaboo), + "KEYPATH_SELECTOR_OPEN": str(self.bin / "open"), + "KEYPATH_SELECTOR_OSASCRIPT": str(self.bin / "osascript"), + "KEYPATH_SELECTOR_SLEEP": str(self.bin / "sleep"), + } + result = subprocess.run([str(TOOL), "--output", str(self.output)], env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.calls.read_text() + self.assertIn("Privacy_Accessibility", calls) + self.assertIn("--expect com.apple.settings.accessibility", calls) + self.assertIn("--expect Allow the applications below to control your computer.", calls) + self.assertIn("--expect Peekaboo Lab Host", calls) + self.assertTrue((self.output / "accessibility-readiness.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py new file mode 100755 index 000000000..7cbf63b27 --- /dev/null +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 + +import json +import os +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +RUNNER = ROOT / "Scripts/lab/scenario-matrix-runner" + + +class MatrixRunnerTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = pathlib.Path(self.temporary.name) + self.installer = self.directory / "KeyPath.zip" + self.installer.write_bytes(b"candidate") + self.log = self.directory / "lab.log" + self.lab = self.directory / "keypath-lab" + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + create) print 'lease_id\tcbx_test_lease'; print 'manifest\t/tmp/manifest' ;; + status) print 'status\tready' ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + self.dashboard_log = self.directory / "dashboard.log" + self.dashboard = self.directory / "dashboard-updater" + self.dashboard.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.dashboard_log)!r} + """)) + self.dashboard.chmod(0o755) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def plan(self, steps: list[str], *, job_id: str = "vm-job") -> pathlib.Path: + path = self.directory / f"{job_id}.json" + path.write_text(json.dumps({ + "schemaVersion": 1, + "cadence": "weekly", + "summary": {"eligiblePairs": 10}, + "jobs": [{ + "id": job_id, "title": "VM job", "provider": "tart", "macOS": 15, + "lane": "managed-functional", "automation": "operator", "ttlMinutes": 120, + "steps": steps, "finalizer": "destroy-owned-lease", + "factors": {"platform": "macos15", "lane": "managed", "family": "install", "boundary": "fresh", "evidence": "runtime"}, + }], + "waves": [[job_id]], + "excluded": [], + })) + return path + + def run_runner(self, plan: pathlib.Path, *extra: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([ + str(RUNNER), "--plan", str(plan), "--state", str(self.directory / "state.json"), + "--artifacts", str(self.directory / "artifacts"), "--commit", "a" * 40, + "--installer", str(self.installer), "--lab", str(self.lab), "--repo", str(ROOT), + "--dashboard-updater", str(self.dashboard), "--dashboard-state", str(self.directory / "dashboard-state.json"), + *extra, + ], text=True, capture_output=True) + + def test_executes_steps_updates_dashboard_and_destroys_owned_lease(self) -> None: + result = self.run_runner(self.plan(["create-fresh-lease", "install-exact-artifact", "artifact-capture"])) + self.assertEqual(result.returncode, 0, result.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["status"], "passed") + self.assertEqual(state["jobs"][0]["status"], "passed") + self.assertEqual(state["jobs"][0]["cleanupStatus"], "passed") + lab_log = self.log.read_text() + self.assertIn("create --macos 15", lab_log) + self.assertIn("install-app cbx_test_lease", lab_log) + self.assertIn("scenario cbx_test_lease artifact-capture", lab_log) + self.assertIn("artifacts cbx_test_lease --output", lab_log) + self.assertIn("destroy cbx_test_lease", lab_log) + dashboard = self.dashboard_log.read_text() + self.assertIn("initialize --plan", dashboard) + self.assertIn("--status running", dashboard) + self.assertIn("--status passed", dashboard) + + def test_human_checkpoint_waits_without_cleanup_then_resumes(self) -> None: + plan = self.plan(["create-fresh-lease", "operator-visible-action", "artifact-capture"]) + first = self.run_runner(plan) + self.assertEqual(first.returncode, 4, first.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["status"], "waiting") + self.assertEqual(state["jobs"][0]["waitingCheckpoint"], "vm-job:operator-visible-action") + self.assertNotIn("destroy", self.log.read_text()) + + second = self.run_runner(plan, "--ack-checkpoint", "vm-job:operator-visible-action") + self.assertEqual(second.returncode, 0, second.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["jobs"][0]["status"], "passed") + self.assertIn("destroy cbx_test_lease", self.log.read_text()) + + def test_refuses_checkpoint_preapproval(self) -> None: + result = self.run_runner(self.plan(["create-fresh-lease", "operator-visible-action"]), + "--ack-checkpoint", "vm-job:operator-visible-action") + self.assertEqual(result.returncode, 2) + self.assertIn("not currently waiting", result.stderr) + self.assertFalse(self.log.exists()) + + def test_recovers_created_lease_but_blocks_other_uncertain_mutations(self) -> None: + plan = self.plan(["create-fresh-lease", "install-exact-artifact"]) + initial = self.run_runner(plan) + self.assertEqual(initial.returncode, 0, initial.stderr) + + state_path = self.directory / "state.json" + state = json.loads(state_path.read_text()) + state["status"] = "running" + state["jobs"][0]["status"] = "running" + state["jobs"][0]["cleanupStatus"] = "pending" + state["jobs"][0]["leaseId"] = "cbx_test_lease" + state["jobs"][0]["steps"][1]["status"] = "running" + state_path.write_text(json.dumps(state)) + resumed = self.run_runner(plan) + self.assertEqual(resumed.returncode, 1) + state = json.loads(state_path.read_text()) + self.assertEqual(state["jobs"][0]["status"], "blocked") + self.assertIn("refusing to repeat", state["jobs"][0]["blocker"]) + + def test_rejects_changed_installer_on_resume(self) -> None: + plan = self.plan(["create-fresh-lease"]) + first = self.run_runner(plan) + self.assertEqual(first.returncode, 0, first.stderr) + self.installer.write_bytes(b"different") + second = self.run_runner(plan) + self.assertEqual(second.returncode, 2) + self.assertIn("installer changed", second.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index f8db212bf..f8c0b12a0 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -557,6 +557,50 @@ These flags change admission only; they do not bypass the operator or physical checkpoint. The executor must still stop at the named step and retain the same result, failure-ownership, artifact-sanitization, and owned-cleanup contracts. +Execute a retained plan with the exact candidate and commit. The runner writes +an owner-only checkpoint after every transition, runs jobs in each provider-safe +wave concurrently, streams those transitions to the dashboard, downloads the +sanitized evidence before cleanup, and refuses to call a VM job passed until its +owned lease has been destroyed successfully: + +```bash +campaign=.keypath-lab/matrix/weekly-$(date +%Y%m%d-%H%M%S) +mkdir -p "$campaign" +Scripts/lab/scenario-matrix \ + --cadence weekly \ + --include-operator \ + --output "$campaign/plan.json" +Scripts/lab/scenario-matrix-runner \ + --plan "$campaign/plan.json" \ + --state "$campaign/state.json" \ + --artifacts "$campaign/artifacts" \ + --commit "$(git rev-parse HEAD)" \ + --installer dist/KeyPath.zip \ + --fixture /path/to/KeyPath-beta3.zip \ + --dashboard-updater ../keypath-lab-state-dashboard/Scripts/lab/update-matrix-dashboard \ + --dashboard-state /tmp/keypath-matrix-state.json +``` + +The beta-to-current case makes `--fixture` mandatory before any lease starts. +If an explicit checkpoint pauses a job, complete the named visible action and +resume the same state file with, for example: + +```bash +Scripts/lab/scenario-matrix-runner ... \ + --ack-checkpoint macos15-cancellation-recovery:operator-cancel-visible-dialog +``` + +An acknowledgement is accepted only for a checkpoint already recorded as +waiting; it cannot pre-authorize or bypass a future interaction. A process +interruption never blindly repeats an uncertain mutation. Lease creation can be +recovered only when both the durable command log and the owned controller status +identify the same ready lease; any other uncertain mutation blocks for evidence +review. Failed jobs still collect available artifacts and destroy their owned +leases, allowing independent later waves to continue. A waiting job retains its +lease and pauses only subsequent work on that provider. `matrix-report.json` +contains campaign/job outcomes and `scenario-report.json` consolidates validated +scenario result files. + ### macOS 27 beta regression capture On every significant macOS 27 beta seed, run the non-destructive evidence From 29a938c233d1f39a5f45a7f5ea9ae34349e48933 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 17:50:24 -0700 Subject: [PATCH 32/99] Include excluded matrix case titles --- Scripts/lab/scenario-matrix | 5 ++++- Scripts/lab/tests/scenario-matrix-tests.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/scenario-matrix b/Scripts/lab/scenario-matrix index 41b5e6c5e..b39969d24 100755 --- a/Scripts/lab/scenario-matrix +++ b/Scripts/lab/scenario-matrix @@ -182,7 +182,10 @@ def build_plan( "waves": schedule(selected, capacities), "jobs": jobs, "excluded": [ - {"id": case["id"], "automation": case["automation"], "reason": "explicit opt-in required"} + { + "id": case["id"], "title": case["title"], + "automation": case["automation"], "reason": "explicit opt-in required", + } for case in exclusions ], } diff --git a/Scripts/lab/tests/scenario-matrix-tests.py b/Scripts/lab/tests/scenario-matrix-tests.py index 93c606435..84536064b 100755 --- a/Scripts/lab/tests/scenario-matrix-tests.py +++ b/Scripts/lab/tests/scenario-matrix-tests.py @@ -41,6 +41,8 @@ def test_nightly_is_unattended_and_requires_cleanup(self): self.assertIn("macos26-selectors", excluded) self.assertIn("macos15-cancellation-recovery", excluded) self.assertIn("macos15-physical-remap", excluded) + physical = next(entry for entry in plan["excluded"] if entry["id"] == "macos15-physical-remap") + self.assertEqual(physical["title"], "macOS 15 physical q-to-w remap") def test_weekly_plan_covers_every_eligible_pair(self): plan = self.run_plan("--cadence", "weekly") From ed07255b8a2bcfece44edee11fc6eb52cd1cfb12 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 17:50:36 -0700 Subject: [PATCH 33/99] Ignore local lab campaign state --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b5e2abcbc..822644cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,7 @@ run-tests-automated.sh .tmp/ output/ test-results/ +.keypath-lab/ # Generated coverage reports (produced in CI; never commit) coverage/ From 4949ec379bd85838ff8323aa36eb16f68d1ce9b3 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 18:20:35 -0700 Subject: [PATCH 34/99] Run matrix installs through product installer --- Scripts/lab/install-runtime | 181 ++++++++++++++++++ Scripts/lab/keypath-lab | 6 + Scripts/lab/remote.sh | 41 ++++ Scripts/lab/scenario-matrix-runner | 23 ++- Scripts/lab/tests/install-runtime-tests.py | 96 ++++++++++ Scripts/lab/tests/keypath-lab-tests.sh | 5 + .../lab/tests/scenario-matrix-runner-tests.py | 39 +++- Sources/KeyPathAppKit/CLI/SystemFacade.swift | 4 + .../KeyPathCLISupport/CLIReportModels.swift | 6 + .../CLI/CLIOutputContractTests.swift | 6 +- .../installer-gui-automation-capabilities.md | 6 + docs/testing/remote-installer-lab.md | 28 ++- 12 files changed, 427 insertions(+), 14 deletions(-) create mode 100755 Scripts/lab/install-runtime create mode 100644 Scripts/lab/tests/install-runtime-tests.py diff --git a/Scripts/lab/install-runtime b/Scripts/lab/install-runtime new file mode 100755 index 000000000..30b20ecd3 --- /dev/null +++ b/Scripts/lab/install-runtime @@ -0,0 +1,181 @@ +#!/bin/zsh +set -euo pipefail + +lane=${1:-} +[[ "$lane" == "managed-functional" || "$lane" == "unmanaged-ui" ]] || { + print -u2 "usage: install-runtime managed-functional|unmanaged-ui" + exit 2 +} + +output="$PWD/.keypath-lab/scenario-output/install-runtime" +state="$output/state.tsv" +cli=${KEYPATH_INSTALL_RUNTIME_CLI:-/Applications/KeyPath.app/Contents/MacOS/keypath-cli} +assert_runtime=${KEYPATH_INSTALL_RUNTIME_ASSERT:-Scripts/lab/assert-runtime-state} +mkdir -p "$output" + +[[ -x "$cli" ]] || { + print -u2 "installed KeyPath CLI is unavailable" + exit 3 +} + +json_field() { + /usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null || true +} + +state_field() { + [[ -f "$state" ]] || return 0 + /usr/bin/awk -F '\t' -v key="$1" '$1 == key { value=$2 } END { print value }' "$state" +} + +valid_id() { + [[ "$1" =~ '^[A-Fa-f0-9-]{36}$' ]] +} + +write_state() { + local runtime_status=$1 + local previous_preflight_plan_id previous_preflight_snapshot_id previous_run_id previous_plan_id + local previous_before_snapshot_id previous_after_snapshot_id + previous_preflight_plan_id=$(state_field preflight_plan_id) + previous_preflight_snapshot_id=$(state_field preflight_snapshot_id) + previous_run_id=$(state_field run_id) + previous_plan_id=$(state_field plan_id) + previous_before_snapshot_id=$(state_field before_snapshot_id) + previous_after_snapshot_id=$(state_field after_snapshot_id) + { + print "status\t$runtime_status" + print "preflight_plan_id\t${preflight_plan_id:-$previous_preflight_plan_id}" + print "preflight_snapshot_id\t${preflight_snapshot_id:-$previous_preflight_snapshot_id}" + print "run_id\t${run_id:-$previous_run_id}" + print "plan_id\t${plan_id:-$previous_plan_id}" + print "before_snapshot_id\t${before_snapshot_id:-$previous_before_snapshot_id}" + print "after_snapshot_id\t${after_snapshot_id:-$previous_after_snapshot_id}" + print "updated_at\t$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$state" +} + +verify_postconditions() { + set +e + "$cli" system inspect --json --quiet --timeout 180 > "$output/post-inspect.json" 2> "$output/post-inspect.stderr" + local inspect_exit=$? + set -e + local operational + operational=$(json_field "$output/post-inspect.json" isOperational) + + set +e + if [[ "$lane" == "managed-functional" ]]; then + "$assert_runtime" ready \ + --managed-policy-manifest /Library/KeyPathLab/managed-policy/manifest.json \ + > "$output/observed-ready.tsv" 2> "$output/observed-ready.stderr" + else + "$assert_runtime" ready \ + > "$output/observed-ready.tsv" 2> "$output/observed-ready.stderr" + fi + local observed_exit=$? + set -e + + if (( inspect_exit == 0 && observed_exit == 0 )) && [[ "$operational" == "true" ]]; then + cat > "$output/assert-state.json" < "$output/preflight-inspect.json" 2> "$output/preflight-inspect.stderr" +preflight_plan_id=$(json_field "$output/preflight-inspect.json" planID) +preflight_snapshot_id=$(json_field "$output/preflight-inspect.json" snapshotID) +valid_id "$preflight_plan_id" || { + print -u2 "system inspect omitted a valid planID" + exit 3 +} +valid_id "$preflight_snapshot_id" || { + print -u2 "system inspect omitted a valid snapshotID" + exit 3 +} + +set +e +"$cli" system install --json --quiet --timeout 600 \ + > "$output/install-report.json" 2> "$output/install-report.stderr" +install_exit=$? +set -e + +run_id=$(json_field "$output/install-report.json" runID) +plan_id=$(json_field "$output/install-report.json" planID) +before_snapshot_id=$(json_field "$output/install-report.json" beforeSnapshotID) +after_snapshot_id=$(json_field "$output/install-report.json" afterSnapshotID) +completion_state=$(json_field "$output/install-report.json" completionState) +user_action_required=$(json_field "$output/install-report.json" userActionRequired) +claimed_success=$(json_field "$output/install-report.json" success) + +for identifier in "$run_id" "$plan_id" "$before_snapshot_id"; do + valid_id "$identifier" || { + print -u2 "installer report omitted required plan lineage" + exit 3 + } +done +[[ -z "$after_snapshot_id" ]] || valid_id "$after_snapshot_id" || { + print -u2 "installer report contained an invalid afterSnapshotID" + exit 3 +} + +if [[ "$completion_state" == "awaitingApproval" || "$user_action_required" == "true" ]]; then + write_state awaiting-approval + print "install_runtime\twaiting" + print "user_action_required\tComplete the KeyPath approvals visible in the disposable guest, then resume this checkpoint." + exit 4 +fi + +if (( install_exit != 0 )) || [[ "$claimed_success" != "true" ]]; then + write_state failed + print -u2 "KeyPath's installer reported failure" + exit 1 +fi + +write_state verifying +if verify_postconditions; then + exit 0 +fi + +cat > "$output/assert-state.json" < None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = pathlib.Path(self.temporary.name) + self.approved = self.directory / "approved" + self.install_count = self.directory / "install-count" + self.cli = self.directory / "keypath-cli" + self.cli.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + if [[ "$1 $2" == "system inspect" ]]; then + operational=false + [[ -f {str(self.approved)!r} ]] && operational=true + print '{{"planID":"{PLAN_ID}","snapshotID":"{SNAPSHOT_ID}","isOperational":'$operational'}}' + exit 0 + fi + if [[ "$1 $2" == "system install" ]]; then + count=0 + [[ -f {str(self.install_count)!r} ]] && count=$(cat {str(self.install_count)!r}) + print $((count + 1)) > {str(self.install_count)!r} + if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "success" ]]; then + print '{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"completed","userActionRequired":false,"success":true}}' + exit 0 + fi + print '{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"awaitingApproval","userActionRequired":true,"success":false}}' + exit 1 + fi + exit 2 + """)) + self.cli.chmod(0o755) + self.assert_runtime = self.directory / "assert-runtime" + self.assert_runtime.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + [[ -f {str(self.approved)!r} ]] || exit 1 + print 'runtime\tready' + """)) + self.assert_runtime.chmod(0o755) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def invoke(self, *, mode: str = "waiting") -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + "KEYPATH_INSTALL_RUNTIME_CLI": str(self.cli), + "KEYPATH_INSTALL_RUNTIME_ASSERT": str(self.assert_runtime), + "KEYPATH_TEST_INSTALL_MODE": mode, + } + return subprocess.run( + [str(INSTALL_RUNTIME), "unmanaged-ui"], cwd=self.directory, + env=env, text=True, capture_output=True, + ) + + def test_waiting_install_is_not_repeated_after_operator_approval(self) -> None: + first = self.invoke() + self.assertEqual(first.returncode, 4, first.stderr) + self.assertIn("install_runtime\twaiting", first.stdout) + self.assertEqual(self.install_count.read_text().strip(), "1") + + self.approved.touch() + second = self.invoke() + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual(self.install_count.read_text().strip(), "1") + evidence = json.loads((self.directory / ".keypath-lab/scenario-output/install-runtime/assert-state.json").read_text()) + self.assertTrue(evidence["runtimeReady"]["agreement"]) + self.assertEqual(evidence["plan"]["runID"], RUN_ID) + + def test_claimed_success_without_independent_ready_state_is_a_failure(self) -> None: + result = self.invoke(mode="success") + self.assertEqual(result.returncode, 1) + self.assertIn("independent runtime postconditions were absent", result.stderr) + evidence = json.loads((self.directory / ".keypath-lab/scenario-output/install-runtime/assert-state.json").read_text()) + self.assertFalse(evidence["runtimeReady"]["agreement"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 1d9b1b03d..81234288d 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -246,8 +246,10 @@ grep -Fq 'Tart guest SSH did not recover after reboot' "$REMOTE" /bin/zsh -n "$LAB_DIR/nameplate-instrumentation" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" /bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" +/bin/zsh -n "$LAB_DIR/install-runtime" python3 "$LAB_DIR/tests/scenario-matrix-tests.py" python3 "$LAB_DIR/tests/scenario-matrix-runner-tests.py" +python3 "$LAB_DIR/tests/install-runtime-tests.py" python3 "$LAB_DIR/tests/macos-26-selector-scenario-tests.py" python3 "$LAB_DIR/tests/macos-27-selector-scenario-tests.py" python3 "$LAB_DIR/tests/physical-remap-session-tests.py" @@ -425,6 +427,9 @@ run_remote scenario cbx_test15 clean-install >/dev/null grep -q 'installer-scenario clean-install' "$ROOT/KeyPathInstallerLab/leases/cbx_test15/commands.tsv" run_remote install-app cbx_test15 >/dev/null grep -q 'install-app 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-app.log" +run_remote install-runtime cbx_test15 >/dev/null +grep -q 'Scripts/lab/install-runtime unmanaged-ui' "$CALLS" +grep -q $'install_runtime_status\tpassed' "$manifest" run_remote install-fixture cbx_test15 >/dev/null grep -q 'install-fixture 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-fixture.log" tart_reboot=$(KEYPATH_LAB_TART_REBOOT_SETTLE_SECONDS=0 KEYPATH_LAB_TART_REBOOT_POLL_SECONDS=0 run_remote reboot-guest cbx_test15) diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py index 7cbf63b27..e8ca7a702 100755 --- a/Scripts/lab/tests/scenario-matrix-runner-tests.py +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -77,7 +77,7 @@ def test_executes_steps_updates_dashboard_and_destroys_owned_lease(self) -> None self.assertEqual(state["jobs"][0]["cleanupStatus"], "passed") lab_log = self.log.read_text() self.assertIn("create --macos 15", lab_log) - self.assertIn("install-app cbx_test_lease", lab_log) + self.assertIn("install-runtime cbx_test_lease", lab_log) self.assertIn("scenario cbx_test_lease artifact-capture", lab_log) self.assertIn("artifacts cbx_test_lease --output", lab_log) self.assertIn("destroy cbx_test_lease", lab_log) @@ -101,6 +101,43 @@ def test_human_checkpoint_waits_without_cleanup_then_resumes(self) -> None: self.assertEqual(state["jobs"][0]["status"], "passed") self.assertIn("destroy cbx_test_lease", self.log.read_text()) + def test_installer_approval_wait_resumes_by_verifying_without_accepting_the_step(self) -> None: + marker = self.directory / "approval-requested" + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + create) print 'lease_id\tcbx_test_lease'; print 'manifest\t/tmp/manifest' ;; + status) print 'status\tready' ;; + install-runtime) + if [[ ! -f {str(marker)!r} ]]; then + touch {str(marker)!r} + print 'install_runtime\twaiting' + print 'user_action_required\tApprove KeyPath' + exit 4 + fi + print 'install_runtime\tpassed' + ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + plan = self.plan(["create-fresh-lease", "install-exact-artifact", "artifact-capture"]) + + first = self.run_runner(plan) + self.assertEqual(first.returncode, 4, first.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["jobs"][0]["status"], "waiting") + self.assertEqual(state["jobs"][0]["steps"][1]["status"], "waiting") + self.assertNotIn("destroy", self.log.read_text()) + + second = self.run_runner(plan, "--ack-checkpoint", "vm-job:install-exact-artifact") + self.assertEqual(second.returncode, 0, second.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["jobs"][0]["steps"][1]["status"], "passed") + self.assertEqual(self.log.read_text().count("install-runtime cbx_test_lease"), 2) + self.assertIn("destroy cbx_test_lease", self.log.read_text()) + def test_refuses_checkpoint_preapproval(self) -> None: result = self.run_runner(self.plan(["create-fresh-lease", "operator-visible-action"]), "--ack-checkpoint", "vm-job:operator-visible-action") diff --git a/Sources/KeyPathAppKit/CLI/SystemFacade.swift b/Sources/KeyPathAppKit/CLI/SystemFacade.swift index 211239ff6..b49704a67 100644 --- a/Sources/KeyPathAppKit/CLI/SystemFacade.swift +++ b/Sources/KeyPathAppKit/CLI/SystemFacade.swift @@ -225,6 +225,8 @@ public struct SystemFacade: Sendable { CLIRuntimeBootstrap.ensureConfigured() if let bundleIssue = Self.systemRepairBundleIssue() { return CLIInspectResult( + planID: nil, + snapshotID: nil, macOSVersion: ProcessInfo.processInfo.operatingSystemVersionString, driverCompatible: false, planStatus: "blocked", @@ -252,6 +254,8 @@ public struct SystemFacade: Sendable { } return CLIInspectResult( + planID: plan.id.uuidString, + snapshotID: context.snapshotID.uuidString, macOSVersion: context.system.macOSVersion, driverCompatible: context.system.driverCompatible, planStatus: planStatus, diff --git a/Sources/KeyPathCLISupport/CLIReportModels.swift b/Sources/KeyPathCLISupport/CLIReportModels.swift index b599f1be1..022bc06d8 100644 --- a/Sources/KeyPathCLISupport/CLIReportModels.swift +++ b/Sources/KeyPathCLISupport/CLIReportModels.swift @@ -261,6 +261,8 @@ public struct CLIRepairTelemetryEvent: Codable, Sendable { } public struct CLIInspectResult: Codable, Sendable { + public let planID: String? + public let snapshotID: String? public let macOSVersion: String public let driverCompatible: Bool public let planStatus: String @@ -275,6 +277,8 @@ public struct CLIInspectResult: Codable, Sendable { public let stateMatrixPlan: [String]? public init( + planID: String? = nil, + snapshotID: String? = nil, macOSVersion: String, driverCompatible: Bool, planStatus: String, @@ -288,6 +292,8 @@ public struct CLIInspectResult: Codable, Sendable { stateMatrixRow: String? = nil, stateMatrixPlan: [String]? = nil ) { + self.planID = planID + self.snapshotID = snapshotID self.macOSVersion = macOSVersion self.driverCompatible = driverCompatible self.planStatus = planStatus diff --git a/Tests/KeyPathTests/CLI/CLIOutputContractTests.swift b/Tests/KeyPathTests/CLI/CLIOutputContractTests.swift index a65fa8abb..54dff04c7 100644 --- a/Tests/KeyPathTests/CLI/CLIOutputContractTests.swift +++ b/Tests/KeyPathTests/CLI/CLIOutputContractTests.swift @@ -257,6 +257,8 @@ final class CLIOutputContractTests: XCTestCase { canAutoFix: true ) let result = CLIInspectResult( + planID: "plan-test", + snapshotID: "snapshot-test", macOSVersion: "26.5.0", driverCompatible: true, planStatus: "ready", @@ -276,7 +278,7 @@ final class CLIOutputContractTests: XCTestCase { let keys = try jsonKeys(result) let required: Set = [ "driverCompatible", "issues", "isOperational", "macOSVersion", "planIntent", - "planStatus", "plannedRecipes", "promptsNeeded", "stateMatrixPlan", + "planID", "planStatus", "plannedRecipes", "promptsNeeded", "snapshotID", "stateMatrixPlan", "stateMatrixRow", "userActionRequired", ] XCTAssertTrue(required.isSubset(of: keys), "Missing required keys: \(required.subtracting(keys))") @@ -285,6 +287,8 @@ final class CLIOutputContractTests: XCTestCase { let decoded = try decoder.decode(CLIInspectResult.self, from: data) XCTAssertEqual(decoded.stateMatrixRow, InstallerStateMatrixRow.helperMissing.rawValue) XCTAssertEqual(decoded.stateMatrixPlan, [InstallerStateMatrixAction.installHelper.rawValue]) + XCTAssertEqual(decoded.planID, "plan-test") + XCTAssertEqual(decoded.snapshotID, "snapshot-test") } func testPermissionIssuesPreserveTriModeSemantics() { diff --git a/docs/testing/installer-gui-automation-capabilities.md b/docs/testing/installer-gui-automation-capabilities.md index 11bab6da1..86c85cc66 100644 --- a/docs/testing/installer-gui-automation-capabilities.md +++ b/docs/testing/installer-gui-automation-capabilities.md @@ -157,6 +157,12 @@ Record the product's `runID`, `planID`, and before/after snapshot IDs in the lab timeline. These IDs join a failed lab action directly to product telemetry and must survive artifact collection. +The executable path is `keypath-lab install-runtime LEASE_ID`. It records the +inspection plan/snapshot identity before execution and the installer report's +run/plan/before/after identity afterward. The matrix's +`install-exact-artifact` checkpoint calls this command; `install-app` is only +the internal bundle-staging primitive and is not installation evidence. + ### Harness retries are not product retries The runner may retry a failed screenshot, stale semantic snapshot, or diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index f8c0b12a0..8ee1b0c4f 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -100,6 +100,7 @@ Scripts/lab/keypath-lab create \ Scripts/lab/keypath-lab list Scripts/lab/keypath-lab status cbx_example Scripts/lab/keypath-lab install-app cbx_example +Scripts/lab/keypath-lab install-runtime cbx_example Scripts/lab/keypath-lab nameplate cbx_example enable Scripts/lab/keypath-lab run cbx_example -- sw_vers Scripts/lab/keypath-lab secure-dialog-input cbx_example \ @@ -484,12 +485,23 @@ injection. The command proves delivery only; the scenario must separately observe its expected application or runtime postcondition. P01 used a captured key chip in KeyPath's Input Capture Experiment as that postcondition. -`install-app` expands the staged ZIP into `/Applications` on the disposable -guest. Tart uses the base image's noninteractive sudo contract. Parallels uses -the same passwordless `prlctl exec` guest-control channel CrabBox already uses -to prepare the disposable clone, scoped to the exact provider resource recorded -in the owned lease manifest. Neither path changes the base image or stores a -guest password. +`install-app` only expands the staged ZIP into `/Applications` on the disposable +guest; it does not prove KeyPath installation. Tart uses the base image's +noninteractive sudo contract for that bundle-staging step. Parallels uses the +same passwordless `prlctl exec` guest-control channel CrabBox already uses to +prepare the disposable clone, scoped to the exact provider resource recorded in +the owned lease manifest. Neither path changes the base image or stores a guest +password. + +`install-runtime` is the reliability-test entry point. It stages the bundle once, +captures `keypath-cli system inspect --json`, invokes the product-owned +`keypath-cli system install --json` plan once, and records the preflight plan and +snapshot IDs plus the install report's run, plan, and before/after snapshot IDs. +It then compares KeyPath's claimed result with `assert-runtime-state` and a fresh +product inspection. If macOS approval is required, it returns a waiting outcome +and preserves the lease. Resuming after the visible approval verifies those +postconditions without repeating the installer mutation. A controller +interruption after mutation starts is fail-closed and requires artifact review. ## Installer scenarios @@ -595,7 +607,9 @@ waiting; it cannot pre-authorize or bypass a future interaction. A process interruption never blindly repeats an uncertain mutation. Lease creation can be recovered only when both the durable command log and the owned controller status identify the same ready lease; any other uncertain mutation blocks for evidence -review. Failed jobs still collect available artifacts and destroy their owned +review. The `install-exact-artifact` matrix step routes through `install-runtime`; +when it waits for macOS approval, acknowledgement means “verify now,” not “assume +the installer passed.” Failed jobs still collect available artifacts and destroy their owned leases, allowing independent later waves to continue. A waiting job retains its lease and pauses only subsequent work on that provider. `matrix-report.json` contains campaign/job outcomes and `scenario-report.json` consolidates validated From 8eefee625954acea77585f907811c2c460e13464 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 18:23:18 -0700 Subject: [PATCH 35/99] Allow artifact builds without local deployment --- Scripts/build-and-sign.sh | 7 +++++++ build.sh | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Scripts/build-and-sign.sh b/Scripts/build-and-sign.sh index 2583ca1cd..079331dc5 100755 --- a/Scripts/build-and-sign.sh +++ b/Scripts/build-and-sign.sh @@ -529,6 +529,11 @@ else create_sparkle_archive fi +# A matrix build needs the exact signed artifact but must not replace or restart +# the operator's installed app. Keep the normal release behavior as the default. +if [ "${SKIP_DEPLOY:-0}" = "1" ]; then + echo "Skipping local deployment and restart (SKIP_DEPLOY=1)" +else # Stop running KeyPath and kanata BEFORE replacing the app bundle. # Replacing binaries while the process is live causes macOS to detect # code page mismatches and kill the process with: @@ -598,6 +603,8 @@ fi # Publish help content to website # ───────────────────────────────────────────────────────────────────── +fi + GHPAGES_DIR="$SCRIPT_DIR/../.worktrees/gh-pages" if [ -d "$GHPAGES_DIR" ] && [ "${SKIP_WEBSITE:-0}" != "1" ] && [ "${SKIP_NOTARIZE:-}" != "1" ]; then echo "" diff --git a/build.sh b/build.sh index e5650d312..dc3002025 100755 --- a/build.sh +++ b/build.sh @@ -2,12 +2,13 @@ set -euo pipefail # Canonical build entry point: delegates to Scripts/build-and-sign.sh -# This builds, signs, notarizes (unless SKIP_NOTARIZE=1), deploys to ~/Applications, and restarts the app. +# This builds, signs, notarizes (unless SKIP_NOTARIZE=1), deploys to /Applications, and restarts the app. # # Usage: # ./build.sh # Full build with notarization # SKIP_NOTARIZE=1 ./build.sh # Skip notarization for faster local testing # SKIP_NOTARIZE=1 SKIP_CODESIGN=1 ./build.sh # Skip notarization + codesign for local dev +# SKIP_NOTARIZE=1 SKIP_DEPLOY=1 ./build.sh # Signed lab artifact without local deployment SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd) From b02f46131e56cef79ac322bd698e7bae555710cb Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 19:00:01 -0700 Subject: [PATCH 36/99] Reuse verified lab archives on resume --- Scripts/lab/keypath-lab | 8 ++++++-- Scripts/lab/remote.sh | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index 1d3e4a499..b73deff40 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -117,6 +117,8 @@ case "$command" in [[ $resolved == "$commit" ]] || die "commit does not resolve exactly: $commit" installer_sha=$(shasum -a 256 "$installer" | awk '{print $1}') + installer_name=$(basename "$installer") + [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" archive_key="${commit}-${installer_sha}" fixture_name= fixture_sha= @@ -126,12 +128,14 @@ case "$command" in fixture_sha=$(shasum -a 256 "$fixture" | awk '{print $1}') archive_key="${archive_key}-${fixture_sha}" fi + if remote archive-status "$archive_key" "$commit" "$installer_sha" "$installer_name"; then + remote create "$macos" "$lane" "$archive_key" "$commit" "$installer_sha" "$installer_name" "$ttl" "$desktop" "$tart_usb_passthrough" + exit $? + fi temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/keypath-lab.XXXXXX") trap 'rm -rf "$temp_dir"' EXIT mkdir -p "$temp_dir/repo/.keypath-lab/installer" git -C "$REPO_ROOT" archive "$commit" | tar -x -C "$temp_dir/repo" - installer_name=$(basename "$installer") - [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" cp "$installer" "$temp_dir/repo/.keypath-lab/installer/$installer_name" if [[ -n $fixture ]]; then mkdir -p "$temp_dir/repo/.keypath-lab/fixtures" diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index ac1b07b4f..8d696d6c7 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -553,6 +553,22 @@ prepare_upload() { mktemp "/tmp/keypath-lab.XXXXXXXX" } +archive_status() { + local key=$1 commit=$2 installer_sha=$3 installer_name=$4 destination ready + valid_id "$key" + [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" + [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" + [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" + destination="$ARCHIVES/$key" + ready="$destination/ready.tsv" + [[ -f "$ready" && -d "$destination/repo/.git" ]] || return 1 + [[ "$(field "$ready" owner)" == "$OWNER" ]] || die "archive ownership mismatch" + [[ "$(field "$ready" keypath_commit)" == "$commit" ]] || die "archive commit mismatch" + [[ "$(field "$ready" installer_sha256)" == "$installer_sha" ]] || die "archive installer checksum mismatch" + [[ "$(field "$ready" installer_name)" == "$installer_name" ]] || die "archive installer name mismatch" + print "archive\tready\t$key" +} + install_archive() { local source=$1 key=$2 commit=$3 installer_sha=$4 installer_name=$5 [[ "$source" =~ '^/tmp/keypath-lab\.[A-Za-z0-9]+$' ]] || die "invalid upload ticket" @@ -2024,6 +2040,7 @@ action=${1:-} shift || true case "$action" in preflight) [[ $# -eq 0 ]] || die "preflight takes no arguments"; preflight ;; + archive-status) [[ $# -eq 4 ]] || die "archive-status requires key, commit, checksum, and name"; archive_status "$@" ;; prepare-upload) [[ $# -eq 1 ]] || die "prepare-upload requires archive key"; prepare_upload "$1" ;; install-archive) [[ $# -eq 5 ]] || die "install-archive requires ticket, key, commit, checksum, and name"; install_archive "$@" ;; create) [[ $# -eq 8 || $# -eq 9 ]] || die "create requires macOS, test lane, archive, commit, checksum, name, ttl, desktop, and optional Tart USB passthrough"; create_lease "$@" ;; From 592a756134a6334c4426cb24e126ab1db5ea06aa Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 19:05:36 -0700 Subject: [PATCH 37/99] Retain ignored lab payloads in archives --- Scripts/lab/remote.sh | 4 ++++ Scripts/lab/tests/keypath-lab-tests.sh | 2 ++ 2 files changed, 6 insertions(+) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 8d696d6c7..a03e5b464 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -598,6 +598,10 @@ install_archive() { git -C "$staging/repo" config user.name "KeyPath Lab" git -C "$staging/repo" config user.email "keypath-lab@localhost" git -C "$staging/repo" add -A + # The product checkout ignores local campaign state under .keypath-lab, but + # an immutable lab archive must retain its installer, policy, and fixture + # payload when it is cloned into a disposable operation worktree. + git -C "$staging/repo" add -f .keypath-lab GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z git -C "$staging/repo" commit -q -m "KeyPath lab archive $commit" [[ -z "$(git -C "$staging/repo" status --porcelain)" ]] || die "archive checkout is dirty" { diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 81234288d..28afda346 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -329,12 +329,14 @@ publish_checksum=$(shasum -a 256 "$LAB_DIR/scenarios/installer-scenario" | awk ' publish_key="$publish_commit-$publish_checksum" mkdir -p "$TMP/upload/repo/.keypath-lab/installer" cp "$LAB_DIR/scenarios/installer-scenario" "$TMP/upload/repo/.keypath-lab/installer/installer.zip" +printf '.keypath-lab/\n' > "$TMP/upload/repo/.gitignore" for pass in 1 2; do ticket=$(run_remote prepare-upload "$publish_key") tar -czf "$ticket" -C "$TMP/upload" repo published=$(run_remote install-archive "$ticket" "$publish_key" "$publish_commit" "$publish_checksum" installer.zip) if [[ $pass == 1 ]]; then assert_contains "$published" $'archive\tcreated'; else assert_contains "$published" $'archive\treused'; fi done +git -C "$ROOT/KeyPathInstallerLab/archives/$publish_key/repo" ls-files --error-unmatch .keypath-lab/installer/installer.zip >/dev/null if find "$ROOT/KeyPathInstallerLab/archives" -maxdepth 1 -name ".staging-$publish_key-*" | grep -q .; then echo "archive publish left a staging directory" >&2 exit 1 From 2aa35fc8e164ea37fdbd0b0f1c29600d53966413 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 19:27:04 -0700 Subject: [PATCH 38/99] Harden managed clone enrollment recovery --- Scripts/lab/desktop-bootstrap | 50 +++--- Scripts/lab/remote.sh | 149 ++++++++++-------- Scripts/lab/scenario-matrix-runner | 30 +++- Scripts/lab/tests/keypath-lab-tests.sh | 12 +- .../lab/tests/scenario-matrix-runner-tests.py | 25 +++ 5 files changed, 167 insertions(+), 99 deletions(-) diff --git a/Scripts/lab/desktop-bootstrap b/Scripts/lab/desktop-bootstrap index 42cd89789..4226c4b5d 100755 --- a/Scripts/lab/desktop-bootstrap +++ b/Scripts/lab/desktop-bootstrap @@ -175,28 +175,36 @@ mcporter_bin=$(command -v mcporter || true) open -a "System Settings" sleep 2 # The first capture can display macOS's private-picker bypass consent sheet. -"$peekaboo_bin" see --app "System Settings" --json > "$output/initial-desktop.json" || true -"$peekaboo_bin" see --app UserNotificationCenter --json > "$output/user-notification-center.json" || true - -consent=$("$python_bin" - "$output/user-notification-center.json" <<'PY' -import json, sys -try: - data = json.load(open(sys.argv[1])).get("data", {}) -except Exception: - raise SystemExit(1) -for element in data.get("ui_elements", []): - if element.get("is_actionable") and element.get("label") == "Allow": - print(data["snapshot_id"], element["id"], sep="\t") - break -PY -) || true - -if [[ -n "$consent" ]]; then - snapshot=${consent%%$'\t'*} - element=${consent#*$'\t'} - "$peekaboo_bin" click --on "$element" --snapshot "$snapshot" --no-auto-focus --json \ - > "$output/peekaboo-consent-click.json" +initial_capture_result=0 +"$peekaboo_bin" see --app "System Settings" --json > "$output/initial-desktop.json" || initial_capture_result=$? +capture_prompt=$(/usr/bin/osascript -l JavaScript -e ' +function run() { + var systemEvents = Application("System Events"); + var full = systemEvents.processes.whose({name: "NotificationCenter"})(); + if (full.length && full[0].windows().length) { + var fullWindow = full[0].windows[0]; + try { + var fullSize = fullWindow.size(); + if (fullWindow.subrole() === "AXSystemDialog" && fullSize[0] === 1024 && fullSize[1] === 768) return "screen-capture"; + } catch (_) {} + } + var privateCenter = systemEvents.processes.whose({name: "UserNotificationCenter"})(); + if (privateCenter.length && privateCenter[0].windows().length) { + var privateWindow = privateCenter[0].windows[0]; + try { + var message = privateWindow.staticTexts().map(function(item) { return item.value() || ""; }).join(" "); + if (privateWindow.subrole() === "AXSystemDialog" && message.indexOf("boo.peekaboo.peekaboo") !== -1 && message.indexOf("private window picker") !== -1) return "private-window-picker"; + } catch (_) {} + } + return ""; +} +' 2>/dev/null || true) +if [[ -n "$capture_prompt" ]]; then + printf '{"status":"waiting","checkpoint":"%s"}\n' "$capture_prompt" > "$output/capture-consent-checkpoint.json" + print "desktop_bootstrap_checkpoint\t$capture_prompt" + exit 4 fi +(( initial_capture_result == 0 )) || die "initial Peekaboo capture failed without a visible consent prompt" "$peekaboo_bin" see --app "System Settings" --json > "$output/desktop-ready.json" "$peekaboo_bin" permissions status --json > "$output/peekaboo-permissions-after.json" diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index a03e5b464..6f70f4daa 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -309,7 +309,7 @@ managed_enrollment_id_for() { } approve_peekaboo_capture() { - local lease=$1 manifest macos resource key ip prompt_command prompt_coords attempt + local lease=$1 manifest macos resource key ip prompt_command prompt_coords private_prompt_command private_prompt_coords attempt approved manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "15" ]] || die "Peekaboo capture approval currently supports only the Tart macOS 15 lane" @@ -323,6 +323,8 @@ approve_peekaboo_capture() { ip=$($TART ip "$resource") [[ "$ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Tart returned an invalid guest address" prompt_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction run() {\n var matches = Application("System Events").processes.whose({name: "NotificationCenter"})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "";\n var window = matches[0].windows[0];\n try {\n var size = window.size();\n if (window.subrole() === "AXSystemDialog" && size[0] === 1024 && size[1] === 768) return "512,399";\n } catch (_) {}\n return "";\n}\'' + private_prompt_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction run() {\n var matches = Application("System Events").processes.whose({name: "UserNotificationCenter"})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "";\n var window = matches[0].windows[0];\n try {\n var message = window.staticTexts().map(function(item) { return item.value() || ""; }).join(" ");\n if (window.subrole() !== "AXSystemDialog" || message.indexOf("boo.peekaboo.peekaboo") === -1 || message.indexOf("private window picker") === -1) return "";\n var buttons = window.buttons().filter(function(item) { return item.name() === "Allow"; });\n if (buttons.length !== 1) return "";\n var position = buttons[0].position();\n var size = buttons[0].size();\n return Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2);\n } catch (_) {}\n return "";\n}\'' + approved=0 prompt_coords= for attempt in {1..20}; do prompt_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" \ @@ -330,16 +332,43 @@ approve_peekaboo_capture() { [[ -n "$prompt_coords" ]] && break sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_POLL_SECONDS:-0.2}" done - if [[ -z "$prompt_coords" ]]; then + if [[ -n "$prompt_coords" ]]; then + [[ "$prompt_coords" =~ '^[0-9]+,[0-9]+$' ]] || die "Peekaboo capture approval prompt coordinates are invalid" + "$CRABBOX" desktop click --provider tart --target macos --id "$resource" \ + --x "${prompt_coords%,*}" --y "${prompt_coords#*,}" >/dev/null + approved=1 + sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_SETTLE_SECONDS:-5}" + fi + + # macOS 15 can follow the full-screen ScreenCaptureKit prompt with a second, + # smaller dialog authorizing Peekaboo's private-window-picker bypass. Leaving + # that dialog on screen makes later RFB coordinates hit the obscured Settings + # sidebar. Match the exact Peekaboo request and derive the Allow button center + # from the live AX tree instead of storing another fixed coordinate. + private_prompt_coords= + for attempt in {1..20}; do + private_prompt_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" \ + "/bin/zsh -lc $(printf %q "$private_prompt_command")") + [[ -n "$private_prompt_coords" ]] && break + sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_POLL_SECONDS:-0.2}" + done + if [[ -n "$private_prompt_coords" ]]; then + [[ "$private_prompt_coords" =~ '^[0-9]+,[0-9]+$' ]] || die "Peekaboo private capture approval coordinates are invalid" + "$CRABBOX" desktop click --provider tart --target macos --id "$resource" \ + --x "${private_prompt_coords%,*}" --y "${private_prompt_coords#*,}" >/dev/null + approved=1 + sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_SETTLE_SECONDS:-5}" + private_prompt_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" \ + "/bin/zsh -lc $(printf %q "$private_prompt_command")") + [[ -z "$private_prompt_coords" ]] || die "Peekaboo private capture approval prompt remained visible" + fi + + if ((approved)); then + record_command "$lease" passed approve-peekaboo-capture + print "peekaboo_capture_approval\tpassed" + else print "peekaboo_capture_approval\talready-approved" - return 0 fi - [[ "$prompt_coords" =~ '^[0-9]+,[0-9]+$' ]] || die "Peekaboo capture approval prompt coordinates are invalid" - "$CRABBOX" desktop click --provider tart --target macos --id "$resource" \ - --x "${prompt_coords%,*}" --y "${prompt_coords#*,}" >/dev/null - sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_SETTLE_SECONDS:-5}" - record_command "$lease" passed approve-peekaboo-capture - print "peekaboo_capture_approval\tpassed" } rehydrate_managed_clone() { @@ -443,7 +472,7 @@ resume_managed_policy() { [[ "$lane" == managed-functional ]] || die "managed policy resume requires a managed-functional lease" set +e - rehydrate_managed_clone "$lease" > "$LOGS/$lease/managed-policy.log" 2>&1 + (rehydrate_managed_clone "$lease") > "$LOGS/$lease/managed-policy.log" 2>&1 result=$? set -e set_field "$manifest" managed_policy_result "$result" @@ -775,6 +804,10 @@ create_lease() { print "tart_usb_passthrough\t$([[ "$tart_usb_passthrough" == "1" ]] && print true || print false)" print "provider_resource\t${provider_resource:-unknown}" } > "$manifest" + # Emit the durable controller identity as soon as the owned manifest exists. + # Callers must be able to adopt and clean up a lease even when a later guest + # verification or managed-policy step fails. + print "lease_id\t$lease" if (( create_status != 0 )); then set_field "$manifest" status provisioning-failed set_field "$manifest" provision_result "$create_status" @@ -796,7 +829,7 @@ create_lease() { set_field "$manifest" macos_build "${build:-unknown}" if [[ "$lane" == managed-functional ]]; then set +e - rehydrate_managed_clone "$lease" > "$LOGS/$lease/managed-policy.log" 2>&1 + (rehydrate_managed_clone "$lease") > "$LOGS/$lease/managed-policy.log" 2>&1 managed_policy_exit=$? set -e set_field "$manifest" managed_policy_result "$managed_policy_exit" @@ -956,7 +989,7 @@ secure_dialog_input() { local lease=$1 app=$2 field_label=$3 submit_button=$4 already_focused=$5 local manifest macos resource key ip secret_file guest_command exit_code local focus_command focus_result button_geometry_command button_coords postcondition_command postcondition_result - local geometry_command geometry native_width native_height logical_width logical_height scale_x scale_y click_x click_y focus_x focus_y + local click_x click_y focus_x focus_y manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "15" ]] || die "secure dialog input currently supports only the Tart macOS 15 lane" @@ -1000,20 +1033,10 @@ secure_dialog_input() { fi IFS=',' read -r _ focus_x focus_y <<< "$focus_result" - if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then - geometry=${KEYPATH_LAB_TEST_DISPLAY_GEOMETRY:-'2048 1536 1024 768'} - else - geometry_command='/usr/bin/osascript -l JavaScript -e '\''ObjC.import("AppKit"); var screen=$.NSScreen.mainScreen; var logical=screen.frame.size; var scale=Number(screen.backingScaleFactor); Math.round(logical.width*scale)+" "+Math.round(logical.height*scale)+" "+Math.round(logical.width)+" "+Math.round(logical.height)'\''' - geometry=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$geometry_command")") - fi - IFS=' ' read -r native_width native_height logical_width logical_height <<< "$geometry" - [[ "$native_width" == <-> && "$native_height" == <-> && "$logical_width" == <-> && "$logical_height" == <-> && "$logical_width" -gt 0 && "$logical_height" -gt 0 ]] || die "secure dialog input could not measure display geometry" - (( native_width % logical_width == 0 && native_height % logical_height == 0 )) || die "secure dialog input measured a non-integral display scale" - scale_x=$((native_width / logical_width)) - scale_y=$((native_height / logical_height)) - (( scale_x == scale_y && scale_x > 0 )) || die "secure dialog input measured inconsistent display scales" - focus_x=$((focus_x * scale_x)) - focus_y=$((focus_y * scale_y)) + # Tart's 1024x768 RFB viewport uses the same coordinate space reported by + # Accessibility. The 2048x1536 backing store is a Retina implementation + # detail; scaling AX coordinates to backing pixels moves the native click + # outside the protected sheet. set +e "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$focus_x" --y "$focus_y" >/dev/null 2>&1 @@ -1024,30 +1047,17 @@ secure_dialog_input() { die "secure dialog input failed while giving the field native pointer focus" fi - if [[ "$app" == "System Settings" ]]; then - # System Settings authorization sheets accept local CGEvent synthesis but - # intentionally ignore remote VNC key events. Stream the secret directly - # into one guest osascript process; it never enters argv, logs, clipboard, - # or a guest file. - local system_settings_type_command - system_settings_type_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("Foundation");\nfunction run() {\n var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;\n var payload = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js;\n if (!payload) throw new Error("secure input is empty");\n var events = Application("System Events");\n var process = events.processes.byName("System Settings");\n var fields = process.windows[0].sheets[0].textFields.whose({subrole: "AXSecureTextField"})();\n if (fields.length === 0) throw new Error("secure text field not found");\n fields[0].focused = true;\n for (var i = 0; i < 128; i++) events.keyCode(51);\n events.keystroke(payload);\n}\'' - set +e - "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$system_settings_type_command")" < "$secret_file" >/dev/null 2>&1 - exit_code=$? - set -e - else - # Clear any value left by a prior interrupted attempt. Backspace is a - # supported RFB keysym, and a fixed upper bound avoids learning or - # logging the protected field's current length. - set +e - printf '\b%.0s' {1..128} | "$CRABBOX" desktop type --provider tart --target macos --id "$resource" >/dev/null 2>&1 - exit_code=$? - if (( exit_code == 0 )); then - "$CRABBOX" desktop type --provider tart --target macos --id "$resource" < "$secret_file" >/dev/null 2>&1 - exit_code=$? - fi - set -e - fi + # Secure authorization sheets ignore Tart's VNC key events. Stream the + # secret directly into one guest osascript process and synthesize local key + # events in the logged-in session. The value never enters argv, logs, the + # clipboard, or a guest file. The process returns only a filled/empty + # postcondition; it never prints the value or its length. + local secure_type_command + secure_type_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("Foundation");\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;\n var payload = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js;\n if (!payload) throw new Error("secure input is empty");\n var events = Application("System Events");\n var process = events.processes.byName(argv[0]);\n if (process.windows().length === 0) throw new Error("secure dialog process not found");\n var window = process.windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var fields = root.textFields.whose({subrole: "AXSecureTextField"})();\n var field = fields.length > 0 ? fields[0] : descendants(root).find(function(element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n if (!field) throw new Error("secure text field not found");\n field.focused = true;\n for (var i = 0; i < 128; i++) events.keyCode(51);\n events.keystroke(payload);\n delay(0.2);\n if (String(field.value()).length === 0) throw new Error("secure text field remained empty");\n return "filled";\n}\' -- '$(printf %q "$app") + set +e + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$secure_type_command")" < "$secret_file" >/dev/null 2>&1 + exit_code=$? + set -e if (( exit_code != 0 )); then record_command "$lease" "failed:42" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" die "secure dialog input failed while streaming masked input" @@ -1064,21 +1074,6 @@ secure_dialog_input() { fi IFS=',' read -r click_x click_y <<< "$button_coords" - if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then - geometry=${KEYPATH_LAB_TEST_DISPLAY_GEOMETRY:-'2048 1536 1024 768'} - else - geometry_command='/usr/bin/osascript -l JavaScript -e '\''ObjC.import("AppKit"); var screen=$.NSScreen.mainScreen; var logical=screen.frame.size; var scale=Number(screen.backingScaleFactor); Math.round(logical.width*scale)+" "+Math.round(logical.height*scale)+" "+Math.round(logical.width)+" "+Math.round(logical.height)'\''' - geometry=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$geometry_command")") - fi - IFS=' ' read -r native_width native_height logical_width logical_height <<< "$geometry" - [[ "$native_width" == <-> && "$native_height" == <-> && "$logical_width" == <-> && "$logical_height" == <-> && "$logical_width" -gt 0 && "$logical_height" -gt 0 ]] || die "secure dialog input could not measure display geometry" - (( native_width % logical_width == 0 && native_height % logical_height == 0 )) || die "secure dialog input measured a non-integral display scale" - scale_x=$((native_width / logical_width)) - scale_y=$((native_height / logical_height)) - (( scale_x == scale_y && scale_x > 0 )) || die "secure dialog input measured inconsistent display scales" - click_x=$((click_x * scale_x)) - click_y=$((click_y * scale_y)) - set +e "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$click_x" --y "$click_y" >/dev/null 2>&1 exit_code=$? @@ -1380,7 +1375,7 @@ scenario() { } desktop_bootstrap() { - local lease=$1 install_tools=$2 manifest macos repo output guest_output command + local lease=$1 install_tools=$2 manifest macos repo output command retry_command exit_code approval_output manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "desktop bootstrap requires a desktop-enabled lease" @@ -1389,7 +1384,27 @@ desktop_bootstrap() { output=".keypath-lab/scenario-output/bootstrap" command=(/bin/zsh Scripts/lab/desktop-bootstrap --output "$output") [[ "$install_tools" == "1" ]] && command+=(--install-tools) - run_command "$lease" "${command[@]}" + if [[ "$macos" == "15" ]]; then + # Clear a consent prompt left by an interrupted bootstrap before asking + # Peekaboo to capture again. + approve_peekaboo_capture "$lease" + fi + set +e + (run_command "$lease" "${command[@]}") + exit_code=$? + set -e + if ((exit_code != 0)) && [[ "$macos" == "15" ]]; then + approval_output=$(approve_peekaboo_capture "$lease") + print -r -- "$approval_output" + if print -r -- "$approval_output" | grep -Fq $'peekaboo_capture_approval\tpassed'; then + retry_command=(/bin/zsh Scripts/lab/desktop-bootstrap --output "$output") + run_command "$lease" "${retry_command[@]}" + else + return "$exit_code" + fi + elif ((exit_code != 0)); then + return "$exit_code" + fi set_field "$manifest" desktop_bootstrap_at "$(utc_now)" set_field "$manifest" desktop_bootstrap_status passed } diff --git a/Scripts/lab/scenario-matrix-runner b/Scripts/lab/scenario-matrix-runner index cd9e32eac..5ad0bc0f5 100755 --- a/Scripts/lab/scenario-matrix-runner +++ b/Scripts/lab/scenario-matrix-runner @@ -21,6 +21,7 @@ from typing import Any TERMINAL = {"passed", "failed", "blocked"} CHECKPOINT_PREFIXES = ("operator-", "physical-") LEASE_PATTERN = re.compile(r"^lease_id\t([A-Za-z0-9._-]+)$", re.MULTILINE) +LEASE_CANDIDATE_PATTERN = re.compile(r"\b(cbx_[A-Za-z0-9._-]+)\b") KNOWN_STEPS = { "run-lab-contract-tests", "run-failure-ownership-self-test", "verify-report-contract", "create-fresh-lease", "create-fresh-lease-with-fixture", "create-desktop-lease", "create-usb-lease", @@ -199,6 +200,25 @@ class MatrixRunner: handle.write(result.stdout + result.stderr) return result + def adopt_create_lease(self, job: dict[str, Any], state: dict[str, Any], + step: dict[str, str], output: str, *, required: bool) -> bool: + exact = LEASE_PATTERN.findall(output) + candidates = exact or LEASE_CANDIDATE_PATTERN.findall(output) + unique = list(dict.fromkeys(candidates)) + if not unique: + if required: + raise CampaignError(f"{job['id']}: create succeeded without a lease_id") + return False + if len(unique) != 1: + raise CampaignError(f"{job['id']}: create output named multiple lease candidates") + lease = unique[0] + status = subprocess.run(self.lab_command("status", lease), text=True, capture_output=True) + if status.returncode != 0: + raise CampaignError(f"{job['id']}: controller did not verify ownership of {lease}") + state["leaseId"] = lease + self.save() + return True + def create_command(self, job: dict[str, Any], step_name: str) -> list[str]: command = self.lab_command( "create", "--macos", str(job["macOS"]), "--lane", job["lane"], @@ -359,6 +379,11 @@ class MatrixRunner: message=f"Running {step['commandStep']}.") for command in self.command_for(job, state, step["commandStep"]): result = self.execute(job_id, step["id"], command) + if step["commandStep"].startswith("create-"): + self.adopt_create_lease( + job, state, step, result.stdout + result.stderr, + required=result.returncode == 0, + ) if result.returncode == 4 and "install_runtime\twaiting" in (result.stdout + result.stderr): state["waitingCheckpoint"] = checkpoint preserve_lease = True @@ -374,11 +399,6 @@ class MatrixRunner: message=state["blocker"], event=f"{job['title']} failed", tone="danger") self.emergency_artifacts(job, state) return "failed" - if step["commandStep"].startswith("create-"): - match = LEASE_PATTERN.search(result.stdout + result.stderr) - if not match: - raise CampaignError(f"{job_id}: create succeeded without a lease_id") - state["leaseId"] = match.group(1) step["status"] = "passed" self.update_job(state, status="running", step=step, step_status="passed", message=f"Verified {step['commandStep']}.") diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 28afda346..ecc8e84d1 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -213,7 +213,7 @@ chmod +x "$ROOT/bin/prlctl" echo test-private-key > "$TMP/id_ed25519" printf 'fixture-password-that-must-not-leak' > "$TMP/secure-input" grep -Fq 'exec 3<> \"\$fifo\"; KEYPATH_GUEST_PASSWORD=; IFS= read -r -t $credential_timeout -u 3 KEYPATH_GUEST_PASSWORD || [[ -n \"\$KEYPATH_GUEST_PASSWORD\" ]]' "$REMOTE" -grep -Fq '"$CRABBOX" desktop type --provider tart --target macos --id "$resource" < "$secret_file"' "$REMOTE" +grep -Fq 'fileHandleWithStandardInput.readDataToEndOfFile' "$REMOTE" if grep -Fq 'events.keystroke(secret)' "$REMOTE"; then echo "SecurityAgent secure input still uses synthetic Accessibility typing" >&2 exit 1 @@ -875,11 +875,11 @@ assert_contains "$secure_agent_result" $'secure_dialog_input\tpassed' grep -Fq 'field.focused' "$TMP/guest-ssh-calls" grep -Fq 'button.position' "$TMP/guest-ssh-calls" grep -Fq 'closed' "$TMP/guest-ssh-calls" -[[ $(grep -c 'crabbox desktop type --provider tart --target macos --id test-resource$' "$CALLS") -ge 2 ]] -grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 800 --y 600' "$CALLS" -grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 800 --y 440' "$CALLS" -cmp -s "$TMP/secure-input" "$TMP/crabbox-desktop-type-stdin" -if grep -q -- '--text' "$CALLS" || grep -q '/usr/bin/sudo\|pbcopy\|the\\ clipboard\|events.keystroke' "$TMP/guest-ssh-calls"; then +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 400 --y 300' "$CALLS" +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 400 --y 220' "$CALLS" +grep -Fq 'fileHandleWithStandardInput' "$TMP/guest-ssh-calls" +cmp -s "$TMP/secure-input" "$TMP/system-settings-secure-stdin" +if grep -q -- '--text' "$CALLS" || grep -q '/usr/bin/sudo\|pbcopy\|the\\ clipboard' "$TMP/guest-ssh-calls"; then echo "SecurityAgent secure input used an unsafe password path" >&2 exit 1 fi diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py index e8ca7a702..cb9d2c1fa 100755 --- a/Scripts/lab/tests/scenario-matrix-runner-tests.py +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -164,6 +164,31 @@ def test_recovers_created_lease_but_blocks_other_uncertain_mutations(self) -> No self.assertEqual(state["jobs"][0]["status"], "blocked") self.assertIn("refusing to repeat", state["jobs"][0]["blocker"]) + def test_failed_create_adopts_controller_lease_and_cleans_it_up(self) -> None: + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + create) + print 'provisioning provider=tart lease=cbx_failed_create slug=test' + exit 1 + ;; + status) print 'status\tmanaged-policy-failed' ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + + result = self.run_runner(self.plan(["create-fresh-lease"])) + self.assertEqual(result.returncode, 1, result.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["jobs"][0]["leaseId"], "cbx_failed_create") + self.assertEqual(state["jobs"][0]["status"], "failed") + self.assertEqual(state["jobs"][0]["cleanupStatus"], "passed") + lab_log = self.log.read_text() + self.assertIn("artifacts cbx_failed_create", lab_log) + self.assertIn("destroy cbx_failed_create", lab_log) + def test_rejects_changed_installer_on_resume(self) -> None: plan = self.plan(["create-fresh-lease"]) first = self.run_runner(plan) From 31654da2b76064f2cc8e06f2cd5f958b932e29bb Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 19:34:58 -0700 Subject: [PATCH 39/99] Read enveloped installer reports --- Scripts/lab/install-runtime | 7 ++++++- Scripts/lab/tests/install-runtime-tests.py | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Scripts/lab/install-runtime b/Scripts/lab/install-runtime index 30b20ecd3..8c62ec7f1 100755 --- a/Scripts/lab/install-runtime +++ b/Scripts/lab/install-runtime @@ -19,7 +19,12 @@ mkdir -p "$output" } json_field() { - /usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null || true + local value + value=$(/usr/bin/plutil -extract "data.$2" raw -o - "$1" 2>/dev/null || true) + if [[ -z "$value" ]]; then + value=$(/usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null || true) + fi + print -r -- "$value" } state_field() { diff --git a/Scripts/lab/tests/install-runtime-tests.py b/Scripts/lab/tests/install-runtime-tests.py index 61a055d09..10c461dbb 100644 --- a/Scripts/lab/tests/install-runtime-tests.py +++ b/Scripts/lab/tests/install-runtime-tests.py @@ -30,7 +30,7 @@ def setUp(self) -> None: if [[ "$1 $2" == "system inspect" ]]; then operational=false [[ -f {str(self.approved)!r} ]] && operational=true - print '{{"planID":"{PLAN_ID}","snapshotID":"{SNAPSHOT_ID}","isOperational":'$operational'}}' + print '{{"apiVersion":1,"data":{{"planID":"{PLAN_ID}","snapshotID":"{SNAPSHOT_ID}","isOperational":'$operational'}}}}' exit 0 fi if [[ "$1 $2" == "system install" ]]; then @@ -38,10 +38,10 @@ def setUp(self) -> None: [[ -f {str(self.install_count)!r} ]] && count=$(cat {str(self.install_count)!r}) print $((count + 1)) > {str(self.install_count)!r} if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "success" ]]; then - print '{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"completed","userActionRequired":false,"success":true}}' + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"completed","userActionRequired":false,"success":true}}}}' exit 0 fi - print '{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"awaitingApproval","userActionRequired":true,"success":false}}' + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"awaitingApproval","userActionRequired":true,"success":false}}}}' exit 1 fi exit 2 From 962ddae2f1037de3b2346eedeeb101ffa7a05f36 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 20:04:27 -0700 Subject: [PATCH 40/99] Harden managed runtime recovery and attestation --- Scripts/lab/mdm/probe-managed-capabilities | 21 +++++++++++--- .../tests/managed-capability-probe-tests.sh | 8 ++++- Scripts/lab/remote.sh | 23 +++++++++++++-- Scripts/lab/scenarios/installer-scenario | 3 +- Scripts/lab/tests/keypath-lab-tests.sh | 29 +++++++++++++++++++ 5 files changed, 75 insertions(+), 9 deletions(-) diff --git a/Scripts/lab/mdm/probe-managed-capabilities b/Scripts/lab/mdm/probe-managed-capabilities index b1c391b65..f8930d5f7 100755 --- a/Scripts/lab/mdm/probe-managed-capabilities +++ b/Scripts/lab/mdm/probe-managed-capabilities @@ -2,13 +2,19 @@ set -euo pipefail usage() { - echo "Usage: $0 --output DIR" >&2 + echo "Usage: $0 --output DIR [--managed-policy-manifest PATH]" >&2 exit 2 } output= -[[ ${1:-} == --output && -n ${2:-} && $# -eq 2 ]] || usage +managed_manifest= +[[ ${1:-} == --output && -n ${2:-} && ($# -eq 2 || $# -eq 4) ]] || usage output=$2 +if [[ $# -eq 4 ]]; then + [[ $3 == --managed-policy-manifest && -n $4 ]] || usage + managed_manifest=$4 + [[ -f $managed_manifest ]] || { echo "managed capability probe: managed-policy manifest missing" >&2; exit 3; } +fi mkdir -p "$output" cli=${KEYPATH_LAB_CLI:-/Applications/KeyPath.app/Contents/MacOS/keypath-cli} @@ -17,13 +23,14 @@ tcp_probe=${KEYPATH_LAB_TCP_PROBE:-$(cd "$(dirname "$0")/.." >/dev/null && pwd - systemextensionsctl=${KEYPATH_LAB_SYSTEMEXTENSIONSCTL:-/usr/bin/systemextensionsctl} sfltool=${KEYPATH_LAB_SFLTOOL:-/usr/bin/sfltool} launchctl=${KEYPATH_LAB_LAUNCHCTL:-/bin/launchctl} +verify_lane=${KEYPATH_LAB_VERIFY_LANE:-$(dirname "$0")/verify-lane} [[ -x $cli ]] || { echo "managed capability probe: installed CLI missing" >&2; exit 3; } set +e "$cli" service status --json > "$output/service-status.json" 2> "$output/service-status.stderr" status_exit=$? set -e -"$python" - "$output/service-status.json" <<'PY' +"$python" - "$output/service-status.json" "$([[ -n $managed_manifest ]] && echo true || echo false)" <<'PY' import json import sys @@ -37,7 +44,6 @@ required = [ "helperWorking", "keyPathAccessibility", "keyPathInputMonitoring", - "kanataAccessibility", "kanataInputMonitoring", "kanataBinaryInstalled", "karabinerDriverInstalled", @@ -46,6 +52,8 @@ required = [ "karabinerDaemonRunning", "vhidHealthy", ] +if sys.argv[2] != "true": + required.append("kanataAccessibility") failed = [key for key in required if status.get(key) is not True] if failed: raise SystemExit("managed capabilities missing: " + ", ".join(failed)) @@ -54,6 +62,11 @@ if not status.get("helperVersion"): PY [[ $status_exit -eq 0 ]] || { echo "managed capability probe: CLI status exited $status_exit" >&2; exit "$status_exit"; } +if [[ -n $managed_manifest ]]; then + "$verify_lane" managed-functional --manifest "$managed_manifest" \ + > "$output/managed-policy-verification.tsv" +fi + "$tcp_probe" > "$output/tcp-readiness.json" 2> "$output/tcp-readiness.stderr" "$systemextensionsctl" list > "$output/system-extensions.txt" 2>&1 grep -Fq 'org.pqrs.Karabiner-DriverKit-VirtualHIDDevice' "$output/system-extensions.txt" || { diff --git a/Scripts/lab/mdm/tests/managed-capability-probe-tests.sh b/Scripts/lab/mdm/tests/managed-capability-probe-tests.sh index 6af70cdae..1362b529c 100755 --- a/Scripts/lab/mdm/tests/managed-capability-probe-tests.sh +++ b/Scripts/lab/mdm/tests/managed-capability-probe-tests.sh @@ -26,7 +26,7 @@ run_probe() { KEYPATH_LAB_SYSTEMEXTENSIONSCTL="$TMP/systemextensionsctl" \ KEYPATH_LAB_SFLTOOL="$TMP/sfltool" \ KEYPATH_LAB_LAUNCHCTL="$TMP/launchctl" \ - "$MDM_DIR/probe-managed-capabilities" --output "$1" + "$MDM_DIR/probe-managed-capabilities" --output "$@" } run_probe "$TMP/pass" >/dev/null @@ -45,6 +45,12 @@ if run_probe "$TMP/missing-permission" >"$TMP/missing.stdout" 2>"$TMP/missing.st fi grep -Fq 'managed capabilities missing: kanataAccessibility' "$TMP/missing.stderr" +write_tool verify-lane '[[ $1 == managed-functional && $2 == --manifest && -f $3 ]] || exit 1; echo "managed_policy_verification passed"' +printf '{"schemaVersion":1}\n' > "$TMP/managed-manifest.json" +KEYPATH_LAB_VERIFY_LANE="$TMP/verify-lane" \ + run_probe "$TMP/managed-attestation" --managed-policy-manifest "$TMP/managed-manifest.json" >/dev/null +grep -Fq 'managed_policy_verification passed' "$TMP/managed-attestation/managed-policy-verification.tsv" + write_tool keypath-cli 'cat </dev/null grep -q 'Scripts/lab/install-runtime unmanaged-ui' "$CALLS" grep -q $'install_runtime_status\tpassed' "$manifest" +awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="mutation-started"} {print}' "$manifest" > "$manifest.tmp" +mv "$manifest.tmp" "$manifest" +recovered_runtime=$(run_remote install-runtime cbx_test15) +assert_contains "$recovered_runtime" $'install_runtime_recovery\tpreflight-only' +grep -q $'install_runtime_status\tpassed' "$manifest" +awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="mutation-started"} {print}' "$manifest" > "$manifest.tmp" +mv "$manifest.tmp" "$manifest" +set +e +uncertain_runtime=$(KEYPATH_LAB_TEST_MUTATION_EVIDENCE=1 run_remote install-runtime cbx_test15 2>&1) +uncertain_runtime_exit=$? +set -e +[[ $uncertain_runtime_exit -ne 0 ]] || { echo "uncertain prior runtime mutation unexpectedly retried" >&2; exit 1; } +assert_contains "$uncertain_runtime" 'install-runtime has an uncertain prior mutation' +grep -q $'install_runtime_status\tmutation-started' "$manifest" +awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="staged"} {print}' "$manifest" > "$manifest.tmp" +mv "$manifest.tmp" "$manifest" +set +e +waiting_runtime=$(KEYPATH_LAB_TEST_INSTALL_RUNTIME_EXIT=4 run_remote install-runtime cbx_test15 2>&1) +waiting_runtime_exit=$? +set -e +[[ $waiting_runtime_exit -eq 4 ]] || { echo "approval wait returned $waiting_runtime_exit instead of 4" >&2; exit 1; } +grep -q $'install_runtime_status\tawaiting-approval' "$manifest" +awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="passed"} {print}' "$manifest" > "$manifest.tmp" +mv "$manifest.tmp" "$manifest" run_remote install-fixture cbx_test15 >/dev/null grep -q 'install-fixture 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-fixture.log" tart_reboot=$(KEYPATH_LAB_TART_REBOOT_SETTLE_SECONDS=0 KEYPATH_LAB_TART_REBOOT_POLL_SECONDS=0 run_remote reboot-guest cbx_test15) From 14316328b9a52d8499c82a0719340e56459aeb04 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 20:09:56 -0700 Subject: [PATCH 41/99] Harden clean install bootstrap and protected clicks --- Scripts/lab/install-runtime | 34 ++++++++++++++++- Scripts/lab/remote.sh | 32 +++++++++++++--- Scripts/lab/tests/install-runtime-tests.py | 43 +++++++++++++++++++++- Scripts/lab/tests/keypath-lab-tests.sh | 12 ++++++ 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/Scripts/lab/install-runtime b/Scripts/lab/install-runtime index 8c62ec7f1..6e91d5288 100755 --- a/Scripts/lab/install-runtime +++ b/Scripts/lab/install-runtime @@ -11,6 +11,10 @@ output="$PWD/.keypath-lab/scenario-output/install-runtime" state="$output/state.tsv" cli=${KEYPATH_INSTALL_RUNTIME_CLI:-/Applications/KeyPath.app/Contents/MacOS/keypath-cli} assert_runtime=${KEYPATH_INSTALL_RUNTIME_ASSERT:-Scripts/lab/assert-runtime-state} +app_open=${KEYPATH_INSTALL_RUNTIME_OPEN:-/usr/bin/open} +app_quit=${KEYPATH_INSTALL_RUNTIME_QUIT:-/usr/bin/osascript} +config_path=${KEYPATH_INSTALL_RUNTIME_CONFIG:-$HOME/.config/keypath/keypath.kbd} +bootstrap_timeout=${KEYPATH_INSTALL_RUNTIME_BOOTSTRAP_TIMEOUT:-60} mkdir -p "$output" [[ -x "$cli" ]] || { @@ -36,6 +40,29 @@ valid_id() { [[ "$1" =~ '^[A-Fa-f0-9-]{36}$' ]] } +ensure_default_config() { + [[ -s "$config_path" ]] && return 0 + + "$app_open" -a KeyPath + local waited=0 + while (( waited < bootstrap_timeout )); do + [[ -s "$config_path" ]] && break + sleep 1 + (( waited += 1 )) + done + + if [[ "$app_quit" == "/usr/bin/osascript" ]]; then + "$app_quit" -e 'tell application "KeyPath" to quit' >/dev/null 2>&1 || true + else + "$app_quit" >/dev/null 2>&1 || true + fi + + [[ -s "$config_path" ]] || { + print -u2 "KeyPath first launch did not create its default user config" + return 1 + } +} + write_state() { local runtime_status=$1 local previous_preflight_plan_id previous_preflight_snapshot_id previous_run_id previous_plan_id @@ -128,6 +155,11 @@ valid_id "$preflight_snapshot_id" || { exit 3 } +ensure_default_config || { + write_state failed + exit 1 +} + set +e "$cli" system install --json --quiet --timeout 600 \ > "$output/install-report.json" 2> "$output/install-report.stderr" @@ -153,7 +185,7 @@ done exit 3 } -if [[ "$completion_state" == "awaitingApproval" || "$user_action_required" == "true" ]]; then +if [[ "$completion_state" == "awaiting-approval" || "$completion_state" == "awaitingApproval" ]]; then write_state awaiting-approval print "install_runtime\twaiting" print "user_action_required\tComplete the KeyPath approvals visible in the disposable guest, then resume this checkpoint." diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 327004e3f..a6d04c2e5 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1189,7 +1189,8 @@ secure_dialog_input() { protected_click() { local lease=$1 app=$2 expected_before=$3 expected_after=$4 coordinate_space=$5 x=$6 y=$7 count=${8:-1} - local manifest macos resource key ip before after guest_command geometry_command geometry + local manifest macos resource key ip before after before_frontmost after_frontmost guest_command geometry_command geometry + local occlusion occlusion_command local native_width native_height logical_width logical_height scale_x scale_y manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) @@ -1203,6 +1204,7 @@ protected_click() { if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then before=${KEYPATH_LAB_TEST_WINDOW_BEFORE:-$expected_before} + before_frontmost=${KEYPATH_LAB_TEST_FRONTMOST_BEFORE:-true} else key="$HOME/Library/Application Support/crabbox/testboxes/$lease/id_ed25519" [[ -f "$key" && ! -L "$key" && -O "$key" ]] || die "owned CrabBox SSH key not found for lease" @@ -1210,9 +1212,13 @@ protected_click() { export PATH="$LAB_ROOT/CompatTools/bin:$LAB_ROOT/SharedTools/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" ip=$($TART ip "$resource") [[ "$ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Tart returned an invalid guest address" - guest_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "";\n return matches[0].windows[0].name() || "__UNTITLED__";\n}\' -- '$(printf %q "$app") - before=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")") + guest_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "false\\t";\n var name = matches[0].windows[0].name() || "__UNTITLED__";\n return String(matches[0].frontmost()) + "\\t" + name;\n}\' -- '$(printf %q "$app") + IFS=$'\t' read -r before_frontmost before <<< "$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")")" fi + [[ "$before_frontmost" == "true" ]] || { + record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --x "$x" --y "$y" + die "protected click precondition failed: '$app' is not frontmost" + } [[ "$expected_before" == "__ANY__" && -n "$before" ]] || [[ "$before" == "$expected_before" ]] || { record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --x "$x" --y "$y" die "protected click precondition failed: expected window '$expected_before', found '${before:-unknown}'" @@ -1235,6 +1241,17 @@ protected_click() { y=$((y * scale_y)) fi + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + occlusion=${KEYPATH_LAB_TEST_OCCLUSION:-} + else + occlusion_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("AppKit");\nfunction run(argv) {\n var x = Number(argv[0]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var y = Number(argv[1]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var events = Application("System Events");\n var processNames = ["NotificationCenter", "UserNotificationCenter"];\n for (var p = 0; p < processNames.length; p++) {\n var matches = events.processes.whose({name: processNames[p]})();\n if (matches.length === 0) continue;\n var windows = matches[0].windows();\n for (var w = 0; w < windows.length; w++) {\n try {\n var position = windows[w].position();\n var size = windows[w].size();\n if (x >= position[0] && x <= position[0] + size[0] && y >= position[1] && y <= position[1] + size[1]) {\n return processNames[p] + ":" + Math.round(position[0]) + "," + Math.round(position[1]) + "," + Math.round(size[0]) + "," + Math.round(size[1]);\n }\n } catch (_) {}\n }\n }\n return "";\n}\' -- '$(printf %q "$x")' '$(printf %q "$y") + occlusion=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$occlusion_command")") || die "protected click could not verify notification occlusion" + fi + [[ -z "$occlusion" ]] || { + record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --x "$x" --y "$y" + die "protected click target is occluded by a notification ($occlusion)" + } + if [[ "$count" == "2" ]]; then "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$x" --y "$y" --count 2 >/dev/null else @@ -1243,15 +1260,20 @@ protected_click() { sleep "${KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS:-1}" if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then after=${KEYPATH_LAB_TEST_WINDOW_AFTER:-$expected_after} + after_frontmost=${KEYPATH_LAB_TEST_FRONTMOST_AFTER:-true} else - after=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")") + IFS=$'\t' read -r after_frontmost after <<< "$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")")" fi if [[ "$after" != "$expected_after" && "$expected_before" == "__ANY__" ]]; then sleep "${KEYPATH_LAB_INITIAL_SETTINGS_RETRY_SECONDS:-5}" "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$x" --y "$y" >/dev/null sleep "${KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS:-1}" - after=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")") + IFS=$'\t' read -r after_frontmost after <<< "$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")")" fi + [[ "$after_frontmost" == "true" ]] || { + record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --after-window "$expected_after" --x "$x" --y "$y" + die "protected click postcondition failed: '$app' is no longer frontmost" + } [[ "$after" == "$expected_after" ]] || { record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --after-window "$expected_after" --x "$x" --y "$y" die "protected click postcondition failed: expected window '$expected_after', found '${after:-unknown}'" diff --git a/Scripts/lab/tests/install-runtime-tests.py b/Scripts/lab/tests/install-runtime-tests.py index 10c461dbb..fc5977cb7 100644 --- a/Scripts/lab/tests/install-runtime-tests.py +++ b/Scripts/lab/tests/install-runtime-tests.py @@ -24,6 +24,21 @@ def setUp(self) -> None: self.directory = pathlib.Path(self.temporary.name) self.approved = self.directory / "approved" self.install_count = self.directory / "install-count" + self.open_count = self.directory / "open-count" + self.config = self.directory / "config/keypath.kbd" + self.app_open = self.directory / "open-keypath" + self.app_open.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + mkdir -p {str(self.config.parent)!r} + print '(defcfg)' > {str(self.config)!r} + count=0 + [[ -f {str(self.open_count)!r} ]] && count=$(cat {str(self.open_count)!r}) + print $((count + 1)) > {str(self.open_count)!r} + """)) + self.app_open.chmod(0o755) + self.app_quit = self.directory / "quit-keypath" + self.app_quit.write_text("#!/bin/zsh\nexit 0\n") + self.app_quit.chmod(0o755) self.cli = self.directory / "keypath-cli" self.cli.write_text(textwrap.dedent(f"""\ #!/bin/zsh @@ -34,6 +49,7 @@ def setUp(self) -> None: exit 0 fi if [[ "$1 $2" == "system install" ]]; then + [[ -s {str(self.config)!r} ]] || {{ print -u2 'config missing before install'; exit 9 }} count=0 [[ -f {str(self.install_count)!r} ]] && count=$(cat {str(self.install_count)!r}) print $((count + 1)) > {str(self.install_count)!r} @@ -41,7 +57,11 @@ def setUp(self) -> None: print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"completed","userActionRequired":false,"success":true}}}}' exit 0 fi - print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"awaitingApproval","userActionRequired":true,"success":false}}}}' + if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "verification-failed" ]]; then + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"verification-failed","userActionRequired":true,"success":false}}}}' + exit 1 + fi + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"awaiting-approval","userActionRequired":true,"success":false}}}}' exit 1 fi exit 2 @@ -63,6 +83,10 @@ def invoke(self, *, mode: str = "waiting") -> subprocess.CompletedProcess[str]: **os.environ, "KEYPATH_INSTALL_RUNTIME_CLI": str(self.cli), "KEYPATH_INSTALL_RUNTIME_ASSERT": str(self.assert_runtime), + "KEYPATH_INSTALL_RUNTIME_OPEN": str(self.app_open), + "KEYPATH_INSTALL_RUNTIME_QUIT": str(self.app_quit), + "KEYPATH_INSTALL_RUNTIME_CONFIG": str(self.config), + "KEYPATH_INSTALL_RUNTIME_BOOTSTRAP_TIMEOUT": "2", "KEYPATH_TEST_INSTALL_MODE": mode, } return subprocess.run( @@ -75,11 +99,13 @@ def test_waiting_install_is_not_repeated_after_operator_approval(self) -> None: self.assertEqual(first.returncode, 4, first.stderr) self.assertIn("install_runtime\twaiting", first.stdout) self.assertEqual(self.install_count.read_text().strip(), "1") + self.assertEqual(self.open_count.read_text().strip(), "1") self.approved.touch() second = self.invoke() self.assertEqual(second.returncode, 0, second.stderr) self.assertEqual(self.install_count.read_text().strip(), "1") + self.assertEqual(self.open_count.read_text().strip(), "1") evidence = json.loads((self.directory / ".keypath-lab/scenario-output/install-runtime/assert-state.json").read_text()) self.assertTrue(evidence["runtimeReady"]["agreement"]) self.assertEqual(evidence["plan"]["runID"], RUN_ID) @@ -91,6 +117,21 @@ def test_claimed_success_without_independent_ready_state_is_a_failure(self) -> N evidence = json.loads((self.directory / ".keypath-lab/scenario-output/install-runtime/assert-state.json").read_text()) self.assertFalse(evidence["runtimeReady"]["agreement"]) + def test_first_launch_creates_default_config_before_install(self) -> None: + result = self.invoke() + self.assertEqual(result.returncode, 4, result.stderr) + self.assertTrue(self.config.is_file()) + self.assertEqual(self.open_count.read_text().strip(), "1") + self.assertEqual(self.install_count.read_text().strip(), "1") + + def test_verification_failure_with_user_action_is_not_approval_wait(self) -> None: + result = self.invoke(mode="verification-failed") + self.assertEqual(result.returncode, 1) + self.assertIn("KeyPath's installer reported failure", result.stderr) + self.assertNotIn("install_runtime\twaiting", result.stdout) + state = (self.directory / ".keypath-lab/scenario-output/install-runtime/state.tsv").read_text() + self.assertIn("status\tfailed", state) + if __name__ == "__main__": unittest.main() diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index ad9b116a4..9f7fa4c21 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -943,6 +943,18 @@ protected_wrong_page_exit=$? set -e [[ $protected_wrong_page_exit -ne 0 ]] || { echo "protected click accepted the wrong destination page" >&2; exit 1; } assert_contains "$protected_wrong_page" "protected click postcondition failed" +set +e +protected_occluded=$(KEYPATH_LAB_TEST_OCCLUSION='NotificationCenter:700,0,324,140' KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote protected-click cbx_desktop15 'System Settings' Accessibility Accessibility native 804 120 2>&1) +protected_occluded_exit=$? +set -e +[[ $protected_occluded_exit -ne 0 ]] || { echo "protected click accepted a notification-occluded target" >&2; exit 1; } +assert_contains "$protected_occluded" "protected click target is occluded by a notification" +set +e +protected_background=$(KEYPATH_LAB_TEST_FRONTMOST_BEFORE=false KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote protected-click cbx_desktop15 'System Settings' Accessibility Accessibility native 402 247 2>&1) +protected_background_exit=$? +set -e +[[ $protected_background_exit -ne 0 ]] || { echo "protected click accepted a background application" >&2; exit 1; } +assert_contains "$protected_background" "is not frontmost" protected_ax_result=$(KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote protected-click cbx_desktop15 'System Settings' Accessibility Accessibility ax 402 247) assert_contains "$protected_ax_result" $'display_scale\t2' grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 494' "$CALLS" From c6360fe349eb73234637b3689cce6556eeb2d699 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 20:13:00 -0700 Subject: [PATCH 42/99] Bind lab archives to the harness commit --- Scripts/lab/keypath-lab | 10 +++++++++- Scripts/lab/tests/keypath-lab-tests.sh | 6 +++--- docs/testing/remote-installer-lab.md | 11 +++++++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index b73deff40..eed052ee8 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -115,11 +115,13 @@ case "$command" in [[ -z $fixture || -f $fixture ]] || die "fixture not found: $fixture" resolved=$(git -C "$REPO_ROOT" rev-parse --verify "$commit^{commit}") [[ $resolved == "$commit" ]] || die "commit does not resolve exactly: $commit" + harness_commit=$(git -C "$REPO_ROOT" rev-parse --verify HEAD^{commit}) + [[ ${KEYPATH_LAB_ALLOW_DIRTY_HARNESS:-0} == 1 || -z $(git -C "$REPO_ROOT" status --short --untracked-files=no) ]] || die "committed lab harness is required before creating an archive" installer_sha=$(shasum -a 256 "$installer" | awk '{print $1}') installer_name=$(basename "$installer") [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" - archive_key="${commit}-${installer_sha}" + archive_key="${commit}-${installer_sha}-h${harness_commit}" fixture_name= fixture_sha= if [[ -n $fixture ]]; then @@ -136,6 +138,11 @@ case "$command" in trap 'rm -rf "$temp_dir"' EXIT mkdir -p "$temp_dir/repo/.keypath-lab/installer" git -C "$REPO_ROOT" archive "$commit" | tar -x -C "$temp_dir/repo" + # Product identity remains pinned to `commit`, while lab-only execution + # comes from an independently recorded harness commit. Including both + # in the archive key makes the payload immutable without forcing a new + # signed product build for every harness correction. + git -C "$REPO_ROOT" archive "$harness_commit" Scripts/lab | tar -x -C "$temp_dir/repo" cp "$installer" "$temp_dir/repo/.keypath-lab/installer/$installer_name" if [[ -n $fixture ]]; then mkdir -p "$temp_dir/repo/.keypath-lab/fixtures" @@ -151,6 +158,7 @@ case "$command" in fi cat > "$temp_dir/repo/.keypath-lab/source.tsv" < "$TMP/KeyPath-beta3.zip" controller_commit=$(git -C "$LAB_DIR/../.." rev-parse HEAD) controller_installer_sha=$(shasum -a 256 "$TMP/KeyPath.zip" | awk '{print $1}') controller_fixture_sha=$(shasum -a 256 "$TMP/KeyPath-beta3.zip" | awk '{print $1}') -controller_archive_key="${controller_commit}-${controller_installer_sha}-${controller_fixture_sha}" -controller_create=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host "$LAB_DIR/keypath-lab" create \ +controller_archive_key="${controller_commit}-${controller_installer_sha}-h${controller_commit}-${controller_fixture_sha}" +controller_create=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host KEYPATH_LAB_ALLOW_DIRTY_HARNESS=1 "$LAB_DIR/keypath-lab" create \ --macos 15 \ --lane unmanaged-ui \ --commit "$controller_commit" \ @@ -1029,7 +1029,7 @@ controller_create=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host --fixture "$TMP/KeyPath-beta3.zip") assert_contains "$controller_create" $'lease_id\tcbx_controller_test' grep -Fq "$controller_archive_key" "$TMP/ssh-args" || { - echo "controller archive key did not include fixture checksum" >&2 + echo "controller archive key did not include harness commit and fixture checksum" >&2 exit 1 } controller_artifacts=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host "$LAB_DIR/keypath-lab" artifacts \ diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 8ee1b0c4f..2136013ec 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -73,10 +73,13 @@ that host resources and provider inventory remain isolated. ## Lifecycle -Creation never syncs the current worktree. It exports the explicit commit with -`git archive`, adds the installer and source metadata, uploads that immutable -payload, and atomically initializes a clean synthetic archive on the external -lab volume. Each lease receives its own clean checkout cloned from that archive. +Creation never syncs the current worktree. It exports the explicit product +commit plus the committed `Scripts/lab` tree from a separately recorded harness +commit with `git archive`, adds the installer and source metadata, uploads that +immutable payload, and atomically initializes a clean synthetic archive on the +external lab volume. The archive key includes both commits, so a harness change +cannot silently reuse or mutate an older product-and-installer cache. Each lease +receives its own clean checkout cloned from that archive. CrabBox's claim therefore binds one disposable lease to one stable repository root, and every run, download, and destructive stop executes from that exact claimed checkout. The interface refuses to sync if Git reports tracked or From 157007707cede9b1ee6fc20f92a97009c3afb023 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 20:19:09 -0700 Subject: [PATCH 43/99] Accept harness-qualified archive keys --- Scripts/lab/remote.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index a6d04c2e5..22b5649b7 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -578,7 +578,7 @@ preflight() { prepare_upload() { valid_id "$1" - [[ "$1" =~ '^[0-9a-f]{40}-[0-9a-f]{64}(-[0-9a-f]{64})?$' ]] || die "invalid archive key" + [[ "$1" =~ '^[0-9a-f]{40}-[0-9a-f]{64}(-h[0-9a-f]{40})?(-[0-9a-f]{64})?$' ]] || die "invalid archive key" mktemp "/tmp/keypath-lab.XXXXXXXX" } From 4f56dae0ff5141e13ba86e523d00573ebf77956b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 20:31:24 -0700 Subject: [PATCH 44/99] Derive harness archives from verified product caches --- Scripts/lab/keypath-lab | 23 +++++++- Scripts/lab/remote.sh | 77 ++++++++++++++++++++++++-- Scripts/lab/tests/keypath-lab-tests.sh | 21 +++++++ docs/testing/remote-installer-lab.md | 7 +++ 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index eed052ee8..898cc699f 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -121,13 +121,15 @@ case "$command" in installer_sha=$(shasum -a 256 "$installer" | awk '{print $1}') installer_name=$(basename "$installer") [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" - archive_key="${commit}-${installer_sha}-h${harness_commit}" + base_archive_key="${commit}-${installer_sha}" + archive_key="${base_archive_key}-h${harness_commit}" fixture_name= fixture_sha= if [[ -n $fixture ]]; then fixture_name=$(basename "$fixture") [[ $fixture_name =~ ^[A-Za-z0-9._-]+$ ]] || die "fixture filename must contain only letters, numbers, dots, underscores, and dashes" fixture_sha=$(shasum -a 256 "$fixture" | awk '{print $1}') + base_archive_key="${base_archive_key}-${fixture_sha}" archive_key="${archive_key}-${fixture_sha}" fi if remote archive-status "$archive_key" "$commit" "$installer_sha" "$installer_name"; then @@ -136,6 +138,25 @@ case "$command" in fi temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/keypath-lab.XXXXXX") trap 'rm -rf "$temp_dir"' EXIT + if remote archive-status "$base_archive_key" "$commit" "$installer_sha" "$installer_name"; then + mkdir -p "$temp_dir/overlay" + git -C "$REPO_ROOT" archive "$harness_commit" Scripts/lab | tar -x -C "$temp_dir/overlay" + if [[ $lane == "managed-functional" ]]; then + mkdir -p "$temp_dir/installer-extract" + /usr/bin/ditto -x -k "$installer" "$temp_dir/installer-extract" + [[ -d $temp_dir/installer-extract/KeyPath.app ]] || die "managed installer must contain KeyPath.app at its root" + "$SCRIPT_DIR/mdm/generate-keypath-profiles" \ + --app "$temp_dir/installer-extract/KeyPath.app" \ + --output "$temp_dir/overlay/.keypath-lab/managed-policy" >/dev/null + fi + tar -czf "$temp_dir/overlay.tgz" -C "$temp_dir/overlay" . + upload_ticket=$(remote prepare-upload "$archive_key") + [[ $upload_ticket == /tmp/keypath-lab.* ]] || die "host returned an invalid upload ticket" + scp -q "$temp_dir/overlay.tgz" "$host:$upload_ticket" + remote derive-archive "$upload_ticket" "$base_archive_key" "$archive_key" "$commit" "$installer_sha" "$installer_name" "$harness_commit" + remote create "$macos" "$lane" "$archive_key" "$commit" "$installer_sha" "$installer_name" "$ttl" "$desktop" "$tart_usb_passthrough" + exit $? + fi mkdir -p "$temp_dir/repo/.keypath-lab/installer" git -C "$REPO_ROOT" archive "$commit" | tar -x -C "$temp_dir/repo" # Product identity remains pinned to `commit`, while lab-only execution diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 22b5649b7..c2453a280 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -44,6 +44,11 @@ valid_id() { [[ "$1" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid identifier: $1" } +valid_archive_key() { + valid_id "$1" + [[ "$1" =~ '^[0-9a-f]{40}-[0-9a-f]{64}(-h[0-9a-f]{40})?(-[0-9a-f]{64})?$' ]] || die "invalid archive key" +} + launcher_for() { case "$1" in 15) print -r -- "$LAUNCHER_15" ;; @@ -577,14 +582,13 @@ preflight() { } prepare_upload() { - valid_id "$1" - [[ "$1" =~ '^[0-9a-f]{40}-[0-9a-f]{64}(-h[0-9a-f]{40})?(-[0-9a-f]{64})?$' ]] || die "invalid archive key" + valid_archive_key "$1" mktemp "/tmp/keypath-lab.XXXXXXXX" } archive_status() { local key=$1 commit=$2 installer_sha=$3 installer_name=$4 destination ready - valid_id "$key" + valid_archive_key "$key" [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" @@ -602,7 +606,7 @@ install_archive() { local source=$1 key=$2 commit=$3 installer_sha=$4 installer_name=$5 [[ "$source" =~ '^/tmp/keypath-lab\.[A-Za-z0-9]+$' ]] || die "invalid upload ticket" [[ -f "$source" && ! -L "$source" && -O "$source" ]] || die "upload ticket is not an owned regular file" - valid_id "$key" + valid_archive_key "$key" [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" @@ -670,6 +674,68 @@ install_archive() { print "archive\tcreated\t$key" } +derive_archive() { + local overlay=$1 source_key=$2 key=$3 commit=$4 installer_sha=$5 installer_name=$6 harness_commit=$7 + [[ "$overlay" =~ '^/tmp/keypath-lab\.[A-Za-z0-9]+$' ]] || die "invalid upload ticket" + [[ -f "$overlay" && ! -L "$overlay" && -O "$overlay" ]] || die "upload ticket is not an owned regular file" + valid_archive_key "$source_key" + valid_archive_key "$key" + [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" + [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" + [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" + [[ "$harness_commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid harness commit" + ensure_roots + local source="$ARCHIVES/$source_key" destination="$ARCHIVES/$key" + local staging="$ARCHIVES/.staging-$key-$$" lock="$ARCHIVES/.lock-$key" attempt actual_sha + [[ -f "$source/ready.tsv" && -d "$source/repo/.git" ]] || die "source archive is unavailable" + [[ "$(field "$source/ready.tsv" owner)" == "$OWNER" ]] || die "source archive ownership mismatch" + [[ "$(field "$source/ready.tsv" keypath_commit)" == "$commit" ]] || die "source archive commit mismatch" + [[ "$(field "$source/ready.tsv" installer_sha256)" == "$installer_sha" ]] || die "source archive installer mismatch" + if [[ -f "$destination/ready.tsv" ]]; then + rm -f "$overlay" + print "archive\treused\t$key" + return + fi + if ! mkdir "$lock" 2>/dev/null; then + rm -f "$overlay" + for attempt in {1..100}; do + [[ -f "$destination/ready.tsv" ]] && { print "archive\treused\t$key"; return; } + sleep 0.1 + done + die "timed out waiting for concurrent derived archive publish: $key" + fi + git clone -q --no-hardlinks "$source/repo" "$staging/repo" + rm -rf "$staging/repo/Scripts/lab" + tar -xzf "$overlay" -C "$staging/repo" + rm -f "$overlay" + actual_sha=$(shasum -a 256 "$staging/repo/.keypath-lab/installer/$installer_name" | awk '{print $1}') + [[ "$actual_sha" == "$installer_sha" ]] || die "derived archive installer checksum mismatch" + git -C "$staging/repo" config user.name "KeyPath Lab" + git -C "$staging/repo" config user.email "keypath-lab@localhost" + git -C "$staging/repo" add -A + git -C "$staging/repo" add -f .keypath-lab + GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z \ + git -C "$staging/repo" commit -q -m "KeyPath lab harness $harness_commit" + [[ -z "$(git -C "$staging/repo" status --porcelain)" ]] || die "derived archive checkout is dirty" + { + print "owner\t$OWNER" + print "keypath_commit\t$commit" + print "harness_commit\t$harness_commit" + print "installer_sha256\t$installer_sha" + print "installer_name\t$installer_name" + print "derived_from\t$source_key" + print "created_at\t$(utc_now)" + } > "$staging/ready.tsv" + if [[ -e "$destination" ]]; then + rm -rf "$staging" + rmdir "$lock" + die "derived archive destination exists without a ready marker: $key" + fi + mv "$staging" "$destination" + rmdir "$lock" + print "archive\tderived\t$key" +} + write_provisional_lease_manifest() { local lease=$1 slug=$2 macos=$3 lane=$4 provider=$5 archive_key=$6 commit=$7 installer_sha=$8 installer_name=$9 repo=${10} created=${11} expires=${12} desktop=${13} local manifest identity_scope @@ -729,7 +795,7 @@ create_lease() { if [[ "${KEYPATH_LAB_TESTING:-0}" != "1" && "$macos" == "15" && "$lane" == "managed-functional" ]]; then desktop=1 fi - valid_id "$archive_key" + valid_archive_key "$archive_key" archive="$ARCHIVES/$archive_key" [[ -f "$archive/ready.tsv" && -d "$archive/repo/.git" ]] || die "prepared archive not found: $archive_key" ttl_seconds=$(duration_seconds "$ttl") @@ -2101,6 +2167,7 @@ case "$action" in archive-status) [[ $# -eq 4 ]] || die "archive-status requires key, commit, checksum, and name"; archive_status "$@" ;; prepare-upload) [[ $# -eq 1 ]] || die "prepare-upload requires archive key"; prepare_upload "$1" ;; install-archive) [[ $# -eq 5 ]] || die "install-archive requires ticket, key, commit, checksum, and name"; install_archive "$@" ;; + derive-archive) [[ $# -eq 7 ]] || die "derive-archive requires ticket, source key, destination key, commit, checksum, name, and harness commit"; derive_archive "$@" ;; create) [[ $# -eq 8 || $# -eq 9 ]] || die "create requires macOS, test lane, archive, commit, checksum, name, ttl, desktop, and optional Tart USB passthrough"; create_lease "$@" ;; install-app) [[ $# -eq 1 ]] || die "install-app requires lease"; install_app "$1" ;; install-runtime) [[ $# -eq 1 ]] || die "install-runtime requires lease"; install_runtime "$1" ;; diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 00209a3b0..cb4d0f476 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -347,6 +347,27 @@ if find "$ROOT/KeyPathInstallerLab/archives" -maxdepth 1 -name ".staging-$publis exit 1 fi +harness_commit=$(printf 'd%.0s' {1..40}) +derived_key="$publish_key-h$harness_commit" +mkdir -p "$TMP/overlay/Scripts/lab" "$TMP/overlay/.keypath-lab/managed-policy" +echo hardened > "$TMP/overlay/Scripts/lab/harness-version" +echo managed > "$TMP/overlay/.keypath-lab/managed-policy/manifest.json" +for pass in 1 2; do + ticket=$(run_remote prepare-upload "$derived_key") + tar -czf "$ticket" -C "$TMP/overlay" . + derived=$(run_remote derive-archive "$ticket" "$publish_key" "$derived_key" "$publish_commit" "$publish_checksum" installer.zip "$harness_commit") + if [[ $pass == 1 ]]; then assert_contains "$derived" $'archive\tderived'; else assert_contains "$derived" $'archive\treused'; fi +done +derived_root="$ROOT/KeyPathInstallerLab/archives/$derived_key" +grep -q '^hardened$' "$derived_root/repo/Scripts/lab/harness-version" +grep -q $'^harness_commit\t'"$harness_commit" "$derived_root/ready.tsv" +grep -q $'^derived_from\t'"$publish_key" "$derived_root/ready.tsv" +[[ $(shasum -a 256 "$derived_root/repo/.keypath-lab/installer/installer.zip" | awk '{print $1}') == "$publish_checksum" ]] +if find "$ROOT/KeyPathInstallerLab/archives" -maxdepth 1 -name ".staging-$derived_key-*" | grep -q .; then + echo "derived archive publish left a staging directory" >&2 + exit 1 +fi + archive_key="$(printf 'a%.0s' {1..40})-$(printf 'b%.0s' {1..64})" repo="$ROOT/KeyPathInstallerLab/archives/$archive_key/repo" mkdir -p "$repo/.keypath-lab/installer" "$repo/.keypath-lab/fixtures" "$repo/Scripts/lab/scenarios" "$repo/Scripts/lab/mdm" diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 2136013ec..4628f3ecc 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -80,6 +80,13 @@ immutable payload, and atomically initializes a clean synthetic archive on the external lab volume. The archive key includes both commits, so a harness change cannot silently reuse or mutate an older product-and-installer cache. Each lease receives its own clean checkout cloned from that archive. + +When the exact product-and-installer archive already exists, a harness-only +change is derived on the mini: the controller uploads only the committed +`Scripts/lab` overlay (and regenerated exact managed policy when needed), while +the mini clones the verified product cache without hard links and publishes a +new harness-qualified archive atomically. This avoids retransferring the signed +product payload while keeping every resulting archive immutable and auditable. CrabBox's claim therefore binds one disposable lease to one stable repository root, and every run, download, and destructive stop executes from that exact claimed checkout. The interface refuses to sync if Git reports tracked or From 6726d6f088505e321c9568981ec51b7cf901e62c Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 20:38:16 -0700 Subject: [PATCH 45/99] Handle sequential capture approval prompts --- Scripts/lab/remote.sh | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index c2453a280..375ef5325 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1480,7 +1480,7 @@ scenario() { } desktop_bootstrap() { - local lease=$1 install_tools=$2 manifest macos repo output command retry_command exit_code approval_output + local lease=$1 install_tools=$2 manifest macos repo output command exit_code approval_output attempt manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "desktop bootstrap requires a desktop-enabled lease" @@ -1494,22 +1494,19 @@ desktop_bootstrap() { # Peekaboo to capture again. approve_peekaboo_capture "$lease" fi - set +e - (run_command "$lease" "${command[@]}") - exit_code=$? - set -e - if ((exit_code != 0)) && [[ "$macos" == "15" ]]; then + exit_code=1 + for attempt in {1..3}; do + set +e + (run_command "$lease" "${command[@]}") + exit_code=$? + set -e + ((exit_code == 0)) && break + [[ "$macos" == "15" ]] || return "$exit_code" approval_output=$(approve_peekaboo_capture "$lease") print -r -- "$approval_output" - if print -r -- "$approval_output" | grep -Fq $'peekaboo_capture_approval\tpassed'; then - retry_command=(/bin/zsh Scripts/lab/desktop-bootstrap --output "$output") - run_command "$lease" "${retry_command[@]}" - else - return "$exit_code" - fi - elif ((exit_code != 0)); then - return "$exit_code" - fi + print -r -- "$approval_output" | grep -Fq $'peekaboo_capture_approval\tpassed' || return "$exit_code" + done + ((exit_code == 0)) || return "$exit_code" set_field "$manifest" desktop_bootstrap_at "$(utc_now)" set_field "$manifest" desktop_bootstrap_status passed } From 8527917e798221a8d97942c7e0efaafbf0f99e0c Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 20:42:52 -0700 Subject: [PATCH 46/99] Avoid resigning capture host during approval retries --- Scripts/lab/remote.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 375ef5325..d4c19cdb3 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1505,6 +1505,10 @@ desktop_bootstrap() { approval_output=$(approve_peekaboo_capture "$lease") print -r -- "$approval_output" print -r -- "$approval_output" | grep -Fq $'peekaboo_capture_approval\tpassed' || return "$exit_code" + # Installing tools re-signs the dedicated Peekaboo host. Do that once; + # re-signing on every consent retry creates a fresh Screen Recording + # request and prevents the bootstrap from ever reaching its postcondition. + command=(/bin/zsh Scripts/lab/desktop-bootstrap --output "$output") done ((exit_code == 0)) || return "$exit_code" set_field "$manifest" desktop_bootstrap_at "$(utc_now)" From c2357c93d8858d224521e82ab98bb57c21925ab8 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 20:59:09 -0700 Subject: [PATCH 47/99] Scope notification occlusion to visible elements --- Scripts/lab/remote.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index d4c19cdb3..6be650573 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1310,7 +1310,7 @@ protected_click() { if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then occlusion=${KEYPATH_LAB_TEST_OCCLUSION:-} else - occlusion_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("AppKit");\nfunction run(argv) {\n var x = Number(argv[0]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var y = Number(argv[1]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var events = Application("System Events");\n var processNames = ["NotificationCenter", "UserNotificationCenter"];\n for (var p = 0; p < processNames.length; p++) {\n var matches = events.processes.whose({name: processNames[p]})();\n if (matches.length === 0) continue;\n var windows = matches[0].windows();\n for (var w = 0; w < windows.length; w++) {\n try {\n var position = windows[w].position();\n var size = windows[w].size();\n if (x >= position[0] && x <= position[0] + size[0] && y >= position[1] && y <= position[1] + size[1]) {\n return processNames[p] + ":" + Math.round(position[0]) + "," + Math.round(position[1]) + "," + Math.round(size[0]) + "," + Math.round(size[1]);\n }\n } catch (_) {}\n }\n }\n return "";\n}\' -- '$(printf %q "$x")' '$(printf %q "$y") + occlusion_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("AppKit");\nfunction contains(element, x, y) {\n try {\n var position = element.position();\n var size = element.size();\n return size[0] > 1 && size[1] > 1 && x >= position[0] && x <= position[0] + size[0] && y >= position[1] && y <= position[1] + size[1];\n } catch (_) { return false; }\n}\nfunction bounds(element) {\n var position = element.position();\n var size = element.size();\n return Math.round(position[0]) + "," + Math.round(position[1]) + "," + Math.round(size[0]) + "," + Math.round(size[1]);\n}\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var x = Number(argv[0]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var y = Number(argv[1]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var events = Application("System Events");\n var processNames = ["NotificationCenter", "UserNotificationCenter"];\n for (var p = 0; p < processNames.length; p++) {\n var matches = events.processes.whose({name: processNames[p]})();\n if (matches.length === 0) continue;\n var windows = matches[0].windows();\n for (var w = 0; w < windows.length; w++) {\n try {\n if (windows[w].subrole() === "AXSystemDialog" && contains(windows[w], x, y)) {\n return processNames[p] + ":dialog:" + bounds(windows[w]);\n }\n } catch (_) {}\n var elements = descendants(windows[w]);\n for (var e = 0; e < elements.length; e++) {\n try {\n var role = elements[e].role();\n if (["AXGroup", "AXButton", "AXStaticText", "AXImage"].indexOf(role) !== -1 && contains(elements[e], x, y)) {\n return processNames[p] + ":" + role + ":" + bounds(elements[e]);\n }\n } catch (_) {}\n }\n }\n }\n return "";\n}\' -- '$(printf %q "$x")' '$(printf %q "$y") occlusion=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$occlusion_command")") || die "protected click could not verify notification occlusion" fi [[ -z "$occlusion" ]] || { From 63b27e21ecbc9a84a26c8897de7ed4b40454a3d3 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 21:04:08 -0700 Subject: [PATCH 48/99] Require screen consent evidence before pausing --- Scripts/lab/desktop-bootstrap | 5 ++++- Scripts/lab/tests/keypath-lab-tests.sh | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/desktop-bootstrap b/Scripts/lab/desktop-bootstrap index 4226c4b5d..42d732adb 100755 --- a/Scripts/lab/desktop-bootstrap +++ b/Scripts/lab/desktop-bootstrap @@ -185,7 +185,10 @@ function run() { var fullWindow = full[0].windows[0]; try { var fullSize = fullWindow.size(); - if (fullWindow.subrole() === "AXSystemDialog" && fullSize[0] === 1024 && fullSize[1] === 768) return "screen-capture"; + var fullMessage = fullWindow.staticTexts().map(function(item) { return item.value() || ""; }).join(" ").toLowerCase(); + var mentionsScreenCapture = fullMessage.indexOf("screen") !== -1 && + (fullMessage.indexOf("record") !== -1 || fullMessage.indexOf("capture") !== -1 || fullMessage.indexOf("share") !== -1); + if (fullWindow.subrole() === "AXSystemDialog" && fullSize[0] === 1024 && fullSize[1] === 768 && mentionsScreenCapture) return "screen-capture"; } catch (_) {} } var privateCenter = systemEvents.processes.whose({name: "UserNotificationCenter"})(); diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index cb4d0f476..d8768ed15 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -243,6 +243,8 @@ grep -Fq '"--mode"' "$LAB_DIR/assets/peekaboo-lab-host.c" grep -Fq '"manual"' "$LAB_DIR/assets/peekaboo-lab-host.c" grep -Fq 'NSScreenCaptureUsageDescription' "$LAB_DIR/desktop-bootstrap" grep -Fq 'NSAccessibilityUsageDescription' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'fullMessage.indexOf("screen")' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'mentionsScreenCapture' "$LAB_DIR/desktop-bootstrap" grep -Fq 'reset-desktop-keychain)' "$LAB_DIR/keypath-lab" grep -Fq 'reboot-guest)' "$LAB_DIR/keypath-lab" grep -Fq 'desktop keychain reset requires a verified console login' "$REMOTE" From f94c829703324f8231f5ba8a4f771fedc002cc91 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 21:09:03 -0700 Subject: [PATCH 49/99] Qualify full-screen notification occlusion --- Scripts/lab/remote.sh | 53 +++++++++++++++++++++++++- Scripts/lab/tests/keypath-lab-tests.sh | 3 ++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 6be650573..aa7c0be79 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1256,7 +1256,7 @@ secure_dialog_input() { protected_click() { local lease=$1 app=$2 expected_before=$3 expected_after=$4 coordinate_space=$5 x=$6 y=$7 count=${8:-1} local manifest macos resource key ip before after before_frontmost after_frontmost guest_command geometry_command geometry - local occlusion occlusion_command + local occlusion occlusion_command occlusion_qualification_script local native_width native_height logical_width logical_height scale_x scale_y manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) @@ -1312,6 +1312,57 @@ protected_click() { else occlusion_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("AppKit");\nfunction contains(element, x, y) {\n try {\n var position = element.position();\n var size = element.size();\n return size[0] > 1 && size[1] > 1 && x >= position[0] && x <= position[0] + size[0] && y >= position[1] && y <= position[1] + size[1];\n } catch (_) { return false; }\n}\nfunction bounds(element) {\n var position = element.position();\n var size = element.size();\n return Math.round(position[0]) + "," + Math.round(position[1]) + "," + Math.round(size[0]) + "," + Math.round(size[1]);\n}\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var x = Number(argv[0]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var y = Number(argv[1]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var events = Application("System Events");\n var processNames = ["NotificationCenter", "UserNotificationCenter"];\n for (var p = 0; p < processNames.length; p++) {\n var matches = events.processes.whose({name: processNames[p]})();\n if (matches.length === 0) continue;\n var windows = matches[0].windows();\n for (var w = 0; w < windows.length; w++) {\n try {\n if (windows[w].subrole() === "AXSystemDialog" && contains(windows[w], x, y)) {\n return processNames[p] + ":dialog:" + bounds(windows[w]);\n }\n } catch (_) {}\n var elements = descendants(windows[w]);\n for (var e = 0; e < elements.length; e++) {\n try {\n var role = elements[e].role();\n if (["AXGroup", "AXButton", "AXStaticText", "AXImage"].indexOf(role) !== -1 && contains(elements[e], x, y)) {\n return processNames[p] + ":" + role + ":" + bounds(elements[e]);\n }\n } catch (_) {}\n }\n }\n }\n return "";\n}\' -- '$(printf %q "$x")' '$(printf %q "$y") occlusion=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$occlusion_command")") || die "protected click could not verify notification occlusion" + if [[ "$occlusion" == *":dialog:"* ]]; then + read -r -d '' occlusion_qualification_script <<'JXA' || true +ObjC.import("AppKit"); +function contains(element, x, y) { + try { + var position = element.position(); + var size = element.size(); + return size[0] > 1 && size[1] > 1 && x >= position[0] && x <= position[0] + size[0] && y >= position[1] && y <= position[1] + size[1]; + } catch (_) { return false; } +} +function descendants(element) { + var result = []; + try { + var children = element.uiElements(); + for (var i = 0; i < children.length; i++) { + result.push(children[i]); + result = result.concat(descendants(children[i])); + } + } catch (_) {} + return result; +} +function run(argv) { + var x = Number(argv[0]) / Number($.NSScreen.mainScreen.backingScaleFactor); + var y = Number(argv[1]) / Number($.NSScreen.mainScreen.backingScaleFactor); + var events = Application("System Events"); + var processNames = ["NotificationCenter", "UserNotificationCenter"]; + for (var p = 0; p < processNames.length; p++) { + var matches = events.processes.whose({name: processNames[p]})(); + if (matches.length === 0) continue; + var windows = matches[0].windows(); + for (var w = 0; w < windows.length; w++) { + try { + var dialogText = windows[w].staticTexts().map(function(item) { return item.value() || ""; }).join(" ").toLowerCase(); + var captureConsent = dialogText.indexOf("screen") !== -1 && (dialogText.indexOf("record") !== -1 || dialogText.indexOf("capture") !== -1 || dialogText.indexOf("share") !== -1); + if (captureConsent || dialogText.indexOf("private window picker") !== -1) return processNames[p] + ":consent-dialog"; + } catch (_) {} + var elements = descendants(windows[w]); + for (var e = 0; e < elements.length; e++) { + try { + var role = elements[e].role(); + if (["AXGroup", "AXButton", "AXStaticText", "AXImage"].indexOf(role) !== -1 && contains(elements[e], x, y)) return processNames[p] + ":" + role; + } catch (_) {} + } + } + } + return ""; +} +JXA + occlusion_command="/usr/bin/osascript -l JavaScript -e $(printf %q "$occlusion_qualification_script") -- $(printf %q "$x") $(printf %q "$y")" + occlusion=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$occlusion_command")") || die "protected click could not qualify notification occlusion" + fi fi [[ -z "$occlusion" ]] || { record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --x "$x" --y "$y" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index d8768ed15..756f77a82 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -225,6 +225,9 @@ if grep -Fq 'events.keystroke(secret)' "$REMOTE"; then fi grep -Fq 'managed_clone_enrollment\talready-enrolled' "$REMOTE" grep -Fq 'window.subrole() === "AXSystemDialog"' "$REMOTE" +grep -Fq 'occlusion_qualification_script' "$REMOTE" +grep -Fq 'captureConsent' "$REMOTE" +grep -Fq 'private window picker' "$REMOTE" grep -Fq 'usb_prefix="$TART_USB_TOOL_ROOT/bin:"' "$REMOTE" if grep -Fq 'peekaboo see --app "System Settings"' "$REMOTE"; then echo "capture prompt guard must not create a new capture request" >&2 From df8d65a2f0eb88b844a4eb58741fb7cdaf084d97 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 21:12:43 -0700 Subject: [PATCH 50/99] Ignore full-screen notification container groups --- Scripts/lab/remote.sh | 8 +++++++- Scripts/lab/tests/keypath-lab-tests.sh | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index aa7c0be79..442862be2 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1352,7 +1352,13 @@ function run(argv) { for (var e = 0; e < elements.length; e++) { try { var role = elements[e].role(); - if (["AXGroup", "AXButton", "AXStaticText", "AXImage"].indexOf(role) !== -1 && contains(elements[e], x, y)) return processNames[p] + ":" + role; + var boundedVisibleRole = ["AXButton", "AXStaticText", "AXImage"].indexOf(role) !== -1; + if (role === "AXGroup") { + var elementSize = elements[e].size(); + var windowSize = windows[w].size(); + boundedVisibleRole = elementSize[0] * elementSize[1] < windowSize[0] * windowSize[1] * 0.5; + } + if (boundedVisibleRole && contains(elements[e], x, y)) return processNames[p] + ":" + role; } catch (_) {} } } diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 756f77a82..dfd48b387 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -228,6 +228,7 @@ grep -Fq 'window.subrole() === "AXSystemDialog"' "$REMOTE" grep -Fq 'occlusion_qualification_script' "$REMOTE" grep -Fq 'captureConsent' "$REMOTE" grep -Fq 'private window picker' "$REMOTE" +grep -Fq 'windowSize[0] * windowSize[1] * 0.5' "$REMOTE" grep -Fq 'usb_prefix="$TART_USB_TOOL_ROOT/bin:"' "$REMOTE" if grep -Fq 'peekaboo see --app "System Settings"' "$REMOTE"; then echo "capture prompt guard must not create a new capture request" >&2 From ff03911490af86052a61acd767b1317cde362d7a Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 21:18:45 -0700 Subject: [PATCH 51/99] Retain completed installer approval checkpoints --- Scripts/lab/install-runtime | 3 ++- Scripts/lab/tests/install-runtime-tests.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/install-runtime b/Scripts/lab/install-runtime index 6e91d5288..ab27f4ad5 100755 --- a/Scripts/lab/install-runtime +++ b/Scripts/lab/install-runtime @@ -185,7 +185,8 @@ done exit 3 } -if [[ "$completion_state" == "awaiting-approval" || "$completion_state" == "awaitingApproval" ]]; then +if [[ "$completion_state" == "awaiting-approval" || "$completion_state" == "awaitingApproval" || + ("$completion_state" == "completed" && "$user_action_required" == "true" && "$claimed_success" != "true") ]]; then write_state awaiting-approval print "install_runtime\twaiting" print "user_action_required\tComplete the KeyPath approvals visible in the disposable guest, then resume this checkpoint." diff --git a/Scripts/lab/tests/install-runtime-tests.py b/Scripts/lab/tests/install-runtime-tests.py index fc5977cb7..c4ed8fa32 100644 --- a/Scripts/lab/tests/install-runtime-tests.py +++ b/Scripts/lab/tests/install-runtime-tests.py @@ -61,6 +61,10 @@ def setUp(self) -> None: print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"verification-failed","userActionRequired":true,"success":false}}}}' exit 1 fi + if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "completed-waiting" ]]; then + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"completed","userActionRequired":true,"success":false}}}}' + exit 1 + fi print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"awaiting-approval","userActionRequired":true,"success":false}}}}' exit 1 fi @@ -132,6 +136,13 @@ def test_verification_failure_with_user_action_is_not_approval_wait(self) -> Non state = (self.directory / ".keypath-lab/scenario-output/install-runtime/state.tsv").read_text() self.assertIn("status\tfailed", state) + def test_completed_install_requiring_permission_is_resumable(self) -> None: + result = self.invoke(mode="completed-waiting") + self.assertEqual(result.returncode, 4, result.stderr) + self.assertIn("install_runtime\twaiting", result.stdout) + state = (self.directory / ".keypath-lab/scenario-output/install-runtime/state.tsv").read_text() + self.assertIn("status\tawaiting-approval", state) + if __name__ == "__main__": unittest.main() From 8e77648c52d353f4478478dfd8526c7d0a5dc98b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 25 Jul 2026 21:43:30 -0700 Subject: [PATCH 52/99] Automate managed Input Monitoring approval --- Scripts/lab/keypath-lab | 7 ++ Scripts/lab/remote.sh | 103 ++++++++++++++++++ Scripts/lab/scenario-matrix-runner | 20 ++++ Scripts/lab/tests/keypath-lab-tests.sh | 9 ++ .../lab/tests/scenario-matrix-runner-tests.py | 33 ++++-- 5 files changed, 163 insertions(+), 9 deletions(-) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index 898cc699f..4edfa99d9 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -27,6 +27,7 @@ Commands: desktop-bootstrap LEASE_ID [--install-tools] nameplate LEASE_ID enable|show|hide|status protected-click LEASE_ID --app APP --window TITLE (--x X --y Y | --ax-x X --ax-y Y) [--after-window TITLE] [--count 1|2] + approve-input-monitoring LEASE_ID desktop-type LEASE_ID --text TEXT secure-dialog-input LEASE_ID --app APP --field LABEL [--submit BUTTON] [--already-focused] resume-managed-policy LEASE_ID @@ -317,6 +318,12 @@ EOF [[ -n $after_window ]] || after_window=$window remote protected-click "$lease" "$app" "$window" "$after_window" "$coordinate_space" "$x" "$y" "$count" ;; + approve-input-monitoring) + lease=${1:-} + [[ -n $lease && $# -eq 1 ]] || usage + require_lease_id "$lease" + remote approve-input-monitoring "$lease" + ;; desktop-type) lease=${1:-} [[ -n $lease ]] || usage diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 442862be2..fd247ecd0 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1412,6 +1412,108 @@ JXA fi } +input_monitoring_rows() { + local lease=$1 manifest resource key ip guest_script guest_command + manifest=$(owned_manifest "$lease") + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + print -r -- "${KEYPATH_LAB_TEST_INPUT_MONITORING_ROWS:-$'Kanata Engine\t0\t402\t247\nkanata-launcher\t0\t402\t289\nKeyPath\t0\t402\t331'}" + return + fi + + resource=$(field "$manifest" provider_resource) + [[ "$resource" =~ '^[A-Za-z0-9._-]+$' && "$resource" != "unknown" ]] || die "invalid Tart resource id" + key="$HOME/Library/Application Support/crabbox/testboxes/$lease/id_ed25519" + [[ -f "$key" && ! -L "$key" && -O "$key" ]] || die "owned CrabBox SSH key not found for lease" + if [[ "${USER:-}" == "clawd" ]]; then export TART_HOME="$LAB_ROOT/TartHome-clawd"; else export TART_HOME="$LAB_ROOT/TartHome"; fi + export PATH="$LAB_ROOT/CompatTools/bin:$LAB_ROOT/SharedTools/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ip=$($TART ip "$resource") + [[ "$ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Tart returned an invalid guest address" + + read -r -d '' guest_script <<'JXA' || true +function safe(callback, fallback) { + try { return callback(); } catch (_) { return fallback; } +} +function descendants(element) { + var result = []; + safe(function() { + element.uiElements().forEach(function(child) { + result.push(child); + result = result.concat(descendants(child)); + }); + }, null); + return result; +} +function run() { + var keyPath = Application("KeyPath"); + if (keyPath.running()) keyPath.quit(); + delay(0.5); + Application("System Events").doShellScript("/usr/bin/open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent'"); + delay(1); + Application("System Settings").activate(); + delay(0.5); + var process = Application("System Events").processes.byName("System Settings"); + if (!process.exists() || process.windows().length === 0) throw new Error("System Settings is unavailable"); + var window = process.windows[0]; + if (window.name() !== "Input Monitoring") throw new Error("Input Monitoring page did not open"); + window.position = [154, 330]; + delay(0.5); + var targets = ["Kanata Engine", "kanata-launcher", "KeyPath"]; + var checkboxes = descendants(window).filter(function(element) { + return safe(function() { return element.role() === "AXCheckBox"; }, false); + }); + return targets.map(function(target) { + var matches = checkboxes.filter(function(element) { + return safe(function() { return element.name() === target; }, false); + }); + if (matches.length !== 1) throw new Error("Expected one Input Monitoring row for " + target); + var checkbox = matches[0]; + var position = checkbox.position(); + var size = checkbox.size(); + return target + "\t" + checkbox.value() + "\t" + + Math.round(position[0] + size[0] / 2) + "\t" + + Math.round(position[1] + size[1] / 2); + }).join("\n"); +} +JXA + guest_command="/usr/bin/osascript -l JavaScript -e $(printf %q "$guest_script")" + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")" +} + +approve_input_monitoring() { + local lease=$1 manifest macos lane rows row target value x y verified + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + lane=$(field "$manifest" test_lane) + [[ "$macos" == "15" ]] || die "Input Monitoring approval currently supports only the Tart macOS 15 lane" + [[ "$lane" == "managed-functional" ]] || die "Input Monitoring approval requires managed-functional policy" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "Input Monitoring approval requires a desktop-enabled lease" + + rows=$(input_monitoring_rows "$lease") || die "could not inspect Input Monitoring rows" + for target in "Kanata Engine" "kanata-launcher" "KeyPath"; do + row=$(print -r -- "$rows" | awk -F '\t' -v target="$target" '$1 == target {print; exit}') + [[ -n "$row" ]] || die "Input Monitoring row is missing: $target" + IFS=$'\t' read -r _ value x y <<< "$row" + [[ "$value" == "0" || "$value" == "1" ]] || die "Input Monitoring row has an invalid state: $target" + [[ "$x" == <-> && "$y" == <-> ]] || die "Input Monitoring row has invalid geometry: $target" + if [[ "$value" == "1" ]]; then + print "input_monitoring_row\t$target\talready-enabled" + continue + fi + + protected_click "$lease" "System Settings" "Input Monitoring" "Input Monitoring" ax "$x" "$y" 1 >/dev/null + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + verified=${KEYPATH_LAB_TEST_INPUT_MONITORING_VERIFY:-1} + else + rows=$(input_monitoring_rows "$lease") || die "could not refresh Input Monitoring rows" + verified=$(print -r -- "$rows" | awk -F '\t' -v target="$target" '$1 == target {print $2; exit}') + fi + [[ "$verified" == "1" ]] || die "Input Monitoring toggle did not remain enabled: $target" + print "input_monitoring_row\t$target\tenabled" + done + record_command "$lease" passed approve-input-monitoring + print "approve_input_monitoring\tpassed" +} + desktop_type() { local lease=$1 text=$2 manifest macos resource manifest=$(owned_manifest "$lease") @@ -2233,6 +2335,7 @@ case "$action" in secure-dialog-input) [[ $# -eq 5 ]] || die "secure-dialog-input requires lease, app, field, optional submit value, and focus mode"; secure_dialog_input "$@" ;; resume-managed-policy) [[ $# -eq 1 ]] || die "resume-managed-policy requires a lease"; resume_managed_policy "$1" ;; protected-click) [[ $# -eq 7 || $# -eq 8 ]] || die "protected-click requires lease, app, before window, after window, coordinate space, x, y, and optional count"; protected_click "$@" ;; + approve-input-monitoring) [[ $# -eq 1 ]] || die "approve-input-monitoring requires lease"; approve_input_monitoring "$1" ;; desktop-type) [[ $# -eq 2 ]] || die "desktop-type requires lease and text"; desktop_type "$@" ;; run) [[ $# -ge 2 ]] || die "run requires lease and command"; run_command "$@" ;; status) [[ $# -eq 1 ]] || die "status requires lease"; print_status "$1" ;; diff --git a/Scripts/lab/scenario-matrix-runner b/Scripts/lab/scenario-matrix-runner index 5ad0bc0f5..1bab3d087 100755 --- a/Scripts/lab/scenario-matrix-runner +++ b/Scripts/lab/scenario-matrix-runner @@ -384,6 +384,26 @@ class MatrixRunner: job, state, step, result.stdout + result.stderr, required=result.returncode == 0, ) + if ( + result.returncode == 4 + and "install_runtime\twaiting" in (result.stdout + result.stderr) + and step["commandStep"] == "install-exact-artifact" + and job.get("macOS") == 15 + and job.get("lane") == "managed-functional" + ): + self.update_job( + state, status="running", step=step, step_status="running", + message="Driving the genuine managed Input Monitoring approval and verifying each row.", + ) + approval = self.execute( + job_id, step["id"], + self.lab_command("approve-input-monitoring", state["leaseId"]), + ) + if approval.returncode == 0: + result = self.execute( + job_id, step["id"], + self.lab_command("install-runtime", state["leaseId"]), + ) if result.returncode == 4 and "install_runtime\twaiting" in (result.stdout + result.stderr): state["waitingCheckpoint"] = checkpoint preserve_lease = True diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index dfd48b387..fc659a28a 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -235,6 +235,7 @@ if grep -Fq 'peekaboo see --app "System Settings"' "$REMOTE"; then exit 1 fi grep -q 'resume-managed-policy)' "$LAB_DIR/keypath-lab" +grep -q 'approve-input-monitoring)' "$LAB_DIR/keypath-lab" grep -Fq '/usr/bin/mktemp /etc/kcpassword.XXXXXXXX' "$REMOTE" grep -Fq "Automatic login user: keypathqa" "$REMOTE" @@ -985,6 +986,14 @@ assert_contains "$protected_background" "is not frontmost" protected_ax_result=$(KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote protected-click cbx_desktop15 'System Settings' Accessibility Accessibility ax 402 247) assert_contains "$protected_ax_result" $'display_scale\t2' grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 494' "$CALLS" +approval_result=$(KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote approve-input-monitoring cbx_desktop15) +assert_contains "$approval_result" $'input_monitoring_row\tKanata Engine\tenabled' +assert_contains "$approval_result" $'input_monitoring_row\tkanata-launcher\tenabled' +assert_contains "$approval_result" $'input_monitoring_row\tKeyPath\tenabled' +assert_contains "$approval_result" $'approve_input_monitoring\tpassed' +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 494' "$CALLS" +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 578' "$CALLS" +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 662' "$CALLS" run_remote desktop-type cbx_desktop15 q >/dev/null grep -q 'crabbox desktop type --provider tart --target macos --id test-resource --text q' "$CALLS" run_remote destroy cbx_desktop15 >/dev/null diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py index cb9d2c1fa..5091c5866 100755 --- a/Scripts/lab/tests/scenario-matrix-runner-tests.py +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -101,7 +101,7 @@ def test_human_checkpoint_waits_without_cleanup_then_resumes(self) -> None: self.assertEqual(state["jobs"][0]["status"], "passed") self.assertIn("destroy cbx_test_lease", self.log.read_text()) - def test_installer_approval_wait_resumes_by_verifying_without_accepting_the_step(self) -> None: + def test_managed_macos15_drives_input_monitoring_then_verifies_runtime(self) -> None: marker = self.directory / "approval-requested" self.lab.write_text(textwrap.dedent(f"""\ #!/bin/zsh @@ -125,19 +125,34 @@ def test_installer_approval_wait_resumes_by_verifying_without_accepting_the_step plan = self.plan(["create-fresh-lease", "install-exact-artifact", "artifact-capture"]) first = self.run_runner(plan) - self.assertEqual(first.returncode, 4, first.stderr) - state = json.loads((self.directory / "state.json").read_text()) - self.assertEqual(state["jobs"][0]["status"], "waiting") - self.assertEqual(state["jobs"][0]["steps"][1]["status"], "waiting") - self.assertNotIn("destroy", self.log.read_text()) - - second = self.run_runner(plan, "--ack-checkpoint", "vm-job:install-exact-artifact") - self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual(first.returncode, 0, first.stderr) state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["jobs"][0]["status"], "passed") self.assertEqual(state["jobs"][0]["steps"][1]["status"], "passed") self.assertEqual(self.log.read_text().count("install-runtime cbx_test_lease"), 2) + self.assertIn("approve-input-monitoring cbx_test_lease", self.log.read_text()) self.assertIn("destroy cbx_test_lease", self.log.read_text()) + def test_failed_automatic_input_monitoring_falls_back_to_retained_checkpoint(self) -> None: + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + create) print 'lease_id\tcbx_test_lease'; print 'manifest\t/tmp/manifest' ;; + status) print 'status\tready' ;; + install-runtime) print 'install_runtime\twaiting'; exit 4 ;; + approve-input-monitoring) print 'target occluded' >&2; exit 1 ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + result = self.run_runner(self.plan(["create-fresh-lease", "install-exact-artifact"])) + self.assertEqual(result.returncode, 4, result.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["status"], "waiting") + self.assertEqual(state["jobs"][0]["steps"][1]["status"], "waiting") + self.assertNotIn("destroy", self.log.read_text()) + def test_refuses_checkpoint_preapproval(self) -> None: result = self.run_runner(self.plan(["create-fresh-lease", "operator-visible-action"]), "--ack-checkpoint", "vm-job:operator-visible-action") From cc20bb676fa0fb9ff95ea46452617e7d2694e134 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 07:13:19 -0700 Subject: [PATCH 53/99] Recover installer approval after transport loss --- Scripts/lab/keypath-lab | 3 +- Scripts/lab/scenario-matrix-runner | 18 +++++++++++ Scripts/lab/tests/keypath-lab-tests.sh | 2 ++ .../lab/tests/scenario-matrix-runner-tests.py | 32 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index 4edfa99d9..4cf4e52a5 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -73,7 +73,8 @@ remote() { printf -v quoted '%q' "$argument" remote_command="$remote_command $quoted" done - ssh -o BatchMode=yes "$host" "$remote_command" < "$REMOTE_SCRIPT" + ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 \ + "$host" "$remote_command" < "$REMOTE_SCRIPT" } require_lease_id() { diff --git a/Scripts/lab/scenario-matrix-runner b/Scripts/lab/scenario-matrix-runner index 1bab3d087..cb71c6a32 100755 --- a/Scripts/lab/scenario-matrix-runner +++ b/Scripts/lab/scenario-matrix-runner @@ -286,6 +286,21 @@ class MatrixRunner: self.update_job(state, status="running", step=step, step_status="passed", message="Recovered the created lease from durable controller evidence.") return True + if step["commandStep"] == "install-exact-artifact" and state.get("leaseId"): + status = subprocess.run( + self.lab_command("status", state["leaseId"]), text=True, capture_output=True, + ) + if status.returncode == 0 and re.search( + r"^install_runtime_status\tawaiting-approval$", status.stdout, re.MULTILINE, + ): + checkpoint = f"{job['id']}:{step['commandStep']}" + state["waitingCheckpoint"] = checkpoint + self.update_job( + state, status="waiting", step=step, step_status="waiting", + message="Recovered the completed installer mutation at its durable macOS approval boundary.", + event=f"{job['title']} needs approval", tone="warning", + ) + return True state["blocker"] = f"Interrupted mutation at {step['id']} has no verified postcondition; refusing to repeat it." self.update_job(state, status="blocked", step=step, step_status="blocked", message=state["blocker"], event=f"{job['title']} blocked", tone="danger") @@ -363,6 +378,9 @@ class MatrixRunner: return "waiting" if step["status"] == "running" and not self.recover_inflight(job, state, step): return "blocked" + if state["status"] == "waiting": + preserve_lease = True + return "waiting" if step["status"] == "passed": continue if checkpoint in self.acknowledged: diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index fc659a28a..08c51a81c 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -236,6 +236,8 @@ if grep -Fq 'peekaboo see --app "System Settings"' "$REMOTE"; then fi grep -q 'resume-managed-policy)' "$LAB_DIR/keypath-lab" grep -q 'approve-input-monitoring)' "$LAB_DIR/keypath-lab" +grep -Fq 'ServerAliveInterval=15' "$LAB_DIR/keypath-lab" +grep -Fq 'ServerAliveCountMax=4' "$LAB_DIR/keypath-lab" grep -Fq '/usr/bin/mktemp /etc/kcpassword.XXXXXXXX' "$REMOTE" grep -Fq "Automatic login user: keypathqa" "$REMOTE" diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py index 5091c5866..c46408847 100755 --- a/Scripts/lab/tests/scenario-matrix-runner-tests.py +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -179,6 +179,38 @@ def test_recovers_created_lease_but_blocks_other_uncertain_mutations(self) -> No self.assertEqual(state["jobs"][0]["status"], "blocked") self.assertIn("refusing to repeat", state["jobs"][0]["blocker"]) + def test_recovers_interrupted_install_at_durable_approval_boundary(self) -> None: + plan = self.plan(["create-fresh-lease", "install-exact-artifact"]) + initial = self.run_runner(plan) + self.assertEqual(initial.returncode, 0, initial.stderr) + + state_path = self.directory / "state.json" + state = json.loads(state_path.read_text()) + state["status"] = "running" + state["jobs"][0]["status"] = "running" + state["jobs"][0]["cleanupStatus"] = "pending" + state["jobs"][0]["leaseId"] = "cbx_test_lease" + state["jobs"][0]["steps"][1]["status"] = "running" + state_path.write_text(json.dumps(state)) + self.log.write_text("") + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + status) print 'status\tready'; print 'install_runtime_status\tawaiting-approval' ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + + resumed = self.run_runner(plan) + self.assertEqual(resumed.returncode, 4, resumed.stderr) + state = json.loads(state_path.read_text()) + self.assertEqual(state["status"], "waiting") + self.assertEqual(state["jobs"][0]["steps"][1]["status"], "waiting") + self.assertEqual(state["jobs"][0]["waitingCheckpoint"], "vm-job:install-exact-artifact") + self.assertNotIn("destroy", self.log.read_text()) + def test_failed_create_adopts_controller_lease_and_cleans_it_up(self) -> None: self.lab.write_text(textwrap.dedent(f"""\ #!/bin/zsh From df23051eadf176dc051aed13cc8bd3850ce7beb4 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 07:15:03 -0700 Subject: [PATCH 54/99] Open privacy settings through Standard Additions --- Scripts/lab/remote.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index fd247ecd0..4836c69d9 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1447,7 +1447,9 @@ function run() { var keyPath = Application("KeyPath"); if (keyPath.running()) keyPath.quit(); delay(0.5); - Application("System Events").doShellScript("/usr/bin/open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent'"); + var current = Application.currentApplication(); + current.includeStandardAdditions = true; + current.doShellScript("/usr/bin/open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent'"); delay(1); Application("System Settings").activate(); delay(0.5); From 5856ad8442fe6fa4ec0a26eb9a18195f2d3453a2 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 07:18:45 -0700 Subject: [PATCH 55/99] Prepare privacy UI without app scripting --- Scripts/lab/remote.sh | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 4836c69d9..d1b03a925 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1413,7 +1413,7 @@ JXA } input_monitoring_rows() { - local lease=$1 manifest resource key ip guest_script guest_command + local lease=$1 manifest resource key ip guest_script guest_command open_command manifest=$(owned_manifest "$lease") if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then print -r -- "${KEYPATH_LAB_TEST_INPUT_MONITORING_ROWS:-$'Kanata Engine\t0\t402\t247\nkanata-launcher\t0\t402\t289\nKeyPath\t0\t402\t331'}" @@ -1444,16 +1444,9 @@ function descendants(element) { return result; } function run() { - var keyPath = Application("KeyPath"); - if (keyPath.running()) keyPath.quit(); - delay(0.5); - var current = Application.currentApplication(); - current.includeStandardAdditions = true; - current.doShellScript("/usr/bin/open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent'"); - delay(1); Application("System Settings").activate(); - delay(0.5); var process = Application("System Events").processes.byName("System Settings"); + for (var attempt = 0; attempt < 20 && (!process.exists() || process.windows().length === 0); attempt++) delay(0.25); if (!process.exists() || process.windows().length === 0) throw new Error("System Settings is unavailable"); var window = process.windows[0]; if (window.name() !== "Input Monitoring") throw new Error("Input Monitoring page did not open"); @@ -1477,6 +1470,10 @@ function run() { }).join("\n"); } JXA + guest_command="/usr/bin/killall -TERM KeyPath >/dev/null 2>&1 || true" + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")" + open_command="/usr/bin/open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent'" + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$open_command")" >/dev/null guest_command="/usr/bin/osascript -l JavaScript -e $(printf %q "$guest_script")" "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")" } From 8a5d35883b699b1229131b4033d6261903bbca66 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 07:39:28 -0700 Subject: [PATCH 56/99] Wait for runtime convergence after reboot --- Scripts/lab/remote.sh | 6 ++- Scripts/lab/scenarios/installer-scenario | 52 ++++++++++++++++++++++-- Scripts/lab/tests/keypath-lab-tests.sh | 6 ++- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index d1b03a925..2b5cae220 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1416,7 +1416,11 @@ input_monitoring_rows() { local lease=$1 manifest resource key ip guest_script guest_command open_command manifest=$(owned_manifest "$lease") if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then - print -r -- "${KEYPATH_LAB_TEST_INPUT_MONITORING_ROWS:-$'Kanata Engine\t0\t402\t247\nkanata-launcher\t0\t402\t289\nKeyPath\t0\t402\t331'}" + if [[ -n "${KEYPATH_LAB_TEST_INPUT_MONITORING_ROWS:-}" ]]; then + print -r -- "$KEYPATH_LAB_TEST_INPUT_MONITORING_ROWS" + else + printf 'Kanata Engine\t0\t402\t247\nkanata-launcher\t0\t402\t289\nKeyPath\t0\t402\t331\n' + fi return fi diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index 07f51c830..0154de460 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -37,6 +37,45 @@ assert_ready() { fi } +wait_for_runtime_ready() { + local ready_output=$1 attempts_output=$2 diagnostics_output=$3 + local timeout=${KEYPATH_LAB_REBOOT_READY_TIMEOUT_SECONDS:-60} + local interval=${KEYPATH_LAB_REBOOT_READY_INTERVAL_SECONDS:-2} + local started=$SECONDS attempt=0 elapsed=0 attempt_stderr + [[ "$timeout" == <-> && "$timeout" -gt 0 ]] || { + print -u2 "invalid reboot readiness timeout: $timeout" + return 2 + } + [[ "$interval" == <-> && "$interval" -gt 0 ]] || { + print -u2 "invalid reboot readiness interval: $interval" + return 2 + } + attempt_stderr="$artifact_dir/ready-attempt.stderr" + : > "$attempts_output" + : > "$diagnostics_output" + while true; do + (( attempt += 1 )) + if assert_ready > "$ready_output" 2> "$attempt_stderr"; then + elapsed=$(( SECONDS - started )) + print "attempt\t$attempt\telapsed_seconds\t$elapsed\tstatus\tpassed" >> "$attempts_output" + [[ ! -s "$attempt_stderr" ]] || cat "$attempt_stderr" >> "$diagnostics_output" + rm -f "$attempt_stderr" + return 0 + fi + elapsed=$(( SECONDS - started )) + print "attempt\t$attempt\telapsed_seconds\t$elapsed\tstatus\twarming" >> "$attempts_output" + { + print "attempt=$attempt elapsed_seconds=$elapsed" + cat "$attempt_stderr" + } >> "$diagnostics_output" + if (( elapsed >= timeout )); then + rm -f "$attempt_stderr" + return 1 + fi + sleep "$interval" + done +} + record_scenario_result() { local scenario_status=$1 summary=$2 shift 2 @@ -160,11 +199,15 @@ case "$name" in --evidence app-identity.txt exit 1 fi - if ! assert_ready | tee "$artifact_dir/ready.tsv"; then + if ! wait_for_runtime_ready \ + "$artifact_dir/ready.tsv" \ + "$artifact_dir/ready-attempts.tsv" \ + "$artifact_dir/ready-attempts.log"; then record_scenario_result failed \ - "The independently ready KeyPath runtime did not recover after reboot." \ + "The independently ready KeyPath runtime did not converge after the bounded reboot recovery window." \ --classification keypath-product-failure --step post-reboot-ready \ - --evidence ready.tsv + --evidence ready.tsv --evidence ready-attempts.tsv \ + --evidence ready-attempts.log exit 1 fi "$installed_cli" service status --json > "$artifact_dir/service-status.json" @@ -173,7 +216,8 @@ case "$name" in record_scenario_result passed \ "App identity and independently ready runtime persisted across a proven guest reboot." \ --evidence boot-time.txt --evidence app-identity.txt \ - --evidence ready.tsv --evidence service-status.json \ + --evidence ready.tsv --evidence ready-attempts.tsv \ + --evidence ready-attempts.log --evidence service-status.json \ --evidence after-reboot.json --evidence tcp-readiness.json ;; uninstall) diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 08c51a81c..453c49b0b 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -274,7 +274,9 @@ fi grep -Fq 'Reboot persistence requires an independently ready runtime baseline.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'The boot marker did not change; no guest reboot was proven.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'KeyPath app identity changed across the guest reboot.' "$LAB_DIR/scenarios/installer-scenario" -grep -Fq 'The independently ready KeyPath runtime did not recover after reboot.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'KEYPATH_LAB_REBOOT_READY_TIMEOUT_SECONDS' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'ready-attempts.tsv' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'did not converge after the bounded reboot recovery window.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'Cancellation recovery requires an independently ready runtime baseline.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'The same lease must pass cancellation-recovery-before before post-cancellation verification.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'Scripts/lab/damage-kanata-service' "$LAB_DIR/scenarios/installer-scenario" @@ -860,7 +862,7 @@ assert_contains "$invalid_resource_output" 'invalid Parallels resource id' [[ $(grep -c '^prlctl capture ' "$CALLS") -eq $prlctl_calls_before ]] run_remote destroy cbx_desktop26 >/dev/null -desktop_create=$(run_remote create 15 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) +desktop_create=$(run_remote create 15 managed-functional "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) assert_contains "$desktop_create" $'lease_id\tcbx_desktop15' grep -q $'status\tprovisioning' "$ROOT/KeyPathInstallerLab/leases/cbx_stale/manifest.tsv" run_remote destroy cbx_stale >/dev/null From 8f0f61bb0b058c83d4ab9a46c0ccad505bfff0e2 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 07:53:12 -0700 Subject: [PATCH 57/99] Bound desktop consent inspection --- Scripts/lab/desktop-bootstrap | 23 ++++++++++++++++++++++- Scripts/lab/tests/keypath-lab-tests.sh | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/desktop-bootstrap b/Scripts/lab/desktop-bootstrap index 42d732adb..fcd1d6616 100755 --- a/Scripts/lab/desktop-bootstrap +++ b/Scripts/lab/desktop-bootstrap @@ -28,6 +28,27 @@ download_verified() { [[ "$actual" == "$expected" ]] || die "checksum mismatch for ${url:t}" } +run_bounded() { + local timeout_seconds=$1 + shift + local pid status=0 elapsed=0 + [[ "$timeout_seconds" == <-> && "$timeout_seconds" -gt 0 ]] || + die "invalid command timeout: $timeout_seconds" + "$@" & + pid=$! + while /bin/kill -0 "$pid" >/dev/null 2>&1; do + if (( elapsed >= timeout_seconds * 10 )); then + /bin/kill -TERM "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + return 124 + fi + sleep 0.1 + (( elapsed += 1 )) + done + wait "$pid" || status=$? + return "$status" +} + install_desktop_tools() { local temp local_bin local_lib peekaboo_home host_app host_contents host_executable local host_launcher_source host_launcher_sha launch_agents host_launch_agent @@ -177,7 +198,7 @@ sleep 2 # The first capture can display macOS's private-picker bypass consent sheet. initial_capture_result=0 "$peekaboo_bin" see --app "System Settings" --json > "$output/initial-desktop.json" || initial_capture_result=$? -capture_prompt=$(/usr/bin/osascript -l JavaScript -e ' +capture_prompt=$(run_bounded 10 /usr/bin/osascript -l JavaScript -e ' function run() { var systemEvents = Application("System Events"); var full = systemEvents.processes.whose({name: "NotificationCenter"})(); diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 453c49b0b..71ac5eb40 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -275,6 +275,7 @@ grep -Fq 'Reboot persistence requires an independently ready runtime baseline.' grep -Fq 'The boot marker did not change; no guest reboot was proven.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'KeyPath app identity changed across the guest reboot.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'KEYPATH_LAB_REBOOT_READY_TIMEOUT_SECONDS' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'run_bounded 10 /usr/bin/osascript' "$LAB_DIR/desktop-bootstrap" grep -Fq 'ready-attempts.tsv' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'did not converge after the bounded reboot recovery window.' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'Cancellation recovery requires an independently ready runtime baseline.' "$LAB_DIR/scenarios/installer-scenario" From 659254335ad58bff23b1986c7be8903878194375 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 08:01:22 -0700 Subject: [PATCH 58/99] Invalidate cached runtime after uninstall --- Scripts/lab/remote.sh | 31 ++++++++++++++++++++++++-- Scripts/lab/tests/keypath-lab-tests.sh | 11 +++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 2b5cae220..4cb8a4172 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -984,6 +984,22 @@ install_runtime() { return 0 fi + if [[ "$runtime_status" == "uninstalled" ]]; then + # Preserve the first installation's durable evidence before creating a + # fresh output directory for the reinstall. Fail closed rather than + # overwriting either evidence set on an ambiguous retry. + run_command "$lease" /bin/zsh -lc \ + 'source=.keypath-lab/scenario-output/install-runtime; target=.keypath-lab/scenario-output/install-runtime-before-reinstall; test -d "$source"; test ! -e "$target"; mv "$source" "$target"' || \ + die "could not preserve the first install-runtime evidence before reinstall" + install_app "$lease" || { + set_field "$manifest" install_runtime_status failed + set_field "$manifest" install_runtime_at "$(utc_now)" + return 1 + } + set_field "$manifest" install_runtime_status staged + runtime_status=staged + fi + if [[ -z "$runtime_status" ]]; then install_app "$lease" || { set_field "$manifest" install_runtime_status failed @@ -1631,14 +1647,25 @@ collect_artifacts() { } scenario() { - local lease=$1 name=$2 manifest repo scenario_script lane + local lease=$1 name=$2 manifest repo scenario_script lane runtime_status exit_code manifest=$(owned_manifest "$lease") repo=$(field "$manifest" worktree) lane=$(field "$manifest" test_lane) prepare_worktree "$repo" scenario_script="Scripts/lab/scenarios/installer-scenario" [[ -x "$repo/$scenario_script" ]] || die "scenario runner missing from archived commit" - run_command "$lease" "/bin/zsh" "$scenario_script" "$name" "$lane" + set +e + (run_command "$lease" "/bin/zsh" "$scenario_script" "$name" "$lane") + exit_code=$? + set -e + if ((exit_code == 0)) && [[ "$name" == "uninstall" ]]; then + runtime_status=$(field "$manifest" install_runtime_status) + if [[ -n "$runtime_status" ]]; then + set_field "$manifest" install_runtime_status uninstalled + set_field "$manifest" uninstall_at "$(utc_now)" + fi + fi + return "$exit_code" } desktop_bootstrap() { diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 71ac5eb40..29ec5b061 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -494,6 +494,17 @@ set -e grep -q $'install_runtime_status\tawaiting-approval' "$manifest" awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="passed"} {print}' "$manifest" > "$manifest.tmp" mv "$manifest.tmp" "$manifest" +run_remote scenario cbx_test15 uninstall >/dev/null +grep -q $'install_runtime_status\tuninstalled' "$manifest" +install_app_count_before=$(grep -c 'install-app 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-app.log") +run_remote install-runtime cbx_test15 >/dev/null +install_app_count_after=$(grep -c 'install-app 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-app.log") +[[ $install_app_count_after -eq $((install_app_count_before + 1)) ]] || { + echo "reinstall did not stage the app after a successful uninstall" >&2 + exit 1 +} +grep -q 'install-runtime-before-reinstall' "$CALLS" +grep -q $'install_runtime_status\tpassed' "$manifest" run_remote install-fixture cbx_test15 >/dev/null grep -q 'install-fixture 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-fixture.log" tart_reboot=$(KEYPATH_LAB_TART_REBOOT_SETTLE_SECONDS=0 KEYPATH_LAB_TART_REBOOT_POLL_SECONDS=0 run_remote reboot-guest cbx_test15) From 18c315afcf810cbe4bfd2c47306b07a3b172496d Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 08:15:24 -0700 Subject: [PATCH 59/99] Route macOS 26 selectors to desktop base --- Scripts/lab/remote.sh | 6 +++--- Scripts/lab/scenario-matrix-runner | 6 +++++- Scripts/lab/tests/keypath-lab-tests.sh | 9 +++++++++ Scripts/lab/tests/scenario-matrix-runner-tests.py | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 4cb8a4172..ff8af6cfe 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -74,8 +74,8 @@ base_for() { local macos=$1 lane=$2 desktop=${3:-0} if [[ "$macos" == "15" ]]; then [[ "$lane" == "managed-functional" ]] && print keypath-macos-15-managed || print ghcr.io/cirruslabs/macos-sequoia-base:latest - elif [[ "$macos" == "27" && "$desktop" == "1" ]]; then - print keypath-macos-27-desktop + elif [[ ("$macos" == "26" || "$macos" == "27") && "$desktop" == "1" ]]; then + print "keypath-macos-$macos-desktop" else [[ "$lane" == "managed-functional" ]] && print "keypath-macos-$macos-managed" || print "keypath-macos-$macos" fi @@ -1708,7 +1708,7 @@ verify_console_login() { local lease=$1 manifest macos resource parallels_cli console_user manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) - [[ "$macos" == "27" ]] || die "inherited console-login verification currently supports only the macOS 27 lane" + [[ "$macos" == "26" || "$macos" == "27" ]] || die "inherited console-login verification requires a macOS 26 or 27 lane" [[ "$(field "$manifest" provider)" == "parallels" ]] || die "inherited console-login verification requires a Parallels lease" [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "inherited console-login verification requires a desktop-enabled lease" resource=$(field "$manifest" provider_resource) diff --git a/Scripts/lab/scenario-matrix-runner b/Scripts/lab/scenario-matrix-runner index cb71c6a32..0a65a7d7c 100755 --- a/Scripts/lab/scenario-matrix-runner +++ b/Scripts/lab/scenario-matrix-runner @@ -264,7 +264,11 @@ class MatrixRunner: if step_name == "reboot-guest": return [self.lab_command("reboot-guest", lease)] if step_name == "desktop-bootstrap": - return [self.lab_command("desktop-bootstrap", lease, "--install-tools")] + commands = [] + if job["provider"] == "parallels": + commands.append(self.lab_command("verify-console-login", lease)) + commands.append(self.lab_command("desktop-bootstrap", lease, "--install-tools")) + return commands if step_name == "artifact-capture": collected = self.args.artifacts / job["id"] / "collected" return [ diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 29ec5b061..890e0b513 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -701,6 +701,15 @@ assert_contains "$artifacts26" $'download_status\t0' grep -q 'crabbox run --provider parallels --target macos --id cbx_test26 --stop-after never --download' "$CALLS" run_remote destroy cbx_test26 >/dev/null +desktop26_create=$(run_remote create 26 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) +assert_contains "$desktop26_create" $'lease_id\tcbx_desktop26' +grep -q -- '--parallels-template keypath-macos-26-desktop' "$CALLS" +grep -q $'base_name\tkeypath-macos-26-desktop' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop26/manifest.tsv" +inherited_console26=$(run_remote verify-console-login cbx_desktop26) +assert_contains "$inherited_console26" $'console_login\tpassed' +assert_contains "$inherited_console26" $'console_login_method\tinherited-base' +run_remote destroy cbx_desktop26 >/dev/null + create27=$(run_remote create 27 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 0) assert_contains "$create27" $'lease_id\tcbx_test27' grep -q $'base_name\tkeypath-macos-27' "$ROOT/KeyPathInstallerLab/leases/cbx_test27/manifest.tsv" diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py index c46408847..6a5d5e282 100755 --- a/Scripts/lab/tests/scenario-matrix-runner-tests.py +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -133,6 +133,20 @@ def test_managed_macos15_drives_input_monitoring_then_verifies_runtime(self) -> self.assertIn("approve-input-monitoring cbx_test_lease", self.log.read_text()) self.assertIn("destroy cbx_test_lease", self.log.read_text()) + def test_parallels_desktop_bootstrap_verifies_inherited_console_first(self) -> None: + plan = self.plan(["create-desktop-lease", "desktop-bootstrap"], job_id="macos26-selectors") + value = json.loads(plan.read_text()) + value["jobs"][0].update({"provider": "parallels", "macOS": 26, "lane": "unmanaged-ui"}) + plan.write_text(json.dumps(value)) + + result = self.run_runner(plan) + + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text().splitlines() + verify_index = next(i for i, call in enumerate(calls) if call.startswith("verify-console-login ")) + bootstrap_index = next(i for i, call in enumerate(calls) if call.startswith("desktop-bootstrap ")) + self.assertLess(verify_index, bootstrap_index) + def test_failed_automatic_input_monitoring_falls_back_to_retained_checkpoint(self) -> None: self.lab.write_text(textwrap.dedent(f"""\ #!/bin/zsh From bdf0af3e00016b6eb24f150cb8046044d4dd7d28 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 08:20:27 -0700 Subject: [PATCH 60/99] Accept macOS 26 accessibility selector variants --- Scripts/lab/macos-26-selector-driver | 13 ++++++++- Scripts/lab/macos-26-selector-scenario | 17 ++++++++--- .../tests/macos-26-selector-scenario-tests.py | 29 +++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/Scripts/lab/macos-26-selector-driver b/Scripts/lab/macos-26-selector-driver index 5a8a4bb16..e7785751c 100755 --- a/Scripts/lab/macos-26-selector-driver +++ b/Scripts/lab/macos-26-selector-driver @@ -43,6 +43,8 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", required=True, type=pathlib.Path) parser.add_argument("--expect", action="append", default=[], help="required text in fresh System Settings AX evidence") + parser.add_argument("--expect-any", action="append", default=[], + help="pipe-separated semantic selector alternatives; at least one must appear") parser.add_argument("--scenario", default="macos-26-selector-probe") args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) @@ -66,14 +68,23 @@ def main() -> int: "--status", "failed", "--summary", "System Settings selector evidence was not valid JSON.", "--classification", "harness-transport-failure", "--step", "ax-snapshot"], check=True) return 1 + alternatives = [[selector for selector in group.split("|") if selector] for group in args.expect_any] missing = [selector for selector in args.expect if not contains(evidence, selector)] + missing += ["one of [" + ", ".join(group) + "]" for group in alternatives + if not group or not any(contains(evidence, selector) for selector in group)] if missing: subprocess.run([str(RESULT), "record", "--output", str(args.output / "result.json"), "--scenario", args.scenario, "--status", "blocked", "--summary", "macOS 26 System Settings surface did not expose the required selector evidence.", "--classification", "unsupported-os-selector", "--step", "selector-admission", "--message", "Missing selectors: " + ", ".join(missing), "--evidence", "system-settings-ax.json"], check=True) return 4 - (args.output / "selector-contract.json").write_text(json.dumps({"schemaVersion": 1, "macOSMajor": 26, "expected": args.expect, "evidence": "system-settings-ax.json"}, indent=2) + "\n") + (args.output / "selector-contract.json").write_text(json.dumps({ + "schemaVersion": 1, + "macOSMajor": 26, + "expected": args.expect, + "expectedAny": alternatives, + "evidence": "system-settings-ax.json", + }, indent=2) + "\n") return 0 diff --git a/Scripts/lab/macos-26-selector-scenario b/Scripts/lab/macos-26-selector-scenario index 4ffb5856b..5da88afbd 100755 --- a/Scripts/lab/macos-26-selector-scenario +++ b/Scripts/lab/macos-26-selector-scenario @@ -22,6 +22,15 @@ sleep_bin=${KEYPATH_SELECTOR_SLEEP:-/bin/sleep} url='x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' readiness="$output/accessibility-readiness.json" +contains_any() { + local file=$1 needle + shift + for needle in "$@"; do + /usr/bin/grep -Fq "$needle" "$file" && return 0 + done + return 1 +} + mkdir -p "$output" "$osascript_bin" -e 'tell application "System Settings" to quit' >/dev/null 2>&1 || true "$sleep_bin" 1 @@ -35,9 +44,9 @@ done ready=0 for attempt in {1..15}; do if "$peekaboo" snapshot --app "System Settings" --output "$readiness" >/dev/null 2>&1 \ - && /usr/bin/grep -Fq 'com.apple.settings.accessibility' "$readiness" \ && /usr/bin/grep -Fq 'Allow the applications below to control your computer.' "$readiness" \ - && /usr/bin/grep -Fq 'Peekaboo Lab Host' "$readiness"; then + && contains_any "$readiness" 'com.apple.settings.accessibility' 'com.apple.Accessibility-Settings.extension' \ + && contains_any "$readiness" 'Peekaboo Lab Host' 'peekaboo_Title'; then ready=1 break fi @@ -47,6 +56,6 @@ done exec "$driver" \ --output "$output" \ --scenario macos-26-selector-probe \ - --expect com.apple.settings.accessibility \ + --expect-any 'com.apple.settings.accessibility|com.apple.Accessibility-Settings.extension' \ --expect 'Allow the applications below to control your computer.' \ - --expect 'Peekaboo Lab Host' + --expect-any 'Peekaboo Lab Host|peekaboo_Title' diff --git a/Scripts/lab/tests/macos-26-selector-scenario-tests.py b/Scripts/lab/tests/macos-26-selector-scenario-tests.py index 3e07eeea3..1c516a24e 100755 --- a/Scripts/lab/tests/macos-26-selector-scenario-tests.py +++ b/Scripts/lab/tests/macos-26-selector-scenario-tests.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import json import os import pathlib import subprocess @@ -7,6 +8,7 @@ TOOL = pathlib.Path(__file__).resolve().parents[1] / "macos-26-selector-scenario" +DRIVER = pathlib.Path(__file__).resolve().parents[1] / "macos-26-selector-driver" class ScenarioTests(unittest.TestCase): @@ -24,12 +26,15 @@ def setUp(self): self.peekaboo = self.bin / "peekaboo" self.peekaboo.write_text( '#!/bin/sh\nout=\nwhile [ $# -gt 0 ]; do [ "$1" = --output ] && out=$2; shift; done\n' - 'printf \'%s\\n\' \'{"identifier":"com.apple.settings.accessibility","label":"Allow the applications below to control your computer.","row":"Peekaboo Lab Host"}\' > "$out"\n' + 'printf \'%s\\n\' \'{"identifier":"com.apple.Accessibility-Settings.extension","label":"Allow the applications below to control your computer.","row":"peekaboo_Title"}\' > "$out"\n' ) self.peekaboo.chmod(0o755) self.driver = self.bin / "driver" self.driver.write_text('#!/bin/sh\nprintf "%s\\n" "driver $*" >> "$CALLS"\n') self.driver.chmod(0o755) + self.sw_vers = self.bin / "sw_vers" + self.sw_vers.write_text('#!/bin/sh\n[ "$1" = -productVersion ] && echo 26.5.2 || echo "ProductName: macOS"\n') + self.sw_vers.chmod(0o755) def tearDown(self): self.tmp.cleanup() @@ -47,11 +52,29 @@ def test_prepares_accessibility_pane_and_uses_macos26_contract(self): self.assertEqual(result.returncode, 0, result.stderr) calls = self.calls.read_text() self.assertIn("Privacy_Accessibility", calls) - self.assertIn("--expect com.apple.settings.accessibility", calls) + self.assertIn("--expect-any com.apple.settings.accessibility|com.apple.Accessibility-Settings.extension", calls) self.assertIn("--expect Allow the applications below to control your computer.", calls) - self.assertIn("--expect Peekaboo Lab Host", calls) + self.assertIn("--expect-any Peekaboo Lab Host|peekaboo_Title", calls) self.assertTrue((self.output / "accessibility-readiness.json").exists()) + def test_driver_accepts_observed_semantic_selector_variants(self): + env = os.environ | { + "PATH": f"{self.bin}:{os.environ['PATH']}", + "KEYPATH_SELECTOR_PEEKABOO": str(self.peekaboo), + "KEYPATH_SELECTOR_RESULT": "/usr/bin/true", + } + result = subprocess.run([ + str(DRIVER), "--output", str(self.output), + "--expect-any", "com.apple.settings.accessibility|com.apple.Accessibility-Settings.extension", + "--expect", "Allow the applications below to control your computer.", + "--expect-any", "Peekaboo Lab Host|peekaboo_Title", + ], env=env, text=True, capture_output=True) + + self.assertEqual(result.returncode, 0, result.stderr) + contract = json.loads((self.output / "selector-contract.json").read_text()) + self.assertEqual(contract["expectedAny"][0][1], "com.apple.Accessibility-Settings.extension") + self.assertEqual(contract["expectedAny"][1][1], "peekaboo_Title") + if __name__ == "__main__": unittest.main() From 40f583336fd96a4f5c75af72f51008ca483f7265 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 08:38:52 -0700 Subject: [PATCH 61/99] Compose retained upgrade fixtures on lab host --- Scripts/lab/keypath-lab | 34 ++++++++- Scripts/lab/remote.sh | 101 ++++++++++++++++++++++++- Scripts/lab/tests/keypath-lab-tests.sh | 18 ++++- 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/Scripts/lab/keypath-lab b/Scripts/lab/keypath-lab index 4cf4e52a5..cc6d69ca2 100755 --- a/Scripts/lab/keypath-lab +++ b/Scripts/lab/keypath-lab @@ -123,8 +123,10 @@ case "$command" in installer_sha=$(shasum -a 256 "$installer" | awk '{print $1}') installer_name=$(basename "$installer") [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" - base_archive_key="${commit}-${installer_sha}" - archive_key="${base_archive_key}-h${harness_commit}" + base_product_archive_key="${commit}-${installer_sha}" + base_archive_key="$base_product_archive_key" + candidate_archive_key="${base_product_archive_key}-h${harness_commit}" + archive_key="$candidate_archive_key" fixture_name= fixture_sha= if [[ -n $fixture ]]; then @@ -140,6 +142,34 @@ case "$command" in fi temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/keypath-lab.XXXXXX") trap 'rm -rf "$temp_dir"' EXIT + if [[ -n $fixture ]] && ! remote archive-status "$candidate_archive_key" "$commit" "$installer_sha" "$installer_name" >/dev/null \ + && remote archive-status "$base_product_archive_key" "$commit" "$installer_sha" "$installer_name" >/dev/null; then + mkdir -p "$temp_dir/candidate-overlay" + git -C "$REPO_ROOT" archive "$harness_commit" Scripts/lab | tar -x -C "$temp_dir/candidate-overlay" + if [[ $lane == "managed-functional" ]]; then + mkdir -p "$temp_dir/candidate-installer-extract" + /usr/bin/ditto -x -k "$installer" "$temp_dir/candidate-installer-extract" + [[ -d $temp_dir/candidate-installer-extract/KeyPath.app ]] || die "managed installer must contain KeyPath.app at its root" + "$SCRIPT_DIR/mdm/generate-keypath-profiles" \ + --app "$temp_dir/candidate-installer-extract/KeyPath.app" \ + --output "$temp_dir/candidate-overlay/.keypath-lab/managed-policy" >/dev/null + fi + tar -czf "$temp_dir/candidate-overlay.tgz" -C "$temp_dir/candidate-overlay" . + upload_ticket=$(remote prepare-upload "$candidate_archive_key") + [[ $upload_ticket == /tmp/keypath-lab.* ]] || die "host returned an invalid upload ticket" + scp -q "$temp_dir/candidate-overlay.tgz" "$host:$upload_ticket" + remote derive-archive "$upload_ticket" "$base_product_archive_key" "$candidate_archive_key" "$commit" "$installer_sha" "$installer_name" "$harness_commit" + fi + if [[ -n $fixture ]] && remote archive-status "$candidate_archive_key" "$commit" "$installer_sha" "$installer_name" >/dev/null; then + fixture_archive_output=$(remote find-fixture-archive "$fixture_sha" "$fixture_name" || true) + fixture_source_key=$(printf '%s\n' "$fixture_archive_output" | awk -F '\t' '$1 == "fixture_archive" {print $2; exit}') + if [[ $fixture_source_key =~ ^[A-Za-z0-9._-]+$ ]]; then + remote derive-fixture-archive "$candidate_archive_key" "$fixture_source_key" "$archive_key" \ + "$commit" "$installer_sha" "$installer_name" "$harness_commit" "$fixture_sha" "$fixture_name" + remote create "$macos" "$lane" "$archive_key" "$commit" "$installer_sha" "$installer_name" "$ttl" "$desktop" "$tart_usb_passthrough" + exit $? + fi + fi if remote archive-status "$base_archive_key" "$commit" "$installer_sha" "$installer_name"; then mkdir -p "$temp_dir/overlay" git -C "$REPO_ROOT" archive "$harness_commit" Scripts/lab | tar -x -C "$temp_dir/overlay" diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index ff8af6cfe..b8ea6104c 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -89,7 +89,8 @@ field() { } set_field() { - local manifest=$1 key=$2 value=$3 temp="${manifest}.tmp.$$" + local manifest=$1 key=$2 value=$3 + local temp="${manifest}.tmp.$$" awk -F '\t' -v key="$key" -v value="$value" 'BEGIN {OFS="\t"} $1 == key {$0=key OFS value; found=1} {print} END {if (!found) print key, value}' "$manifest" > "$temp" mv "$temp" "$manifest" } @@ -736,6 +737,102 @@ derive_archive() { print "archive\tderived\t$key" } +find_fixture_archive() { + local fixture_sha=$1 fixture_name=$2 candidate source key actual_sha + [[ "$fixture_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid fixture checksum" + [[ "$fixture_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid fixture name" + ensure_roots + for candidate in "$ARCHIVES"/*(/N); do + source="$candidate/repo/.keypath-lab/source.tsv" + [[ -f "$candidate/ready.tsv" && -f "$source" ]] || continue + [[ "$(field "$candidate/ready.tsv" owner)" == "$OWNER" ]] || continue + [[ "$(field "$source" fixture_name)" == "$fixture_name" ]] || continue + [[ "$(field "$source" fixture_sha256)" == "$fixture_sha" ]] || continue + [[ -f "$candidate/repo/.keypath-lab/fixtures/$fixture_name" && ! -L "$candidate/repo/.keypath-lab/fixtures/$fixture_name" ]] || continue + actual_sha=$(shasum -a 256 "$candidate/repo/.keypath-lab/fixtures/$fixture_name" | awk '{print $1}') + [[ "$actual_sha" == "$fixture_sha" ]] || continue + key=${candidate:t} + valid_archive_key "$key" + print "fixture_archive\t$key" + return 0 + done + return 1 +} + +derive_fixture_archive() { + local source_key=$1 fixture_source_key=$2 key=$3 commit=$4 installer_sha=$5 installer_name=$6 + local harness_commit=$7 fixture_sha=$8 fixture_name=$9 + local source="$ARCHIVES/$source_key" fixture_source="$ARCHIVES/$fixture_source_key" destination="$ARCHIVES/$key" + local staging="$ARCHIVES/.staging-$key-$$" lock="$ARCHIVES/.lock-$key" attempt actual_sha fixture_path source_metadata + valid_archive_key "$source_key" + valid_archive_key "$fixture_source_key" + valid_archive_key "$key" + [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" + [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" + [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" + [[ "$harness_commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid harness commit" + [[ "$fixture_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid fixture checksum" + [[ "$fixture_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid fixture name" + [[ "$key" == "$source_key-$fixture_sha" ]] || die "fixture-derived archive key does not match its source and checksum" + ensure_roots + [[ -f "$source/ready.tsv" && -d "$source/repo/.git" ]] || die "candidate archive is unavailable" + [[ "$(field "$source/ready.tsv" owner)" == "$OWNER" ]] || die "candidate archive ownership mismatch" + [[ "$(field "$source/ready.tsv" keypath_commit)" == "$commit" ]] || die "candidate archive commit mismatch" + [[ "$(field "$source/ready.tsv" installer_sha256)" == "$installer_sha" ]] || die "candidate archive installer mismatch" + [[ "$(field "$source/ready.tsv" harness_commit)" == "$harness_commit" ]] || die "candidate archive harness mismatch" + source_metadata="$fixture_source/repo/.keypath-lab/source.tsv" + fixture_path="$fixture_source/repo/.keypath-lab/fixtures/$fixture_name" + [[ -f "$fixture_source/ready.tsv" && "$(field "$fixture_source/ready.tsv" owner)" == "$OWNER" ]] || die "fixture archive ownership mismatch" + [[ -f "$source_metadata" && "$(field "$source_metadata" fixture_name)" == "$fixture_name" ]] || die "fixture archive name mismatch" + [[ "$(field "$source_metadata" fixture_sha256)" == "$fixture_sha" ]] || die "fixture archive metadata checksum mismatch" + [[ -f "$fixture_path" && ! -L "$fixture_path" ]] || die "fixture archive payload is unavailable" + actual_sha=$(shasum -a 256 "$fixture_path" | awk '{print $1}') + [[ "$actual_sha" == "$fixture_sha" ]] || die "fixture archive payload checksum mismatch" + if [[ -f "$destination/ready.tsv" ]]; then + [[ "$(field "$destination/ready.tsv" fixture_sha256)" == "$fixture_sha" ]] || die "fixture-derived archive checksum mismatch" + print "archive\treused\t$key" + return + fi + if ! mkdir "$lock" 2>/dev/null; then + for attempt in {1..100}; do + [[ -f "$destination/ready.tsv" ]] && { print "archive\treused\t$key"; return; } + sleep 0.1 + done + die "timed out waiting for concurrent fixture-derived archive publish: $key" + fi + git clone -q --no-hardlinks "$source/repo" "$staging/repo" + mkdir -p "$staging/repo/.keypath-lab/fixtures" + cp "$fixture_path" "$staging/repo/.keypath-lab/fixtures/$fixture_name" + set_field "$staging/repo/.keypath-lab/source.tsv" fixture_name "$fixture_name" + set_field "$staging/repo/.keypath-lab/source.tsv" fixture_sha256 "$fixture_sha" + git -C "$staging/repo" config user.name "KeyPath Lab" + git -C "$staging/repo" config user.email "keypath-lab@localhost" + git -C "$staging/repo" add -f .keypath-lab + GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z \ + git -C "$staging/repo" commit -q -m "KeyPath lab fixture $fixture_sha" + [[ -z "$(git -C "$staging/repo" status --porcelain)" ]] || die "fixture-derived archive checkout is dirty" + { + print "owner\t$OWNER" + print "keypath_commit\t$commit" + print "harness_commit\t$harness_commit" + print "installer_sha256\t$installer_sha" + print "installer_name\t$installer_name" + print "fixture_sha256\t$fixture_sha" + print "fixture_name\t$fixture_name" + print "derived_from\t$source_key" + print "fixture_derived_from\t$fixture_source_key" + print "created_at\t$(utc_now)" + } > "$staging/ready.tsv" + if [[ -e "$destination" ]]; then + rm -rf "$staging" + rmdir "$lock" + die "fixture-derived archive destination exists without a ready marker: $key" + fi + mv "$staging" "$destination" + rmdir "$lock" + print "archive\tfixture-derived\t$key" +} + write_provisional_lease_manifest() { local lease=$1 slug=$2 macos=$3 lane=$4 provider=$5 archive_key=$6 commit=$7 installer_sha=$8 installer_name=$9 repo=${10} created=${11} expires=${12} desktop=${13} local manifest identity_scope @@ -2358,6 +2455,8 @@ case "$action" in prepare-upload) [[ $# -eq 1 ]] || die "prepare-upload requires archive key"; prepare_upload "$1" ;; install-archive) [[ $# -eq 5 ]] || die "install-archive requires ticket, key, commit, checksum, and name"; install_archive "$@" ;; derive-archive) [[ $# -eq 7 ]] || die "derive-archive requires ticket, source key, destination key, commit, checksum, name, and harness commit"; derive_archive "$@" ;; + find-fixture-archive) [[ $# -eq 2 ]] || die "find-fixture-archive requires fixture checksum and name"; find_fixture_archive "$@" ;; + derive-fixture-archive) [[ $# -eq 9 ]] || die "derive-fixture-archive requires source key, fixture source key, destination key, commit, installer checksum, installer name, harness commit, fixture checksum, and fixture name"; derive_fixture_archive "$@" ;; create) [[ $# -eq 8 || $# -eq 9 ]] || die "create requires macOS, test lane, archive, commit, checksum, name, ttl, desktop, and optional Tart USB passthrough"; create_lease "$@" ;; install-app) [[ $# -eq 1 ]] || die "install-app requires lease"; install_app "$1" ;; install-runtime) [[ $# -eq 1 ]] || die "install-runtime requires lease"; install_runtime "$1" ;; diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 890e0b513..87e46541f 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -346,6 +346,8 @@ publish_checksum=$(shasum -a 256 "$LAB_DIR/scenarios/installer-scenario" | awk ' publish_key="$publish_commit-$publish_checksum" mkdir -p "$TMP/upload/repo/.keypath-lab/installer" cp "$LAB_DIR/scenarios/installer-scenario" "$TMP/upload/repo/.keypath-lab/installer/installer.zip" +printf 'keypath_commit\t%s\ninstaller_name\tinstaller.zip\ninstaller_sha256\t%s\n' \ + "$publish_commit" "$publish_checksum" > "$TMP/upload/repo/.keypath-lab/source.tsv" printf '.keypath-lab/\n' > "$TMP/upload/repo/.gitignore" for pass in 1 2; do ticket=$(run_remote prepare-upload "$publish_key") @@ -403,7 +405,8 @@ chmod +x "$repo/Scripts/lab/nameplate-instrumentation" chmod +x "$repo/Scripts/lab/mdm/publish-managed-profiles" echo installer > "$repo/.keypath-lab/installer/KeyPath.zip" echo older-installer > "$repo/.keypath-lab/fixtures/KeyPath-beta3.zip" -printf 'fixture_name\tKeyPath-beta3.zip\n' > "$repo/.keypath-lab/source.tsv" +fixture_checksum=$(shasum -a 256 "$repo/.keypath-lab/fixtures/KeyPath-beta3.zip" | awk '{print $1}') +printf 'fixture_name\tKeyPath-beta3.zip\nfixture_sha256\t%s\n' "$fixture_checksum" > "$repo/.keypath-lab/source.tsv" mkdir -p "$ROOT/KeyPathInstallerLab/managed-identities" printf '%s\n' '15151515-1515-1515-1515-151515151515' \ > "$ROOT/KeyPathInstallerLab/managed-identities/keypath-macos-15-managed.enrollment-id" @@ -418,6 +421,19 @@ cat > "$ROOT/KeyPathInstallerLab/archives/$archive_key/ready.tsv" </dev/null + commit=$(printf 'a%.0s' {1..40}) checksum=$(printf 'b%.0s' {1..64}) set +e From ae3bfe97304630e72a38fd8a50525106dc45a77f Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 08:44:07 -0700 Subject: [PATCH 62/99] Run macOS 26 upgrade through owned controller --- Scripts/lab/scenarios/installer-scenario | 22 +++++++++++++++++++++- Scripts/lab/scenarios/matrix-catalog.json | 2 +- Scripts/lab/tests/scenario-matrix-tests.py | 5 +++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index 0154de460..a14e84d78 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -154,6 +154,26 @@ case "$name" in Scripts/lab/assert-app-version 1.0.0 | tee "$artifact_dir/final-version.tsv" /usr/bin/codesign -dvvv /Applications/KeyPath.app > "$artifact_dir/final-signature.txt" 2>&1 ;; + upgrade-beta3-before) + Scripts/lab/assert-app-version 1.0.0-beta3 | tee "$artifact_dir/beta-version.tsv" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/beta-app-identity.txt" + record_scenario_result passed \ + "The signed beta-3 fixture is installed before the controller-owned upgrade." \ + --evidence beta-version.tsv --evidence beta-app-identity.txt + ;; + upgrade-beta3-after) + require_installed_cli + Scripts/lab/assert-app-version 1.0.0 | tee "$artifact_dir/current-version.tsv" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/current-app-identity.txt" + assert_ready | tee "$artifact_dir/current-ready.tsv" + "$installed_cli" system inspect --json > "$artifact_dir/current-inspect.json" + record_scenario_result passed \ + "The exact current artifact upgraded beta-3 and converged to an independently ready runtime." \ + --evidence current-version.tsv --evidence current-app-identity.txt \ + --evidence current-ready.tsv --evidence current-inspect.json + ;; reboot-persistence-before) require_installed_cli if ! assert_ready | tee "$artifact_dir/ready.tsv"; then @@ -386,7 +406,7 @@ case "$name" in ;; *) print -u2 "Unknown scenario: $name" - print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current reboot-persistence-before reboot-persistence-after uninstall reinstall cancellation-recovery-before cancellation-recovery-after failure-ownership-self-test artifact-capture macos-26-selector-scenario macos-27-selector-scenario macos-27-regression" + print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current upgrade-beta3-before upgrade-beta3-after reboot-persistence-before reboot-persistence-after uninstall reinstall cancellation-recovery-before cancellation-recovery-after failure-ownership-self-test artifact-capture macos-26-selector-scenario macos-27-selector-scenario macos-27-regression" exit 2 ;; esac diff --git a/Scripts/lab/scenarios/matrix-catalog.json b/Scripts/lab/scenarios/matrix-catalog.json index accf4e8dd..c6417750e 100644 --- a/Scripts/lab/scenarios/matrix-catalog.json +++ b/Scripts/lab/scenarios/matrix-catalog.json @@ -78,7 +78,7 @@ "estimatedMinutes": 50, "nightlyCore": true, "cleanup": "destroy-owned-lease", - "steps": ["create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-to-current", "artifact-capture"], + "steps": ["create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-before", "install-exact-artifact", "upgrade-beta3-after", "artifact-capture"], "factors": {"platform": "macos26", "lane": "managed", "family": "upgrade", "boundary": "version", "evidence": "signature"} }, { diff --git a/Scripts/lab/tests/scenario-matrix-tests.py b/Scripts/lab/tests/scenario-matrix-tests.py index 84536064b..e29f159c4 100755 --- a/Scripts/lab/tests/scenario-matrix-tests.py +++ b/Scripts/lab/tests/scenario-matrix-tests.py @@ -56,6 +56,11 @@ def test_operator_and_physical_cases_require_separate_opt_in(self): operator_ids = {job["id"] for job in operator["jobs"]} self.assertIn("macos15-cancellation-recovery", operator_ids) self.assertNotIn("macos15-physical-remap", operator_ids) + upgrade = next(job for job in operator["jobs"] if job["id"] == "macos26-upgrade") + self.assertEqual(upgrade["steps"], [ + "create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-before", + "install-exact-artifact", "upgrade-beta3-after", "artifact-capture", + ]) physical = self.run_plan("--cadence", "weekly", "--include-physical") physical_ids = {job["id"] for job in physical["jobs"]} From 111a733aa1b7ddd9cfa7a5d8feb6fe16b2f3bd43 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 08:45:04 -0700 Subject: [PATCH 63/99] Admit upgrade evidence steps in campaign runner --- Scripts/lab/scenario-matrix-runner | 1 + 1 file changed, 1 insertion(+) diff --git a/Scripts/lab/scenario-matrix-runner b/Scripts/lab/scenario-matrix-runner index 0a65a7d7c..e46bf7c7e 100755 --- a/Scripts/lab/scenario-matrix-runner +++ b/Scripts/lab/scenario-matrix-runner @@ -27,6 +27,7 @@ KNOWN_STEPS = { "create-fresh-lease", "create-fresh-lease-with-fixture", "create-desktop-lease", "create-usb-lease", "install-exact-artifact", "install-fixture", "desktop-bootstrap", "reboot-guest", "artifact-capture", "managed-capabilities", "repair-kanata-service", "upgrade-beta3-to-current", + "upgrade-beta3-before", "upgrade-beta3-after", "reboot-persistence-before", "reboot-persistence-after", "uninstall", "reinstall", "launch", "helper-daemon-health", "cancellation-recovery-before", "cancellation-recovery-after", "macos-26-selector-scenario", "macos-27-selector-scenario", From e3444c08bd94ff0a5df635e6bfdda21aa84aa396 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 08:53:46 -0700 Subject: [PATCH 64/99] Capture Kanata service failure evidence --- Scripts/lab/capture-controller-state | 38 ++++++++++++++++++++++++++ Scripts/lab/remote.sh | 15 ++++++++-- Scripts/lab/tests/keypath-lab-tests.sh | 1 + 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100755 Scripts/lab/capture-controller-state diff --git a/Scripts/lab/capture-controller-state b/Scripts/lab/capture-controller-state new file mode 100755 index 000000000..9802a7d06 --- /dev/null +++ b/Scripts/lab/capture-controller-state @@ -0,0 +1,38 @@ +#!/bin/zsh +set -euo pipefail + +output="$PWD/.keypath-lab/scenario-output/controller-capture" +logs="$output/logs" +service_logs="$output/service-logs" +capture_home="${KEYPATH_CAPTURE_USER_HOME:-$HOME}" + +mkdir -p "$logs" "$service_logs" +sw_vers > "$output/sw-vers.txt" +date -u +%Y-%m-%dT%H:%M:%SZ > "$output/captured-at.txt" +cp -R "$capture_home/Library/Logs/KeyPath/." "$logs/" 2>/dev/null || true + +/bin/launchctl print system/com.keypath.kanata > "$output/kanata-launchd.txt" 2>&1 || true +/bin/ps -axo pid,ppid,user,state,lstart,command > "$output/processes.txt" 2>&1 || true +/usr/bin/log show --last 10m --style compact \ + --predicate 'process == "kanata" OR process == "kanata-launcher" OR eventMessage CONTAINS[c] "com.keypath.kanata"' \ + > "$output/kanata-unified.log" 2>&1 || true + +for source in \ + /var/log/com.keypath.kanata.stdout.log \ + /var/log/com.keypath.kanata.stderr.log +do + if [[ -r "$source" ]]; then + cp "$source" "$service_logs/${source:t}" + elif /usr/bin/sudo -n /usr/bin/test -r "$source" 2>/dev/null; then + /usr/bin/sudo -n /bin/cp "$source" "$service_logs/${source:t}" 2>/dev/null || true + fi +done + +if [[ -x /Applications/KeyPath.app/Contents/MacOS/keypath-cli ]]; then + /Applications/KeyPath.app/Contents/MacOS/keypath-cli system inspect --json \ + > "$output/system-inspect.json" 2>/dev/null || true +fi + +if (( EUID == 0 )) && [[ -n "${KEYPATH_CAPTURE_OWNER:-}" ]]; then + /usr/sbin/chown -R "$KEYPATH_CAPTURE_OWNER":staff "$output" 2>/dev/null || true +fi diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index b8ea6104c..82a6d5144 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1667,7 +1667,7 @@ list_leases() { } collect_artifacts() { - local lease=$1 manifest output exit_code macos repo archive provider_resource parallels_cli + local lease=$1 manifest output exit_code macos repo archive provider_resource parallels_cli guest_repo local parallels_resource_pattern='^[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}$' local nameplate_restore=0 nameplate_hide_status=not-needed nameplate_restore_status=not-needed manifest=$(owned_manifest "$lease") @@ -1692,9 +1692,20 @@ collect_artifacts() { fi fi archive="$output/scenario-output.tar.gz" + if [[ "${KEYPATH_LAB_TESTING:-0}" != "1" && "$macos" != "15" ]]; then + provider_resource=$(field "$manifest" provider_resource) + [[ "$provider_resource" =~ $parallels_resource_pattern ]] || 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" + guest_repo="/Users/keypathqa/crabbox/$lease/repo" + "$parallels_cli" exec "$provider_resource" /usr/bin/env \ + KEYPATH_CAPTURE_USER_HOME=/Users/keypathqa KEYPATH_CAPTURE_OWNER=keypathqa \ + /bin/zsh "$guest_repo/Scripts/lab/capture-controller-state" \ + > "$output/privileged-controller-capture.log" 2>&1 || true + fi set +e (cd "$repo" && run_with_download "$macos" "$lease" ".keypath-lab/scenario-output.tar.gz" "$archive" \ - /bin/zsh -lc 'set -e; out=.keypath-lab/scenario-output/controller-capture; mkdir -p "$out/logs"; sw_vers > "$out/sw-vers.txt"; date -u +%Y-%m-%dT%H:%M:%SZ > "$out/captured-at.txt"; cp -R "$HOME/Library/Logs/KeyPath/." "$out/logs/" 2>/dev/null || true; /Applications/KeyPath.app/Contents/MacOS/keypath-cli system inspect --json > "$out/system-inspect.json" 2>/dev/null || true; tar -czf .keypath-lab/scenario-output.tar.gz -C .keypath-lab scenario-output') > "$output/download.log" 2>&1 + /bin/zsh -lc 'set -e; /bin/zsh Scripts/lab/capture-controller-state; tar -czf .keypath-lab/scenario-output.tar.gz -C .keypath-lab scenario-output') > "$output/download.log" 2>&1 exit_code=$? set -e if (( exit_code == 0 )); then diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 87e46541f..34dd09ced 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -261,6 +261,7 @@ grep -Fq 'Tart guest SSH did not recover after reboot' "$REMOTE" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" /bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" /bin/zsh -n "$LAB_DIR/install-runtime" +/bin/zsh -n "$LAB_DIR/capture-controller-state" python3 "$LAB_DIR/tests/scenario-matrix-tests.py" python3 "$LAB_DIR/tests/scenario-matrix-runner-tests.py" python3 "$LAB_DIR/tests/install-runtime-tests.py" From b764a4d750abb9daebf34875a0e6b942936ec50a Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 09:03:36 -0700 Subject: [PATCH 65/99] Preserve managed base for desktop leases --- Scripts/lab/remote.sh | 2 +- Scripts/lab/tests/keypath-lab-tests.sh | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 82a6d5144..43267de41 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -74,7 +74,7 @@ base_for() { local macos=$1 lane=$2 desktop=${3:-0} if [[ "$macos" == "15" ]]; then [[ "$lane" == "managed-functional" ]] && print keypath-macos-15-managed || print ghcr.io/cirruslabs/macos-sequoia-base:latest - elif [[ ("$macos" == "26" || "$macos" == "27") && "$desktop" == "1" ]]; then + elif [[ ("$macos" == "26" || "$macos" == "27") && "$desktop" == "1" && "$lane" != "managed-functional" ]]; then print "keypath-macos-$macos-desktop" else [[ "$lane" == "managed-functional" ]] && print "keypath-macos-$macos-managed" || print "keypath-macos-$macos" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 34dd09ced..c1725bb43 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -598,6 +598,14 @@ assert_contains "$managed_busy_output" $'managed_identity_busy\tactive_lease=cbx assert_contains "$managed_busy_output" $'scope=shared:26262626-2626-2626-2626-262626262626' run_remote destroy cbx_test26 >/dev/null +managed_desktop_create=$(run_remote create 26 managed-functional "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) +assert_contains "$managed_desktop_create" $'lease_id\tcbx_desktop26' +managed_desktop_manifest="$ROOT/KeyPathInstallerLab/leases/cbx_desktop26/manifest.tsv" +grep -q $'base_name\tkeypath-macos-26-managed' "$managed_desktop_manifest" +grep -q $'desktop_enabled\ttrue' "$managed_desktop_manifest" +grep -q -- '--parallels-template keypath-macos-26-managed' "$CALLS" +run_remote destroy cbx_desktop26 >/dev/null + set +e lock_output=$(KEYPATH_LAB_ADMISSION_WAIT_ATTEMPTS=1 run_remote create 15 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 0 2>&1) lock_exit=$? From 375bde3afc9765d553dea65213b589ebd1a1af8b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 09:13:48 -0700 Subject: [PATCH 66/99] Model Parallels no-input upgrade boundary --- Scripts/lab/install-runtime | 44 +++++++++++++++++++ Scripts/lab/keypath-lab | 6 +++ Scripts/lab/remote.sh | 22 +++++++++- Scripts/lab/scenario-matrix-runner | 6 ++- Scripts/lab/scenarios/installer-scenario | 27 +++++++++--- Scripts/lab/scenarios/matrix-catalog.json | 2 +- Scripts/lab/tests/install-runtime-tests.py | 32 +++++++++++++- .../lab/tests/scenario-matrix-runner-tests.py | 12 +++++ Scripts/lab/tests/scenario-matrix-tests.py | 2 +- docs/testing/remote-installer-lab.md | 9 +++- 10 files changed, 147 insertions(+), 15 deletions(-) diff --git a/Scripts/lab/install-runtime b/Scripts/lab/install-runtime index ab27f4ad5..95f0646d1 100755 --- a/Scripts/lab/install-runtime +++ b/Scripts/lab/install-runtime @@ -15,6 +15,9 @@ app_open=${KEYPATH_INSTALL_RUNTIME_OPEN:-/usr/bin/open} app_quit=${KEYPATH_INSTALL_RUNTIME_QUIT:-/usr/bin/osascript} config_path=${KEYPATH_INSTALL_RUNTIME_CONFIG:-$HOME/.config/keypath/keypath.kbd} bootstrap_timeout=${KEYPATH_INSTALL_RUNTIME_BOOTSTRAP_TIMEOUT:-60} +allow_no_input_device=${KEYPATH_INSTALL_RUNTIME_ALLOW_NO_INPUT_DEVICE:-0} +kanata_binary=${KEYPATH_INSTALL_RUNTIME_KANATA_BINARY:-/Applications/KeyPath.app/Contents/Library/KeyPath/Kanata Engine.app/Contents/MacOS/kanata} +launchctl_binary=${KEYPATH_INSTALL_RUNTIME_LAUNCHCTL:-/bin/launchctl} mkdir -p "$output" [[ -x "$cli" ]] || { @@ -133,6 +136,44 @@ EOF return 1 } +verify_no_input_device_boundary() { + [[ "$allow_no_input_device" == "1" ]] || return 1 + [[ "$completion_state" == "verification-failed" && "$claimed_success" == "false" ]] || return 1 + + local failure_reason failed_postcondition + failure_reason=$(json_field "$output/install-report.json" failureReason) + failed_postcondition=$(json_field "$output/install-report.json" failedPostconditions.0) + [[ "$failure_reason" == *"Kanata did not become running + TCP responsive within readiness timeout"* ]] || return 1 + [[ "$failed_postcondition" == "runtime-ready-or-approval-pending" ]] || return 1 + [[ -x "$kanata_binary" && -x "$launchctl_binary" ]] || return 1 + + "$launchctl_binary" print system/com.keypath.kanata > "$output/no-input-launchd.txt" 2>&1 || return 1 + "$kanata_binary" --list > "$output/no-input-device-list.txt" 2>&1 || return 1 + /usr/bin/grep -Fq 'managed_by = com.apple.xpc.ServiceManagement' "$output/no-input-launchd.txt" || return 1 + /usr/bin/grep -Fq 'last exit code = 1' "$output/no-input-launchd.txt" || return 1 + /usr/bin/grep -Fq 'No devices found.' "$output/no-input-device-list.txt" || return 1 + + cat > "$output/assert-state.json" < "$artifact_dir/current-inspect.json" - record_scenario_result passed \ - "The exact current artifact upgraded beta-3 and converged to an independently ready runtime." \ - --evidence current-version.tsv --evidence current-app-identity.txt \ - --evidence current-ready.tsv --evidence current-inspect.json + install_state="$PWD/.keypath-lab/scenario-output/install-runtime/state.tsv" + install_boundary="$PWD/.keypath-lab/scenario-output/install-runtime/assert-state.json" + install_status=$(/usr/bin/awk -F '\t' '$1 == "status" {value=$2} END {print value}' "$install_state") + if [[ "$install_status" == "environment-limited-no-input-device" ]]; then + [[ -s "$install_boundary" ]] || { + record_scenario_result failed \ + "The no-input upgrade boundary lacked its independent controller evidence." \ + --classification harness-transport-failure --step no-input-boundary + exit 1 + } + /bin/cp "$install_boundary" "$artifact_dir/install-boundary.json" + record_scenario_result passed \ + "The exact current artifact upgraded beta-3 with preserved identity; runtime startup reached the independently proven Parallels no-input-device boundary." \ + --evidence current-version.tsv --evidence current-app-identity.txt \ + --evidence current-inspect.json --evidence install-boundary.json + else + assert_ready | tee "$artifact_dir/current-ready.tsv" + record_scenario_result passed \ + "The exact current artifact upgraded beta-3 and converged to an independently ready runtime." \ + --evidence current-version.tsv --evidence current-app-identity.txt \ + --evidence current-ready.tsv --evidence current-inspect.json + fi ;; reboot-persistence-before) require_installed_cli diff --git a/Scripts/lab/scenarios/matrix-catalog.json b/Scripts/lab/scenarios/matrix-catalog.json index c6417750e..6ac6494cd 100644 --- a/Scripts/lab/scenarios/matrix-catalog.json +++ b/Scripts/lab/scenarios/matrix-catalog.json @@ -78,7 +78,7 @@ "estimatedMinutes": 50, "nightlyCore": true, "cleanup": "destroy-owned-lease", - "steps": ["create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-before", "install-exact-artifact", "upgrade-beta3-after", "artifact-capture"], + "steps": ["create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-before", "install-upgrade-artifact", "upgrade-beta3-after", "artifact-capture"], "factors": {"platform": "macos26", "lane": "managed", "family": "upgrade", "boundary": "version", "evidence": "signature"} }, { diff --git a/Scripts/lab/tests/install-runtime-tests.py b/Scripts/lab/tests/install-runtime-tests.py index c4ed8fa32..9ecf28c46 100644 --- a/Scripts/lab/tests/install-runtime-tests.py +++ b/Scripts/lab/tests/install-runtime-tests.py @@ -58,7 +58,7 @@ def setUp(self) -> None: exit 0 fi if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "verification-failed" ]]; then - print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"verification-failed","userActionRequired":true,"success":false}}}}' + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"verification-failed","userActionRequired":true,"success":false,"failureReason":"Kanata did not become running + TCP responsive within readiness timeout","failedPostconditions":["runtime-ready-or-approval-pending"]}}}}' exit 1 fi if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "completed-waiting" ]]; then @@ -78,11 +78,21 @@ def setUp(self) -> None: print 'runtime\tready' """)) self.assert_runtime.chmod(0o755) + self.kanata = self.directory / "kanata" + self.kanata.write_text("#!/bin/zsh\nprint 'No devices found. Ensure a physical device is attached.'\n") + self.kanata.chmod(0o755) + self.launchctl = self.directory / "launchctl" + self.launchctl.write_text(textwrap.dedent("""\ + #!/bin/zsh + print 'managed_by = com.apple.xpc.ServiceManagement' + print 'last exit code = 1' + """)) + self.launchctl.chmod(0o755) def tearDown(self) -> None: self.temporary.cleanup() - def invoke(self, *, mode: str = "waiting") -> subprocess.CompletedProcess[str]: + def invoke(self, *, mode: str = "waiting", allow_no_input: bool = False) -> subprocess.CompletedProcess[str]: env = { **os.environ, "KEYPATH_INSTALL_RUNTIME_CLI": str(self.cli), @@ -91,6 +101,9 @@ def invoke(self, *, mode: str = "waiting") -> subprocess.CompletedProcess[str]: "KEYPATH_INSTALL_RUNTIME_QUIT": str(self.app_quit), "KEYPATH_INSTALL_RUNTIME_CONFIG": str(self.config), "KEYPATH_INSTALL_RUNTIME_BOOTSTRAP_TIMEOUT": "2", + "KEYPATH_INSTALL_RUNTIME_KANATA_BINARY": str(self.kanata), + "KEYPATH_INSTALL_RUNTIME_LAUNCHCTL": str(self.launchctl), + "KEYPATH_INSTALL_RUNTIME_ALLOW_NO_INPUT_DEVICE": "1" if allow_no_input else "0", "KEYPATH_TEST_INSTALL_MODE": mode, } return subprocess.run( @@ -136,6 +149,21 @@ def test_verification_failure_with_user_action_is_not_approval_wait(self) -> Non state = (self.directory / ".keypath-lab/scenario-output/install-runtime/state.tsv").read_text() self.assertIn("status\tfailed", state) + def test_upgrade_can_record_exact_no_input_provider_boundary(self) -> None: + result = self.invoke(mode="verification-failed", allow_no_input=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("install_runtime\tenvironment-limited-no-input-device", result.stdout) + state = (self.directory / ".keypath-lab/scenario-output/install-runtime/state.tsv").read_text() + self.assertIn("status\tenvironment-limited-no-input-device", state) + evidence = json.loads((self.directory / ".keypath-lab/scenario-output/install-runtime/assert-state.json").read_text()) + self.assertEqual(evidence["boundary"]["inputDevices"], 0) + + def test_upgrade_boundary_fails_closed_without_zero_device_evidence(self) -> None: + self.kanata.write_text("#!/bin/zsh\nprint 'Available keyboard devices: physical keyboard'\n") + result = self.invoke(mode="verification-failed", allow_no_input=True) + self.assertEqual(result.returncode, 1) + self.assertIn("KeyPath's installer reported failure", result.stderr) + def test_completed_install_requiring_permission_is_resumable(self) -> None: result = self.invoke(mode="completed-waiting") self.assertEqual(result.returncode, 4, result.stderr) diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py index 6a5d5e282..bbbbd6f3e 100755 --- a/Scripts/lab/tests/scenario-matrix-runner-tests.py +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -86,6 +86,18 @@ def test_executes_steps_updates_dashboard_and_destroys_owned_lease(self) -> None self.assertIn("--status running", dashboard) self.assertIn("--status passed", dashboard) + def test_upgrade_boundary_uses_dedicated_controller_command(self) -> None: + fixture = self.directory / "KeyPath-beta3.zip" + fixture.write_bytes(b"fixture") + plan = self.plan(["create-fresh-lease-with-fixture", "install-upgrade-artifact"]) + + result = self.run_runner(plan, "--fixture", str(fixture)) + + self.assertEqual(result.returncode, 0, result.stderr) + lab_log = self.log.read_text() + self.assertIn("--fixture", lab_log) + self.assertIn("install-upgrade-runtime cbx_test_lease", lab_log) + def test_human_checkpoint_waits_without_cleanup_then_resumes(self) -> None: plan = self.plan(["create-fresh-lease", "operator-visible-action", "artifact-capture"]) first = self.run_runner(plan) diff --git a/Scripts/lab/tests/scenario-matrix-tests.py b/Scripts/lab/tests/scenario-matrix-tests.py index e29f159c4..4d8c5bf2e 100755 --- a/Scripts/lab/tests/scenario-matrix-tests.py +++ b/Scripts/lab/tests/scenario-matrix-tests.py @@ -59,7 +59,7 @@ def test_operator_and_physical_cases_require_separate_opt_in(self): upgrade = next(job for job in operator["jobs"] if job["id"] == "macos26-upgrade") self.assertEqual(upgrade["steps"], [ "create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-before", - "install-exact-artifact", "upgrade-beta3-after", "artifact-capture", + "install-upgrade-artifact", "upgrade-beta3-after", "artifact-capture", ]) physical = self.run_plan("--cadence", "weekly", "--include-physical") diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 4628f3ecc..92e9a1228 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -617,8 +617,13 @@ waiting; it cannot pre-authorize or bypass a future interaction. A process interruption never blindly repeats an uncertain mutation. Lease creation can be recovered only when both the durable command log and the owned controller status identify the same ready lease; any other uncertain mutation blocks for evidence -review. The `install-exact-artifact` matrix step routes through `install-runtime`; -when it waits for macOS approval, acknowledgement means “verify now,” not “assume +review. The `install-exact-artifact` matrix step routes through `install-runtime`. +The macOS 26 upgrade uses `install-upgrade-runtime`: it retains the same strict +installer lineage and signature checks, but can record the exact, independently +proven Parallels Apple-virtualization boundary where the registered Kanata +service has zero guest-visible input devices. No other installer failure is +accepted by that boundary. When an installer step waits for macOS approval, +acknowledgement means “verify now,” not “assume the installer passed.” Failed jobs still collect available artifacts and destroy their owned leases, allowing independent later waves to continue. A waiting job retains its lease and pauses only subsequent work on that provider. `matrix-report.json` From 8e9beee1a4f37beb630fdefe121d3ca4bcec84ff Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 09:25:19 -0700 Subject: [PATCH 67/99] Record launch and helper health evidence --- Scripts/lab/scenarios/installer-scenario | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index f374a5968..ce7287f00 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -107,6 +107,11 @@ case "$name" in require_installed_cli "$installed_cli" system inspect --json | tee "$artifact_dir/system-inspect.json" Scripts/lab/probe-kanata-tcp 2> "$artifact_dir/tcp-readiness.stderr" | tee "$artifact_dir/tcp-readiness.json" + assert_ready | tee "$artifact_dir/ready.tsv" + record_scenario_result passed \ + "The installed helper, managed services, Kanata process, and TCP control plane are independently healthy." \ + --evidence system-inspect.json --evidence tcp-readiness.json \ + --evidence tcp-readiness.stderr --evidence ready.tsv ;; managed-capabilities) if [[ "$lane" != "managed-functional" ]]; then @@ -124,6 +129,11 @@ case "$name" in open -a KeyPath sleep 3 pgrep -fl '/Applications/KeyPath.app/Contents/MacOS/KeyPath' | tee "$artifact_dir/processes.txt" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + record_scenario_result passed \ + "The exact signed KeyPath application launched and remained running in the disposable guest." \ + --evidence processes.txt --evidence app-identity.txt ;; repair-reinstall) require_installed_cli From c029ff5e29bc562238ae15eba3c69adc6e2e10fb Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 09:37:30 -0700 Subject: [PATCH 68/99] Launch GUI before cancellation checkpoint --- Scripts/lab/scenarios/installer-scenario | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index ce7287f00..ce43f6395 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -338,7 +338,22 @@ case "$name" in "$installed_cli" system inspect --json > "$artifact_dir/system-inspect.json" /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app capture_app_identity "$artifact_dir/app-identity.txt" - /usr/bin/pgrep -x KeyPath > "$artifact_dir/keypath-pid.txt" + /usr/bin/open -a KeyPath + gui_ready=0 + for _ in {1..40}; do + if /usr/bin/pgrep -x KeyPath > "$artifact_dir/keypath-pid.txt"; then + gui_ready=1 + break + fi + sleep 0.25 + done + if (( gui_ready == 0 )); then + record_scenario_result failed \ + "The installed KeyPath GUI did not launch for the cancellation checkpoint." \ + --classification keypath-product-failure --step cancellation-gui-launch \ + --evidence ready.tsv --evidence app-identity.txt + exit 1 + fi config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" if [[ ! -f "$config_sentinel" ]]; then mkdir -p "${config_sentinel:h}" From 158b12072b88db6d1381a0706f995da1289d09b1 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 09:56:47 -0700 Subject: [PATCH 69/99] Record successful lab scenario evidence --- Scripts/lab/macos-26-selector-driver | 6 ++++++ Scripts/lab/macos-27-selector-driver | 7 +++++++ Scripts/lab/scenarios/installer-scenario | 15 +++++++++++++-- Scripts/lab/tests/keypath-lab-tests.sh | 3 +++ .../lab/tests/macos-26-selector-driver-tests.py | 3 +++ .../lab/tests/macos-27-selector-driver-tests.py | 5 +++++ 6 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Scripts/lab/macos-26-selector-driver b/Scripts/lab/macos-26-selector-driver index e7785751c..531b41ddf 100755 --- a/Scripts/lab/macos-26-selector-driver +++ b/Scripts/lab/macos-26-selector-driver @@ -85,6 +85,12 @@ def main() -> int: "expectedAny": alternatives, "evidence": "system-settings-ax.json", }, indent=2) + "\n") + subprocess.run([ + str(RESULT), "record", "--output", str(args.output / "result.json"), + "--scenario", args.scenario, "--status", "passed", + "--summary", "macOS 26 exposed every accepted fail-closed System Settings selector.", + "--evidence", "system-settings-ax.json", "--evidence", "selector-contract.json", + ], check=True) return 0 diff --git a/Scripts/lab/macos-27-selector-driver b/Scripts/lab/macos-27-selector-driver index 96a806dd8..b3c113b0b 100755 --- a/Scripts/lab/macos-27-selector-driver +++ b/Scripts/lab/macos-27-selector-driver @@ -133,6 +133,13 @@ def main() -> int: "evidence": snapshot.name, "preflightEvidence": preflight_path.name, }, indent=2) + "\n") + subprocess.run([ + str(RESULT), "record", "--output", str(args.output / "result.json"), + "--scenario", args.scenario, "--status", "passed", + "--summary", "macOS 27 exposed every accepted fail-closed System Settings selector.", + "--evidence", snapshot.name, "--evidence", "selector-contract.json", + "--evidence", preflight_path.name, + ], check=True) return 0 diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index ce43f6395..c195c82fa 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -124,6 +124,11 @@ case "$name" in require_installed_cli Scripts/lab/mdm/probe-managed-capabilities --output "$artifact_dir" \ --managed-policy-manifest /Library/KeyPathLab/managed-policy/manifest.json + record_scenario_result passed \ + "The managed macOS runtime exposed every required KeyPath capability, policy assertion, system extension, launchd service, and TCP control-plane check." \ + --evidence service-status.json --evidence managed-policy-verification.tsv \ + --evidence system-extensions.txt --evidence kanata-launchd.txt \ + --evidence tcp-readiness.json --evidence result.tsv ;; launch) open -a KeyPath @@ -143,7 +148,7 @@ case "$name" in repair-kanata-service) require_installed_cli state="$artifact_dir/run-state.json" - result="$artifact_dir/result.json" + runner_result="$artifact_dir/runner-result.json" plan="$PWD/Scripts/lab/scenarios/plans/repair-kanata-service.json" if [[ ! -f "$state" ]]; then assert_ready | tee "$artifact_dir/pre-damage-ready.tsv" @@ -152,9 +157,15 @@ case "$name" in "$installed_cli" service status --json > "$artifact_dir/damaged-status.json" || true Scripts/lab/assert-runtime-state degraded | tee "$artifact_dir/damaged-state.tsv" fi - Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$result" + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$runner_result" "$installed_cli" service status --json > "$artifact_dir/post-repair-status.json" assert_ready | tee "$artifact_dir/post-repair-ready.tsv" + record_scenario_result passed \ + "A deliberate Kanata service failure was detected and repaired once to an independently ready runtime." \ + --evidence pre-damage-ready.tsv --evidence pre-damage-status.json \ + --evidence damage.tsv --evidence damaged-status.json --evidence damaged-state.tsv \ + --evidence run-state.json --evidence runner-result.json \ + --evidence post-repair-status.json --evidence post-repair-ready.tsv ;; upgrade-beta3-to-current) plan="$PWD/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index c1725bb43..45c10d760 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -284,6 +284,9 @@ grep -Fq 'The same lease must pass cancellation-recovery-before before post-canc grep -Fq 'Scripts/lab/damage-kanata-service' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'cancellation-recovery-before)' "$LAB_DIR/scenarios/installer-scenario" grep -Fq 'cancellation-recovery-after)' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'The managed macOS runtime exposed every required KeyPath capability' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'runner-result.json' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'A deliberate Kanata service failure was detected and repaired once' "$LAB_DIR/scenarios/installer-scenario" grep -q 'macos-27-regression)' "$LAB_DIR/scenarios/installer-scenario" run_remote() { diff --git a/Scripts/lab/tests/macos-26-selector-driver-tests.py b/Scripts/lab/tests/macos-26-selector-driver-tests.py index 14cb75f2c..571c9ca8d 100755 --- a/Scripts/lab/tests/macos-26-selector-driver-tests.py +++ b/Scripts/lab/tests/macos-26-selector-driver-tests.py @@ -28,6 +28,9 @@ def test_accepts_fresh_macos_26_selector_evidence(self): result = self.call("--expect", "InputMonitoring", "--expect", "KeyPath") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads((self.output / "selector-contract.json").read_text())["macOSMajor"], 26) + outcome = json.loads((self.output / "result.json").read_text()) + self.assertEqual(outcome["status"], "passed") + self.assertEqual(outcome["evidence"], ["system-settings-ax.json", "selector-contract.json"]) def test_rejects_another_macos_version_without_snapshot(self): result = self.call("--expect", "KeyPath", version="15.7.7") diff --git a/Scripts/lab/tests/macos-27-selector-driver-tests.py b/Scripts/lab/tests/macos-27-selector-driver-tests.py index 4cb84db9f..7330e9ba8 100755 --- a/Scripts/lab/tests/macos-27-selector-driver-tests.py +++ b/Scripts/lab/tests/macos-27-selector-driver-tests.py @@ -64,6 +64,11 @@ def test_accepts_fresh_macos_27_selector_evidence(self): contract = json.loads((self.output / "selector-contract.json").read_text()) self.assertEqual(contract["macOSMajor"], 27) self.assertEqual(contract["macOSBuild"], "26A5378j") + outcome = json.loads((self.output / "result.json").read_text()) + self.assertEqual(outcome["status"], "passed") + self.assertEqual(outcome["evidence"], [ + "system-settings-ax.json", "selector-contract.json", "peekaboo-preflight.json" + ]) def test_requires_at_least_one_selector(self): result = self.call() From 8ca4bb19bd369448dfb3d3be9bc4574f8aaa63bf Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 10:13:22 -0700 Subject: [PATCH 70/99] Require only durable repair evidence --- Scripts/lab/scenarios/installer-scenario | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index c195c82fa..33213b3de 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -164,7 +164,7 @@ case "$name" in "A deliberate Kanata service failure was detected and repaired once to an independently ready runtime." \ --evidence pre-damage-ready.tsv --evidence pre-damage-status.json \ --evidence damage.tsv --evidence damaged-status.json --evidence damaged-state.tsv \ - --evidence run-state.json --evidence runner-result.json \ + --evidence run-state.json \ --evidence post-repair-status.json --evidence post-repair-ready.tsv ;; upgrade-beta3-to-current) From 33f6ed64f296b661aad146f3d5c85ca3db476438 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 10:19:22 -0700 Subject: [PATCH 71/99] Harden secure dialog submission --- Scripts/lab/remote.sh | 48 +++++++++++++++++++------- Scripts/lab/tests/keypath-lab-tests.sh | 14 ++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index d72d8e45b..699b32342 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -1202,6 +1202,7 @@ secure_dialog_input() { local lease=$1 app=$2 field_label=$3 submit_button=$4 already_focused=$5 local manifest macos resource key ip secret_file guest_command exit_code local focus_command focus_result button_geometry_command button_coords postcondition_command postcondition_result + local button_press_command button_press_result native_submit_exit focus_transport submit_transport local click_x click_y focus_x focus_y manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) @@ -1251,13 +1252,17 @@ secure_dialog_input() { # detail; scaling AX coordinates to backing pixels moves the native click # outside the protected sheet. + focus_transport=accessibility-and-native-pointer set +e "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$focus_x" --y "$focus_y" >/dev/null 2>&1 exit_code=$? set -e if (( exit_code != 0 )); then - record_command "$lease" "failed:41" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" - die "secure dialog input failed while giving the field native pointer focus" + # The Accessibility focus postcondition above is authoritative, and the + # secure typing step below independently proves that the field accepts + # input. Tart's RFB pointer transport may be unavailable even while the + # logged-in session remains fully controllable through System Events. + focus_transport=accessibility-only fi # Secure authorization sheets ignore Tart's VNC key events. Stream the @@ -1287,22 +1292,39 @@ secure_dialog_input() { fi IFS=',' read -r click_x click_y <<< "$button_coords" + button_press_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var process = Application("System Events").processes.byName(argv[0]);\n var window = process.windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var button = root.buttons().find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) button = descendants(root).find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) throw new Error("submit button not found");\n var actions = button.actions.whose({name: "AXPress"})();\n if (actions.length !== 1) throw new Error("submit button does not expose one AXPress action");\n actions[0].perform();\n return "pressed";\n}\' -- '$(printf %q "$app")' '$(printf %q "$submit_button") + + submit_transport=native-pointer set +e "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$click_x" --y "$click_y" >/dev/null 2>&1 - exit_code=$? + native_submit_exit=$? set -e - if (( exit_code != 0 )); then - record_command "$lease" "failed:43" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" - die "secure dialog input failed while submitting the dialog" - fi postcondition_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "closed";\n var window = matches[0].windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var open = root.textFields.whose({subrole: "AXSecureTextField"})().length > 0 || descendants(root).some(function (element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n return open ? "open" : "closed";\n}\' -- '$(printf %q "$app") postcondition_result=open - for attempt in {1..150}; do - postcondition_result=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$postcondition_command")" &2 exit 1 fi +secure_agent_fallback=$(KEYPATH_LAB_TEST_CRABBOX_CLICK_FAIL=1 run_remote secure-dialog-input cbx_desktop15 SecurityAgent AXSecureTextField Enroll 0) +assert_contains "$secure_agent_fallback" $'secure_dialog_input\tpassed' +assert_contains "$secure_agent_fallback" $'secure_dialog_focus_transport\taccessibility-only' +assert_contains "$secure_agent_fallback" $'secure_dialog_submit_transport\taccessibility-press-fallback' +grep -Fq 'AXPress' "$TMP/guest-ssh-calls" secure_settings_result=$(run_remote secure-dialog-input cbx_desktop15 'System Settings' AXSecureTextField 'Modify Settings' 0) assert_contains "$secure_settings_result" $'secure_dialog_input\tpassed' grep -q 'System.*Settings.*Modify.*Settings' "$TMP/guest-ssh-calls" From 5a8f2d3399dc6c2387424d0bf637696e1a929c18 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 12:49:14 -0700 Subject: [PATCH 72/99] Add Pico 2 W physical HID fixture --- Scripts/lab/pico-hid-fixture-client | 181 ++++++++++ Scripts/lab/pico-hid-fixture/.gitignore | 2 + Scripts/lab/pico-hid-fixture/CMakeLists.txt | 65 ++++ Scripts/lab/pico-hid-fixture/README.md | 134 +++++++ .../pico-hid-fixture/src/fixture_config.h.in | 9 + .../lab/pico-hid-fixture/src/fixture_core.c | 327 ++++++++++++++++++ .../lab/pico-hid-fixture/src/fixture_core.h | 85 +++++ .../lab/pico-hid-fixture/src/http_server.c | 285 +++++++++++++++ .../lab/pico-hid-fixture/src/http_server.h | 12 + Scripts/lab/pico-hid-fixture/src/lwipopts.h | 40 +++ Scripts/lab/pico-hid-fixture/src/main.c | 120 +++++++ .../lab/pico-hid-fixture/src/tusb_config.h | 33 ++ .../pico-hid-fixture/src/usb_descriptors.c | 107 ++++++ .../pico-hid-fixture/src/usb_descriptors.h | 6 + .../tests/fixture_core_tests.c | 157 +++++++++ .../lab/pico-hid-fixture/tests/run-tests.sh | 13 + Scripts/lab/tests/keypath-lab-tests.sh | 2 + .../tests/pico-hid-fixture-client-tests.py | 104 ++++++ 18 files changed, 1682 insertions(+) create mode 100755 Scripts/lab/pico-hid-fixture-client create mode 100644 Scripts/lab/pico-hid-fixture/.gitignore create mode 100644 Scripts/lab/pico-hid-fixture/CMakeLists.txt create mode 100644 Scripts/lab/pico-hid-fixture/README.md create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_config.h.in create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_core.c create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_core.h create mode 100644 Scripts/lab/pico-hid-fixture/src/http_server.c create mode 100644 Scripts/lab/pico-hid-fixture/src/http_server.h create mode 100644 Scripts/lab/pico-hid-fixture/src/lwipopts.h create mode 100644 Scripts/lab/pico-hid-fixture/src/main.c create mode 100644 Scripts/lab/pico-hid-fixture/src/tusb_config.h create mode 100644 Scripts/lab/pico-hid-fixture/src/usb_descriptors.c create mode 100644 Scripts/lab/pico-hid-fixture/src/usb_descriptors.h create mode 100644 Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c create mode 100755 Scripts/lab/pico-hid-fixture/tests/run-tests.sh create mode 100755 Scripts/lab/tests/pico-hid-fixture-client-tests.py diff --git a/Scripts/lab/pico-hid-fixture-client b/Scripts/lab/pico-hid-fixture-client new file mode 100755 index 000000000..f63f7f5a7 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture-client @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Compile deterministic text into Pico HID reports and control the KeyPath fixture.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import sys +import urllib.error +import urllib.request +import zlib + + +SHIFT = 0x02 +USAGE: dict[str, tuple[int, int]] = {} +for offset, character in enumerate("abcdefghijklmnopqrstuvwxyz"): + USAGE[character] = (0x04 + offset, 0) + USAGE[character.upper()] = (0x04 + offset, SHIFT) +for character, usage in zip("1234567890", range(0x1E, 0x28)): + USAGE[character] = (usage, 0) +for character, shifted, usage in zip("1234567890", "!@#$%^&*()", range(0x1E, 0x28)): + USAGE[shifted] = (usage, SHIFT) +USAGE.update({ + "\n": (0x28, 0), "\t": (0x2B, 0), " ": (0x2C, 0), + "-": (0x2D, 0), "_": (0x2D, SHIFT), "=": (0x2E, 0), "+": (0x2E, SHIFT), + "[": (0x2F, 0), "{": (0x2F, SHIFT), "]": (0x30, 0), "}": (0x30, SHIFT), + "\\": (0x31, 0), "|": (0x31, SHIFT), ";": (0x33, 0), ":": (0x33, SHIFT), + "'": (0x34, 0), '"': (0x34, SHIFT), "`": (0x35, 0), "~": (0x35, SHIFT), + ",": (0x36, 0), "<": (0x36, SHIFT), ".": (0x37, 0), ">": (0x37, SHIFT), + "/": (0x38, 0), "?": (0x38, SHIFT), +}) + + +def compile_text(run_id: str, text: str, interval_ms: int, hold_ms: int, + repeat_count: int, cycle_gap_ms: int) -> str: + if not run_id or len(run_id) > 48 or any(not (ch.isalnum() or ch in "-_.") for ch in run_id): + raise ValueError("run id must contain 1-48 letters, digits, dots, underscores, or hyphens") + if not text: + raise ValueError("input text is empty") + if not 1 <= repeat_count <= 10_000: + raise ValueError("repeat count must be between 1 and 10000") + if not 2 <= hold_ms < interval_ms: + raise ValueError("hold duration must be at least 2 ms and shorter than the interval") + events: list[str] = [] + at_us = 0 + for index, character in enumerate(text): + try: + usage, modifiers = USAGE[character] + except KeyError as error: + raise ValueError(f"unsupported US keyboard character at offset {index}: {character!r}") from error + events.append(f"{at_us} {modifiers} {usage} 0 0 0 0 0") + events.append(f"{at_us + hold_ms * 1000} 0 0 0 0 0 0 0") + at_us += interval_ms * 1000 + cycle_us = at_us + cycle_gap_ms * 1000 + event_payload = "\n".join(events) + "\n" + crc = zlib.crc32(event_payload.encode("ascii")) & 0xFFFFFFFF + return f"KPHID1 {run_id} {len(events)} {repeat_count} {cycle_us} {crc:08x}\n{event_payload}" + + +class FixtureClient: + def __init__(self, host: str, token: str, port: int = 8080, timeout: float = 10.0): + if not token: + raise ValueError("fixture bearer token is empty") + self.base_url = f"http://{host}:{port}" + self.token = token + self.timeout = timeout + + def request(self, method: str, path: str, body: bytes | None = None) -> str: + request = urllib.request.Request( + self.base_url + path, + data=body, + method=method, + headers={"Authorization": f"Bearer {self.token}"}, + ) + if body is not None: + request.add_header("Content-Type", "text/plain; charset=us-ascii") + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + return response.read().decode("utf-8") + except urllib.error.HTTPError as error: + detail = error.read().decode("utf-8", errors="replace").strip() + raise RuntimeError(f"fixture returned HTTP {error.code}: {detail}") from error + + def status(self) -> dict[str, object]: + return json.loads(self.request("GET", "/v1/status")) + + def load_script(self, script: str) -> dict[str, object]: + return json.loads(self.request("POST", "/v1/script", script.encode("ascii"))) + + def arm(self, run_id: str) -> dict[str, object]: + return json.loads(self.request("POST", "/v1/arm", run_id.encode("ascii"))) + + def start(self, run_id: str, delay_ms: int) -> dict[str, object]: + return json.loads(self.request("POST", "/v1/start", f"{run_id} {delay_ms}".encode("ascii"))) + + def abort(self) -> dict[str, object]: + return json.loads(self.request("POST", "/v1/abort", b"")) + + def trace(self, start: int = 0, limit: int = 8) -> list[dict[str, object]]: + lines = self.request("GET", f"/v1/trace?from={start}&limit={limit}").splitlines() + return [json.loads(line) for line in lines] + + +def token_from_environment() -> str: + token = os.environ.get("KEYPATH_FIXTURE_TOKEN", "") + if not token: + raise SystemExit("KEYPATH_FIXTURE_TOKEN is required; do not put the token on the command line") + return token + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + root.add_argument("--host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + root.add_argument("--port", type=int, default=8080) + commands = root.add_subparsers(dest="command", required=True) + commands.add_parser("status") + compile_command = commands.add_parser("compile-text") + compile_command.add_argument("--run-id", required=True) + compile_command.add_argument("--text", required=True, type=pathlib.Path) + compile_command.add_argument("--interval-ms", type=int, default=80) + compile_command.add_argument("--hold-ms", type=int, default=30) + compile_command.add_argument("--repeat", type=int, default=1) + compile_command.add_argument("--cycle-gap-ms", type=int, default=500) + compile_command.add_argument("--output", type=pathlib.Path) + load_command = commands.add_parser("load-script") + load_command.add_argument("script", type=pathlib.Path) + arm_command = commands.add_parser("arm") + arm_command.add_argument("run_id") + start_command = commands.add_parser("start") + start_command.add_argument("run_id") + start_command.add_argument("--delay-ms", type=int, default=2000) + commands.add_parser("abort") + trace_command = commands.add_parser("trace") + trace_command.add_argument("--from", dest="start", type=int, default=0) + trace_command.add_argument("--limit", type=int, default=8) + run_command = commands.add_parser("run-text") + run_command.add_argument("--run-id", required=True) + run_command.add_argument("--text", required=True, type=pathlib.Path) + run_command.add_argument("--interval-ms", type=int, default=80) + run_command.add_argument("--hold-ms", type=int, default=30) + run_command.add_argument("--repeat", type=int, default=1) + run_command.add_argument("--cycle-gap-ms", type=int, default=500) + run_command.add_argument("--delay-ms", type=int, default=2000) + return root + + +def print_json(value: object) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) + + +def main() -> int: + arguments = parser().parse_args() + if arguments.command == "compile-text": + script = compile_text(arguments.run_id, arguments.text.read_text(), arguments.interval_ms, + arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms) + if arguments.output: + arguments.output.write_text(script) + print(arguments.output) + else: + sys.stdout.write(script) + return 0 + + client = FixtureClient(arguments.host, token_from_environment(), arguments.port) + if arguments.command == "status": print_json(client.status()) + elif arguments.command == "load-script": print_json(client.load_script(arguments.script.read_text())) + elif arguments.command == "arm": print_json(client.arm(arguments.run_id)) + elif arguments.command == "start": print_json(client.start(arguments.run_id, arguments.delay_ms)) + elif arguments.command == "abort": print_json(client.abort()) + elif arguments.command == "trace": print_json(client.trace(arguments.start, arguments.limit)) + elif arguments.command == "run-text": + script = compile_text(arguments.run_id, arguments.text.read_text(), arguments.interval_ms, + arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms) + print_json({"load": client.load_script(script), "arm": client.arm(arguments.run_id), + "start": client.start(arguments.run_id, arguments.delay_ms)}) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/pico-hid-fixture/.gitignore b/Scripts/lab/pico-hid-fixture/.gitignore new file mode 100644 index 000000000..b4a02542d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/.gitignore @@ -0,0 +1,2 @@ +build/ +*.uf2 diff --git a/Scripts/lab/pico-hid-fixture/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/CMakeLists.txt new file mode 100644 index 000000000..0c71735d8 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/CMakeLists.txt @@ -0,0 +1,65 @@ +cmake_minimum_required(VERSION 3.13) + +if(NOT DEFINED PICO_SDK_PATH) + if(DEFINED ENV{PICO_SDK_PATH}) + set(PICO_SDK_PATH "$ENV{PICO_SDK_PATH}") + else() + message(FATAL_ERROR "Set PICO_SDK_PATH to a Raspberry Pi Pico SDK checkout") + endif() +endif() + +set(PICO_BOARD pico2_w CACHE STRING "Pico board") +include(${PICO_SDK_PATH}/external/pico_sdk_import.cmake) + +project(keypath_pico_hid_fixture C CXX ASM) +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) +pico_sdk_init() + +foreach(secret_name KEYPATH_WIFI_SSID KEYPATH_WIFI_PASSWORD KEYPATH_FIXTURE_TOKEN) + if(NOT DEFINED ENV{${secret_name}} OR "$ENV{${secret_name}}" STREQUAL "") + message(FATAL_ERROR "${secret_name} must be supplied in the build environment") + endif() + set(secret_value "$ENV{${secret_name}}") + string(REPLACE "\\" "\\\\" secret_value "${secret_value}") + string(REPLACE "\"" "\\\"" secret_value "${secret_value}") + set(${secret_name}_ESCAPED "${secret_value}") +endforeach() + +string(LENGTH "$ENV{KEYPATH_FIXTURE_TOKEN}" fixture_token_length) +if(fixture_token_length LESS 16) + message(FATAL_ERROR "KEYPATH_FIXTURE_TOKEN must contain at least 16 characters") +endif() + +configure_file(src/fixture_config.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/fixture_config.h @ONLY) + +add_executable(keypath_pico_hid_fixture + src/fixture_core.c + src/http_server.c + src/main.c + src/usb_descriptors.c +) + +target_include_directories(keypath_pico_hid_fixture PRIVATE + src + ${CMAKE_CURRENT_BINARY_DIR}/generated +) + +target_compile_definitions(keypath_pico_hid_fixture PRIVATE + KEYPATH_FIXTURE_FIRMWARE_VERSION="0.1.0" +) + +target_compile_options(keypath_pico_hid_fixture PRIVATE -Wall -Wextra -Werror) +target_link_libraries(keypath_pico_hid_fixture PRIVATE + pico_stdlib + pico_unique_id + pico_cyw43_arch_lwip_poll + pico_lwip_mdns + tinyusb_device + tinyusb_board + hardware_watchdog +) + +pico_enable_stdio_usb(keypath_pico_hid_fixture 0) +pico_enable_stdio_uart(keypath_pico_hid_fixture 0) +pico_add_extra_outputs(keypath_pico_hid_fixture) diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md new file mode 100644 index 000000000..7df104b72 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -0,0 +1,134 @@ +# KeyPath Pico 2 W physical HID fixture + +This lab-only firmware turns an otherwise unmodified Raspberry Pi Pico 2 W into a deterministic +USB keyboard controlled over Wi-Fi. The VM owns the Pico's USB device; the Mac mini uses the +independent Wi-Fi control plane to load, arm, start, abort, and inspect locally timed HID scripts. + +The fixture is deliberately not part of any KeyPath product target. + +## Hardware + +- Raspberry Pi Pico 2 W +- One data-capable micro-USB cable to the Mac mini +- Lab Wi-Fi access + +No serial adapter, debugger, shield, speaker, external power supply, or second microcontroller is +required. The onboard green LED reports fixture state. + +## Safety model + +- USB exposes only a standard boot-keyboard HID interface. There is no USB serial control path for + the VM to confuse with the input oracle. +- Every cycle must end with a full all-keys-released report. +- Boot, arm, remote abort, watchdog recovery, and USB reconnect all queue an all-keys-released + report before further input. +- A running script fails closed if USB is removed. +- Scripts are bounded by event, repeat, duration, request-size, and trace limits. +- Uploaded event bytes are CRC32-verified before the fixture can arm. +- HTTP endpoints require a bearer token. Credentials are build inputs and are never committed. +- Control traffic is HTTP rather than TLS, so operate it only on the isolated, WPA2-protected lab + Wi-Fi; the bearer token is defense in depth, not a substitute for network isolation. +- The Pico schedules every report locally. Wi-Fi jitter can shift the start acknowledgement but + cannot alter inter-key timing after the script starts. + +## LED states + +| State | Onboard LED | +|---|---| +| Wi-Fi disconnected | repeating double blink | +| Wi-Fi connected / idle | brief heartbeat | +| Script loaded | double blink | +| Armed | solid | +| Running | fast blink | +| Complete | repeating triple blink | +| Error | rapid blink | + +## Build + +Install CMake, an Arm embedded compiler, and the Raspberry Pi Pico SDK. Keep Wi-Fi credentials and +the fixture token out of the repository: + +```bash +export PICO_SDK_PATH=/absolute/path/to/pico-sdk +export KEYPATH_WIFI_SSID='lab-network' +export KEYPATH_WIFI_PASSWORD='...' +export KEYPATH_FIXTURE_TOKEN='at-least-16-random-characters' +cmake -S Scripts/lab/pico-hid-fixture -B Scripts/lab/pico-hid-fixture/build -G Ninja +cmake --build Scripts/lab/pico-hid-fixture/build +``` + +The resulting `keypath_pico_hid_fixture.uf2` can be installed by holding BOOTSEL while connecting +the Pico and copying the UF2 to the mounted `RPI-RP2` volume. Build-time secrets remain in the +local build directory and embedded firmware; delete that directory before sharing artifacts. + +When real lab credentials are provisioned, store them through the existing sops-backed secret +workflow and have the build wrapper export them without printing their values. + +## Script protocol + +The first line is: + +```text +KPHID1 RUN_ID EVENT_COUNT REPEAT_COUNT CYCLE_US CRC32 +``` + +Each remaining line is one complete boot-keyboard report: + +```text +AT_US MODIFIERS KEY1 KEY2 KEY3 KEY4 KEY5 KEY6 +``` + +Times are absolute within one cycle and must increase. The last report must contain seven zeros. +The event payload, including final newlines, is protected by the header CRC32. A short cycle can be +repeated many times without storing tens of thousands of reports in Pico RAM. + +## HTTP API + +The fixture advertises `keypath-hid-fixture.local` over mDNS on port 8080. Every request requires +`Authorization: Bearer TOKEN`. + +| Method | Path | Body | +|---|---|---| +| GET | `/v1/status` | — | +| POST | `/v1/script` | Complete `KPHID1` script | +| POST | `/v1/arm` | Run ID | +| POST | `/v1/start` | `RUN_ID DELAY_MS` | +| POST | `/v1/abort` | Empty | +| GET | `/v1/trace?from=N&limit=8` | — | + +Commands are state-checked. A script cannot start unless the same run ID was loaded and armed. +The start delay must be 100–60000 ms, giving the guest observer time to become ready. + +## Mac mini client + +`Scripts/lab/pico-hid-fixture-client` uses only Python's standard library. It compiles US-keyboard +text into full HID reports and calls the fixture API: + +```bash +export KEYPATH_FIXTURE_HOST=keypath-hid-fixture.local +export KEYPATH_FIXTURE_TOKEN='...' + +Scripts/lab/pico-hid-fixture-client status +Scripts/lab/pico-hid-fixture-client compile-text \ + --run-id swift-load-001 --text reference.txt --repeat 20 --output /tmp/swift-load.kphid +Scripts/lab/pico-hid-fixture-client load-script /tmp/swift-load.kphid +Scripts/lab/pico-hid-fixture-client arm swift-load-001 +Scripts/lab/pico-hid-fixture-client start swift-load-001 --delay-ms 2000 +Scripts/lab/pico-hid-fixture-client trace +``` + +`run-text` combines compile, load, arm, and start. Production scenarios should still perform each +stage explicitly so the VM can prove USB admission, arm its independent observers, and verify the +requested load threshold before `start`. + +## Verification without hardware + +```bash +Scripts/lab/pico-hid-fixture/tests/run-tests.sh +python3 Scripts/lab/tests/pico-hid-fixture-client-tests.py +``` + +These tests cover CRC and script admission, timing/repeat execution, trace ordering, lateness +metrics, boot/abort/unmount releases, US-keyboard compilation, bearer authentication, endpoint +selection, and NDJSON trace decoding. The firmware has also been cross-compiled against Pico SDK +2.3.0 for `pico2_w`; a physical USB/VM run remains required before declaring it hardware-proven. diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_config.h.in b/Scripts/lab/pico-hid-fixture/src/fixture_config.h.in new file mode 100644 index 000000000..cf7d9bf1a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_config.h.in @@ -0,0 +1,9 @@ +#ifndef KEYPATH_FIXTURE_CONFIG_H +#define KEYPATH_FIXTURE_CONFIG_H + +#define KEYPATH_WIFI_SSID "@KEYPATH_WIFI_SSID_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD "@KEYPATH_WIFI_PASSWORD_ESCAPED@" +#define KEYPATH_FIXTURE_TOKEN "@KEYPATH_FIXTURE_TOKEN_ESCAPED@" +#define KEYPATH_FIXTURE_HTTP_PORT 8080u + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.c b/Scripts/lab/pico-hid-fixture/src/fixture_core.c new file mode 100644 index 000000000..78337caf2 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.c @@ -0,0 +1,327 @@ +#include "fixture_core.h" + +#include +#include +#include +#include +#include + +static void set_error(char *target, size_t capacity, const char *message) { + if (target && capacity) { + snprintf(target, capacity, "%s", message ? message : "unknown error"); + } +} + +static bool valid_run_id(const char *value) { + size_t length = value ? strlen(value) : 0u; + if (length == 0u || length > FIXTURE_MAX_RUN_ID) return false; + for (size_t index = 0; index < length; ++index) { + unsigned char character = (unsigned char)value[index]; + if (!(isalnum(character) || character == '-' || character == '_' || character == '.')) return false; + } + return true; +} + +static bool report_is_empty(const fixture_event_t *event) { + if (event->modifiers != 0u) return false; + for (size_t index = 0; index < 6u; ++index) { + if (event->keys[index] != 0u) return false; + } + return true; +} + +static bool report_has_unique_keys(const fixture_event_t *event) { + for (size_t left = 0; left < 6u; ++left) { + if (event->keys[left] == 0u) continue; + for (size_t right = left + 1u; right < 6u; ++right) { + if (event->keys[left] == event->keys[right]) return false; + } + } + return true; +} + +void fixture_init(fixture_t *fixture) { + memset(fixture, 0, sizeof(*fixture)); + fixture->state = FIXTURE_IDLE; + fixture->pending_release = true; +} + +const char *fixture_state_name(fixture_state_t state) { + switch (state) { + case FIXTURE_BOOTING: return "booting"; + case FIXTURE_IDLE: return "idle"; + case FIXTURE_LOADED: return "loaded"; + case FIXTURE_ARMED: return "armed"; + case FIXTURE_RUNNING: return "running"; + case FIXTURE_COMPLETE: return "complete"; + case FIXTURE_ABORTED: return "aborted"; + case FIXTURE_ERROR: return "error"; + } + return "unknown"; +} + +uint32_t fixture_crc32(const void *bytes, size_t length) { + const uint8_t *cursor = bytes; + uint32_t crc = 0xffffffffu; + for (size_t index = 0; index < length; ++index) { + crc ^= cursor[index]; + for (uint8_t bit = 0; bit < 8u; ++bit) { + crc = (crc >> 1u) ^ (0xedb88320u & (uint32_t)-(int32_t)(crc & 1u)); + } + } + return ~crc; +} + +bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, + char *error, size_t error_capacity) { + if (!fixture || !body || length == 0u) { + set_error(error, error_capacity, "script body is empty"); + return false; + } + if (fixture->state == FIXTURE_RUNNING || fixture->state == FIXTURE_ARMED) { + set_error(error, error_capacity, "fixture is armed or running"); + return false; + } + + // Invalidate the previous script before parsing into the fixture's bounded + // event storage. A malformed replacement must never leave a partly + // overwritten script available to arm. + fixture->state = FIXTURE_IDLE; + fixture->event_count = 0u; + fixture->run_id[0] = '\0'; + + const char *newline = memchr(body, '\n', length); + if (!newline) { + set_error(error, error_capacity, "script header is incomplete"); + return false; + } + size_t header_length = (size_t)(newline - body); + if (header_length >= 160u) { + set_error(error, error_capacity, "script header is too long"); + return false; + } + char header[160]; + memcpy(header, body, header_length); + header[header_length] = '\0'; + + char run_id[FIXTURE_MAX_RUN_ID + 1u] = {0}; + uint32_t event_count = 0u, repeat_count = 0u, cycle_us = 0u, declared_crc = 0u; + char extra = '\0'; + int fields = sscanf(header, "KPHID1 %48s %" SCNu32 " %" SCNu32 " %" SCNu32 " %" SCNx32 " %c", + run_id, &event_count, &repeat_count, &cycle_us, &declared_crc, &extra); + if (fields != 5) { + set_error(error, error_capacity, "script header must be KPHID1 run events repeats cycle_us crc32"); + return false; + } + if (!valid_run_id(run_id)) { + set_error(error, error_capacity, "run id contains unsupported characters"); + return false; + } + if (event_count == 0u || event_count > FIXTURE_MAX_EVENTS) { + set_error(error, error_capacity, "event count is outside fixture limits"); + return false; + } + if (repeat_count == 0u || repeat_count > FIXTURE_MAX_REPEATS || + (uint64_t)event_count * repeat_count > FIXTURE_MAX_TOTAL_REPORTS) { + set_error(error, error_capacity, "repeat count is outside fixture limits"); + return false; + } + if (cycle_us == 0u) { + set_error(error, error_capacity, "cycle duration must be positive"); + return false; + } + + const char *events_body = newline + 1; + size_t events_length = length - (size_t)(events_body - body); + if (fixture_crc32(events_body, events_length) != declared_crc) { + set_error(error, error_capacity, "script CRC32 does not match payload"); + return false; + } + + const char *cursor = events_body; + const char *end = body + length; + uint32_t previous_at = 0u; + for (uint32_t index = 0u; index < event_count; ++index) { + const char *line_end = memchr(cursor, '\n', (size_t)(end - cursor)); + if (!line_end) line_end = end; + size_t line_length = (size_t)(line_end - cursor); + if (line_length == 0u || line_length >= 128u) { + set_error(error, error_capacity, "event line is empty or too long"); + return false; + } + char line[128]; + memcpy(line, cursor, line_length); + line[line_length] = '\0'; + fixture_event_t event = {0}; + unsigned int values[7] = {0}; + fields = sscanf(line, "%" SCNu32 " %u %u %u %u %u %u %u %c", &event.at_us, + &values[0], &values[1], &values[2], &values[3], &values[4], &values[5], &values[6], &extra); + if (fields != 8) { + set_error(error, error_capacity, "event must contain time, modifiers, and six key usages"); + return false; + } + if ((index > 0u && event.at_us <= previous_at) || event.at_us >= cycle_us) { + set_error(error, error_capacity, "event times must increase and remain inside the cycle"); + return false; + } + for (size_t value_index = 0u; value_index < 7u; ++value_index) { + if (values[value_index] > 255u) { + set_error(error, error_capacity, "HID report values must fit in one byte"); + return false; + } + } + event.modifiers = (uint8_t)values[0]; + for (size_t key = 0u; key < 6u; ++key) event.keys[key] = (uint8_t)values[key + 1u]; + if (!report_has_unique_keys(&event)) { + set_error(error, error_capacity, "a report contains duplicate nonzero key usages"); + return false; + } + fixture->events[index] = event; + previous_at = event.at_us; + cursor = line_end < end ? line_end + 1 : end; + } + while (cursor < end && isspace((unsigned char)*cursor)) ++cursor; + if (cursor != end) { + set_error(error, error_capacity, "script contains more event lines than declared"); + return false; + } + if (!report_is_empty(&fixture->events[event_count - 1u])) { + set_error(error, error_capacity, "each cycle must end with an all-keys-released report"); + return false; + } + + snprintf(fixture->run_id, sizeof(fixture->run_id), "%s", run_id); + fixture->script_crc32 = declared_crc; + fixture->event_count = event_count; + fixture->repeat_count = repeat_count; + fixture->cycle_us = cycle_us; + fixture->state = FIXTURE_LOADED; + fixture->next_event = 0u; + fixture->current_repeat = 0u; + fixture->reports_submitted = 0u; + fixture->transfers_completed = 0u; + fixture->late_reports = 0u; + fixture->maximum_lateness_us = 0; + fixture->submitted_crc32 = 0u; + fixture->trace_head = 0u; + fixture->trace_count = 0u; + fixture->error[0] = '\0'; + set_error(error, error_capacity, ""); + return true; +} + +bool fixture_arm(fixture_t *fixture, const char *run_id, char *error, size_t error_capacity) { + if (fixture->state != FIXTURE_LOADED) { + set_error(error, error_capacity, "a loaded script is required before arming"); + return false; + } + if (!run_id || strcmp(run_id, fixture->run_id) != 0) { + set_error(error, error_capacity, "run id does not match loaded script"); + return false; + } + fixture->state = FIXTURE_ARMED; + fixture->pending_release = true; + return true; +} + +bool fixture_start(fixture_t *fixture, const char *run_id, uint32_t delay_ms, uint64_t now_us, + char *error, size_t error_capacity) { + if (fixture->state != FIXTURE_ARMED) { + set_error(error, error_capacity, "fixture must be armed before starting"); + return false; + } + if (!run_id || strcmp(run_id, fixture->run_id) != 0) { + set_error(error, error_capacity, "run id does not match armed script"); + return false; + } + if (delay_ms < 100u || delay_ms > 60000u) { + set_error(error, error_capacity, "start delay must be between 100 and 60000 ms"); + return false; + } + fixture->start_at_us = now_us + (uint64_t)delay_ms * 1000u; + fixture->next_event = 0u; + fixture->current_repeat = 0u; + fixture->state = FIXTURE_RUNNING; + return true; +} + +void fixture_abort(fixture_t *fixture, const char *reason) { + if (!fixture) return; + fixture->state = FIXTURE_ABORTED; + fixture->pending_release = true; + snprintf(fixture->error, sizeof(fixture->error), "%s", reason ? reason : "aborted"); +} + +void fixture_note_transfer_complete(fixture_t *fixture) { + if (fixture) fixture->transfers_completed++; +} + +static void trace_report(fixture_t *fixture, uint64_t scheduled, uint64_t submitted, + uint8_t modifiers, const uint8_t keys[6]) { + uint32_t slot = fixture->trace_head; + fixture_trace_t *trace = &fixture->trace[slot]; + trace->sequence = fixture->reports_submitted; + trace->scheduled_us = scheduled; + trace->submitted_us = submitted; + trace->lateness_us = (int64_t)(submitted - scheduled); + trace->modifiers = modifiers; + memcpy(trace->keys, keys, 6u); + fixture->trace_head = (slot + 1u) % FIXTURE_TRACE_CAPACITY; + if (fixture->trace_count < FIXTURE_TRACE_CAPACITY) fixture->trace_count++; +} + +static bool submit_report(fixture_t *fixture, uint64_t scheduled, uint64_t now_us, + uint8_t modifiers, const uint8_t keys[6], + fixture_send_report_fn send_report, void *send_context) { + if (!send_report(modifiers, keys, send_context)) return false; + int64_t lateness = (int64_t)(now_us - scheduled); + fixture->reports_submitted++; + if (lateness > 1000) fixture->late_reports++; + if (lateness > fixture->maximum_lateness_us) fixture->maximum_lateness_us = lateness; + uint8_t report[7] = {modifiers, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}; + fixture->submitted_crc32 ^= fixture_crc32(report, sizeof(report)) + (uint32_t)fixture->reports_submitted; + trace_report(fixture, scheduled, now_us, modifiers, keys); + return true; +} + +void fixture_poll(fixture_t *fixture, uint64_t now_us, bool usb_mounted, bool usb_ready, + fixture_send_report_fn send_report, void *send_context) { + if (!fixture || !send_report) return; + if (fixture->usb_mounted && !usb_mounted && fixture->state == FIXTURE_RUNNING) { + fixture->state = FIXTURE_ERROR; + fixture->pending_release = true; + snprintf(fixture->error, sizeof(fixture->error), "USB unmounted during a running script"); + } + fixture->usb_mounted = usb_mounted; + if (!usb_mounted || !usb_ready) return; + + static const uint8_t empty_keys[6] = {0}; + if (fixture->pending_release) { + if (send_report(0u, empty_keys, send_context)) fixture->pending_release = false; + return; + } + if (fixture->state != FIXTURE_RUNNING || now_us < fixture->start_at_us) return; + + uint64_t scheduled = fixture->start_at_us + (uint64_t)fixture->current_repeat * fixture->cycle_us + + fixture->events[fixture->next_event].at_us; + if (now_us < scheduled) return; + fixture_event_t *event = &fixture->events[fixture->next_event]; + if (!submit_report(fixture, scheduled, now_us, event->modifiers, event->keys, send_report, send_context)) return; + + fixture->next_event++; + if (fixture->next_event == fixture->event_count) { + fixture->next_event = 0u; + fixture->current_repeat++; + if (fixture->current_repeat == fixture->repeat_count) fixture->state = FIXTURE_COMPLETE; + } +} + +uint32_t fixture_trace_count(const fixture_t *fixture) { + return fixture ? fixture->trace_count : 0u; +} + +const fixture_trace_t *fixture_trace_at(const fixture_t *fixture, uint32_t chronological_index) { + if (!fixture || chronological_index >= fixture->trace_count) return NULL; + uint32_t oldest = (fixture->trace_head + FIXTURE_TRACE_CAPACITY - fixture->trace_count) % FIXTURE_TRACE_CAPACITY; + return &fixture->trace[(oldest + chronological_index) % FIXTURE_TRACE_CAPACITY]; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.h b/Scripts/lab/pico-hid-fixture/src/fixture_core.h new file mode 100644 index 000000000..edbbfb3a6 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.h @@ -0,0 +1,85 @@ +#ifndef KEYPATH_FIXTURE_CORE_H +#define KEYPATH_FIXTURE_CORE_H + +#include +#include +#include + +#define FIXTURE_MAX_EVENTS 2048u +#define FIXTURE_MAX_REPEATS 10000u +#define FIXTURE_MAX_TOTAL_REPORTS 1000000u +#define FIXTURE_MAX_RUN_ID 48u +#define FIXTURE_TRACE_CAPACITY 256u + +typedef enum { + FIXTURE_BOOTING = 0, + FIXTURE_IDLE, + FIXTURE_LOADED, + FIXTURE_ARMED, + FIXTURE_RUNNING, + FIXTURE_COMPLETE, + FIXTURE_ABORTED, + FIXTURE_ERROR, +} fixture_state_t; + +typedef struct { + uint32_t at_us; + uint8_t modifiers; + uint8_t keys[6]; +} fixture_event_t; + +typedef struct { + uint64_t sequence; + uint64_t scheduled_us; + uint64_t submitted_us; + int64_t lateness_us; + uint8_t modifiers; + uint8_t keys[6]; +} fixture_trace_t; + +typedef struct { + fixture_state_t state; + char run_id[FIXTURE_MAX_RUN_ID + 1u]; + uint32_t script_crc32; + uint32_t event_count; + uint32_t repeat_count; + uint32_t cycle_us; + fixture_event_t events[FIXTURE_MAX_EVENTS]; + + uint64_t start_at_us; + uint32_t next_event; + uint32_t current_repeat; + uint64_t reports_submitted; + uint64_t transfers_completed; + uint64_t late_reports; + int64_t maximum_lateness_us; + uint32_t submitted_crc32; + bool pending_release; + bool usb_mounted; + + fixture_trace_t trace[FIXTURE_TRACE_CAPACITY]; + uint32_t trace_head; + uint32_t trace_count; + char error[128]; +} fixture_t; + +typedef bool (*fixture_send_report_fn)(uint8_t modifiers, const uint8_t keys[6], void *context); + +void fixture_init(fixture_t *fixture); +const char *fixture_state_name(fixture_state_t state); +uint32_t fixture_crc32(const void *bytes, size_t length); + +bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, + char *error, size_t error_capacity); +bool fixture_arm(fixture_t *fixture, const char *run_id, char *error, size_t error_capacity); +bool fixture_start(fixture_t *fixture, const char *run_id, uint32_t delay_ms, uint64_t now_us, + char *error, size_t error_capacity); +void fixture_abort(fixture_t *fixture, const char *reason); +void fixture_note_transfer_complete(fixture_t *fixture); +void fixture_poll(fixture_t *fixture, uint64_t now_us, bool usb_mounted, bool usb_ready, + fixture_send_report_fn send_report, void *send_context); + +uint32_t fixture_trace_count(const fixture_t *fixture); +const fixture_trace_t *fixture_trace_at(const fixture_t *fixture, uint32_t chronological_index); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/http_server.c b/Scripts/lab/pico-hid-fixture/src/http_server.c new file mode 100644 index 000000000..24f014947 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/http_server.c @@ -0,0 +1,285 @@ +#include "http_server.h" + +#include +#include +#include +#include + +#include "lwip/pbuf.h" +#include "lwip/tcp.h" +#include "pico/stdlib.h" + +#define REQUEST_CAPACITY (96u * 1024u) +#define RESPONSE_CAPACITY 4096u + +typedef struct { + struct tcp_pcb *pcb; + size_t length; + size_t expected_length; + bool headers_parsed; + char request[REQUEST_CAPACITY + 1u]; +} http_client_t; + +static fixture_t *server_fixture; +static const char *server_token; +static bool network_connected; +static char network_address[48] = "unassigned"; +static bool client_active; + +static void close_client(http_client_t *client) { + if (!client) return; + if (client->pcb) { + tcp_arg(client->pcb, NULL); + tcp_recv(client->pcb, NULL); + tcp_err(client->pcb, NULL); + tcp_close(client->pcb); + } + client_active = false; + free(client); +} + +static const char *reason_phrase(int status) { + switch (status) { + case 200: return "OK"; + case 201: return "Created"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 404: return "Not Found"; + case 409: return "Conflict"; + case 413: return "Payload Too Large"; + case 503: return "Service Unavailable"; + default: return "Error"; + } +} + +static void reply(http_client_t *client, int status, const char *content_type, const char *body) { + char response[RESPONSE_CAPACITY]; + size_t body_length = body ? strlen(body) : 0u; + int count = snprintf(response, sizeof(response), + "HTTP/1.1 %d %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %zu\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n\r\n%s", + status, reason_phrase(status), content_type, body_length, body ? body : ""); + if (count > 0 && (size_t)count < sizeof(response)) { + tcp_write(client->pcb, response, (u16_t)count, TCP_WRITE_FLAG_COPY); + tcp_output(client->pcb); + } + close_client(client); +} + +static void json_message(http_client_t *client, int status, bool ok, const char *message) { + char body[256]; + snprintf(body, sizeof(body), "{\"ok\":%s,\"message\":\"%s\"}\n", + ok ? "true" : "false", message ? message : ""); + reply(client, status, "application/json", body); +} + +static char *trim(char *value) { + while (*value == ' ' || *value == '\t' || *value == '\r' || *value == '\n') ++value; + char *end = value + strlen(value); + while (end > value && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n')) --end; + *end = '\0'; + return value; +} + +static bool authorized(const char *request) { + const char *header = strstr(request, "Authorization: Bearer "); + if (!header) return false; + header += strlen("Authorization: Bearer "); + const char *end = strstr(header, "\r\n"); + size_t token_length = strlen(server_token); + return end && (size_t)(end - header) == token_length && memcmp(header, server_token, token_length) == 0; +} + +static void status_response(http_client_t *client) { + char body[1024]; + snprintf(body, sizeof(body), + "{\"ok\":true,\"firmware\":\"%s\",\"state\":\"%s\"," + "\"runId\":\"%s\",\"scriptCRC32\":\"%08" PRIx32 "\"," + "\"eventCount\":%" PRIu32 ",\"repeatCount\":%" PRIu32 "," + "\"currentRepeat\":%" PRIu32 ",\"reportsSubmitted\":%" PRIu64 "," + "\"transfersCompleted\":%" PRIu64 ",\"lateReports\":%" PRIu64 "," + "\"maximumLatenessUs\":%" PRId64 ",\"submittedCRC32\":\"%08" PRIx32 "\"," + "\"usbMounted\":%s,\"wifiConnected\":%s,\"address\":\"%s\"," + "\"error\":\"%s\"}\n", + KEYPATH_FIXTURE_FIRMWARE_VERSION, fixture_state_name(server_fixture->state), + server_fixture->run_id, server_fixture->script_crc32, + server_fixture->event_count, server_fixture->repeat_count, + server_fixture->current_repeat, server_fixture->reports_submitted, + server_fixture->transfers_completed, server_fixture->late_reports, + server_fixture->maximum_lateness_us, server_fixture->submitted_crc32, + server_fixture->usb_mounted ? "true" : "false", + network_connected ? "true" : "false", network_address, server_fixture->error); + reply(client, 200, "application/json", body); +} + +static unsigned int query_number(const char *path, const char *name, unsigned int fallback) { + const char *query = strchr(path, '?'); + if (!query) return fallback; + char needle[32]; + snprintf(needle, sizeof(needle), "%s=", name); + const char *found = strstr(query + 1, needle); + if (!found) return fallback; + return (unsigned int)strtoul(found + strlen(needle), NULL, 10); +} + +static void trace_response(http_client_t *client, const char *path) { + unsigned int from = query_number(path, "from", 0u); + unsigned int limit = query_number(path, "limit", 8u); + if (limit > 8u) limit = 8u; + uint32_t available = fixture_trace_count(server_fixture); + char body[3072]; + size_t used = (size_t)snprintf(body, sizeof(body), + "{\"runId\":\"%s\",\"from\":%u,\"available\":%" PRIu32 "}\n", + server_fixture->run_id, from, available); + for (unsigned int index = 0u; index < limit && from + index < available; ++index) { + const fixture_trace_t *trace = fixture_trace_at(server_fixture, from + index); + int count = snprintf(body + used, sizeof(body) - used, + "{\"sequence\":%" PRIu64 ",\"scheduledUs\":%" PRIu64 "," + "\"submittedUs\":%" PRIu64 ",\"latenessUs\":%" PRId64 "," + "\"modifiers\":%u,\"keys\":[%u,%u,%u,%u,%u,%u]}\n", + trace->sequence, trace->scheduled_us, trace->submitted_us, + trace->lateness_us, trace->modifiers, trace->keys[0], trace->keys[1], + trace->keys[2], trace->keys[3], trace->keys[4], trace->keys[5]); + if (count < 0 || (size_t)count >= sizeof(body) - used) break; + used += (size_t)count; + } + reply(client, 200, "application/x-ndjson", body); +} + +static void dispatch(http_client_t *client) { + char method[8] = {0}, path[160] = {0}; + if (sscanf(client->request, "%7s %159s", method, path) != 2) { + json_message(client, 400, false, "invalid request line"); + return; + } + if (!authorized(client->request)) { + json_message(client, 401, false, "bearer token required"); + return; + } + char *header_end = strstr(client->request, "\r\n\r\n"); + char *body = header_end ? header_end + 4 : client->request + client->length; + + if (strcmp(method, "GET") == 0 && strcmp(path, "/v1/status") == 0) { + status_response(client); + } else if (strcmp(method, "GET") == 0 && strncmp(path, "/v1/trace", 9u) == 0) { + trace_response(client, path); + } else if (strcmp(method, "POST") == 0 && strcmp(path, "/v1/script") == 0) { + char error[128]; + size_t body_length = client->length - (size_t)(body - client->request); + if (fixture_load_script(server_fixture, body, body_length, error, sizeof(error))) { + json_message(client, 201, true, "script loaded and CRC verified"); + } else { + json_message(client, 409, false, error); + } + } else if (strcmp(method, "POST") == 0 && strcmp(path, "/v1/arm") == 0) { + char error[128]; + if (fixture_arm(server_fixture, trim(body), error, sizeof(error))) { + json_message(client, 200, true, "fixture armed; safety release queued"); + } else { + json_message(client, 409, false, error); + } + } else if (strcmp(method, "POST") == 0 && strcmp(path, "/v1/start") == 0) { + char run_id[FIXTURE_MAX_RUN_ID + 1u] = {0}; + unsigned int delay_ms = 0u; + char extra = '\0', error[128]; + if (sscanf(trim(body), "%48s %u %c", run_id, &delay_ms, &extra) != 2) { + json_message(client, 400, false, "start body must contain run_id and delay_ms"); + } else if (fixture_start(server_fixture, run_id, delay_ms, time_us_64(), error, sizeof(error))) { + json_message(client, 200, true, "locally timed script scheduled"); + } else { + json_message(client, 409, false, error); + } + } else if (strcmp(method, "POST") == 0 && strcmp(path, "/v1/abort") == 0) { + fixture_abort(server_fixture, "remote abort"); + json_message(client, 200, true, "aborted; all-keys-released report queued"); + } else { + json_message(client, 404, false, "endpoint not found"); + } +} + +static err_t receive_callback(void *argument, struct tcp_pcb *pcb, struct pbuf *packet, err_t error) { + (void)error; + http_client_t *client = argument; + if (!packet) { + close_client(client); + return ERR_OK; + } + if (client->length + packet->tot_len > REQUEST_CAPACITY) { + pbuf_free(packet); + json_message(client, 413, false, "request exceeds fixture capacity"); + return ERR_OK; + } + pbuf_copy_partial(packet, client->request + client->length, packet->tot_len, 0u); + client->length += packet->tot_len; + client->request[client->length] = '\0'; + tcp_recved(pcb, packet->tot_len); + pbuf_free(packet); + + if (!client->headers_parsed) { + char *header_end = strstr(client->request, "\r\n\r\n"); + if (!header_end) return ERR_OK; + client->headers_parsed = true; + size_t header_length = (size_t)(header_end + 4 - client->request); + size_t content_length = 0u; + const char *length_header = strstr(client->request, "Content-Length:"); + if (length_header && length_header < header_end) { + content_length = strtoul(length_header + strlen("Content-Length:"), NULL, 10); + } + client->expected_length = header_length + content_length; + if (client->expected_length > REQUEST_CAPACITY) { + json_message(client, 413, false, "declared body exceeds fixture capacity"); + return ERR_OK; + } + } + if (client->headers_parsed && client->length >= client->expected_length) dispatch(client); + return ERR_OK; +} + +static void error_callback(void *argument, err_t error) { + (void)error; + http_client_t *client = argument; + if (client) client->pcb = NULL; + close_client(client); +} + +static err_t accept_callback(void *argument, struct tcp_pcb *pcb, err_t error) { + (void)argument; + if (error != ERR_OK || !pcb || client_active) { + if (pcb) tcp_abort(pcb); + return ERR_ABRT; + } + http_client_t *client = calloc(1u, sizeof(*client)); + if (!client) { + tcp_abort(pcb); + return ERR_ABRT; + } + client_active = true; + client->pcb = pcb; + tcp_arg(pcb, client); + tcp_recv(pcb, receive_callback); + tcp_err(pcb, error_callback); + return ERR_OK; +} + +bool fixture_http_server_init(fixture_t *fixture, const char *bearer_token, uint16_t port) { + server_fixture = fixture; + server_token = bearer_token; + struct tcp_pcb *server = tcp_new_ip_type(IPADDR_TYPE_ANY); + if (!server) return false; + if (tcp_bind(server, NULL, port) != ERR_OK) { + tcp_close(server); + return false; + } + server = tcp_listen_with_backlog(server, 1u); + if (!server) return false; + tcp_accept(server, accept_callback); + return true; +} + +void fixture_http_set_network(bool connected, const char *address) { + network_connected = connected; + snprintf(network_address, sizeof(network_address), "%s", address ? address : "unassigned"); +} diff --git a/Scripts/lab/pico-hid-fixture/src/http_server.h b/Scripts/lab/pico-hid-fixture/src/http_server.h new file mode 100644 index 000000000..11eaa0ca6 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/http_server.h @@ -0,0 +1,12 @@ +#ifndef KEYPATH_FIXTURE_HTTP_SERVER_H +#define KEYPATH_FIXTURE_HTTP_SERVER_H + +#include +#include + +#include "fixture_core.h" + +bool fixture_http_server_init(fixture_t *fixture, const char *bearer_token, uint16_t port); +void fixture_http_set_network(bool connected, const char *address); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/lwipopts.h b/Scripts/lab/pico-hid-fixture/src/lwipopts.h new file mode 100644 index 000000000..4275a6619 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/lwipopts.h @@ -0,0 +1,40 @@ +#ifndef KEYPATH_LWIPOPTS_H +#define KEYPATH_LWIPOPTS_H + +// Raw lwIP configuration for the Pico SDK's polling CYW43 integration. The +// fixture exposes one small HTTP control server and deliberately has no socket +// or netconn compatibility layer. +#define NO_SYS 1 +#define SYS_LIGHTWEIGHT_PROT 0 + +#define LWIP_RAW 1 +#define LWIP_TCP 1 +#define LWIP_UDP 1 +#define LWIP_DHCP 1 +#define LWIP_DNS 1 +#define LWIP_IGMP 1 +#define LWIP_SOCKET 0 +#define LWIP_NETCONN 0 + +#define LWIP_NETIF_HOSTNAME 1 +#define LWIP_NETIF_STATUS_CALLBACK 1 +#define LWIP_NETIF_LINK_CALLBACK 1 +#define LWIP_DHCP_DOES_ACD_CHECK 0 +#define LWIP_MDNS_RESPONDER 1 +#define LWIP_NUM_NETIF_CLIENT_DATA 1 +#define MDNS_MAX_SERVICES 1 + +#define MEM_ALIGNMENT 4 +#define MEM_SIZE 16000 +#define MEMP_NUM_TCP_PCB 4 +#define MEMP_NUM_TCP_PCB_LISTEN 2 +#define MEMP_NUM_TCP_SEG 16 + +#define TCP_MSS 1460 +#define TCP_WND (4 * TCP_MSS) +#define TCP_SND_BUF (4 * TCP_MSS) +#define TCP_QUEUE_OOSEQ 0 + +#define LWIP_CHKSUM_ALGORITHM 3 + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/main.c b/Scripts/lab/pico-hid-fixture/src/main.c new file mode 100644 index 000000000..da5cf54cb --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/main.c @@ -0,0 +1,120 @@ +#include +#include + +#include "bsp/board_api.h" +#include "fixture_config.h" +#include "fixture_core.h" +#include "hardware/watchdog.h" +#include "http_server.h" +#include "lwip/apps/mdns.h" +#include "lwip/ip4_addr.h" +#include "lwip/netif.h" +#include "pico/cyw43_arch.h" +#include "pico/stdlib.h" +#include "tusb.h" +#include "usb_descriptors.h" + +static fixture_t fixture; +static bool mdns_started; + +static bool send_keyboard_report(uint8_t modifiers, const uint8_t keys[6], void *context) { + (void)context; + if (!tud_hid_ready()) return false; + return tud_hid_keyboard_report(REPORT_ID_KEYBOARD, modifiers, keys); +} + +void tud_hid_report_complete_cb(uint8_t instance, uint8_t const *report, uint16_t length) { + (void)instance; + (void)report; + (void)length; + fixture_note_transfer_complete(&fixture); +} + +static bool wifi_is_connected(void) { + return cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA) == CYW43_LINK_UP; +} + +static void announce_fixture(void) { + if (!netif_default) return; + if (!mdns_started) { + mdns_resp_init(); + if (mdns_resp_add_netif(netif_default, "keypath-hid-fixture") != ERR_OK) return; + mdns_resp_add_service(netif_default, "KeyPath HID fixture", "_http", + DNSSD_PROTO_TCP, KEYPATH_FIXTURE_HTTP_PORT, NULL, NULL); + mdns_started = true; + } + mdns_resp_announce(netif_default); +} + +static void update_led(uint64_t now_ms, bool wifi_connected) { + uint32_t phase = (uint32_t)(now_ms % 2000u); + bool on = false; + if (!wifi_connected) { + on = phase < 50u || (phase >= 150u && phase < 200u); + } else { + switch (fixture.state) { + case FIXTURE_ARMED: on = true; break; + case FIXTURE_RUNNING: on = (phase % 200u) < 100u; break; + case FIXTURE_COMPLETE: on = phase < 80u || (phase >= 180u && phase < 260u) || + (phase >= 360u && phase < 440u); break; + case FIXTURE_ERROR: on = (phase % 120u) < 60u; break; + case FIXTURE_LOADED: on = phase < 80u || (phase >= 180u && phase < 260u); break; + default: on = phase < 80u; break; + } + } + cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, on ? 1 : 0); +} + +int main(void) { + board_init(); + fixture_init(&fixture); + + tusb_rhport_init_t usb = { + .role = TUSB_ROLE_DEVICE, + .speed = TUSB_SPEED_FULL, + }; + if (!tud_rhport_init(BOARD_TUD_RHPORT, &usb)) return 1; + board_init_after_tusb(); + + if (cyw43_arch_init()) { + fixture.state = FIXTURE_ERROR; + snprintf(fixture.error, sizeof(fixture.error), "CYW43 initialization failed"); + while (true) { + tud_task(); + fixture_poll(&fixture, time_us_64(), tud_mounted(), tud_hid_ready(), + send_keyboard_report, NULL); + } + } + cyw43_arch_enable_sta_mode(); + cyw43_arch_wifi_connect_async(KEYPATH_WIFI_SSID, KEYPATH_WIFI_PASSWORD, CYW43_AUTH_WPA2_AES_PSK); + if (!fixture_http_server_init(&fixture, KEYPATH_FIXTURE_TOKEN, KEYPATH_FIXTURE_HTTP_PORT)) { + fixture.state = FIXTURE_ERROR; + snprintf(fixture.error, sizeof(fixture.error), "HTTP server initialization failed"); + } + watchdog_enable(8000u, true); + + uint64_t next_wifi_retry_ms = 15000u; + bool previous_wifi = false; + while (true) { + uint64_t now_us = time_us_64(); + uint64_t now_ms = now_us / 1000u; + tud_task(); + cyw43_arch_poll(); + bool connected = wifi_is_connected(); + if (connected != previous_wifi) { + const char *address = "unassigned"; + if (connected && netif_default) address = ip4addr_ntoa(netif_ip4_addr(netif_default)); + fixture_http_set_network(connected, address); + if (connected) announce_fixture(); + previous_wifi = connected; + } + if (!connected && now_ms >= next_wifi_retry_ms) { + cyw43_arch_wifi_connect_async(KEYPATH_WIFI_SSID, KEYPATH_WIFI_PASSWORD, CYW43_AUTH_WPA2_AES_PSK); + next_wifi_retry_ms = now_ms + 15000u; + } + fixture_poll(&fixture, now_us, tud_mounted(), tud_hid_ready(), send_keyboard_report, NULL); + update_led(now_ms, connected); + watchdog_update(); + tight_loop_contents(); + } +} diff --git a/Scripts/lab/pico-hid-fixture/src/tusb_config.h b/Scripts/lab/pico-hid-fixture/src/tusb_config.h new file mode 100644 index 000000000..df4837eea --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/tusb_config.h @@ -0,0 +1,33 @@ +#ifndef KEYPATH_TUSB_CONFIG_H +#define KEYPATH_TUSB_CONFIG_H + +#ifndef BOARD_TUD_RHPORT +#define BOARD_TUD_RHPORT 0 +#endif + +#ifndef BOARD_TUD_MAX_SPEED +#define BOARD_TUD_MAX_SPEED OPT_MODE_DEFAULT_SPEED +#endif + +#ifndef CFG_TUSB_MCU +#error CFG_TUSB_MCU must be defined by the Pico SDK +#endif + +#ifndef CFG_TUSB_OS +#define CFG_TUSB_OS OPT_OS_NONE +#endif + +#ifndef CFG_TUSB_DEBUG +#define CFG_TUSB_DEBUG 0 +#endif +#define CFG_TUD_ENABLED 1 +#define CFG_TUD_MAX_SPEED BOARD_TUD_MAX_SPEED +#define CFG_TUD_ENDPOINT0_SIZE 64 +#define CFG_TUD_HID 1 +#define CFG_TUD_CDC 0 +#define CFG_TUD_MSC 0 +#define CFG_TUD_MIDI 0 +#define CFG_TUD_VENDOR 0 +#define CFG_TUD_HID_EP_BUFSIZE 16 + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c new file mode 100644 index 000000000..06a19660b --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c @@ -0,0 +1,107 @@ +#include + +#include "bsp/board_api.h" +#include "tusb.h" +#include "usb_descriptors.h" + +#define USB_VID 0xCafe +#define USB_PID 0x4010 +#define USB_BCD 0x0200 +#define EPNUM_HID 0x81 + +tusb_desc_device_t const device_descriptor = { + .bLength = sizeof(tusb_desc_device_t), + .bDescriptorType = TUSB_DESC_DEVICE, + .bcdUSB = USB_BCD, + .bDeviceClass = 0, + .bDeviceSubClass = 0, + .bDeviceProtocol = 0, + .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE, + .idVendor = USB_VID, + .idProduct = USB_PID, + .bcdDevice = 0x0100, + .iManufacturer = 1, + .iProduct = 2, + .iSerialNumber = 3, + .bNumConfigurations = 1, +}; + +uint8_t const *tud_descriptor_device_cb(void) { + return (uint8_t const *)&device_descriptor; +} + +uint8_t const hid_report_descriptor[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)) +}; + +uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) { + (void)instance; + return hid_report_descriptor; +} + +enum { ITF_NUM_HID, ITF_NUM_TOTAL }; +#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN) + +uint8_t const configuration_descriptor[] = { + TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, + TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(ITF_NUM_HID, 0, HID_ITF_PROTOCOL_KEYBOARD, + sizeof(hid_report_descriptor), EPNUM_HID, + CFG_TUD_HID_EP_BUFSIZE, 1), +}; + +uint8_t const *tud_descriptor_configuration_cb(uint8_t index) { + (void)index; + return configuration_descriptor; +} + +static const char *string_descriptors[] = { + (const char[]){0x09, 0x04}, + "KeyPath Lab", + "KeyPath Physical HID Fixture", + NULL, +}; +static uint16_t string_buffer[33]; + +uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t language_id) { + (void)language_id; + size_t count = 0u; + if (index == 0u) { + memcpy(&string_buffer[1], string_descriptors[0], 2u); + count = 1u; + } else if (index == 3u) { + count = board_usb_get_serial(string_buffer + 1, 32u); + } else { + if (index >= sizeof(string_descriptors) / sizeof(string_descriptors[0])) return NULL; + const char *value = string_descriptors[index]; + if (!value) return NULL; + count = strlen(value); + if (count > 32u) count = 32u; + for (size_t character = 0u; character < count; ++character) { + string_buffer[character + 1u] = (uint8_t)value[character]; + } + } + string_buffer[0] = (uint16_t)((TUSB_DESC_STRING << 8u) | (2u * count + 2u)); + return string_buffer; +} + +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t *buffer, + uint16_t requested_length) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)requested_length; + return 0u; +} + +void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t const *buffer, + uint16_t buffer_size) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)buffer_size; +} diff --git a/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h new file mode 100644 index 000000000..9358ed7c9 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h @@ -0,0 +1,6 @@ +#ifndef KEYPATH_USB_DESCRIPTORS_H +#define KEYPATH_USB_DESCRIPTORS_H + +enum { REPORT_ID_KEYBOARD = 1 }; + +#endif diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c new file mode 100644 index 000000000..dd0a612a2 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -0,0 +1,157 @@ +#include "fixture_core.h" + +#include +#include +#include + +typedef struct { + uint32_t count; + uint8_t modifiers[32]; + uint8_t keys[32][6]; +} reports_t; + +static bool capture_report(uint8_t modifiers, const uint8_t keys[6], void *context) { + reports_t *reports = context; + assert(reports->count < 32u); + reports->modifiers[reports->count] = modifiers; + memcpy(reports->keys[reports->count], keys, 6u); + reports->count++; + return true; +} + +static void make_script(char *output, size_t capacity, const char *run_id, + uint32_t repeats, const char *events, uint32_t event_count, uint32_t cycle_us) { + uint32_t crc = fixture_crc32(events, strlen(events)); + snprintf(output, capacity, "KPHID1 %s %u %u %u %08x\n%s", + run_id, event_count, repeats, cycle_us, crc, events); +} + +static void drain_initial_release(fixture_t *fixture, reports_t *reports) { + fixture_poll(fixture, 0u, true, true, capture_report, reports); + assert(reports->count == 1u); +} + +static void test_load_arm_run_and_repeat(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "run-42", 2u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture.state == FIXTURE_LOADED); + assert(fixture_arm(&fixture, "run-42", error, sizeof(error))); + fixture_poll(&fixture, 10u, true, true, capture_report, &reports); + assert(reports.count == 2u); /* pre-arm safety release */ + assert(fixture_start(&fixture, "run-42", 100u, 1000000u, error, sizeof(error))); + + fixture_poll(&fixture, 1099999u, true, true, capture_report, &reports); + assert(reports.count == 2u); + fixture_poll(&fixture, 1100000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 1150000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 1200000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 1250000u, true, true, capture_report, &reports); + assert(fixture.state == FIXTURE_COMPLETE); + assert(fixture.reports_submitted == 4u); + assert(reports.count == 6u); + assert(reports.keys[2][0] == 4u); + assert(reports.keys[3][0] == 0u); + assert(fixture_trace_count(&fixture) == 4u); + assert(fixture_trace_at(&fixture, 0u)->sequence == 1u); +} + +static void test_rejects_corrupt_and_unsafe_scripts(void) { + fixture_t fixture; + fixture_init(&fixture); + char error[128]; + + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512]; + make_script(script, sizeof(script), "safe", 1u, events, 2u, 100000u); + script[strlen(script) - 2u] = '1'; + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "CRC32")); + + const char *held = "0 0 4 0 0 0 0 0\n"; + make_script(script, sizeof(script), "unsafe", 1u, held, 1u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "all-keys-released")); + + const char *duplicate = "0 0 4 4 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + make_script(script, sizeof(script), "duplicate", 1u, duplicate, 2u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "duplicate")); +} + +static void test_failed_replacement_invalidates_previous_script(void) { + fixture_t fixture; + fixture_init(&fixture); + char script[512], error[128]; + + const char *safe = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + make_script(script, sizeof(script), "previous", 1u, safe, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + + const char *unsafe = "0 0 5 0 0 0 0 0\n"; + make_script(script, sizeof(script), "replacement", 1u, unsafe, 1u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture.state == FIXTURE_IDLE); + assert(fixture.event_count == 0u); + assert(fixture.run_id[0] == '\0'); + assert(!fixture_arm(&fixture, "previous", error, sizeof(error))); +} + +static void test_abort_and_unmount_force_release(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "safety", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture_arm(&fixture, "safety", error, sizeof(error))); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + assert(fixture_start(&fixture, "safety", 100u, 1000u, error, sizeof(error))); + fixture_poll(&fixture, 101000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 101001u, false, false, capture_report, &reports); + assert(fixture.state == FIXTURE_ERROR); + assert(fixture.pending_release); + fixture_poll(&fixture, 101002u, true, true, capture_report, &reports); + assert(!fixture.pending_release); + assert(reports.keys[reports.count - 1u][0] == 0u); + + fixture_abort(&fixture, "operator abort"); + assert(fixture.state == FIXTURE_ABORTED); + fixture_poll(&fixture, 101003u, true, true, capture_report, &reports); + assert(reports.keys[reports.count - 1u][0] == 0u); +} + +static void test_lateness_metrics(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "late", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture_arm(&fixture, "late", error, sizeof(error))); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + assert(fixture_start(&fixture, "late", 100u, 0u, error, sizeof(error))); + fixture_poll(&fixture, 103000u, true, true, capture_report, &reports); + assert(fixture.late_reports == 1u); + assert(fixture.maximum_lateness_us == 3000); +} + +int main(void) { + test_load_arm_run_and_repeat(); + test_rejects_corrupt_and_unsafe_scripts(); + test_failed_replacement_invalidates_previous_script(); + test_abort_and_unmount_force_release(); + test_lateness_metrics(); + puts("pico fixture core tests passed"); + return 0; +} diff --git a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh new file mode 100755 index 000000000..75b60765c --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +fixture_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_binary=$(mktemp "${TMPDIR:-/tmp}/keypath-pico-core.XXXXXX") +trap 'rm -f "$test_binary"' EXIT HUP INT TERM + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$fixture_root/src" \ + "$fixture_root/src/fixture_core.c" \ + "$fixture_root/tests/fixture_core_tests.c" \ + -o "$test_binary" +"$test_binary" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index aafc1dc5f..bb2cf76a4 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -275,6 +275,8 @@ python3 "$LAB_DIR/tests/install-runtime-tests.py" python3 "$LAB_DIR/tests/macos-26-selector-scenario-tests.py" python3 "$LAB_DIR/tests/macos-27-selector-scenario-tests.py" python3 "$LAB_DIR/tests/physical-remap-session-tests.py" +python3 "$LAB_DIR/tests/pico-hid-fixture-client-tests.py" +"$LAB_DIR/pico-hid-fixture/tests/run-tests.sh" if grep -Eq 'local[[:space:]]+status=' "$LAB_DIR/scenarios/installer-scenario"; then echo "installer scenario must not shadow zsh's read-only status parameter" >&2 exit 1 diff --git a/Scripts/lab/tests/pico-hid-fixture-client-tests.py b/Scripts/lab/tests/pico-hid-fixture-client-tests.py new file mode 100755 index 000000000..4980361c6 --- /dev/null +++ b/Scripts/lab/tests/pico-hid-fixture-client-tests.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + +import importlib.util +import importlib.machinery +import json +import pathlib +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +CLIENT_PATH = ROOT / "Scripts/lab/pico-hid-fixture-client" +LOADER = importlib.machinery.SourceFileLoader("pico_hid_fixture_client", str(CLIENT_PATH)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +assert SPEC and SPEC.loader +CLIENT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CLIENT) + + +class RecordingHandler(BaseHTTPRequestHandler): + requests = [] + + def log_message(self, _format, *_arguments): + pass + + def _handle(self): + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + self.__class__.requests.append((self.command, self.path, self.headers.get("Authorization"), body)) + if self.path == "/v1/status": + payload = {"ok": True, "state": "idle"} + elif self.path.startswith("/v1/trace"): + encoded = b'{"runId":"r","from":0,"available":1}\n{"sequence":1}\n' + self.send_response(200) + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + return + else: + payload = {"ok": True} + encoded = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + do_GET = _handle + do_POST = _handle + + +class ClientTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + RecordingHandler.requests = [] + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), RecordingHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.thread.join() + + def setUp(self): + RecordingHandler.requests.clear() + self.client = CLIENT.FixtureClient("127.0.0.1", "test-token", self.server.server_port) + + def test_compile_text_emits_complete_reports_and_crc(self): + script = CLIENT.compile_text("run-1", "aA 1!\n", 80, 30, 2, 500) + header, payload = script.split("\n", 1) + fields = header.split() + self.assertEqual(fields[:5], ["KPHID1", "run-1", "12", "2", "980000"]) + self.assertEqual(int(fields[5], 16), CLIENT.zlib.crc32(payload.encode("ascii")) & 0xFFFFFFFF) + lines = payload.splitlines() + self.assertEqual(lines[0], "0 0 4 0 0 0 0 0") + self.assertEqual(lines[2], "80000 2 4 0 0 0 0 0") + self.assertEqual(lines[-1].split()[1:], ["0", "0", "0", "0", "0", "0", "0"]) + + def test_compile_rejects_unsupported_text_and_unsafe_timing(self): + with self.assertRaisesRegex(ValueError, "unsupported"): + CLIENT.compile_text("run", "🙂", 80, 30, 1, 0) + with self.assertRaisesRegex(ValueError, "hold duration"): + CLIENT.compile_text("run", "a", 20, 20, 1, 0) + + def test_client_authenticates_and_uses_expected_endpoints(self): + self.assertEqual(self.client.status()["state"], "idle") + self.client.arm("run-2") + self.client.start("run-2", 2000) + self.client.abort() + paths = [(method, path) for method, path, _auth, _body in RecordingHandler.requests] + self.assertEqual(paths, [("GET", "/v1/status"), ("POST", "/v1/arm"), + ("POST", "/v1/start"), ("POST", "/v1/abort")]) + self.assertTrue(all(auth == "Bearer test-token" for _method, _path, auth, _body in RecordingHandler.requests)) + + def test_trace_decodes_ndjson(self): + trace = self.client.trace(0, 8) + self.assertEqual(trace[0]["available"], 1) + self.assertEqual(trace[1]["sequence"], 1) + + +if __name__ == "__main__": + unittest.main() From 2851518b143a7ab7672cbc7b0a3e35b4ef9d50e6 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 16:22:26 -0700 Subject: [PATCH 73/99] Add Waveshare ESP32-S3 HID fixture firmware --- Scripts/lab/pico-hid-fixture/README.md | 78 ++++- .../pico-hid-fixture/src/fixture_ui_model.c | 82 +++++ .../pico-hid-fixture/src/fixture_ui_model.h | 65 ++++ .../.gitignore | 4 + .../CMakeLists.txt | 4 + .../dependencies.lock | 177 +++++++++++ .../main/CMakeLists.txt | 50 +++ .../main/Kconfig.projbuild | 32 ++ .../main/app_main.c | 23 ++ .../main/fixture_board.c | 93 ++++++ .../main/fixture_board.h | 10 + .../main/fixture_config.h.in | 9 + .../main/fixture_display.c | 294 ++++++++++++++++++ .../main/fixture_display.h | 6 + .../main/fixture_http.c | 265 ++++++++++++++++ .../main/fixture_http.h | 8 + .../main/fixture_qemu_smoke.c | 77 +++++ .../main/fixture_qemu_smoke.h | 6 + .../main/fixture_runtime.c | 238 ++++++++++++++ .../main/fixture_runtime.h | 35 +++ .../main/idf_component.yml | 6 + .../sdkconfig.defaults | 25 ++ .../sdkconfig.qemu.defaults | 26 ++ .../tests/fixture_core_tests.c | 72 ++++- .../tests/run-esp32-qemu-smoke.sh | 69 ++++ .../lab/pico-hid-fixture/tests/run-tests.sh | 1 + 26 files changed, 1740 insertions(+), 15 deletions(-) create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_ui_model.h create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/.gitignore create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/CMakeLists.txt create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/dependencies.lock create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.h create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/idf_component.yml create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults create mode 100755 Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 7df104b72..b39aadbb3 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -1,19 +1,23 @@ -# KeyPath Pico 2 W physical HID fixture +# KeyPath physical HID fixture -This lab-only firmware turns an otherwise unmodified Raspberry Pi Pico 2 W into a deterministic -USB keyboard controlled over Wi-Fi. The VM owns the Pico's USB device; the Mac mini uses the -independent Wi-Fi control plane to load, arm, start, abort, and inspect locally timed HID scripts. +This lab-only firmware turns a Wi-Fi microcontroller into a deterministic USB keyboard. The VM +owns the fixture's USB device; the Mac mini uses the independent Wi-Fi control plane to load, arm, +start, abort, and inspect locally timed HID scripts. The fixture is deliberately not part of any KeyPath product target. -## Hardware +## Hardware targets -- Raspberry Pi Pico 2 W -- One data-capable micro-USB cable to the Mac mini +- **Primary:** Waveshare ESP32-S3-Touch-LCD-1.69 (240×280 display, capacitive touch, buzzer, + function button, 8 MB PSRAM, and 16 MB flash) +- **Legacy:** Raspberry Pi Pico 2 W +- One data-capable USB cable to the Mac mini - Lab Wi-Fi access -No serial adapter, debugger, shield, speaker, external power supply, or second microcontroller is -required. The onboard green LED reports fixture state. +The Waveshare board needs no shield, speaker, external debugger, power supply, or second +microcontroller. Its display presents the run state and timing pressure; touch or the physical +button aborts an armed/running script, and its buzzer provides sparse transition cues. Pico 2 W +uses its onboard green LED instead. ## Safety model @@ -28,10 +32,13 @@ required. The onboard green LED reports fixture state. - HTTP endpoints require a bearer token. Credentials are build inputs and are never committed. - Control traffic is HTTP rather than TLS, so operate it only on the isolated, WPA2-protected lab Wi-Fi; the bearer token is defense in depth, not a substitute for network isolation. -- The Pico schedules every report locally. Wi-Fi jitter can shift the start acknowledgement but +- The fixture schedules every report locally. Wi-Fi jitter can shift the start acknowledgement but cannot alter inter-key timing after the script starts. +- On ESP32-S3, the HID scheduler owns core 1 at high priority. USB service, network control, sound, + and display work remain on core 0. The motion governor drops the display from 30 to 20 to 4 FPS + before animation can compete with HID timing. -## LED states +## Pico LED states | State | Onboard LED | |---|---| @@ -43,7 +50,34 @@ required. The onboard green LED reports fixture state. | Complete | repeating triple blink | | Error | rapid blink | -## Build +## Waveshare ESP32-S3 build + +ESP-IDF 5.5.5 and its Python tools are installed locally under +`~/.cache/keypath-esp32/esp-idf`. The target uses Waveshare's official board component and LVGL. +Build credentials are supplied only through the environment: + +```bash +source ~/.cache/keypath-esp32/esp-idf/export.sh +export KEYPATH_WIFI_SSID='lab-network' +export KEYPATH_WIFI_PASSWORD='...' +export KEYPATH_FIXTURE_TOKEN='at-least-16-random-characters' +idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 build +``` + +The board revision defaults to 2, whose buzzer is on GPIO42. Revision 1 used GPIO33; change +`KeyPath fixture → Waveshare board revision` with `idf.py menuconfig` if the delivered board is an +older revision. Flashing waits for the physical board: + +```bash +idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 -p PORT flash +``` + +The on-device scene is intentionally calm while idle and more expressive during state changes and +runs: orbital particles, a live report-progress arc, animated key cells, color-coded state changes, +and a completion flourish. A visible `HID PRIORITY` mode means detected timing pressure has reduced +the animation rate to protect keyboard delivery. + +## Pico 2 W build Install CMake, an Arm embedded compiler, and the Raspberry Pi Pico SDK. Keep Wi-Fi credentials and the fixture token out of the repository: @@ -126,9 +160,25 @@ requested load threshold before `start`. ```bash Scripts/lab/pico-hid-fixture/tests/run-tests.sh python3 Scripts/lab/tests/pico-hid-fixture-client-tests.py +Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh ``` These tests cover CRC and script admission, timing/repeat execution, trace ordering, lateness metrics, boot/abort/unmount releases, US-keyboard compilation, bearer authentication, endpoint -selection, and NDJSON trace decoding. The firmware has also been cross-compiled against Pico SDK -2.3.0 for `pico2_w`; a physical USB/VM run remains required before declaring it hardware-proven. +selection, NDJSON trace decoding, and the adaptive UI model. The QEMU test boots an ESP32-S3 image +and executes the real parser, scheduler, trace logic, and UI state model on the emulated Xtensa +cores. QEMU does not emulate the Waveshare LCD/touch/buzzer or the ESP32-S3 native USB device +controller, so the physical USB/VM, display, touch, sound, and timing acceptance checks still wait +for the board. + +## First-board acceptance + +1. Flash revision 2, confirm the display, touch coordinates, function button, and transition tones; + retry revision 1 only if the buzzer is silent. +2. Confirm macOS reports exactly one boot-keyboard HID interface and no serial or storage interface. +3. Attach USB directly to a disposable VM and verify a baseline script's received text, report + count, submitted CRC32, transfer completions, and lateness trace. +4. Repeat under sustained CPU, memory, disk, and UI load; `HID PRIORITY` should reduce animation + while the trace remains complete and correctly ordered. +5. During an active run, test touch abort, button abort, Wi-Fi abort, and USB removal. Every path + must end with an all-keys-released report before the fixture can be called hardware-proven. diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c new file mode 100644 index 000000000..e74bc509a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c @@ -0,0 +1,82 @@ +#include "fixture_ui_model.h" + +#include + +static fixture_ui_scene_t scene_for(const fixture_ui_input_t *input) { + if (!input->wifi_connected && input->state != FIXTURE_ERROR) return FIXTURE_UI_CONNECTING; + switch (input->state) { + case FIXTURE_BOOTING: return FIXTURE_UI_BOOT; + case FIXTURE_IDLE: return FIXTURE_UI_IDLE; + case FIXTURE_LOADED: return FIXTURE_UI_LOADED; + case FIXTURE_ARMED: return FIXTURE_UI_ARMED; + case FIXTURE_RUNNING: return FIXTURE_UI_RUNNING; + case FIXTURE_COMPLETE: return FIXTURE_UI_COMPLETE; + case FIXTURE_ABORTED: return FIXTURE_UI_ABORTED; + case FIXTURE_ERROR: return FIXTURE_UI_ERROR; + } + return FIXTURE_UI_ERROR; +} + +void fixture_ui_model_init(fixture_ui_model_t *model) { + memset(model, 0, sizeof(*model)); + model->previous_state = FIXTURE_BOOTING; +} + +fixture_ui_output_t fixture_ui_model_step(fixture_ui_model_t *model, + const fixture_ui_input_t *input, + uint64_t now_ms) { + fixture_ui_output_t output = {0}; + output.scene = scene_for(input); + + uint64_t elapsed = model->initialized && now_ms > model->previous_update_ms + ? now_ms - model->previous_update_ms + : 0u; + uint64_t report_delta = model->initialized && input->reports_submitted >= model->previous_reports + ? input->reports_submitted - model->previous_reports + : 0u; + uint64_t late_delta = model->initialized && input->late_reports >= model->previous_late_reports + ? input->late_reports - model->previous_late_reports + : 0u; + + uint32_t decay = elapsed > 1000u ? 1000u : (uint32_t)elapsed; + uint32_t energy = model->energy_per_mille > decay ? model->energy_per_mille - decay : 0u; + uint64_t impulse = report_delta * 95u; + if (impulse > 1000u) impulse = 1000u; + energy += (uint32_t)impulse; + if (energy > 1000u) energy = 1000u; + model->energy_per_mille = (uint16_t)energy; + + bool new_lateness_peak = input->maximum_lateness_us > 1500 && + input->maximum_lateness_us > model->previous_maximum_lateness_us; + bool new_pressure = input->state == FIXTURE_RUNNING && (late_delta > 0u || new_lateness_peak); + if (new_pressure) model->protected_until_ms = now_ms + 1500u; + output.pressure_warning = new_pressure || now_ms < model->protected_until_ms; + + if (output.pressure_warning) { + output.quality = FIXTURE_UI_PROTECTED; + output.frame_interval_ms = 250u; + } else if (input->state == FIXTURE_RUNNING) { + output.quality = FIXTURE_UI_ACTIVE; + output.frame_interval_ms = 50u; + } else { + output.quality = FIXTURE_UI_SHOWCASE; + output.frame_interval_ms = 33u; + } + + uint64_t total = (uint64_t)input->event_count * input->repeat_count; + if (total > 0u) { + uint64_t progress = input->reports_submitted * 1000u / total; + output.progress_per_mille = (uint16_t)(progress > 1000u ? 1000u : progress); + } + output.energy_per_mille = model->energy_per_mille; + output.completion_burst = model->initialized && model->previous_state != FIXTURE_COMPLETE && + input->state == FIXTURE_COMPLETE; + + model->previous_reports = input->reports_submitted; + model->previous_late_reports = input->late_reports; + model->previous_maximum_lateness_us = input->maximum_lateness_us; + model->previous_update_ms = now_ms; + model->previous_state = input->state; + model->initialized = true; + return output; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.h new file mode 100644 index 000000000..eb4a9acb3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.h @@ -0,0 +1,65 @@ +#ifndef KEYPATH_FIXTURE_UI_MODEL_H +#define KEYPATH_FIXTURE_UI_MODEL_H + +#include +#include + +#include "fixture_core.h" + +typedef enum { + FIXTURE_UI_BOOT = 0, + FIXTURE_UI_CONNECTING, + FIXTURE_UI_IDLE, + FIXTURE_UI_LOADED, + FIXTURE_UI_ARMED, + FIXTURE_UI_RUNNING, + FIXTURE_UI_COMPLETE, + FIXTURE_UI_ABORTED, + FIXTURE_UI_ERROR, +} fixture_ui_scene_t; + +typedef enum { + FIXTURE_UI_SHOWCASE = 0, + FIXTURE_UI_ACTIVE, + FIXTURE_UI_PROTECTED, +} fixture_ui_quality_t; + +typedef struct { + fixture_state_t state; + bool wifi_connected; + bool usb_mounted; + uint32_t event_count; + uint32_t repeat_count; + uint32_t current_repeat; + uint64_t reports_submitted; + uint64_t late_reports; + int64_t maximum_lateness_us; +} fixture_ui_input_t; + +typedef struct { + fixture_ui_scene_t scene; + fixture_ui_quality_t quality; + uint16_t progress_per_mille; + uint16_t energy_per_mille; + uint16_t frame_interval_ms; + bool completion_burst; + bool pressure_warning; +} fixture_ui_output_t; + +typedef struct { + uint64_t previous_reports; + uint64_t previous_late_reports; + int64_t previous_maximum_lateness_us; + uint64_t previous_update_ms; + uint64_t protected_until_ms; + fixture_state_t previous_state; + uint16_t energy_per_mille; + bool initialized; +} fixture_ui_model_t; + +void fixture_ui_model_init(fixture_ui_model_t *model); +fixture_ui_output_t fixture_ui_model_step(fixture_ui_model_t *model, + const fixture_ui_input_t *input, + uint64_t now_ms); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/.gitignore b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/.gitignore new file mode 100644 index 000000000..55267f8a3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/.gitignore @@ -0,0 +1,4 @@ +build/ +managed_components/ +sdkconfig +sdkconfig.old diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/CMakeLists.txt new file mode 100644 index 000000000..b54d29a46 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(keypath_esp32_s3_hid_fixture) diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/dependencies.lock b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/dependencies.lock new file mode 100644 index 000000000..d9cf7c042 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/dependencies.lock @@ -0,0 +1,177 @@ +dependencies: + espressif/cmake_utilities: + component_hash: 351350613ceafba240b761b4ea991e0f231ac7a9f59a9ee901f751bddc0bb18f + dependencies: + - name: idf + require: private + version: '>=4.1' + source: + registry_url: https://components.espressif.com + type: service + version: 0.5.3 + espressif/esp_io_expander: + component_hash: 7b7f91270172e46ca4597e3874f48d8412e280e8c295559f5922ec38656e3e5f + dependencies: + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.1 + espressif/esp_lcd_panel_io_additions: + component_hash: 1856c8b2c11aaa8a9fd67481582162bcad25f7064661a995f35c2ee8dd10ebcc + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/esp_io_expander + registry_url: https://components.espressif.com + require: public + version: ^1 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.0.1~1 + espressif/esp_lcd_touch: + component_hash: 3f85a7d95af876f1a6ecca8eb90a81614890d0f03a038390804e5a77e2caf862 + dependencies: + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.1 + espressif/esp_lcd_touch_cst816s: + component_hash: f9fb32ca8642f802f193e9de582a7e3f073c2a8d949d781494bbd9ebf1d2d8ff + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.2.0 + - name: idf + require: private + version: '>=5.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.1.1~2 + espressif/esp_lvgl_port: + component_hash: fb6c1fdfe70d4ca39a035d63ca394a35f17ec84ce16ff98ecb13a54155d75da4 + dependencies: + - name: idf + require: private + version: '>=5.2' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: public + version: '>=8,<10' + source: + registry_url: https://components.espressif.com + type: service + version: 2.8.0~1 + espressif/esp_tinyusb: + component_hash: 9a73a76a6bc17907f6e523342ff6b1e440023e8aac2c11f4691212106ec24e66 + dependencies: + - name: idf + require: private + version: '>=5.0' + - name: espressif/tinyusb + registry_url: https://components.espressif.com + require: public + version: '>=0.17.0~2' + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32s2 + - esp32s3 + - esp32p4 + - esp32h4 + - esp32s31 + version: 2.2.1 + espressif/mdns: + component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.11.3 + espressif/tinyusb: + component_hash: a72b7d67472914ab76309340fd50d578b31e310963d45ad0f81144bde3314752 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32s2 + - esp32s3 + - esp32p4 + - esp32h4 + - esp32s31 + version: 0.21.0~1 + idf: + source: + type: idf + version: 5.5.5 + lvgl/lvgl: + component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 9.5.0 + waveshare/esp32_s3_touch_lcd_1_69: + component_hash: d66d289981e6fcba599dd36e530ab3c863f15206dfd2d0b1fc0d6bbb57efd783 + dependencies: + - name: espressif/esp_lcd_panel_io_additions + registry_url: https://components.espressif.com + require: private + version: ^1 + - name: espressif/esp_lcd_touch_cst816s + registry_url: https://components.espressif.com + require: private + version: '*' + - name: espressif/esp_lvgl_port + registry_url: https://components.espressif.com + require: public + version: ^2 + - name: idf + require: private + version: '>=5.3' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: private + version: '>=8,<10' + - name: espressif/usb + registry_url: https://components.espressif.com + require: public + rules: + - if: idf_version >=6.0 + version: ^1.0.0 + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32s3 + version: 2.0.0 +direct_dependencies: +- espressif/esp_tinyusb +- espressif/mdns +- idf +- lvgl/lvgl +- waveshare/esp32_s3_touch_lcd_1_69 +manifest_hash: dd2fe7cb72e099a8802f5d9751108d3c2c674ca0b23accd245ae5f102d4bdefe +target: esp32s3 +version: 2.0.0 diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt new file mode 100644 index 000000000..ae6a1afc8 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt @@ -0,0 +1,50 @@ +foreach(secret_name KEYPATH_WIFI_SSID KEYPATH_WIFI_PASSWORD KEYPATH_FIXTURE_TOKEN) + if(NOT DEFINED ENV{${secret_name}} OR "$ENV{${secret_name}}" STREQUAL "") + message(FATAL_ERROR "${secret_name} must be supplied in the build environment") + endif() + set(secret_value "$ENV{${secret_name}}") + string(REPLACE "\\" "\\\\" secret_value "${secret_value}") + string(REPLACE "\"" "\\\"" secret_value "${secret_value}") + set(${secret_name}_ESCAPED "${secret_value}") +endforeach() + +string(LENGTH "$ENV{KEYPATH_FIXTURE_TOKEN}" fixture_token_length) +if(fixture_token_length LESS 16) + message(FATAL_ERROR "KEYPATH_FIXTURE_TOKEN must contain at least 16 characters") +endif() + +get_filename_component(component_dir "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) +set(shared_source "${component_dir}/../../../src") +set(generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated") +file(MAKE_DIRECTORY "${generated_dir}") +configure_file("${component_dir}/fixture_config.h.in" "${generated_dir}/fixture_config.h" @ONLY) + +idf_component_register( + SRCS + "app_main.c" + "fixture_board.c" + "fixture_display.c" + "fixture_http.c" + "fixture_qemu_smoke.c" + "fixture_runtime.c" + "${shared_source}/fixture_core.c" + "${shared_source}/fixture_ui_model.c" + INCLUDE_DIRS + "." + "${shared_source}" + "${generated_dir}" + REQUIRES + esp_event + esp_http_server + esp_netif + esp_timer + esp_wifi + mdns + nvs_flash +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) + +if("$ENV{KEYPATH_QEMU_SMOKE}" STREQUAL "1") + target_compile_definitions(${COMPONENT_LIB} PRIVATE KEYPATH_QEMU_SMOKE=1) +endif() diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild new file mode 100644 index 000000000..75b10f866 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild @@ -0,0 +1,32 @@ +menu "KeyPath HID fixture" + + config KEYPATH_FIXTURE_BOARD_REVISION + int "Waveshare board hardware revision" + range 1 2 + default 2 + help + Select 2 for current boards. Revision 1 used GPIO33 for the buzzer; + revision 2 uses GPIO42. The LCD, touch, and native USB pins are shared. + + config KEYPATH_FIXTURE_HTTP_PORT + int "Fixture HTTP port" + range 1024 65535 + default 8080 + + config KEYPATH_FIXTURE_BRIGHTNESS + int "Display brightness percent" + range 10 100 + default 85 + + config KEYPATH_FIXTURE_SOUND + bool "Enable state-change tones" + default y + + config KEYPATH_FIXTURE_REDUCED_MOTION + bool "Reduce display motion" + default n + help + Keep state, color, and progress feedback while disabling orbital + movement, pulsing geometry, and the completion particle flourish. + +endmenu diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c new file mode 100644 index 000000000..82bd0c1f2 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c @@ -0,0 +1,23 @@ +#include "esp_log.h" + +#include "fixture_board.h" +#include "fixture_display.h" +#include "fixture_http.h" +#include "fixture_qemu_smoke.h" +#include "fixture_runtime.h" + +static const char *TAG = "keypath_fixture"; + +void app_main(void) { +#ifdef KEYPATH_QEMU_SMOKE + fixture_qemu_smoke_run(); + return; +#endif + fixture_runtime_init(); + fixture_board_init(); + ESP_ERROR_CHECK(fixture_runtime_start_usb()); + fixture_runtime_start_executor(); + fixture_display_start(); + ESP_ERROR_CHECK(fixture_network_start()); + ESP_LOGI(TAG, "KeyPath ESP32-S3 physical HID fixture ready"); +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c new file mode 100644 index 000000000..c20872b9f --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c @@ -0,0 +1,93 @@ +#include "fixture_board.h" + +#include + +#include "driver/gpio.h" +#include "driver/ledc.h" +#include "fixture_runtime.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "sdkconfig.h" + +#define FIXTURE_BUTTON GPIO_NUM_0 +#if CONFIG_KEYPATH_FIXTURE_BOARD_REVISION == 1 +#define FIXTURE_BUZZER GPIO_NUM_33 +#else +#define FIXTURE_BUZZER GPIO_NUM_42 +#endif + +typedef struct { + uint16_t frequency_hz; + uint16_t duration_ms; +} tone_request_t; + +static QueueHandle_t tone_queue; +static volatile bool button_enabled; + +static void board_task(void *context) { + (void)context; + bool previous_pressed = false; + while (true) { + tone_request_t tone; + if (xQueueReceive(tone_queue, &tone, pdMS_TO_TICKS(10)) == pdTRUE) { +#if CONFIG_KEYPATH_FIXTURE_SOUND + ledc_set_freq(LEDC_LOW_SPEED_MODE, LEDC_TIMER_2, tone.frequency_hz); + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_2, 128u); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_2); + vTaskDelay(pdMS_TO_TICKS(tone.duration_ms)); + ledc_stop(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_2, 0u); +#endif + } + bool pressed = gpio_get_level(FIXTURE_BUTTON) == 0; + if (button_enabled && pressed && !previous_pressed) { + fixture_runtime_abort("physical button abort"); + fixture_board_tone(220u, 90u); + } + previous_pressed = pressed; + } +} + +void fixture_board_init(void) { + gpio_config_t button = { + .pin_bit_mask = 1ULL << FIXTURE_BUTTON, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + ESP_ERROR_CHECK(gpio_config(&button)); +#if CONFIG_KEYPATH_FIXTURE_SOUND + ledc_timer_config_t timer = { + .speed_mode = LEDC_LOW_SPEED_MODE, + .duty_resolution = LEDC_TIMER_8_BIT, + .timer_num = LEDC_TIMER_2, + .freq_hz = 880, + .clk_cfg = LEDC_AUTO_CLK, + }; + ledc_channel_config_t channel = { + .gpio_num = FIXTURE_BUZZER, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = LEDC_CHANNEL_2, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = LEDC_TIMER_2, + .duty = 0, + .hpoint = 0, + }; + ESP_ERROR_CHECK(ledc_timer_config(&timer)); + ESP_ERROR_CHECK(ledc_channel_config(&channel)); +#endif + tone_queue = xQueueCreate(4u, sizeof(tone_request_t)); + configASSERT(tone_queue); + configASSERT(xTaskCreatePinnedToCore(board_task, "fixture_board", 3072, NULL, 5, NULL, 0) == pdPASS); +} + +void fixture_board_tone(unsigned int frequency_hz, unsigned int duration_ms) { + if (!tone_queue || frequency_hz > UINT16_MAX || duration_ms > UINT16_MAX) return; + tone_request_t request = {(uint16_t)frequency_hz, (uint16_t)duration_ms}; + xQueueSend(tone_queue, &request, 0u); +} + +void fixture_board_update(bool armed_or_running) { + button_enabled = armed_or_running; +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h new file mode 100644 index 000000000..db55b68f5 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h @@ -0,0 +1,10 @@ +#ifndef KEYPATH_ESP32_FIXTURE_BOARD_H +#define KEYPATH_ESP32_FIXTURE_BOARD_H + +#include + +void fixture_board_init(void); +void fixture_board_tone(unsigned int frequency_hz, unsigned int duration_ms); +void fixture_board_update(bool armed_or_running); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in new file mode 100644 index 000000000..b643e0e7d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in @@ -0,0 +1,9 @@ +#ifndef KEYPATH_ESP32_FIXTURE_CONFIG_H +#define KEYPATH_ESP32_FIXTURE_CONFIG_H + +#define KEYPATH_WIFI_SSID "@KEYPATH_WIFI_SSID_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD "@KEYPATH_WIFI_PASSWORD_ESCAPED@" +#define KEYPATH_FIXTURE_TOKEN "@KEYPATH_FIXTURE_TOKEN_ESCAPED@" +#define KEYPATH_FIXTURE_FIRMWARE_VERSION "0.2.0-esp32s3" + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c new file mode 100644 index 000000000..cb45a9d78 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c @@ -0,0 +1,294 @@ +#include "fixture_display.h" + +#include +#include + +#include "bsp/esp32_s3_touch_lcd_1_69.h" +#include "esp_timer.h" +#include "fixture_board.h" +#include "fixture_runtime.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lvgl.h" +#include "sdkconfig.h" + +#define PARTICLE_COUNT 12u +#define KEY_COUNT 6u + +typedef struct { + lv_obj_t *screen; + lv_obj_t *halo_outer; + lv_obj_t *halo_inner; + lv_obj_t *orbit; + lv_obj_t *progress; + lv_obj_t *core; + lv_obj_t *keys[KEY_COUNT]; + lv_obj_t *particles[PARTICLE_COUNT]; + lv_obj_t *eyebrow; + lv_obj_t *state; + lv_obj_t *detail; + lv_obj_t *quality; + fixture_ui_scene_t previous_scene; + float phase; + uint64_t completion_started_ms; +} display_ui_t; + +static display_ui_t ui; + +static const char *scene_name(fixture_ui_scene_t scene) { + switch (scene) { + case FIXTURE_UI_BOOT: return "WAKING UP"; + case FIXTURE_UI_CONNECTING: return "JOINING LAB"; + case FIXTURE_UI_IDLE: return "READY"; + case FIXTURE_UI_LOADED: return "SCRIPT LOADED"; + case FIXTURE_UI_ARMED: return "ARMED"; + case FIXTURE_UI_RUNNING: return "TYPING"; + case FIXTURE_UI_COMPLETE: return "RUN COMPLETE"; + case FIXTURE_UI_ABORTED: return "RUN STOPPED"; + case FIXTURE_UI_ERROR: return "ATTENTION"; + } + return "UNKNOWN"; +} + +static lv_color_t accent_for(fixture_ui_scene_t scene) { + switch (scene) { + case FIXTURE_UI_RUNNING: return lv_color_hex(0x55c7ff); + case FIXTURE_UI_COMPLETE: return lv_color_hex(0x44d7a8); + case FIXTURE_UI_ARMED: return lv_color_hex(0xffb454); + case FIXTURE_UI_ABORTED: return lv_color_hex(0x9c7cff); + case FIXTURE_UI_ERROR: return lv_color_hex(0xff5c72); + default: return lv_color_hex(0x56ddb3); + } +} + +static lv_obj_t *make_circle(lv_obj_t *parent, int size, lv_color_t color, lv_opa_t opacity) { + lv_obj_t *object = lv_obj_create(parent); + lv_obj_remove_style_all(object); + lv_obj_set_size(object, size, size); + lv_obj_set_style_radius(object, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(object, color, 0); + lv_obj_set_style_bg_opa(object, opacity, 0); + lv_obj_clear_flag(object, LV_OBJ_FLAG_CLICKABLE); + return object; +} + +static void touch_event(lv_event_t *event) { + if (lv_event_get_code(event) != LV_EVENT_PRESSED) return; + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + if (snapshot.ui.state == FIXTURE_ARMED || snapshot.ui.state == FIXTURE_RUNNING) { + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x241323), 0); + fixture_runtime_abort("touch abort"); + fixture_board_tone(220u, 90u); + } +} + +static void build_ui(void) { + ui.screen = lv_screen_active(); + lv_obj_remove_style_all(ui.screen); + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x071117), 0); + lv_obj_set_style_bg_opa(ui.screen, LV_OPA_COVER, 0); + lv_obj_add_flag(ui.screen, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(ui.screen, touch_event, LV_EVENT_PRESSED, NULL); + + ui.eyebrow = lv_label_create(ui.screen); + lv_label_set_text_static(ui.eyebrow, "KEYPATH / HID ORACLE"); + lv_obj_set_style_text_color(ui.eyebrow, lv_color_hex(0x71909d), 0); + lv_obj_set_style_text_letter_space(ui.eyebrow, 2, 0); + lv_obj_align(ui.eyebrow, LV_ALIGN_TOP_MID, 0, 15); + + ui.halo_outer = make_circle(ui.screen, 154, lv_color_hex(0x173946), LV_OPA_20); + lv_obj_align(ui.halo_outer, LV_ALIGN_CENTER, 0, -2); + ui.halo_inner = make_circle(ui.screen, 124, lv_color_hex(0x153541), LV_OPA_30); + lv_obj_align(ui.halo_inner, LV_ALIGN_CENTER, 0, -2); + + ui.orbit = lv_arc_create(ui.screen); + lv_obj_set_size(ui.orbit, 142, 142); + lv_obj_align(ui.orbit, LV_ALIGN_CENTER, 0, -2); + lv_arc_set_bg_angles(ui.orbit, 0, 360); + lv_arc_set_angles(ui.orbit, 20, 104); + lv_obj_set_style_arc_width(ui.orbit, 2, LV_PART_MAIN); + lv_obj_set_style_arc_color(ui.orbit, lv_color_hex(0x183943), LV_PART_MAIN); + lv_obj_set_style_arc_width(ui.orbit, 4, LV_PART_INDICATOR); + lv_obj_set_style_arc_rounded(ui.orbit, true, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(ui.orbit, LV_OPA_TRANSP, LV_PART_KNOB); + lv_obj_clear_flag(ui.orbit, LV_OBJ_FLAG_CLICKABLE); + + ui.progress = lv_arc_create(ui.screen); + lv_obj_set_size(ui.progress, 112, 112); + lv_obj_align(ui.progress, LV_ALIGN_CENTER, 0, -2); + lv_arc_set_range(ui.progress, 0, 1000); + lv_arc_set_bg_angles(ui.progress, 135, 45); + lv_arc_set_value(ui.progress, 0); + lv_obj_set_style_arc_width(ui.progress, 5, LV_PART_MAIN); + lv_obj_set_style_arc_color(ui.progress, lv_color_hex(0x18343e), LV_PART_MAIN); + lv_obj_set_style_arc_width(ui.progress, 5, LV_PART_INDICATOR); + lv_obj_set_style_arc_rounded(ui.progress, true, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(ui.progress, LV_OPA_TRANSP, LV_PART_KNOB); + lv_obj_clear_flag(ui.progress, LV_OBJ_FLAG_CLICKABLE); + + ui.core = make_circle(ui.screen, 76, lv_color_hex(0x0d2028), LV_OPA_COVER); + lv_obj_set_style_border_width(ui.core, 1, 0); + lv_obj_set_style_border_color(ui.core, lv_color_hex(0x2f6970), 0); + lv_obj_align(ui.core, LV_ALIGN_CENTER, 0, -2); + + for (size_t index = 0; index < KEY_COUNT; ++index) { + ui.keys[index] = lv_obj_create(ui.core); + lv_obj_remove_style_all(ui.keys[index]); + lv_obj_set_size(ui.keys[index], 17, 14); + lv_obj_set_style_radius(ui.keys[index], 4, 0); + lv_obj_set_style_bg_color(ui.keys[index], lv_color_hex(0x56ddb3), 0); + lv_obj_set_style_bg_opa(ui.keys[index], LV_OPA_30, 0); + int column = (int)(index % 3u); + int row = (int)(index / 3u); + lv_obj_set_pos(ui.keys[index], 8 + column * 21, 17 + row * 19); + } + + for (size_t index = 0; index < PARTICLE_COUNT; ++index) { + ui.particles[index] = make_circle(ui.screen, index % 3u == 0u ? 6 : 4, + lv_color_hex(0x56ddb3), LV_OPA_50); + } + + ui.state = lv_label_create(ui.screen); + lv_label_set_text_static(ui.state, "WAKING UP"); + lv_obj_set_style_text_font(ui.state, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(ui.state, lv_color_hex(0xe9f7f4), 0); + lv_obj_align(ui.state, LV_ALIGN_BOTTOM_MID, 0, -40); + + ui.detail = lv_label_create(ui.screen); + lv_label_set_text_static(ui.detail, "USB + Wi-Fi control"); + lv_obj_set_style_text_color(ui.detail, lv_color_hex(0x78909a), 0); + lv_obj_align(ui.detail, LV_ALIGN_BOTTOM_MID, 0, -20); + + ui.quality = lv_label_create(ui.screen); + lv_label_set_text_static(ui.quality, "CINEMATIC"); + lv_obj_set_style_text_color(ui.quality, lv_color_hex(0x52727e), 0); + lv_obj_set_style_text_letter_space(ui.quality, 1, 0); + lv_obj_align(ui.quality, LV_ALIGN_TOP_RIGHT, -10, 38); + ui.previous_scene = FIXTURE_UI_BOOT; +} + +static void announce_transition(const fixture_ui_output_t *output) { + if (output->scene == ui.previous_scene) return; + switch (output->scene) { + case FIXTURE_UI_LOADED: fixture_board_tone(660u, 45u); break; + case FIXTURE_UI_ARMED: fixture_board_tone(880u, 70u); break; + case FIXTURE_UI_RUNNING: fixture_board_tone(1100u, 50u); break; + case FIXTURE_UI_COMPLETE: + fixture_board_tone(880u, 55u); + fixture_board_tone(1320u, 90u); + break; + case FIXTURE_UI_ERROR: fixture_board_tone(180u, 180u); break; + default: break; + } + ui.previous_scene = output->scene; +} + +static void render(const fixture_ui_output_t *output, uint64_t now_ms) { + lv_color_t accent = accent_for(output->scene); + int energy = output->energy_per_mille; + float speed = output->scene == FIXTURE_UI_RUNNING ? 0.19f : 0.055f; +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + speed = 0.0f; +#endif + if (output->quality != FIXTURE_UI_PROTECTED) ui.phase += speed; + if (ui.phase > 6.2831853f) ui.phase -= 6.2831853f; + + float completion_wave = 0.0f; +#if !CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + if (ui.completion_started_ms > 0u && now_ms >= ui.completion_started_ms) { + uint64_t elapsed_ms = now_ms - ui.completion_started_ms; + if (elapsed_ms < 700u) completion_wave = sinf((float)elapsed_ms / 700.0f * 3.1415927f); + } +#endif + + lv_obj_set_style_bg_color(ui.screen, output->scene == FIXTURE_UI_ERROR + ? lv_color_hex(0x190b13) + : lv_color_hex(0x071117), 0); + lv_label_set_text_static(ui.state, scene_name(output->scene)); + lv_obj_set_style_text_color(ui.state, accent, 0); + lv_obj_set_style_arc_color(ui.orbit, accent, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(ui.progress, accent, LV_PART_INDICATOR); + lv_obj_set_style_border_color(ui.core, accent, 0); + lv_arc_set_value(ui.progress, output->progress_per_mille); + + int orbit_start = (int)(ui.phase * 57.29578f) % 360; + int orbit_length = output->scene == FIXTURE_UI_RUNNING ? 55 + energy / 14 : 82; + lv_arc_set_angles(ui.orbit, orbit_start, orbit_start + orbit_length); + + int pulse = (int)((sinf(ui.phase * 1.7f) + 1.0f) * 12.0f + completion_wave * 24.0f); +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + pulse = 0; +#endif + lv_obj_set_size(ui.halo_inner, 119 + pulse / 2 + energy / 90, + 119 + pulse / 2 + energy / 90); + lv_obj_align(ui.halo_inner, LV_ALIGN_CENTER, 0, -2); + lv_obj_set_style_bg_color(ui.halo_inner, accent, 0); + lv_obj_set_style_bg_opa(ui.halo_inner, (lv_opa_t)(12 + energy / 28), 0); + lv_obj_set_style_bg_color(ui.halo_outer, accent, 0); + lv_obj_set_style_bg_opa(ui.halo_outer, (lv_opa_t)(6 + energy / 45), 0); + + for (size_t index = 0; index < KEY_COUNT; ++index) { + float wave = sinf(ui.phase * 2.4f - (float)index * 0.7f); + int opacity = 35 + (int)((wave + 1.0f) * 38.0f) + energy / 8; + if (opacity > 255) opacity = 255; + lv_obj_set_style_bg_color(ui.keys[index], accent, 0); + lv_obj_set_style_bg_opa(ui.keys[index], (lv_opa_t)opacity, 0); + } + + float radius = output->scene == FIXTURE_UI_COMPLETE ? 82.0f : 72.0f + energy / 80.0f; + radius += completion_wave * 34.0f; + for (size_t index = 0; index < PARTICLE_COUNT; ++index) { + float angle = ui.phase * (1.0f + (float)(index % 3u) * 0.13f) + + (float)index * 0.5235988f; + float wobble = sinf(ui.phase * 1.9f + (float)index) * (3.0f + energy / 170.0f); + int x = 120 + (int)(cosf(angle) * (radius + wobble)); + int y = 138 + (int)(sinf(angle) * (radius + wobble)); + lv_obj_set_pos(ui.particles[index], x, y); + lv_obj_set_style_bg_color(ui.particles[index], accent, 0); + lv_obj_set_style_bg_opa(ui.particles[index], + (lv_opa_t)(40 + ((index * 31u + (unsigned int)(ui.phase * 80)) % 150u)), 0); + } + + if (output->quality == FIXTURE_UI_PROTECTED) { + lv_label_set_text_static(ui.quality, "HID PRIORITY"); + lv_obj_set_style_text_color(ui.quality, lv_color_hex(0xffb454), 0); + } else if (output->quality == FIXTURE_UI_ACTIVE) { + lv_label_set_text_static(ui.quality, "LIVE 20 FPS"); + lv_obj_set_style_text_color(ui.quality, lv_color_hex(0x55c7ff), 0); + } else { + lv_label_set_text_static(ui.quality, "CINEMATIC"); + lv_obj_set_style_text_color(ui.quality, lv_color_hex(0x52727e), 0); + } +} + +static void display_task(void *context) { + (void)context; + lv_display_t *display = bsp_display_start(); + configASSERT(display); + ESP_ERROR_CHECK(bsp_display_brightness_set(CONFIG_KEYPATH_FIXTURE_BRIGHTNESS)); + configASSERT(bsp_display_lock(0u)); + build_ui(); + bsp_display_unlock(); + + fixture_ui_model_t model; + fixture_ui_model_init(&model); + while (true) { + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); + fixture_ui_output_t output = fixture_ui_model_step(&model, &snapshot.ui, now_ms); + if (output.completion_burst) ui.completion_started_ms = now_ms; + announce_transition(&output); + fixture_board_update(snapshot.ui.state == FIXTURE_ARMED || snapshot.ui.state == FIXTURE_RUNNING); + if (bsp_display_lock(20u)) { + render(&output, now_ms); + bsp_display_unlock(); + } + vTaskDelay(pdMS_TO_TICKS(output.frame_interval_ms)); + } +} + +void fixture_display_start(void) { + configASSERT(xTaskCreatePinnedToCore(display_task, "fixture_display", 8192, NULL, 6, NULL, 0) == pdPASS); +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h new file mode 100644 index 000000000..0a9322a7a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h @@ -0,0 +1,6 @@ +#ifndef KEYPATH_ESP32_FIXTURE_DISPLAY_H +#define KEYPATH_ESP32_FIXTURE_DISPLAY_H + +void fixture_display_start(void); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c new file mode 100644 index 000000000..1cef8d5a6 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -0,0 +1,265 @@ +#include "fixture_http.h" + +#include +#include +#include + +#include "esp_event.h" +#include "esp_check.h" +#include "esp_heap_caps.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "fixture_config.h" +#include "fixture_runtime.h" +#include "freertos/FreeRTOS.h" +#include "mdns.h" +#include "nvs_flash.h" +#include "sdkconfig.h" + +#define SCRIPT_CAPACITY (96u * 1024u) + +static const char *TAG = "fixture_network"; +static char *script_buffer; +static httpd_handle_t http_server; + +static bool authorized(httpd_req_t *request) { + char value[192]; + if (httpd_req_get_hdr_value_str(request, "Authorization", value, sizeof(value)) != ESP_OK) return false; + static const char prefix[] = "Bearer "; + return strncmp(value, prefix, sizeof(prefix) - 1u) == 0 && + strcmp(value + sizeof(prefix) - 1u, KEYPATH_FIXTURE_TOKEN) == 0; +} + +static esp_err_t send_json(httpd_req_t *request, const char *status, const char *body) { + httpd_resp_set_status(request, status); + httpd_resp_set_type(request, "application/json"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + return httpd_resp_sendstr(request, body); +} + +static esp_err_t require_auth(httpd_req_t *request) { + if (authorized(request)) return ESP_OK; + send_json(request, "401 Unauthorized", "{\"ok\":false,\"message\":\"bearer token required\"}\n"); + return ESP_FAIL; +} + +static esp_err_t status_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + bool wifi_connected; + char address[48]; + fixture_runtime_network_snapshot(&wifi_connected, address, sizeof(address)); + char body[1280]; + snprintf(body, sizeof(body), + "{\"ok\":true,\"firmware\":\"%s\",\"platform\":\"waveshare-esp32-s3-touch-lcd-1.69\"," + "\"state\":\"%s\",\"runId\":\"%s\",\"scriptCRC32\":\"%08" PRIx32 "\"," + "\"eventCount\":%" PRIu32 ",\"repeatCount\":%" PRIu32 ",\"currentRepeat\":%" PRIu32 "," + "\"reportsSubmitted\":%" PRIu64 ",\"transfersCompleted\":%" PRIu64 "," + "\"lateReports\":%" PRIu64 ",\"maximumLatenessUs\":%" PRId64 "," + "\"submittedCRC32\":\"%08" PRIx32 "\",\"usbMounted\":%s," + "\"wifiConnected\":%s,\"address\":\"%s\",\"error\":\"%s\"}\n", + KEYPATH_FIXTURE_FIRMWARE_VERSION, fixture_state_name(snapshot.ui.state), snapshot.run_id, + snapshot.script_crc32, snapshot.ui.event_count, snapshot.ui.repeat_count, + snapshot.ui.current_repeat, snapshot.ui.reports_submitted, snapshot.transfers_completed, + snapshot.ui.late_reports, snapshot.ui.maximum_lateness_us, snapshot.submitted_crc32, + snapshot.ui.usb_mounted ? "true" : "false", wifi_connected ? "true" : "false", + address, snapshot.error); + return send_json(request, "200 OK", body); +} + +static esp_err_t receive_body(httpd_req_t *request, size_t maximum, size_t *length) { + if (request->content_len <= 0 || (size_t)request->content_len > maximum) return ESP_ERR_INVALID_SIZE; + size_t received = 0u; + while (received < (size_t)request->content_len) { + int count = httpd_req_recv(request, script_buffer + received, + (size_t)request->content_len - received); + if (count == HTTPD_SOCK_ERR_TIMEOUT) continue; + if (count <= 0) return ESP_FAIL; + received += (size_t)count; + } + script_buffer[received] = '\0'; + *length = received; + return ESP_OK; +} + +static char *trim(char *value) { + while (*value == ' ' || *value == '\t' || *value == '\r' || *value == '\n') ++value; + char *end = value + strlen(value); + while (end > value && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n')) --end; + *end = '\0'; + return value; +} + +static esp_err_t script_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + size_t length; + esp_err_t result = receive_body(request, SCRIPT_CAPACITY, &length); + if (result != ESP_OK) return send_json(request, "413 Payload Too Large", + "{\"ok\":false,\"message\":\"script exceeds fixture capacity\"}\n"); + char error[128]; + if (!fixture_runtime_load(script_buffer, length, error, sizeof(error))) { + char body[192]; + snprintf(body, sizeof(body), "{\"ok\":false,\"message\":\"%s\"}\n", error); + return send_json(request, "409 Conflict", body); + } + return send_json(request, "201 Created", + "{\"ok\":true,\"message\":\"script loaded and CRC verified\"}\n"); +} + +static esp_err_t small_command(httpd_req_t *request, const char *operation) { + if (require_auth(request) != ESP_OK) return ESP_OK; + size_t length; + esp_err_t result = receive_body(request, 127u, &length); + if (result != ESP_OK) return send_json(request, "400 Bad Request", + "{\"ok\":false,\"message\":\"invalid command body\"}\n"); + (void)length; + char *body_value = trim(script_buffer); + char error[128]; + bool ok = false; + const char *success = NULL; + if (strcmp(operation, "arm") == 0) { + ok = fixture_runtime_arm(body_value, error, sizeof(error)); + success = "fixture armed; safety release queued"; + } else { + char run_id[FIXTURE_MAX_RUN_ID + 1u] = {0}; + unsigned int delay_ms; + char extra; + if (sscanf(body_value, "%48s %u %c", run_id, &delay_ms, &extra) != 2) { + return send_json(request, "400 Bad Request", + "{\"ok\":false,\"message\":\"start body must contain run_id and delay_ms\"}\n"); + } + ok = fixture_runtime_start(run_id, delay_ms, error, sizeof(error)); + success = "locally timed script scheduled"; + } + if (!ok) { + char body[192]; + snprintf(body, sizeof(body), "{\"ok\":false,\"message\":\"%s\"}\n", error); + return send_json(request, "409 Conflict", body); + } + char response[192]; + snprintf(response, sizeof(response), "{\"ok\":true,\"message\":\"%s\"}\n", success); + return send_json(request, "200 OK", response); +} + +static esp_err_t arm_handler(httpd_req_t *request) { return small_command(request, "arm"); } +static esp_err_t start_handler(httpd_req_t *request) { return small_command(request, "start"); } + +static esp_err_t abort_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + fixture_runtime_abort("remote abort"); + return send_json(request, "200 OK", + "{\"ok\":true,\"message\":\"aborted; all-keys-released report queued\"}\n"); +} + +static unsigned int query_number(httpd_req_t *request, const char *name, unsigned int fallback) { + char query[96], value[24]; + if (httpd_req_get_url_query_str(request, query, sizeof(query)) != ESP_OK) return fallback; + if (httpd_query_key_value(query, name, value, sizeof(value)) != ESP_OK) return fallback; + return (unsigned int)strtoul(value, NULL, 10); +} + +static esp_err_t trace_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + unsigned int from = query_number(request, "from", 0u); + unsigned int limit = query_number(request, "limit", 8u); + if (limit > 8u) limit = 8u; + uint32_t available = fixture_runtime_trace_count(); + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + httpd_resp_set_type(request, "application/x-ndjson"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + char line[512]; + snprintf(line, sizeof(line), "{\"runId\":\"%s\",\"from\":%u,\"available\":%" PRIu32 "}\n", + snapshot.run_id, from, available); + httpd_resp_send_chunk(request, line, HTTPD_RESP_USE_STRLEN); + for (unsigned int offset = 0u; offset < limit && from + offset < available; ++offset) { + fixture_trace_t trace; + if (!fixture_runtime_trace_at(from + offset, &trace)) break; + snprintf(line, sizeof(line), + "{\"sequence\":%" PRIu64 ",\"scheduledUs\":%" PRIu64 "," + "\"submittedUs\":%" PRIu64 ",\"latenessUs\":%" PRId64 "," + "\"modifiers\":%u,\"keys\":[%u,%u,%u,%u,%u,%u]}\n", + trace.sequence, trace.scheduled_us, trace.submitted_us, trace.lateness_us, + trace.modifiers, trace.keys[0], trace.keys[1], trace.keys[2], + trace.keys[3], trace.keys[4], trace.keys[5]); + httpd_resp_send_chunk(request, line, HTTPD_RESP_USE_STRLEN); + } + return httpd_resp_send_chunk(request, NULL, 0u); +} + +static esp_err_t start_http_server(void) { + if (http_server) return ESP_OK; + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = CONFIG_KEYPATH_FIXTURE_HTTP_PORT; + config.max_uri_handlers = 6; + config.stack_size = 8192; + config.core_id = 0; + config.task_priority = 4; + ESP_RETURN_ON_ERROR(httpd_start(&http_server, &config), TAG, "HTTP server start failed"); + const httpd_uri_t handlers[] = { + {.uri = "/v1/status", .method = HTTP_GET, .handler = status_handler}, + {.uri = "/v1/script", .method = HTTP_POST, .handler = script_handler}, + {.uri = "/v1/arm", .method = HTTP_POST, .handler = arm_handler}, + {.uri = "/v1/start", .method = HTTP_POST, .handler = start_handler}, + {.uri = "/v1/abort", .method = HTTP_POST, .handler = abort_handler}, + {.uri = "/v1/trace", .method = HTTP_GET, .handler = trace_handler}, + }; + for (size_t index = 0; index < sizeof(handlers) / sizeof(handlers[0]); ++index) { + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(http_server, &handlers[index]), TAG, + "URI registration failed"); + } + return ESP_OK; +} + +static void wifi_event(void *argument, esp_event_base_t base, int32_t id, void *data) { + (void)argument; + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + fixture_runtime_set_network(false, "unassigned"); + esp_wifi_connect(); + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = data; + char address[16]; + snprintf(address, sizeof(address), IPSTR, IP2STR(&event->ip_info.ip)); + fixture_runtime_set_network(true, address); + start_http_server(); + } +} + +esp_err_t fixture_network_start(void) { + script_buffer = heap_caps_malloc(SCRIPT_CAPACITY + 1u, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!script_buffer) return ESP_ERR_NO_MEM; + esp_err_t result = nvs_flash_init(); + if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + result = nvs_flash_init(); + } + ESP_RETURN_ON_ERROR(result, TAG, "NVS initialization failed"); + ESP_RETURN_ON_ERROR(esp_netif_init(), TAG, "network stack initialization failed"); + ESP_RETURN_ON_ERROR(esp_event_loop_create_default(), TAG, "event loop initialization failed"); + if (!esp_netif_create_default_wifi_sta()) return ESP_FAIL; + + wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT(); + ESP_RETURN_ON_ERROR(esp_wifi_init(&init), TAG, "Wi-Fi initialization failed"); + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event, NULL)); + wifi_config_t wifi = {0}; + snprintf((char *)wifi.sta.ssid, sizeof(wifi.sta.ssid), "%s", KEYPATH_WIFI_SSID); + snprintf((char *)wifi.sta.password, sizeof(wifi.sta.password), "%s", KEYPATH_WIFI_PASSWORD); + wifi.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + wifi.sta.pmf_cfg.capable = true; + wifi.sta.pmf_cfg.required = false; + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi)); + + ESP_ERROR_CHECK(mdns_init()); + ESP_ERROR_CHECK(mdns_hostname_set("keypath-hid-fixture")); + ESP_ERROR_CHECK(mdns_instance_name_set("KeyPath HID fixture")); + ESP_ERROR_CHECK(mdns_service_add("KeyPath HID fixture", "_http", "_tcp", + CONFIG_KEYPATH_FIXTURE_HTTP_PORT, NULL, 0)); + return esp_wifi_start(); +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h new file mode 100644 index 000000000..fa56b74de --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h @@ -0,0 +1,8 @@ +#ifndef KEYPATH_ESP32_FIXTURE_HTTP_H +#define KEYPATH_ESP32_FIXTURE_HTTP_H + +#include "esp_err.h" + +esp_err_t fixture_network_start(void); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c new file mode 100644 index 000000000..d413cf444 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c @@ -0,0 +1,77 @@ +#include "fixture_qemu_smoke.h" + +#include +#include +#include +#include + +#include "esp_rom_sys.h" +#include "fixture_core.h" +#include "fixture_ui_model.h" + +typedef struct { + uint32_t count; + uint8_t first_key; + uint8_t final_key; +} smoke_reports_t; + +static bool capture_report(uint8_t modifiers, const uint8_t keys[6], void *context) { + (void)modifiers; + smoke_reports_t *reports = context; + if (reports->count == 0u) reports->first_key = keys[0]; + reports->final_key = keys[0]; + reports->count++; + return true; +} + +static bool run_core_smoke(void) { + static const char events[] = + "0 0 4 0 0 0 0 0\n" + "50000 0 0 0 0 0 0 0\n"; + static char script[256]; + static char error[128]; + static fixture_t fixture; + static smoke_reports_t reports; + + fixture_init(&fixture); + fixture_poll(&fixture, 0u, true, true, capture_report, &reports); + int length = snprintf(script, sizeof(script), "KPHID1 qemu-smoke 2 2 100000 %08" PRIx32 "\n%s", + fixture_crc32(events, strlen(events)), events); + if (length <= 0 || (size_t)length >= sizeof(script)) return false; + if (!fixture_load_script(&fixture, script, (size_t)length, error, sizeof(error))) return false; + if (!fixture_arm(&fixture, "qemu-smoke", error, sizeof(error))) return false; + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + if (!fixture_start(&fixture, "qemu-smoke", 100u, 0u, error, sizeof(error))) return false; + fixture_poll(&fixture, 100000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 150000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 200000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 250000u, true, true, capture_report, &reports); + + static fixture_ui_model_t ui; + fixture_ui_model_init(&ui); + fixture_ui_input_t input = { + .state = fixture.state, + .wifi_connected = true, + .usb_mounted = true, + .event_count = fixture.event_count, + .repeat_count = fixture.repeat_count, + .reports_submitted = fixture.reports_submitted, + }; + fixture_ui_output_t output = fixture_ui_model_step(&ui, &input, 250u); + + return fixture.state == FIXTURE_COMPLETE && + fixture.reports_submitted == 4u && + fixture_trace_count(&fixture) == 4u && + reports.count == 6u && + reports.final_key == 0u && + output.scene == FIXTURE_UI_COMPLETE && + output.progress_per_mille == 1000u; +} + +void fixture_qemu_smoke_run(void) { + if (run_core_smoke()) { + esp_rom_printf("KEYPATH_QEMU_SMOKE_PASS\n"); + } else { + esp_rom_printf("KEYPATH_QEMU_SMOKE_FAIL\n"); + } +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.h new file mode 100644 index 000000000..ca8b1ed9c --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.h @@ -0,0 +1,6 @@ +#ifndef KEYPATH_FIXTURE_QEMU_SMOKE_H +#define KEYPATH_FIXTURE_QEMU_SMOKE_H + +void fixture_qemu_smoke_run(void); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c new file mode 100644 index 000000000..66399a8a8 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c @@ -0,0 +1,238 @@ +#include "fixture_runtime.h" + +#include +#include + +#include "class/hid/hid_device.h" +#include "esp_mac.h" +#include "esp_rom_sys.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "tinyusb.h" +#include "tinyusb_default_config.h" + +#define REPORT_ID_KEYBOARD 1u +#define HID_ENDPOINT 0x81u +#define CONFIGURATION_LENGTH (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN) + +static fixture_t fixture; +static StaticSemaphore_t fixture_mutex_storage; +static SemaphoreHandle_t fixture_mutex; +static bool network_connected; +static char network_address[48] = "unassigned"; + +static void runtime_lock(void) { + configASSERT(xSemaphoreTake(fixture_mutex, portMAX_DELAY) == pdTRUE); +} + +static void runtime_unlock(void) { + configASSERT(xSemaphoreGive(fixture_mutex) == pdTRUE); +} + +static const tusb_desc_device_t device_descriptor = { + .bLength = sizeof(tusb_desc_device_t), + .bDescriptorType = TUSB_DESC_DEVICE, + .bcdUSB = 0x0200, + .bDeviceClass = 0, + .bDeviceSubClass = 0, + .bDeviceProtocol = 0, + .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE, + .idVendor = 0xcafe, + .idProduct = 0x4010, + .bcdDevice = 0x0200, + .iManufacturer = 1, + .iProduct = 2, + .iSerialNumber = 3, + .bNumConfigurations = 1, +}; + +static const uint8_t hid_report_descriptor[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), +}; + +static const uint8_t configuration_descriptor[] = { + TUD_CONFIG_DESCRIPTOR(1, 1, 0, CONFIGURATION_LENGTH, + TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(0, 4, HID_ITF_PROTOCOL_KEYBOARD, + sizeof(hid_report_descriptor), HID_ENDPOINT, 16, 1), +}; + +static char serial_number[13]; +static const char *string_descriptors[] = { + (const char[]){0x09, 0x04}, + "KeyPath Lab", + "KeyPath Physical HID Fixture", + serial_number, + "Keyboard oracle", +}; + +uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) { + (void)instance; + return hid_report_descriptor; +} + +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t *buffer, + uint16_t requested_length) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)requested_length; + return 0u; +} + +void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t const *buffer, + uint16_t buffer_size) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)buffer_size; +} + +void tud_hid_report_complete_cb(uint8_t instance, uint8_t const *report, uint16_t length) { + (void)instance; + (void)report; + (void)length; + runtime_lock(); + fixture_note_transfer_complete(&fixture); + runtime_unlock(); +} + +static bool send_keyboard_report(uint8_t modifiers, const uint8_t keys[6], void *context) { + (void)context; + return tud_hid_ready() && tud_hid_keyboard_report(REPORT_ID_KEYBOARD, modifiers, keys); +} + +static void executor_task(void *context) { + (void)context; + while (true) { + bool running; + runtime_lock(); + fixture_poll(&fixture, (uint64_t)esp_timer_get_time(), tud_mounted(), tud_hid_ready(), + send_keyboard_report, NULL); + running = fixture.state == FIXTURE_RUNNING; + runtime_unlock(); + + if (running) { + esp_rom_delay_us(25u); + } else { + vTaskDelay(pdMS_TO_TICKS(1)); + } + } +} + +void fixture_runtime_init(void) { + fixture_mutex = xSemaphoreCreateMutexStatic(&fixture_mutex_storage); + configASSERT(fixture_mutex); + fixture_init(&fixture); +} + +esp_err_t fixture_runtime_start_usb(void) { + uint8_t mac[6]; + esp_err_t result = esp_read_mac(mac, ESP_MAC_WIFI_STA); + if (result != ESP_OK) return result; + snprintf(serial_number, sizeof(serial_number), "%02X%02X%02X%02X%02X%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + tinyusb_config_t config = TINYUSB_DEFAULT_CONFIG(); + /* Keep USB service off the dedicated, busy-waiting HID scheduler core. */ + config.task = TINYUSB_TASK_CUSTOM(4096, 18, 0); + config.descriptor.device = &device_descriptor; + config.descriptor.full_speed_config = configuration_descriptor; +#if (TUD_OPT_HIGH_SPEED) + config.descriptor.high_speed_config = configuration_descriptor; +#endif + config.descriptor.string = string_descriptors; + config.descriptor.string_count = sizeof(string_descriptors) / sizeof(string_descriptors[0]); + return tinyusb_driver_install(&config); +} + +void fixture_runtime_start_executor(void) { + configASSERT(xTaskCreatePinnedToCore(executor_task, "fixture_hid", 4096, NULL, 20, NULL, 1) == pdPASS); +} + +void fixture_runtime_set_network(bool connected, const char *address) { + runtime_lock(); + network_connected = connected; + snprintf(network_address, sizeof(network_address), "%s", address ? address : "unassigned"); + runtime_unlock(); +} + +void fixture_runtime_network_snapshot(bool *connected, char *address, size_t capacity) { + runtime_lock(); + if (connected) *connected = network_connected; + if (address && capacity) snprintf(address, capacity, "%s", network_address); + runtime_unlock(); +} + +void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot) { + memset(snapshot, 0, sizeof(*snapshot)); + runtime_lock(); + snapshot->ui.state = fixture.state; + snapshot->ui.wifi_connected = network_connected; + snapshot->ui.usb_mounted = fixture.usb_mounted; + snapshot->ui.event_count = fixture.event_count; + snapshot->ui.repeat_count = fixture.repeat_count; + snapshot->ui.current_repeat = fixture.current_repeat; + snapshot->ui.reports_submitted = fixture.reports_submitted; + snapshot->ui.late_reports = fixture.late_reports; + snapshot->ui.maximum_lateness_us = fixture.maximum_lateness_us; + snprintf(snapshot->run_id, sizeof(snapshot->run_id), "%s", fixture.run_id); + snprintf(snapshot->error, sizeof(snapshot->error), "%s", fixture.error); + snapshot->script_crc32 = fixture.script_crc32; + snapshot->next_event = fixture.next_event; + snapshot->transfers_completed = fixture.transfers_completed; + snapshot->submitted_crc32 = fixture.submitted_crc32; + runtime_unlock(); +} + +bool fixture_runtime_load(const char *body, size_t length, char *error, size_t capacity) { + runtime_lock(); + bool ok = fixture_load_script(&fixture, body, length, error, capacity); + runtime_unlock(); + return ok; +} + +bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity) { + runtime_lock(); + bool ok = fixture_arm(&fixture, run_id, error, capacity); + runtime_unlock(); + return ok; +} + +bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity) { + runtime_lock(); + bool ok = fixture_start(&fixture, run_id, delay_ms, (uint64_t)esp_timer_get_time(), error, capacity); + runtime_unlock(); + return ok; +} + +void fixture_runtime_abort(const char *reason) { + runtime_lock(); + fixture_abort(&fixture, reason); + runtime_unlock(); +} + +uint32_t fixture_runtime_trace_count(void) { + runtime_lock(); + uint32_t count = fixture_trace_count(&fixture); + runtime_unlock(); + return count; +} + +bool fixture_runtime_trace_at(uint32_t index, fixture_trace_t *trace) { + bool found = false; + runtime_lock(); + const fixture_trace_t *source = fixture_trace_at(&fixture, index); + if (source) { + *trace = *source; + found = true; + } + runtime_unlock(); + return found; +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h new file mode 100644 index 000000000..0baa48d4d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h @@ -0,0 +1,35 @@ +#ifndef KEYPATH_ESP32_FIXTURE_RUNTIME_H +#define KEYPATH_ESP32_FIXTURE_RUNTIME_H + +#include +#include +#include + +#include "esp_err.h" +#include "fixture_core.h" +#include "fixture_ui_model.h" + +typedef struct { + fixture_ui_input_t ui; + char run_id[FIXTURE_MAX_RUN_ID + 1u]; + char error[128]; + uint32_t script_crc32; + uint32_t next_event; + uint64_t transfers_completed; + uint32_t submitted_crc32; +} fixture_runtime_snapshot_t; + +void fixture_runtime_init(void); +esp_err_t fixture_runtime_start_usb(void); +void fixture_runtime_start_executor(void); +void fixture_runtime_set_network(bool connected, const char *address); +void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot); +void fixture_runtime_network_snapshot(bool *connected, char *address, size_t capacity); +bool fixture_runtime_load(const char *body, size_t length, char *error, size_t capacity); +bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity); +bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity); +void fixture_runtime_abort(const char *reason); +uint32_t fixture_runtime_trace_count(void); +bool fixture_runtime_trace_at(uint32_t index, fixture_trace_t *trace); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/idf_component.yml b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/idf_component.yml new file mode 100644 index 000000000..4be786dd4 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/idf_component.yml @@ -0,0 +1,6 @@ +dependencies: + idf: "==5.5.5" + espressif/esp_tinyusb: "^2.0.0" + espressif/mdns: "^1.0.3" + waveshare/esp32_s3_touch_lcd_1_69: "==2.0.0" + lvgl/lvgl: "^9.3.0" diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults new file mode 100644 index 000000000..5069d61ae --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults @@ -0,0 +1,25 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_LV_COLOR_DEPTH_16=y +CONFIG_LV_CONF_MINIMAL=y +CONFIG_LV_USE_ARC=y +CONFIG_LV_USE_LABEL=y +CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_BUILD_EXAMPLES=n +CONFIG_LV_BUILD_DEMOS=n +CONFIG_BSP_DISPLAY_LVGL_BUF_HEIGHT=40 +CONFIG_TINYUSB_HID_COUNT=1 +CONFIG_TINYUSB_CDC_COUNT=0 +CONFIG_TINYUSB_MIDI_COUNT=0 +CONFIG_ESP_CONSOLE_NONE=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=8 +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults new file mode 100644 index 000000000..850598058 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults @@ -0,0 +1,26 @@ +# QEMU smoke configuration. Keep hardware-relevant defaults aligned with sdkconfig.defaults. +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_LV_COLOR_DEPTH_16=y +CONFIG_LV_CONF_MINIMAL=y +CONFIG_LV_USE_ARC=y +CONFIG_LV_USE_LABEL=y +CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_BUILD_EXAMPLES=n +CONFIG_LV_BUILD_DEMOS=n +CONFIG_BSP_DISPLAY_LVGL_BUF_HEIGHT=40 +CONFIG_TINYUSB_HID_COUNT=1 +CONFIG_TINYUSB_CDC_COUNT=0 +CONFIG_TINYUSB_MIDI_COUNT=0 +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=8 +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index dd0a612a2..9a06f9b4f 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -1,4 +1,5 @@ #include "fixture_core.h" +#include "fixture_ui_model.h" #include #include @@ -146,12 +147,81 @@ static void test_lateness_metrics(void) { assert(fixture.maximum_lateness_us == 3000); } +static void test_ui_model_prioritizes_hid_and_tracks_progress(void) { + fixture_ui_model_t model; + fixture_ui_model_init(&model); + fixture_ui_input_t input = { + .state = FIXTURE_RUNNING, + .wifi_connected = true, + .usb_mounted = true, + .event_count = 10u, + .repeat_count = 10u, + }; + fixture_ui_output_t output = fixture_ui_model_step(&model, &input, 1000u); + assert(output.scene == FIXTURE_UI_RUNNING); + assert(output.quality == FIXTURE_UI_ACTIVE); + assert(output.frame_interval_ms == 50u); + + input.reports_submitted = 25u; + output = fixture_ui_model_step(&model, &input, 1010u); + assert(output.progress_per_mille == 250u); + assert(output.energy_per_mille > 900u); + + input.late_reports = 1u; + input.maximum_lateness_us = 2000; + output = fixture_ui_model_step(&model, &input, 1020u); + assert(output.quality == FIXTURE_UI_PROTECTED); + assert(output.frame_interval_ms == 250u); + assert(output.pressure_warning); + + output = fixture_ui_model_step(&model, &input, 2400u); + assert(output.quality == FIXTURE_UI_PROTECTED); + output = fixture_ui_model_step(&model, &input, 2600u); + assert(output.quality == FIXTURE_UI_ACTIVE); + + input.state = FIXTURE_COMPLETE; + input.reports_submitted = 100u; + output = fixture_ui_model_step(&model, &input, 2610u); + assert(output.scene == FIXTURE_UI_COMPLETE); + assert(output.progress_per_mille == 1000u); + assert(output.completion_burst); + output = fixture_ui_model_step(&model, &input, 2620u); + assert(!output.completion_burst); +} + +static void test_ui_model_connection_error_and_counter_reset(void) { + fixture_ui_model_t model; + fixture_ui_model_init(&model); + fixture_ui_input_t input = {.state = FIXTURE_BOOTING}; + fixture_ui_output_t output = fixture_ui_model_step(&model, &input, 0u); + assert(output.scene == FIXTURE_UI_CONNECTING); + + input.state = FIXTURE_ERROR; + output = fixture_ui_model_step(&model, &input, 10u); + assert(output.scene == FIXTURE_UI_ERROR); + + input.state = FIXTURE_RUNNING; + input.wifi_connected = true; + input.event_count = 2u; + input.repeat_count = 2u; + input.reports_submitted = 10u; + output = fixture_ui_model_step(&model, &input, 20u); + assert(output.progress_per_mille == 1000u); + + input.reports_submitted = 0u; + output = fixture_ui_model_step(&model, &input, 30u); + assert(output.progress_per_mille == 0u); + assert(output.energy_per_mille <= 1000u); +} + int main(void) { test_load_arm_run_and_repeat(); test_rejects_corrupt_and_unsafe_scripts(); test_failed_replacement_invalidates_previous_script(); test_abort_and_unmount_force_release(); test_lateness_metrics(); - puts("pico fixture core tests passed"); + test_ui_model_prioritizes_hid_and_tracks_progress(); + test_ui_model_connection_error_and_counter_reset(); + puts("physical HID fixture core tests passed"); return 0; } diff --git a/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh new file mode 100755 index 000000000..be9d89b19 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -euo pipefail + +fixture_root=$(cd "$(dirname "$0")/.." && pwd) +target="$fixture_root/targets/waveshare-esp32-s3-touch-lcd-1.69" +idf_path=${IDF_PATH:-"$HOME/.cache/keypath-esp32/esp-idf"} +build_dir=${KEYPATH_ESP32_QEMU_BUILD_DIR:-"${TMPDIR:-/tmp}/keypath-esp32-qemu-smoke-build"} +qemu_defaults="$target/sdkconfig.qemu.defaults" +defaults_stamp="$build_dir/.keypath-qemu-defaults.sha256" + +if [[ ! -f "$idf_path/export.sh" ]]; then + echo "ESP-IDF was not found at $idf_path" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +source "$idf_path/export.sh" >/dev/null +export KEYPATH_WIFI_SSID=fixture-qemu-placeholder +export KEYPATH_WIFI_PASSWORD=fixture-qemu-placeholder +export KEYPATH_FIXTURE_TOKEN=fixture-qemu-token-placeholder +export KEYPATH_QEMU_SMOKE=1 + +defaults_hash=$(shasum -a 256 "$qemu_defaults" | awk '{print $1}') +stored_hash=$(cat "$defaults_stamp" 2>/dev/null || true) +if [[ ! -f "$build_dir/sdkconfig" || "$stored_hash" != "$defaults_hash" ]]; then + rm -f "$build_dir/sdkconfig" + idf.py -C "$target" -B "$build_dir" \ + -D "SDKCONFIG=$build_dir/sdkconfig" \ + -D "SDKCONFIG_DEFAULTS=$qemu_defaults" \ + reconfigure build >/dev/null + printf '%s\n' "$defaults_hash" >"$defaults_stamp" +else + idf.py -C "$target" -B "$build_dir" build >/dev/null +fi + +log_file=$(mktemp "${TMPDIR:-/tmp}/keypath-qemu-smoke.XXXXXX") +qemu_pid= +cleanup() { + if [[ -n "$qemu_pid" ]] && kill -0 "$qemu_pid" 2>/dev/null; then + child_pids=$(pgrep -P "$qemu_pid" 2>/dev/null || true) + [[ -z "$child_pids" ]] || kill $child_pids 2>/dev/null || true + kill "$qemu_pid" 2>/dev/null || true + wait "$qemu_pid" 2>/dev/null || true + fi + rm -f "$log_file" +} +trap cleanup EXIT INT TERM + +idf.py -C "$target" -B "$build_dir" qemu >"$log_file" 2>&1 & +qemu_pid=$! +for _ in $(seq 1 120); do + if grep -q "KEYPATH_QEMU_SMOKE_PASS" "$log_file"; then + echo "ESP32-S3 QEMU smoke test passed" + exit 0 + fi + if grep -q "KEYPATH_QEMU_SMOKE_FAIL" "$log_file"; then + cat "$log_file" >&2 + exit 1 + fi + if ! kill -0 "$qemu_pid" 2>/dev/null; then + cat "$log_file" >&2 + exit 1 + fi + sleep 0.25 +done + +cat "$log_file" >&2 +echo "Timed out waiting for the ESP32-S3 QEMU smoke marker" >&2 +exit 1 diff --git a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh index 75b60765c..437463d79 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh @@ -8,6 +8,7 @@ trap 'rm -f "$test_binary"' EXIT HUP INT TERM cc -std=c11 -Wall -Wextra -Werror -pedantic \ -I"$fixture_root/src" \ "$fixture_root/src/fixture_core.c" \ + "$fixture_root/src/fixture_ui_model.c" \ "$fixture_root/tests/fixture_core_tests.c" \ -o "$test_binary" "$test_binary" From ecf826f8889703041f09a48e6ce3fe699a8265e8 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 17:20:43 -0700 Subject: [PATCH 74/99] Animate physical fixture campaign results --- Scripts/lab/pico-hid-fixture-client | 39 +++- Scripts/lab/pico-hid-fixture/README.md | 25 ++- .../src/fixture_presentation.c | 63 ++++++ .../src/fixture_presentation.h | 49 +++++ .../pico-hid-fixture/src/fixture_ui_model.c | 2 +- .../main/CMakeLists.txt | 2 + .../main/fixture_display.c | 182 ++++++++++++++++-- .../main/fixture_http.c | 79 +++++++- .../main/fixture_runtime.c | 9 + .../main/fixture_runtime.h | 3 + .../tests/fixture_core_tests.c | 17 +- .../lab/pico-hid-fixture/tests/run-tests.sh | 1 + Scripts/lab/scenario-matrix-runner | 58 ++++++ .../tests/pico-hid-fixture-client-tests.py | 12 ++ .../lab/tests/scenario-matrix-runner-tests.py | 29 ++- 15 files changed, 542 insertions(+), 28 deletions(-) create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_presentation.c create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_presentation.h diff --git a/Scripts/lab/pico-hid-fixture-client b/Scripts/lab/pico-hid-fixture-client index f63f7f5a7..68a374126 100755 --- a/Scripts/lab/pico-hid-fixture-client +++ b/Scripts/lab/pico-hid-fixture-client @@ -63,11 +63,14 @@ class FixtureClient: def __init__(self, host: str, token: str, port: int = 8080, timeout: float = 10.0): if not token: raise ValueError("fixture bearer token is empty") + if timeout <= 0: + raise ValueError("fixture timeout must be positive") self.base_url = f"http://{host}:{port}" self.token = token self.timeout = timeout - def request(self, method: str, path: str, body: bytes | None = None) -> str: + def request(self, method: str, path: str, body: bytes | None = None, + content_type: str = "text/plain; charset=us-ascii") -> str: request = urllib.request.Request( self.base_url + path, data=body, @@ -75,7 +78,7 @@ class FixtureClient: headers={"Authorization": f"Bearer {self.token}"}, ) if body is not None: - request.add_header("Content-Type", "text/plain; charset=us-ascii") + request.add_header("Content-Type", content_type) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: return response.read().decode("utf-8") @@ -98,6 +101,10 @@ class FixtureClient: def abort(self) -> dict[str, object]: return json.loads(self.request("POST", "/v1/abort", b"")) + def present(self, presentation: dict[str, object]) -> dict[str, object]: + body = json.dumps(presentation, separators=(",", ":")).encode("ascii") + return json.loads(self.request("POST", "/v1/presentation", body, "application/json")) + def trace(self, start: int = 0, limit: int = 8) -> list[dict[str, object]]: lines = self.request("GET", f"/v1/trace?from={start}&limit={limit}").splitlines() return [json.loads(line) for line in lines] @@ -114,6 +121,7 @@ def parser() -> argparse.ArgumentParser: root = argparse.ArgumentParser(description=__doc__) root.add_argument("--host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) root.add_argument("--port", type=int, default=8080) + root.add_argument("--timeout", type=float, default=10.0) commands = root.add_subparsers(dest="command", required=True) commands.add_parser("status") compile_command = commands.add_parser("compile-text") @@ -135,6 +143,22 @@ def parser() -> argparse.ArgumentParser: trace_command = commands.add_parser("trace") trace_command.add_argument("--from", dest="start", type=int, default=0) trace_command.add_argument("--limit", type=int, default=8) + present_command = commands.add_parser("present", help="drive the fixture's animated test narrative") + present_command.add_argument("--phase", required=True, + choices=("auto", "preparing", "countdown", "testing", "observing", + "resolving", "result", "next")) + present_command.add_argument("--result", choices=("none", "pass", "fail", "inconclusive"), default="none") + present_command.add_argument("--progress", type=int, choices=range(0, 1001), default=0, metavar="0..1000") + present_command.add_argument("--title", default="") + present_command.add_argument("--detail", default="") + present_command.add_argument("--next", default="") + present_command.add_argument("--reports-expected", type=int, default=0) + present_command.add_argument("--reports-observed", type=int, default=0) + present_command.add_argument("--dropped", type=int, default=0) + present_command.add_argument("--duplicated", type=int, default=0) + present_command.add_argument("--repeated", type=int, default=0) + present_command.add_argument("--latency-p95-us", type=int, default=0) + present_command.add_argument("--safe-release", action="store_true") run_command = commands.add_parser("run-text") run_command.add_argument("--run-id", required=True) run_command.add_argument("--text", required=True, type=pathlib.Path) @@ -162,13 +186,22 @@ def main() -> int: sys.stdout.write(script) return 0 - client = FixtureClient(arguments.host, token_from_environment(), arguments.port) + client = FixtureClient(arguments.host, token_from_environment(), arguments.port, arguments.timeout) if arguments.command == "status": print_json(client.status()) elif arguments.command == "load-script": print_json(client.load_script(arguments.script.read_text())) elif arguments.command == "arm": print_json(client.arm(arguments.run_id)) elif arguments.command == "start": print_json(client.start(arguments.run_id, arguments.delay_ms)) elif arguments.command == "abort": print_json(client.abort()) elif arguments.command == "trace": print_json(client.trace(arguments.start, arguments.limit)) + elif arguments.command == "present": + print_json(client.present({ + "phase": arguments.phase, "result": arguments.result, "progress": arguments.progress, + "title": arguments.title, "detail": arguments.detail, "next": arguments.next, + "reportsExpected": arguments.reports_expected, "reportsObserved": arguments.reports_observed, + "dropped": arguments.dropped, "duplicated": arguments.duplicated, + "repeated": arguments.repeated, "latencyP95Us": arguments.latency_p95_us, + "safeRelease": arguments.safe_release, + })) elif arguments.command == "run-text": script = compile_text(arguments.run_id, arguments.text.read_text(), arguments.interval_ms, arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms) diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index b39aadbb3..09f90a57f 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -35,8 +35,8 @@ uses its onboard green LED instead. - The fixture schedules every report locally. Wi-Fi jitter can shift the start acknowledgement but cannot alter inter-key timing after the script starts. - On ESP32-S3, the HID scheduler owns core 1 at high priority. USB service, network control, sound, - and display work remain on core 0. The motion governor drops the display from 30 to 20 to 4 FPS - before animation can compete with HID timing. + and display work remain on core 0. The motion governor drops the display from 30 to 20 to 8 FPS + and removes expensive particle layers before animation can compete with HID timing. ## Pico LED states @@ -72,10 +72,12 @@ older revision. Flashing waits for the physical board: idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 -p PORT flash ``` -The on-device scene is intentionally calm while idle and more expressive during state changes and -runs: orbital particles, a live report-progress arc, animated key cells, color-coded state changes, -and a completion flourish. A visible `HID PRIORITY` mode means detected timing pressure has reduced -the animation rate to protect keyboard delivery. +The on-device scene is continuously alive: a breathing reactor, orbiting HID packets, a live +progress arc, and time-based motion remain smooth even when the frame rate changes. Preparing, +countdown, typing, observation, resolution, pass, fail, and inconclusive phases morph through +colored icons instead of hard cuts. A visible `HID PRIORITY` mode keeps a restrained orbit at 8 FPS +while shedding most particles, so the display still communicates life without competing with +keyboard delivery. ## Pico 2 W build @@ -129,6 +131,7 @@ The fixture advertises `keypath-hid-fixture.local` over mDNS on port 8080. Every | POST | `/v1/start` | `RUN_ID DELAY_MS` | | POST | `/v1/abort` | Empty | | GET | `/v1/trace?from=N&limit=8` | — | +| POST | `/v1/presentation` | Bounded JSON phase, result, progress, labels, and metrics | Commands are state-checked. A script cannot start unless the same run ID was loaded and armed. The start delay must be 100–60000 ms, giving the guest observer time to become ready. @@ -149,12 +152,22 @@ Scripts/lab/pico-hid-fixture-client load-script /tmp/swift-load.kphid Scripts/lab/pico-hid-fixture-client arm swift-load-001 Scripts/lab/pico-hid-fixture-client start swift-load-001 --delay-ms 2000 Scripts/lab/pico-hid-fixture-client trace +Scripts/lab/pico-hid-fixture-client present --phase observing --progress 850 \ + --title 'Swift stress' --detail 'Comparing received text' +Scripts/lab/pico-hid-fixture-client present --phase result --result pass --progress 1000 \ + --title 'Swift stress' --detail 'No repeated keys' \ + --reports-expected 400 --reports-observed 400 --latency-p95-us 620 --safe-release ``` `run-text` combines compile, load, arm, and start. Production scenarios should still perform each stage explicitly so the VM can prove USB admission, arm its independent observers, and verify the requested load threshold before `start`. +The matrix campaign executor mirrors live phases and its final classified result to the display +when invoked with `--hid-presentation`. This path is intentionally presentation-only: display +delivery failures are logged to `hid-fixture-presentation.log` and never change test evidence or +campaign classification. The fixture token remains environment-only. + ## Verification without hardware ```bash diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c new file mode 100644 index 000000000..05ec1cf69 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c @@ -0,0 +1,63 @@ +#include "fixture_presentation.h" + +#include +#include + +void fixture_presentation_init(fixture_presentation_t *presentation) { + memset(presentation, 0, sizeof(*presentation)); + presentation->phase = FIXTURE_PRESENT_AUTO; +} + +const char *fixture_presentation_phase_name(fixture_presentation_phase_t phase) { + switch (phase) { + case FIXTURE_PRESENT_AUTO: return "auto"; + case FIXTURE_PRESENT_PREPARING: return "preparing"; + case FIXTURE_PRESENT_COUNTDOWN: return "countdown"; + case FIXTURE_PRESENT_TESTING: return "testing"; + case FIXTURE_PRESENT_OBSERVING: return "observing"; + case FIXTURE_PRESENT_RESOLVING: return "resolving"; + case FIXTURE_PRESENT_RESULT: return "result"; + case FIXTURE_PRESENT_NEXT: return "next"; + } + return "auto"; +} + +const char *fixture_result_name(fixture_result_t result) { + switch (result) { + case FIXTURE_RESULT_NONE: return "none"; + case FIXTURE_RESULT_PASS: return "pass"; + case FIXTURE_RESULT_FAIL: return "fail"; + case FIXTURE_RESULT_INCONCLUSIVE: return "inconclusive"; + } + return "none"; +} + +bool fixture_presentation_parse_phase(const char *value, fixture_presentation_phase_t *phase) { + if (!value || !phase) return false; + for (int candidate = FIXTURE_PRESENT_AUTO; candidate <= FIXTURE_PRESENT_NEXT; ++candidate) { + if (strcmp(value, fixture_presentation_phase_name((fixture_presentation_phase_t)candidate)) == 0) { + *phase = (fixture_presentation_phase_t)candidate; + return true; + } + } + return false; +} + +bool fixture_presentation_parse_result(const char *value, fixture_result_t *result) { + if (!value || !result) return false; + for (int candidate = FIXTURE_RESULT_NONE; candidate <= FIXTURE_RESULT_INCONCLUSIVE; ++candidate) { + if (strcmp(value, fixture_result_name((fixture_result_t)candidate)) == 0) { + *result = (fixture_result_t)candidate; + return true; + } + } + return false; +} + +bool fixture_presentation_text_valid(const char *value, size_t maximum_length) { + if (!value || strlen(value) > maximum_length) return false; + for (const unsigned char *cursor = (const unsigned char *)value; *cursor; ++cursor) { + if (*cursor < 0x20u || *cursor > 0x7eu || *cursor == '"' || *cursor == '\\') return false; + } + return true; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h new file mode 100644 index 000000000..53adcc420 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h @@ -0,0 +1,49 @@ +#ifndef KEYPATH_FIXTURE_PRESENTATION_H +#define KEYPATH_FIXTURE_PRESENTATION_H + +#include +#include +#include + +typedef enum { + FIXTURE_PRESENT_AUTO = 0, + FIXTURE_PRESENT_PREPARING, + FIXTURE_PRESENT_COUNTDOWN, + FIXTURE_PRESENT_TESTING, + FIXTURE_PRESENT_OBSERVING, + FIXTURE_PRESENT_RESOLVING, + FIXTURE_PRESENT_RESULT, + FIXTURE_PRESENT_NEXT, +} fixture_presentation_phase_t; + +typedef enum { + FIXTURE_RESULT_NONE = 0, + FIXTURE_RESULT_PASS, + FIXTURE_RESULT_FAIL, + FIXTURE_RESULT_INCONCLUSIVE, +} fixture_result_t; + +typedef struct { + fixture_presentation_phase_t phase; + fixture_result_t result; + uint16_t progress_per_mille; + uint32_t reports_expected; + uint32_t reports_observed; + uint32_t dropped; + uint32_t duplicated; + uint32_t repeated; + uint32_t latency_p95_us; + bool safe_release; + char title[33]; + char detail[49]; + char next[33]; +} fixture_presentation_t; + +void fixture_presentation_init(fixture_presentation_t *presentation); +const char *fixture_presentation_phase_name(fixture_presentation_phase_t phase); +const char *fixture_result_name(fixture_result_t result); +bool fixture_presentation_parse_phase(const char *value, fixture_presentation_phase_t *phase); +bool fixture_presentation_parse_result(const char *value, fixture_result_t *result); +bool fixture_presentation_text_valid(const char *value, size_t maximum_length); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c index e74bc509a..14be57015 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c @@ -54,7 +54,7 @@ fixture_ui_output_t fixture_ui_model_step(fixture_ui_model_t *model, if (output.pressure_warning) { output.quality = FIXTURE_UI_PROTECTED; - output.frame_interval_ms = 250u; + output.frame_interval_ms = 125u; } else if (input->state == FIXTURE_RUNNING) { output.quality = FIXTURE_UI_ACTIVE; output.frame_interval_ms = 50u; diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt index ae6a1afc8..f64faac78 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt @@ -28,6 +28,7 @@ idf_component_register( "fixture_qemu_smoke.c" "fixture_runtime.c" "${shared_source}/fixture_core.c" + "${shared_source}/fixture_presentation.c" "${shared_source}/fixture_ui_model.c" INCLUDE_DIRS "." @@ -39,6 +40,7 @@ idf_component_register( esp_netif esp_timer esp_wifi + json mdns nvs_flash ) diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c index cb45a9d78..af04e1ea9 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c @@ -1,5 +1,6 @@ #include "fixture_display.h" +#include #include #include @@ -22,6 +23,8 @@ typedef struct { lv_obj_t *orbit; lv_obj_t *progress; lv_obj_t *core; + lv_obj_t *icon_front; + lv_obj_t *icon_back; lv_obj_t *keys[KEY_COUNT]; lv_obj_t *particles[PARTICLE_COUNT]; lv_obj_t *eyebrow; @@ -29,7 +32,11 @@ typedef struct { lv_obj_t *detail; lv_obj_t *quality; fixture_ui_scene_t previous_scene; + fixture_result_t previous_result; + int visual_stage; float phase; + uint64_t previous_render_ms; + uint64_t icon_transition_started_ms; uint64_t completion_started_ms; } display_ui_t; @@ -61,6 +68,71 @@ static lv_color_t accent_for(fixture_ui_scene_t scene) { } } +static lv_color_t presentation_accent(const fixture_presentation_t *presentation, + fixture_ui_scene_t scene) { + if (presentation->result == FIXTURE_RESULT_PASS) return lv_color_hex(0x44d7a8); + if (presentation->result == FIXTURE_RESULT_FAIL) return lv_color_hex(0xff5c72); + if (presentation->result == FIXTURE_RESULT_INCONCLUSIVE) return lv_color_hex(0xffb454); + switch (presentation->phase) { + case FIXTURE_PRESENT_PREPARING: return lv_color_hex(0x9c7cff); + case FIXTURE_PRESENT_COUNTDOWN: return lv_color_hex(0xffb454); + case FIXTURE_PRESENT_TESTING: return lv_color_hex(0x55c7ff); + case FIXTURE_PRESENT_OBSERVING: return lv_color_hex(0xb58cff); + case FIXTURE_PRESENT_RESOLVING: return lv_color_hex(0x36d8d0); + case FIXTURE_PRESENT_NEXT: return lv_color_hex(0x55c7ff); + default: return accent_for(scene); + } +} + +static int visual_stage(const fixture_presentation_t *presentation, fixture_ui_scene_t scene) { + if (presentation->result != FIXTURE_RESULT_NONE) return 32 + (int)presentation->result; + if (presentation->phase != FIXTURE_PRESENT_AUTO) return 16 + (int)presentation->phase; + return (int)scene; +} + +static const char *symbol_for(int stage) { + if (stage == 33) return LV_SYMBOL_OK; + if (stage == 34) return LV_SYMBOL_CLOSE; + if (stage == 35) return LV_SYMBOL_WARNING; + switch (stage) { + case FIXTURE_UI_BOOT: return LV_SYMBOL_POWER; + case FIXTURE_UI_CONNECTING: return LV_SYMBOL_WIFI; + case FIXTURE_UI_IDLE: return LV_SYMBOL_KEYBOARD; + case FIXTURE_UI_LOADED: return LV_SYMBOL_DOWNLOAD; + case FIXTURE_UI_ARMED: return LV_SYMBOL_WARNING; + case FIXTURE_UI_RUNNING: return LV_SYMBOL_PLAY; + case FIXTURE_UI_COMPLETE: return LV_SYMBOL_OK; + case FIXTURE_UI_ABORTED: return LV_SYMBOL_STOP; + case FIXTURE_UI_ERROR: return LV_SYMBOL_CLOSE; + case 17: return LV_SYMBOL_REFRESH; + case 18: return LV_SYMBOL_BELL; + case 19: return LV_SYMBOL_KEYBOARD; + case 20: return LV_SYMBOL_EYE_OPEN; + case 21: return LV_SYMBOL_SETTINGS; + case 22: return LV_SYMBOL_OK; + case 23: return LV_SYMBOL_NEXT; + default: return LV_SYMBOL_POWER; + } +} + +static const char *presentation_title(const fixture_presentation_t *presentation, + fixture_ui_scene_t scene) { + if (presentation->result == FIXTURE_RESULT_PASS) return "TEST PASSED"; + if (presentation->result == FIXTURE_RESULT_FAIL) return "TEST FAILED"; + if (presentation->result == FIXTURE_RESULT_INCONCLUSIVE) return "NEEDS REVIEW"; + if (presentation->title[0]) return presentation->title; + switch (presentation->phase) { + case FIXTURE_PRESENT_PREPARING: return "PREPARING"; + case FIXTURE_PRESENT_COUNTDOWN: return "STAND BY"; + case FIXTURE_PRESENT_TESTING: return "SENDING KEYS"; + case FIXTURE_PRESENT_OBSERVING: return "OBSERVING"; + case FIXTURE_PRESENT_RESOLVING: return "RESOLVING"; + case FIXTURE_PRESENT_RESULT: return "RESULT"; + case FIXTURE_PRESENT_NEXT: return "UP NEXT"; + default: return scene_name(scene); + } +} + static lv_obj_t *make_circle(lv_obj_t *parent, int size, lv_color_t color, lv_opa_t opacity) { lv_obj_t *object = lv_obj_create(parent); lv_obj_remove_style_all(object); @@ -132,6 +204,16 @@ static void build_ui(void) { lv_obj_set_style_border_color(ui.core, lv_color_hex(0x2f6970), 0); lv_obj_align(ui.core, LV_ALIGN_CENTER, 0, -2); + ui.icon_back = lv_label_create(ui.screen); + lv_label_set_text_static(ui.icon_back, LV_SYMBOL_POWER); + lv_obj_set_style_text_font(ui.icon_back, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_opa(ui.icon_back, LV_OPA_TRANSP, 0); + lv_obj_align(ui.icon_back, LV_ALIGN_CENTER, 0, -2); + ui.icon_front = lv_label_create(ui.screen); + lv_label_set_text_static(ui.icon_front, LV_SYMBOL_POWER); + lv_obj_set_style_text_font(ui.icon_front, &lv_font_montserrat_20, 0); + lv_obj_align(ui.icon_front, LV_ALIGN_CENTER, 0, -2); + for (size_t index = 0; index < KEY_COUNT; ++index) { ui.keys[index] = lv_obj_create(ui.core); lv_obj_remove_style_all(ui.keys[index]); @@ -153,11 +235,17 @@ static void build_ui(void) { lv_label_set_text_static(ui.state, "WAKING UP"); lv_obj_set_style_text_font(ui.state, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(ui.state, lv_color_hex(0xe9f7f4), 0); + lv_obj_set_width(ui.state, 220); + lv_label_set_long_mode(ui.state, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(ui.state, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(ui.state, LV_ALIGN_BOTTOM_MID, 0, -40); ui.detail = lv_label_create(ui.screen); lv_label_set_text_static(ui.detail, "USB + Wi-Fi control"); lv_obj_set_style_text_color(ui.detail, lv_color_hex(0x78909a), 0); + lv_obj_set_width(ui.detail, 220); + lv_label_set_long_mode(ui.detail, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(ui.detail, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(ui.detail, LV_ALIGN_BOTTOM_MID, 0, -20); ui.quality = lv_label_create(ui.screen); @@ -166,6 +254,7 @@ static void build_ui(void) { lv_obj_set_style_text_letter_space(ui.quality, 1, 0); lv_obj_align(ui.quality, LV_ALIGN_TOP_RIGHT, -10, 38); ui.previous_scene = FIXTURE_UI_BOOT; + ui.visual_stage = -1; } static void announce_transition(const fixture_ui_output_t *output) { @@ -184,15 +273,37 @@ static void announce_transition(const fixture_ui_output_t *output) { ui.previous_scene = output->scene; } -static void render(const fixture_ui_output_t *output, uint64_t now_ms) { - lv_color_t accent = accent_for(output->scene); +static void announce_result(fixture_result_t result) { + if (result == ui.previous_result) return; + if (result == FIXTURE_RESULT_PASS) { + fixture_board_tone(880u, 55u); + fixture_board_tone(1320u, 95u); + } else if (result == FIXTURE_RESULT_FAIL) { + fixture_board_tone(180u, 190u); + } else if (result == FIXTURE_RESULT_INCONCLUSIVE) { + fixture_board_tone(540u, 80u); + } + ui.previous_result = result; +} + +static void render(const fixture_ui_output_t *output, + const fixture_presentation_t *presentation, uint64_t now_ms) { + lv_color_t accent = presentation_accent(presentation, output->scene); int energy = output->energy_per_mille; - float speed = output->scene == FIXTURE_UI_RUNNING ? 0.19f : 0.055f; + uint64_t elapsed_ms = ui.previous_render_ms && now_ms > ui.previous_render_ms + ? now_ms - ui.previous_render_ms : output->frame_interval_ms; + if (elapsed_ms > 250u) elapsed_ms = 250u; + float speed = output->scene == FIXTURE_UI_RUNNING ? 4.2f : 1.55f; + if (presentation->phase == FIXTURE_PRESENT_COUNTDOWN) speed = 2.6f; + if (presentation->phase == FIXTURE_PRESENT_TESTING) speed = 4.8f; + if (presentation->phase == FIXTURE_PRESENT_OBSERVING) speed = 1.1f; + if (output->quality == FIXTURE_UI_PROTECTED) speed = 0.48f; #if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION speed = 0.0f; #endif - if (output->quality != FIXTURE_UI_PROTECTED) ui.phase += speed; + ui.phase += speed * (float)elapsed_ms / 1000.0f; if (ui.phase > 6.2831853f) ui.phase -= 6.2831853f; + ui.previous_render_ms = now_ms; float completion_wave = 0.0f; #if !CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION @@ -205,12 +316,33 @@ static void render(const fixture_ui_output_t *output, uint64_t now_ms) { lv_obj_set_style_bg_color(ui.screen, output->scene == FIXTURE_UI_ERROR ? lv_color_hex(0x190b13) : lv_color_hex(0x071117), 0); - lv_label_set_text_static(ui.state, scene_name(output->scene)); + lv_label_set_text(ui.state, presentation_title(presentation, output->scene)); lv_obj_set_style_text_color(ui.state, accent, 0); lv_obj_set_style_arc_color(ui.orbit, accent, LV_PART_INDICATOR); lv_obj_set_style_arc_color(ui.progress, accent, LV_PART_INDICATOR); lv_obj_set_style_border_color(ui.core, accent, 0); - lv_arc_set_value(ui.progress, output->progress_per_mille); + uint16_t progress = presentation->phase == FIXTURE_PRESENT_AUTO + ? output->progress_per_mille : presentation->progress_per_mille; + lv_arc_set_value(ui.progress, progress); + + int stage = visual_stage(presentation, output->scene); + if (stage != ui.visual_stage) { + lv_label_set_text(ui.icon_back, symbol_for(ui.visual_stage)); + lv_label_set_text(ui.icon_front, symbol_for(stage)); + ui.visual_stage = stage; + ui.icon_transition_started_ms = now_ms; + } + uint64_t transition_elapsed = now_ms - ui.icon_transition_started_ms; + float transition = transition_elapsed >= 240u ? 1.0f : (float)transition_elapsed / 240.0f; + float eased = 1.0f - powf(1.0f - transition, 3.0f); + lv_obj_set_style_text_color(ui.icon_front, accent, 0); + lv_obj_set_style_text_color(ui.icon_back, accent, 0); + lv_obj_set_style_text_opa(ui.icon_front, (lv_opa_t)(255.0f * eased), 0); + lv_obj_set_style_text_opa(ui.icon_back, (lv_opa_t)(255.0f * (1.0f - transition)), 0); + int icon_pulse = (int)(sinf(ui.phase * 1.4f) * 7.0f); + lv_obj_set_style_transform_scale(ui.icon_front, 232 + (int)(24.0f * eased) + icon_pulse, 0); + lv_obj_set_style_translate_x(ui.icon_front, (int)(7.0f * (1.0f - eased)), 0); + lv_obj_set_style_translate_x(ui.icon_back, (int)(-9.0f * transition), 0); int orbit_start = (int)(ui.phase * 57.29578f) % 360; int orbit_length = output->scene == FIXTURE_UI_RUNNING ? 55 + energy / 14 : 82; @@ -220,9 +352,8 @@ static void render(const fixture_ui_output_t *output, uint64_t now_ms) { #if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION pulse = 0; #endif - lv_obj_set_size(ui.halo_inner, 119 + pulse / 2 + energy / 90, - 119 + pulse / 2 + energy / 90); - lv_obj_align(ui.halo_inner, LV_ALIGN_CENTER, 0, -2); + int halo_size = 119 + pulse / 2 + energy / 90; + lv_obj_set_style_transform_scale(ui.halo_inner, halo_size * 256 / 124, 0); lv_obj_set_style_bg_color(ui.halo_inner, accent, 0); lv_obj_set_style_bg_opa(ui.halo_inner, (lv_opa_t)(12 + energy / 28), 0); lv_obj_set_style_bg_color(ui.halo_outer, accent, 0); @@ -233,12 +364,17 @@ static void render(const fixture_ui_output_t *output, uint64_t now_ms) { int opacity = 35 + (int)((wave + 1.0f) * 38.0f) + energy / 8; if (opacity > 255) opacity = 255; lv_obj_set_style_bg_color(ui.keys[index], accent, 0); - lv_obj_set_style_bg_opa(ui.keys[index], (lv_opa_t)opacity, 0); + lv_obj_set_style_bg_opa(ui.keys[index], (lv_opa_t)(opacity / 3), 0); } float radius = output->scene == FIXTURE_UI_COMPLETE ? 82.0f : 72.0f + energy / 80.0f; radius += completion_wave * 34.0f; for (size_t index = 0; index < PARTICLE_COUNT; ++index) { + if (output->quality == FIXTURE_UI_PROTECTED && index >= 4u) { + lv_obj_add_flag(ui.particles[index], LV_OBJ_FLAG_HIDDEN); + continue; + } + lv_obj_clear_flag(ui.particles[index], LV_OBJ_FLAG_HIDDEN); float angle = ui.phase * (1.0f + (float)(index % 3u) * 0.13f) + (float)index * 0.5235988f; float wobble = sinf(ui.phase * 1.9f + (float)index) * (3.0f + energy / 170.0f); @@ -250,6 +386,29 @@ static void render(const fixture_ui_output_t *output, uint64_t now_ms) { (lv_opa_t)(40 + ((index * 31u + (unsigned int)(ui.phase * 80)) % 150u)), 0); } + if (presentation->detail[0]) { + lv_label_set_text(ui.detail, presentation->detail); + } else if (presentation->result != FIXTURE_RESULT_NONE && + (presentation->dropped || presentation->duplicated || presentation->repeated)) { + char findings[64]; + snprintf(findings, sizeof(findings), "DROP %" PRIu32 " DUP %" PRIu32 " REPEAT %" PRIu32, + presentation->dropped, presentation->duplicated, presentation->repeated); + lv_label_set_text(ui.detail, findings); + } else if (presentation->phase == FIXTURE_PRESENT_NEXT && presentation->next[0]) { + lv_label_set_text(ui.detail, presentation->next); + } else { + lv_label_set_text_static(ui.detail, "USB + Wi-Fi control"); + } + + if (presentation->result != FIXTURE_RESULT_NONE && presentation->reports_expected > 0u) { + char metrics[64]; + snprintf(metrics, sizeof(metrics), "%" PRIu32 "/%" PRIu32 " P95 %" PRIu32 "us%s", + presentation->reports_observed, presentation->reports_expected, + presentation->latency_p95_us, presentation->safe_release ? " SAFE" : ""); + lv_label_set_text(ui.quality, metrics); + lv_obj_set_style_text_color(ui.quality, accent, 0); + } else + if (output->quality == FIXTURE_UI_PROTECTED) { lv_label_set_text_static(ui.quality, "HID PRIORITY"); lv_obj_set_style_text_color(ui.quality, lv_color_hex(0xffb454), 0); @@ -280,9 +439,10 @@ static void display_task(void *context) { fixture_ui_output_t output = fixture_ui_model_step(&model, &snapshot.ui, now_ms); if (output.completion_burst) ui.completion_started_ms = now_ms; announce_transition(&output); + announce_result(snapshot.presentation.result); fixture_board_update(snapshot.ui.state == FIXTURE_ARMED || snapshot.ui.state == FIXTURE_RUNNING); if (bsp_display_lock(20u)) { - render(&output, now_ms); + render(&output, &snapshot.presentation, now_ms); bsp_display_unlock(); } vTaskDelay(pdMS_TO_TICKS(output.frame_interval_ms)); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c index 1cef8d5a6..6cb514f83 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -4,6 +4,7 @@ #include #include +#include "cJSON.h" #include "esp_event.h" #include "esp_check.h" #include "esp_heap_caps.h" @@ -52,7 +53,7 @@ static esp_err_t status_handler(httpd_req_t *request) { bool wifi_connected; char address[48]; fixture_runtime_network_snapshot(&wifi_connected, address, sizeof(address)); - char body[1280]; + char body[1792]; snprintf(body, sizeof(body), "{\"ok\":true,\"firmware\":\"%s\",\"platform\":\"waveshare-esp32-s3-touch-lcd-1.69\"," "\"state\":\"%s\",\"runId\":\"%s\",\"scriptCRC32\":\"%08" PRIx32 "\"," @@ -60,13 +61,24 @@ static esp_err_t status_handler(httpd_req_t *request) { "\"reportsSubmitted\":%" PRIu64 ",\"transfersCompleted\":%" PRIu64 "," "\"lateReports\":%" PRIu64 ",\"maximumLatenessUs\":%" PRId64 "," "\"submittedCRC32\":\"%08" PRIx32 "\",\"usbMounted\":%s," - "\"wifiConnected\":%s,\"address\":\"%s\",\"error\":\"%s\"}\n", + "\"wifiConnected\":%s,\"address\":\"%s\",\"error\":\"%s\"," + "\"presentation\":{\"phase\":\"%s\",\"result\":\"%s\",\"progress\":%u," + "\"title\":\"%s\",\"detail\":\"%s\",\"next\":\"%s\"," + "\"reportsExpected\":%" PRIu32 ",\"reportsObserved\":%" PRIu32 "," + "\"dropped\":%" PRIu32 ",\"duplicated\":%" PRIu32 ",\"repeated\":%" PRIu32 "," + "\"latencyP95Us\":%" PRIu32 ",\"safeRelease\":%s}}\n", KEYPATH_FIXTURE_FIRMWARE_VERSION, fixture_state_name(snapshot.ui.state), snapshot.run_id, snapshot.script_crc32, snapshot.ui.event_count, snapshot.ui.repeat_count, snapshot.ui.current_repeat, snapshot.ui.reports_submitted, snapshot.transfers_completed, snapshot.ui.late_reports, snapshot.ui.maximum_lateness_us, snapshot.submitted_crc32, snapshot.ui.usb_mounted ? "true" : "false", wifi_connected ? "true" : "false", - address, snapshot.error); + address, snapshot.error, + fixture_presentation_phase_name(snapshot.presentation.phase), + fixture_result_name(snapshot.presentation.result), snapshot.presentation.progress_per_mille, + snapshot.presentation.title, snapshot.presentation.detail, snapshot.presentation.next, + snapshot.presentation.reports_expected, snapshot.presentation.reports_observed, + snapshot.presentation.dropped, snapshot.presentation.duplicated, snapshot.presentation.repeated, + snapshot.presentation.latency_p95_us, snapshot.presentation.safe_release ? "true" : "false"); return send_json(request, "200 OK", body); } @@ -154,6 +166,64 @@ static esp_err_t abort_handler(httpd_req_t *request) { "{\"ok\":true,\"message\":\"aborted; all-keys-released report queued\"}\n"); } +static bool json_u32(cJSON *root, const char *name, uint32_t maximum, uint32_t *output) { + cJSON *item = cJSON_GetObjectItemCaseSensitive(root, name); + if (!item) return true; + if (!cJSON_IsNumber(item) || item->valuedouble < 0.0 || item->valuedouble > maximum || + item->valuedouble != (double)(uint32_t)item->valuedouble) return false; + *output = (uint32_t)item->valuedouble; + return true; +} + +static bool json_text(cJSON *root, const char *name, char *output, size_t capacity) { + cJSON *item = cJSON_GetObjectItemCaseSensitive(root, name); + if (!item) return true; + if (!cJSON_IsString(item) || !fixture_presentation_text_valid(item->valuestring, capacity - 1u)) return false; + snprintf(output, capacity, "%s", item->valuestring); + return true; +} + +static esp_err_t presentation_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + size_t length; + if (receive_body(request, 1023u, &length) != ESP_OK) { + return send_json(request, "400 Bad Request", "{\"ok\":false,\"message\":\"invalid presentation body\"}\n"); + } + cJSON *root = cJSON_ParseWithLength(script_buffer, length); + fixture_presentation_t presentation; + fixture_presentation_init(&presentation); + cJSON *phase = root ? cJSON_GetObjectItemCaseSensitive(root, "phase") : NULL; + cJSON *result = root ? cJSON_GetObjectItemCaseSensitive(root, "result") : NULL; + uint32_t progress = 0u; + cJSON *safe_release = root ? cJSON_GetObjectItemCaseSensitive(root, "safeRelease") : NULL; + bool valid = root && cJSON_IsObject(root) && cJSON_IsString(phase) && + fixture_presentation_parse_phase(phase->valuestring, &presentation.phase) && + (!result || (cJSON_IsString(result) && + fixture_presentation_parse_result(result->valuestring, &presentation.result))) && + json_u32(root, "progress", 1000u, &progress) && + json_u32(root, "reportsExpected", UINT32_MAX, &presentation.reports_expected) && + json_u32(root, "reportsObserved", UINT32_MAX, &presentation.reports_observed) && + json_u32(root, "dropped", UINT32_MAX, &presentation.dropped) && + json_u32(root, "duplicated", UINT32_MAX, &presentation.duplicated) && + json_u32(root, "repeated", UINT32_MAX, &presentation.repeated) && + json_u32(root, "latencyP95Us", UINT32_MAX, &presentation.latency_p95_us) && + json_text(root, "title", presentation.title, sizeof(presentation.title)) && + json_text(root, "detail", presentation.detail, sizeof(presentation.detail)) && + json_text(root, "next", presentation.next, sizeof(presentation.next)) && + (!safe_release || cJSON_IsBool(safe_release)); + if (valid) { + presentation.progress_per_mille = (uint16_t)progress; + presentation.safe_release = safe_release && cJSON_IsTrue(safe_release); + fixture_runtime_set_presentation(&presentation); + } + cJSON_Delete(root); + if (!valid) { + return send_json(request, "400 Bad Request", + "{\"ok\":false,\"message\":\"invalid phase, result, metric, or display text\"}\n"); + } + return send_json(request, "200 OK", "{\"ok\":true,\"message\":\"presentation updated\"}\n"); +} + static unsigned int query_number(httpd_req_t *request, const char *name, unsigned int fallback) { char query[96], value[24]; if (httpd_req_get_url_query_str(request, query, sizeof(query)) != ESP_OK) return fallback; @@ -194,7 +264,7 @@ static esp_err_t start_http_server(void) { if (http_server) return ESP_OK; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = CONFIG_KEYPATH_FIXTURE_HTTP_PORT; - config.max_uri_handlers = 6; + config.max_uri_handlers = 7; config.stack_size = 8192; config.core_id = 0; config.task_priority = 4; @@ -206,6 +276,7 @@ static esp_err_t start_http_server(void) { {.uri = "/v1/start", .method = HTTP_POST, .handler = start_handler}, {.uri = "/v1/abort", .method = HTTP_POST, .handler = abort_handler}, {.uri = "/v1/trace", .method = HTTP_GET, .handler = trace_handler}, + {.uri = "/v1/presentation", .method = HTTP_POST, .handler = presentation_handler}, }; for (size_t index = 0; index < sizeof(handlers) / sizeof(handlers[0]); ++index) { ESP_RETURN_ON_ERROR(httpd_register_uri_handler(http_server, &handlers[index]), TAG, diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c index 66399a8a8..4a8a777a8 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c @@ -22,6 +22,7 @@ static StaticSemaphore_t fixture_mutex_storage; static SemaphoreHandle_t fixture_mutex; static bool network_connected; static char network_address[48] = "unassigned"; +static fixture_presentation_t presentation; static void runtime_lock(void) { configASSERT(xSemaphoreTake(fixture_mutex, portMAX_DELAY) == pdTRUE); @@ -130,6 +131,7 @@ void fixture_runtime_init(void) { fixture_mutex = xSemaphoreCreateMutexStatic(&fixture_mutex_storage); configASSERT(fixture_mutex); fixture_init(&fixture); + fixture_presentation_init(&presentation); } esp_err_t fixture_runtime_start_usb(void) { @@ -188,6 +190,7 @@ void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot) { snapshot->next_event = fixture.next_event; snapshot->transfers_completed = fixture.transfers_completed; snapshot->submitted_crc32 = fixture.submitted_crc32; + snapshot->presentation = presentation; runtime_unlock(); } @@ -218,6 +221,12 @@ void fixture_runtime_abort(const char *reason) { runtime_unlock(); } +void fixture_runtime_set_presentation(const fixture_presentation_t *value) { + runtime_lock(); + presentation = *value; + runtime_unlock(); +} + uint32_t fixture_runtime_trace_count(void) { runtime_lock(); uint32_t count = fixture_trace_count(&fixture); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h index 0baa48d4d..f2736496f 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h @@ -7,6 +7,7 @@ #include "esp_err.h" #include "fixture_core.h" +#include "fixture_presentation.h" #include "fixture_ui_model.h" typedef struct { @@ -17,6 +18,7 @@ typedef struct { uint32_t next_event; uint64_t transfers_completed; uint32_t submitted_crc32; + fixture_presentation_t presentation; } fixture_runtime_snapshot_t; void fixture_runtime_init(void); @@ -29,6 +31,7 @@ bool fixture_runtime_load(const char *body, size_t length, char *error, size_t c bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity); bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity); void fixture_runtime_abort(const char *reason); +void fixture_runtime_set_presentation(const fixture_presentation_t *presentation); uint32_t fixture_runtime_trace_count(void); bool fixture_runtime_trace_at(uint32_t index, fixture_trace_t *trace); diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index 9a06f9b4f..3126239d4 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -1,4 +1,5 @@ #include "fixture_core.h" +#include "fixture_presentation.h" #include "fixture_ui_model.h" #include @@ -171,7 +172,7 @@ static void test_ui_model_prioritizes_hid_and_tracks_progress(void) { input.maximum_lateness_us = 2000; output = fixture_ui_model_step(&model, &input, 1020u); assert(output.quality == FIXTURE_UI_PROTECTED); - assert(output.frame_interval_ms == 250u); + assert(output.frame_interval_ms == 125u); assert(output.pressure_warning); output = fixture_ui_model_step(&model, &input, 2400u); @@ -189,6 +190,19 @@ static void test_ui_model_prioritizes_hid_and_tracks_progress(void) { assert(!output.completion_burst); } +static void test_presentation_contract(void) { + fixture_presentation_t presentation; + fixture_presentation_init(&presentation); + assert(presentation.phase == FIXTURE_PRESENT_AUTO); + assert(fixture_presentation_parse_phase("observing", &presentation.phase)); + assert(presentation.phase == FIXTURE_PRESENT_OBSERVING); + assert(fixture_presentation_parse_result("inconclusive", &presentation.result)); + assert(presentation.result == FIXTURE_RESULT_INCONCLUSIVE); + assert(fixture_presentation_text_valid("Swift stress / pass 2", 32u)); + assert(!fixture_presentation_text_valid("unsafe \"label\"", 32u)); + assert(!fixture_presentation_parse_phase("dancing", &presentation.phase)); +} + static void test_ui_model_connection_error_and_counter_reset(void) { fixture_ui_model_t model; fixture_ui_model_init(&model); @@ -222,6 +236,7 @@ int main(void) { test_lateness_metrics(); test_ui_model_prioritizes_hid_and_tracks_progress(); test_ui_model_connection_error_and_counter_reset(); + test_presentation_contract(); puts("physical HID fixture core tests passed"); return 0; } diff --git a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh index 437463d79..68babfa1f 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh @@ -8,6 +8,7 @@ trap 'rm -f "$test_binary"' EXIT HUP INT TERM cc -std=c11 -Wall -Wextra -Werror -pedantic \ -I"$fixture_root/src" \ "$fixture_root/src/fixture_core.c" \ + "$fixture_root/src/fixture_presentation.c" \ "$fixture_root/src/fixture_ui_model.c" \ "$fixture_root/tests/fixture_core_tests.c" \ -o "$test_binary" diff --git a/Scripts/lab/scenario-matrix-runner b/Scripts/lab/scenario-matrix-runner index ce96893fa..6ce8bc532 100755 --- a/Scripts/lab/scenario-matrix-runner +++ b/Scripts/lab/scenario-matrix-runner @@ -140,6 +140,7 @@ class MatrixRunner: self.job_states = {job["id"]: job for job in state["jobs"]} self.acknowledged = set(args.ack_checkpoint) self.selected = set(args.only_job) if args.only_job else set(self.jobs) + self.presentation_enabled = args.hid_presentation def save(self) -> None: with self.lock: @@ -158,6 +159,26 @@ class MatrixRunner: if result.returncode: raise CampaignError(f"dashboard update failed: {result.stderr.strip() or result.stdout.strip()}") + def present(self, phase: str, *, result: str = "none", progress: int = 0, + title: str = "", detail: str = "", reports_expected: int = 0, + reports_observed: int = 0) -> None: + """Update the optional physical display without making it a campaign dependency.""" + if not self.presentation_enabled: + return + command = [str(self.args.hid_fixture_client), "--host", self.args.hid_fixture_host, + "--timeout", "2.0", "present", + "--phase", phase, "--result", result, "--progress", str(max(0, min(progress, 1000))), + "--title", title[:32], "--detail", detail[:48], + "--reports-expected", str(reports_expected), + "--reports-observed", str(reports_observed)] + with self.lock: + response = subprocess.run(command, cwd=self.args.repo, text=True, capture_output=True) + if response.returncode: + log = self.args.artifacts / "hid-fixture-presentation.log" + log.parent.mkdir(parents=True, exist_ok=True) + with log.open("a") as handle: + handle.write(f"{now()} {response.stderr.strip() or response.stdout.strip()}\n") + def update_job(self, job_state: dict[str, Any], *, status: str | None = None, step: dict[str, str] | None = None, step_status: str | None = None, message: str | None = None, event: str | None = None, tone: str = "info") -> None: @@ -185,6 +206,19 @@ class MatrixRunner: if event: arguments += ["--event-title", event, "--event-tone", tone] self.dashboard(*arguments) + if status in ("failed", "blocked"): + self.present("result", result="fail" if status == "failed" else "inconclusive", + progress=progress * 10, title=job_state["id"], detail=message or "Evidence incomplete") + elif status == "waiting": + self.present("next", progress=progress * 10, title=job_state["id"], detail=message or "Action needed") + elif status == "passed": + self.present("resolving", progress=1000, title=job_state["id"], detail="Scenario evidence verified") + elif step_status == "running": + self.present("testing", progress=progress * 10, title=job_state["id"], + detail=(step or {}).get("commandStep", "Running scenario")) + elif step_status == "passed": + self.present("observing", progress=progress * 10, title=job_state["id"], + detail=(step or {}).get("commandStep", "Evidence captured")) def lab_command(self, *parts: str) -> list[str]: command = [str(self.args.lab)] @@ -472,6 +506,7 @@ class MatrixRunner: self.dashboard("campaign", "--status", "running", "--message", "Campaign executor preflight passed; wave execution started.", "--next-action", "Watch the active wave and intervene only if a checkpoint requests attention.", "--event-title", "Matrix campaign started") + self.present("preparing", title="MATRIX CAMPAIGN", detail="Admitting provider-safe waves") waiting_providers: set[str] = set() saw_failure = False last_wave = 0 @@ -500,6 +535,8 @@ class MatrixRunner: "--message", f"Wave {last_wave} reached an explicit checkpoint; independent providers ran as far as capacity allowed.", "--next-action", "Complete the visible checkpoint, acknowledge it, and resume the same campaign state file.", "--event-title", "Matrix campaign needs attention", "--event-tone", "warning") + self.present("next", result="inconclusive", title="ACTION NEEDED", + detail="A visible checkpoint is waiting") return 4 if saw_failure: self.state["status"] = "blocked" @@ -509,6 +546,8 @@ class MatrixRunner: "--message", "All runnable waves completed, but one or more jobs have classified failures.", "--next-action", "Review failed evidence and repair the owning layer before a new campaign.", "--event-title", "Matrix campaign found failures", "--event-tone", "danger") + self.present("result", result="fail", progress=1000, title="MATRIX FAILED", + detail="Review classified failure evidence") return 1 selected_states = [self.job_states[job_id]["status"] for job_id in self.selected] complete = all(status == "passed" for status in selected_states) @@ -521,6 +560,15 @@ class MatrixRunner: "--next-action", "Run the remaining queued jobs." if partial else ("Review the consolidated evidence report." if complete else "Review blocked jobs before resuming."), "--event-title", "Selected matrix jobs passed" if partial else ("Matrix campaign passed" if complete else "Matrix campaign incomplete"), "--event-tone", "success" if complete else "danger") + if complete and not partial: + self.present("result", result="pass", progress=1000, title="MATRIX PASSED", + detail="Every selected scenario verified", reports_expected=len(selected_states), + reports_observed=len(selected_states)) + elif complete: + self.present("next", progress=1000, title="WAVE PASSED", detail="More matrix jobs remain") + else: + self.present("result", result="inconclusive", progress=1000, + title="MATRIX INCOMPLETE", detail="Evidence remains unresolved") return 0 if complete else 1 @@ -551,12 +599,17 @@ def main() -> None: parser.add_argument("--repo", type=pathlib.Path) parser.add_argument("--dashboard-updater", type=pathlib.Path) parser.add_argument("--dashboard-state", type=pathlib.Path, default=pathlib.Path("/tmp/keypath-matrix-state.json")) + parser.add_argument("--hid-fixture-client", type=pathlib.Path) + parser.add_argument("--hid-fixture-host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + parser.add_argument("--hid-presentation", action="store_true", + help="mirror campaign phases and results to the physical HID fixture display") parser.add_argument("--ack-checkpoint", action="append", default=[]) parser.add_argument("--only-job", action="append", default=[]) args = parser.parse_args() root = pathlib.Path(__file__).resolve().parents[2] args.repo = (args.repo or root).resolve() args.lab = (args.lab or root / "Scripts/lab/keypath-lab").resolve() + args.hid_fixture_client = (args.hid_fixture_client or root / "Scripts/lab/pico-hid-fixture-client").resolve() try: plan = validate_plan(json.loads(args.plan.read_text())) if not re.fullmatch(r"[0-9a-fA-F]{40}", args.commit): @@ -575,6 +628,11 @@ def main() -> None: raise CampaignError("unknown --only-job: " + ", ".join(sorted(unknown))) if args.dashboard_updater and not args.dashboard_updater.is_file(): raise CampaignError("dashboard updater does not exist") + if args.hid_presentation: + if not os.environ.get("KEYPATH_FIXTURE_TOKEN"): + raise CampaignError("--hid-presentation requires KEYPATH_FIXTURE_TOKEN") + if not args.hid_fixture_client.is_file(): + raise CampaignError("HID fixture client does not exist") state = load_or_create_state(args, plan) waiting = {job.get("waitingCheckpoint") for job in state["jobs"] if job.get("waitingCheckpoint")} invalid_acknowledgements = set(args.ack_checkpoint) - waiting diff --git a/Scripts/lab/tests/pico-hid-fixture-client-tests.py b/Scripts/lab/tests/pico-hid-fixture-client-tests.py index 4980361c6..643fa67b5 100755 --- a/Scripts/lab/tests/pico-hid-fixture-client-tests.py +++ b/Scripts/lab/tests/pico-hid-fixture-client-tests.py @@ -83,6 +83,8 @@ def test_compile_rejects_unsupported_text_and_unsafe_timing(self): CLIENT.compile_text("run", "🙂", 80, 30, 1, 0) with self.assertRaisesRegex(ValueError, "hold duration"): CLIENT.compile_text("run", "a", 20, 20, 1, 0) + with self.assertRaisesRegex(ValueError, "timeout"): + CLIENT.FixtureClient("fixture", "token", timeout=0) def test_client_authenticates_and_uses_expected_endpoints(self): self.assertEqual(self.client.status()["state"], "idle") @@ -99,6 +101,16 @@ def test_trace_decodes_ndjson(self): self.assertEqual(trace[0]["available"], 1) self.assertEqual(trace[1]["sequence"], 1) + def test_presentation_uses_bounded_json_channel(self): + self.client.present({"phase": "result", "result": "pass", "progress": 1000, + "title": "Swift stress", "reportsExpected": 40, + "reportsObserved": 40, "safeRelease": True}) + method, path, auth, body = RecordingHandler.requests[-1] + self.assertEqual((method, path, auth), ("POST", "/v1/presentation", "Bearer test-token")) + payload = json.loads(body) + self.assertEqual(payload["result"], "pass") + self.assertEqual(payload["reportsObserved"], 40) + if __name__ == "__main__": unittest.main() diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py index bbbbd6f3e..1c5d0e28e 100755 --- a/Scripts/lab/tests/scenario-matrix-runner-tests.py +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -59,14 +59,15 @@ def plan(self, steps: list[str], *, job_id: str = "vm-job") -> pathlib.Path: })) return path - def run_runner(self, plan: pathlib.Path, *extra: str) -> subprocess.CompletedProcess[str]: + def run_runner(self, plan: pathlib.Path, *extra: str, + environment=None) -> subprocess.CompletedProcess[str]: return subprocess.run([ str(RUNNER), "--plan", str(plan), "--state", str(self.directory / "state.json"), "--artifacts", str(self.directory / "artifacts"), "--commit", "a" * 40, "--installer", str(self.installer), "--lab", str(self.lab), "--repo", str(ROOT), "--dashboard-updater", str(self.dashboard), "--dashboard-state", str(self.directory / "dashboard-state.json"), *extra, - ], text=True, capture_output=True) + ], text=True, capture_output=True, env=environment) def test_executes_steps_updates_dashboard_and_destroys_owned_lease(self) -> None: result = self.run_runner(self.plan(["create-fresh-lease", "install-exact-artifact", "artifact-capture"])) @@ -86,6 +87,30 @@ def test_executes_steps_updates_dashboard_and_destroys_owned_lease(self) -> None self.assertIn("--status running", dashboard) self.assertIn("--status passed", dashboard) + def test_optional_hid_presentation_tracks_campaign_without_exposing_token(self) -> None: + presentation_log = self.directory / "presentation.log" + presenter = self.directory / "fixture-client" + presenter.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(presentation_log)!r} + print '{{"ok":true}}' + """)) + presenter.chmod(0o755) + environment = os.environ.copy() + environment["KEYPATH_FIXTURE_TOKEN"] = "fixture-test-secret" + + result = self.run_runner(self.plan(["create-fresh-lease", "artifact-capture"]), + "--hid-presentation", "--hid-fixture-client", str(presenter), + environment=environment) + + self.assertEqual(result.returncode, 0, result.stderr) + calls = presentation_log.read_text() + self.assertIn("--timeout 2.0 present --phase preparing", calls) + self.assertIn("--timeout 2.0 present --phase testing", calls) + self.assertIn("--timeout 2.0 present --phase observing", calls) + self.assertIn("--result pass", calls) + self.assertNotIn("fixture-test-secret", calls) + def test_upgrade_boundary_uses_dedicated_controller_command(self) -> None: fixture = self.directory / "KeyPath-beta3.zip" fixture.write_bytes(b"fixture") From a9da20d695f3df4cec1e3b0dd324850ee2a6f747 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 17:56:19 -0700 Subject: [PATCH 75/99] Prepare ESP32 fixture for first-board install --- Scripts/lab/pico-hid-fixture-tool | 241 ++++++++++++++++++ Scripts/lab/pico-hid-fixture/README.md | 45 +++- .../src/fixture_visual_model.c | 131 ++++++++++ .../src/fixture_visual_model.h | 38 +++ .../pico-hid-fixture/src/fixture_wifi_model.c | 22 ++ .../pico-hid-fixture/src/fixture_wifi_model.h | 18 ++ .../main/CMakeLists.txt | 17 +- .../main/fixture_config.h.in | 11 +- .../main/fixture_display.c | 153 ++++------- .../main/fixture_http.c | 57 +++-- .../main/fixture_qemu_smoke.c | 12 +- .../main/fixture_runtime.c | 8 +- .../main/fixture_runtime.h | 4 +- .../tests/fixture_core_tests.c | 63 +++++ .../tests/run-esp32-qemu-smoke.sh | 8 +- .../lab/pico-hid-fixture/tests/run-tests.sh | 2 + Scripts/lab/tests/keypath-lab-tests.sh | 1 + .../lab/tests/pico-hid-fixture-tool-tests.py | 106 ++++++++ .../keypath-hid-fixture-first-board.md | 74 ++++++ 19 files changed, 874 insertions(+), 137 deletions(-) create mode 100755 Scripts/lab/pico-hid-fixture-tool create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.c create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.h create mode 100644 Scripts/lab/tests/pico-hid-fixture-tool-tests.py create mode 100644 docs/testing/keypath-hid-fixture-first-board.md diff --git a/Scripts/lab/pico-hid-fixture-tool b/Scripts/lab/pico-hid-fixture-tool new file mode 100755 index 000000000..32414c302 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture-tool @@ -0,0 +1,241 @@ +#!/bin/bash +set -euo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fixture_root="$script_dir/pico-hid-fixture" +target="$fixture_root/targets/waveshare-esp32-s3-touch-lcd-1.69" +idf_path=${KEYPATH_FIXTURE_IDF_PATH:-"$HOME/.cache/keypath-esp32/esp-idf"} +build_dir=${KEYPATH_FIXTURE_BUILD_DIR:-"$HOME/.cache/keypath-esp32/keypath-fixture-build"} +sdkconfig=${KEYPATH_FIXTURE_SDKCONFIG:-"$HOME/.cache/keypath-esp32/keypath-fixture-sdkconfig"} +secrets_file=${KEYPATH_FIXTURE_SECRETS_FILE:-"$HOME/dotfiles/secrets.env"} +device_dir=${KEYPATH_FIXTURE_DEVICE_DIR:-/dev} +add_secret_app=${KEYPATH_FIXTURE_ADD_SECRET_APP:-"/Applications/Add Secret.app"} + +required_secrets="KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 KEYPATH_WIFI_SSID_2 KEYPATH_WIFI_PASSWORD_2 KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 KEYPATH_FIXTURE_TOKEN" + +usage() { + printf '%s\n' \ + "Usage: Scripts/lab/pico-hid-fixture-tool COMMAND [OPTIONS]" \ + "" \ + "Commands:" \ + " configure Securely add any missing fixture credentials" \ + " doctor Check toolchain, credentials, and connected board" \ + " test Run host, campaign, and ESP32 QEMU tests" \ + " build Build production firmware with real credentials" \ + " flash [--port DEV] Build and flash the connected Waveshare board" \ + " install [--port DEV] Run checks, tests, build, flash, and health check" \ + " status Query the running fixture over Wi-Fi" \ + "" \ + "The fixture token is read from the environment or sops; never pass it on the command line." +} + +fail() { + printf 'fixture-tool: %s\n' "$*" >&2 + exit 1 +} + +have_secret() { + secret_name=$1 + secret_value=$(printenv "$secret_name" 2>/dev/null || true) + [[ -n "$secret_value" ]] +} + +load_credentials() { + missing=0 + for secret_name in $required_secrets; do + if ! have_secret "$secret_name"; then + missing=1 + break + fi + done + if [[ "$missing" -eq 0 ]]; then + return + fi + command -v sops >/dev/null 2>&1 || fail "sops is required to read $secrets_file" + [[ -f "$secrets_file" ]] || fail "encrypted secrets file is missing: $secrets_file" + while IFS='=' read -r secret_name secret_value; do + case "$secret_name" in + KEYPATH_WIFI_SSID_1|KEYPATH_WIFI_PASSWORD_1|KEYPATH_WIFI_SSID_2|KEYPATH_WIFI_PASSWORD_2|KEYPATH_WIFI_SSID_3|KEYPATH_WIFI_PASSWORD_3|KEYPATH_FIXTURE_TOKEN) + printf -v "$secret_name" '%s' "$secret_value" + export "$secret_name" + ;; + esac + done < <(sops -d "$secrets_file") +} + +validate_credentials() { + load_credentials + for secret_name in $required_secrets; do + have_secret "$secret_name" || fail "$secret_name is not configured; run '$0 configure'" + secret_value=$(printenv "$secret_name") + case "$secret_value" in + *placeholder*|*PLACEHOLDER*) fail "$secret_name still contains a placeholder" ;; + esac + done + [[ ${#KEYPATH_FIXTURE_TOKEN} -ge 16 ]] || fail "KEYPATH_FIXTURE_TOKEN must contain at least 16 characters" +} + +idf_activate() { + [[ -f "$idf_path/export.sh" ]] || fail "ESP-IDF is missing at $idf_path" + # shellcheck disable=SC1090 + source "$idf_path/export.sh" >/dev/null +} + +port_candidates() { + find "$device_dir" -maxdepth 1 -type c \( \ + -name 'cu.usbmodem*' -o -name 'cu.wchusbserial*' -o -name 'cu.SLAB_USBtoUART*' \ + \) -print 2>/dev/null | sort +} + +detect_port() { + requested=${1:-} + if [[ -n "$requested" ]]; then + [[ -c "$requested" ]] || fail "serial device does not exist: $requested" + printf '%s\n' "$requested" + return + fi + candidates=$(port_candidates) + count=$(printf '%s\n' "$candidates" | awk 'NF { count++ } END { print count + 0 }') + if [[ "$count" -eq 0 ]]; then + fail "no ESP32 serial port found; connect the board, hold BOOT, tap RESET, release BOOT, then retry" + fi + if [[ "$count" -gt 1 ]]; then + printf 'fixture-tool: multiple serial devices found:\n%s\n' "$candidates" >&2 + fail "retry with --port DEVICE" + fi + printf '%s\n' "$candidates" +} + +configure_secret() { + secret_name=$1 + [[ -d "$add_secret_app" ]] || fail "Add Secret.app is missing: $add_secret_app" + printf 'Add %s in the secure window, then press Enter.\n' "$secret_name" + open -W -n "$add_secret_app" --args --key "$secret_name" + result_file="$HOME/.config/add-secret/result.json" + [[ -f "$result_file" ]] || fail "Add Secret.app did not write a result" + python3 - "$result_file" "$secret_name" <<'PY' +import json +import pathlib +import sys + +result = json.loads(pathlib.Path(sys.argv[1]).read_text()) +if result.get("status") != "success" or result.get("key") != sys.argv[2]: + raise SystemExit(f"Add Secret.app did not save {sys.argv[2]}: {result.get('status', 'unknown')}") +PY +} + +command_configure() { + for secret_name in $required_secrets; do + if grep -q "^${secret_name}=" "$secrets_file" 2>/dev/null; then + printf 'configured %s\n' "$secret_name" + else + configure_secret "$secret_name" + fi + done + validate_credentials + printf 'credentials configured securely\n' +} + +command_doctor() { + host_os=${KEYPATH_FIXTURE_HOST_OS:-$(uname -s)} + [[ "$host_os" == Darwin ]] || fail "flashing is supported from macOS" + [[ -d "$target" ]] || fail "firmware target is missing: $target" + [[ -f "$idf_path/export.sh" ]] || fail "ESP-IDF 5.5 toolchain is missing: $idf_path" + command -v cmake >/dev/null 2>&1 || fail "cmake is missing" + command -v ninja >/dev/null 2>&1 || fail "ninja is missing" + command -v python3 >/dev/null 2>&1 || fail "python3 is missing" + validate_credentials + printf 'ok macOS host\n' + printf 'ok ESP-IDF toolchain\n' + printf 'ok production credentials (values hidden)\n' + candidates=$(port_candidates) + if [[ -n "$candidates" ]]; then + printf 'ok serial device\n%s\n' "$candidates" + else + printf 'wait board not connected (expected until hardware arrives)\n' + fi +} + +command_test() { + "$fixture_root/tests/run-tests.sh" + python3 "$script_dir/tests/pico-hid-fixture-client-tests.py" + python3 "$script_dir/tests/pico-hid-fixture-tool-tests.py" + python3 "$script_dir/tests/scenario-matrix-runner-tests.py" + "$fixture_root/tests/run-esp32-qemu-smoke.sh" +} + +command_build() { + validate_credentials + idf_activate + idf.py -C "$target" -B "$build_dir" -D "SDKCONFIG=$sdkconfig" build + [[ -f "$build_dir/keypath_esp32_s3_hid_fixture.bin" ]] || fail "build completed without a firmware binary" + printf 'firmware %s\n' "$build_dir/keypath_esp32_s3_hid_fixture.bin" +} + +parse_port() { + selected_port= + while [[ $# -gt 0 ]]; do + case "$1" in + --port) + [[ $# -ge 2 ]] || fail "--port requires a device" + selected_port=$2 + shift 2 + ;; + *) fail "unknown option: $1" ;; + esac + done +} + +command_flash() { + parse_port "$@" + command_build + port=$(detect_port "$selected_port") + idf.py -C "$target" -B "$build_dir" -p "$port" flash + printf 'flashed %s\n' "$port" + printf 'next watch the display for JOINING LAB, then READY\n' +} + +command_status() { + validate_credentials + "$script_dir/pico-hid-fixture-client" status +} + +command_install() { + parse_port "$@" + command_doctor + command_test + if [[ -n "$selected_port" ]]; then + command_flash --port "$selected_port" + else + command_flash + fi + printf 'waiting for Wi-Fi health check' + attempt=0 + while [[ "$attempt" -lt 30 ]]; do + if command_status >/dev/null 2>&1; then + printf '\ninstallation verified over Wi-Fi\n' + command_status + return + fi + printf '.' + sleep 2 + attempt=$((attempt + 1)) + done + printf '\n' >&2 + fail "firmware flashed but Wi-Fi health check failed; read the on-screen state and run '$0 status'" +} + +command=${1:-} +[[ -n "$command" ]] || { usage; exit 2; } +shift +case "$command" in + configure) [[ $# -eq 0 ]] || fail "configure takes no options"; command_configure ;; + doctor) [[ $# -eq 0 ]] || fail "doctor takes no options"; command_doctor ;; + test) [[ $# -eq 0 ]] || fail "test takes no options"; command_test ;; + build) [[ $# -eq 0 ]] || fail "build takes no options"; command_build ;; + flash) command_flash "$@" ;; + install) command_install "$@" ;; + status) [[ $# -eq 0 ]] || fail "status takes no options"; command_status ;; + help|-h|--help) usage ;; + *) usage >&2; fail "unknown command: $command" ;; +esac diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 09f90a57f..e968cfb09 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -6,6 +6,32 @@ start, abort, and inspect locally timed HID scripts. The fixture is deliberately not part of any KeyPath product target. +## First board: one-command install from a Mac + +The checked-in `pico-hid-fixture-tool` owns setup, tests, builds, flashing, and the first Wi-Fi +health check. Before the board arrives, configure the three ordered Wi-Fi profiles and control +token without putting their values in a shell history or chat: + +```bash +Scripts/lab/pico-hid-fixture-tool configure +Scripts/lab/pico-hid-fixture-tool doctor +``` + +`configure` opens Add Secret.app once for each missing value and stores it through sops. A doctor +result of `wait board not connected` is healthy before the hardware arrives. When the board is +connected tomorrow, the complete path is: + +```bash +Scripts/lab/pico-hid-fixture-tool install +``` + +That command validates the Mac and credentials, runs the host and QEMU suites, builds production +firmware, detects an unambiguous serial port, flashes it, and waits for the authenticated Wi-Fi +status endpoint. If no port appears, hold **BOOT**, tap **RESET**, then release **BOOT** and rerun; +use `install --port /dev/cu...` only when more than one serial device is connected. The exact +hands-on checklist and failure routing are in +[`docs/testing/keypath-hid-fixture-first-board.md`](../../../docs/testing/keypath-hid-fixture-first-board.md). + ## Hardware targets - **Primary:** Waveshare ESP32-S3-Touch-LCD-1.69 (240×280 display, capacitive touch, buzzer, @@ -37,6 +63,9 @@ uses its onboard green LED instead. - On ESP32-S3, the HID scheduler owns core 1 at high priority. USB service, network control, sound, and display work remain on core 0. The motion governor drops the display from 30 to 20 to 8 FPS and removes expensive particle layers before animation can compete with HID timing. +- Production exposes HID only—no USB serial console—so the VM observes the same device shape used + by the test. The screen reports Wi-Fi/IP/USB state, while authenticated `/v1/status` and + `/v1/trace` provide the deeper diagnostics and exact firmware build identifier. ## Pico LED states @@ -50,7 +79,7 @@ uses its onboard green LED instead. | Complete | repeating triple blink | | Error | rapid blink | -## Waveshare ESP32-S3 build +## Waveshare ESP32-S3 manual build ESP-IDF 5.5.5 and its Python tools are installed locally under `~/.cache/keypath-esp32/esp-idf`. The target uses Waveshare's official board component and LVGL. @@ -58,8 +87,12 @@ Build credentials are supplied only through the environment: ```bash source ~/.cache/keypath-esp32/esp-idf/export.sh -export KEYPATH_WIFI_SSID='lab-network' -export KEYPATH_WIFI_PASSWORD='...' +export KEYPATH_WIFI_SSID_1='primary-network' +export KEYPATH_WIFI_PASSWORD_1='...' +export KEYPATH_WIFI_SSID_2='fallback-network-one' +export KEYPATH_WIFI_PASSWORD_2='...' +export KEYPATH_WIFI_SSID_3='fallback-network-two' +export KEYPATH_WIFI_PASSWORD_3='...' export KEYPATH_FIXTURE_TOKEN='at-least-16-random-characters' idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 build ``` @@ -72,6 +105,9 @@ older revision. Flashing waits for the physical board: idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 -p PORT flash ``` +Prefer `pico-hid-fixture-tool install` for normal use. The manual commands remain documented for +toolchain debugging and CI reproduction. + The on-device scene is continuously alive: a breathing reactor, orbiting HID packets, a live progress arc, and time-based motion remain smooth even when the frame rate changes. Preparing, countdown, typing, observation, resolution, pass, fail, and inconclusive phases morph through @@ -178,7 +214,8 @@ Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh These tests cover CRC and script admission, timing/repeat execution, trace ordering, lateness metrics, boot/abort/unmount releases, US-keyboard compilation, bearer authentication, endpoint -selection, NDJSON trace decoding, and the adaptive UI model. The QEMU test boots an ESP32-S3 image +selection, secure installer behavior, presentation/result resolution, NDJSON trace decoding, and +the adaptive UI model. The QEMU test boots an ESP32-S3 image and executes the real parser, scheduler, trace logic, and UI state model on the emulated Xtensa cores. QEMU does not emulate the Waveshare LCD/touch/buzzer or the ESP32-S3 native USB device controller, so the physical USB/VM, display, touch, sound, and timing acceptance checks still wait diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c new file mode 100644 index 000000000..dbc5e56de --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c @@ -0,0 +1,131 @@ +#include "fixture_visual_model.h" + +#include + +static const char *scene_title(fixture_ui_scene_t scene) { + switch (scene) { + case FIXTURE_UI_BOOT: return "WAKING UP"; + case FIXTURE_UI_CONNECTING: return "JOINING LAB"; + case FIXTURE_UI_IDLE: return "READY"; + case FIXTURE_UI_LOADED: return "SCRIPT LOADED"; + case FIXTURE_UI_ARMED: return "ARMED"; + case FIXTURE_UI_RUNNING: return "TYPING"; + case FIXTURE_UI_COMPLETE: return "RUN COMPLETE"; + case FIXTURE_UI_ABORTED: return "RUN STOPPED"; + case FIXTURE_UI_ERROR: return "ATTENTION"; + } + return "UNKNOWN"; +} + +static fixture_visual_icon_t scene_icon(fixture_ui_scene_t scene) { + switch (scene) { + case FIXTURE_UI_BOOT: return FIXTURE_ICON_POWER; + case FIXTURE_UI_CONNECTING: return FIXTURE_ICON_WIFI; + case FIXTURE_UI_IDLE: return FIXTURE_ICON_KEYBOARD; + case FIXTURE_UI_LOADED: return FIXTURE_ICON_DOWNLOAD; + case FIXTURE_UI_ARMED: return FIXTURE_ICON_WARNING; + case FIXTURE_UI_RUNNING: return FIXTURE_ICON_PLAY; + case FIXTURE_UI_COMPLETE: return FIXTURE_ICON_OK; + case FIXTURE_UI_ABORTED: return FIXTURE_ICON_STOP; + case FIXTURE_UI_ERROR: return FIXTURE_ICON_CLOSE; + } + return FIXTURE_ICON_WARNING; +} + +static uint32_t scene_accent(fixture_ui_scene_t scene) { + switch (scene) { + case FIXTURE_UI_RUNNING: return 0x55c7ffu; + case FIXTURE_UI_COMPLETE: return 0x44d7a8u; + case FIXTURE_UI_ARMED: return 0xffb454u; + case FIXTURE_UI_ABORTED: return 0x9c7cffu; + case FIXTURE_UI_ERROR: return 0xff5c72u; + default: return 0x56ddb3u; + } +} + +static void apply_phase(const fixture_presentation_t *presentation, + fixture_visual_output_t *visual, const char **title) { + switch (presentation->phase) { + case FIXTURE_PRESENT_PREPARING: + visual->icon = FIXTURE_ICON_REFRESH; + visual->accent_rgb = 0x9c7cffu; + *title = "PREPARING"; + break; + case FIXTURE_PRESENT_COUNTDOWN: + visual->icon = FIXTURE_ICON_BELL; + visual->accent_rgb = 0xffb454u; + visual->angular_speed_milliradians = 2600u; + *title = "STAND BY"; + break; + case FIXTURE_PRESENT_TESTING: + visual->icon = FIXTURE_ICON_KEYBOARD; + visual->accent_rgb = 0x55c7ffu; + visual->angular_speed_milliradians = 4800u; + *title = "SENDING KEYS"; + break; + case FIXTURE_PRESENT_OBSERVING: + visual->icon = FIXTURE_ICON_EYE; + visual->accent_rgb = 0xb58cffu; + visual->angular_speed_milliradians = 1100u; + *title = "OBSERVING"; + break; + case FIXTURE_PRESENT_RESOLVING: + visual->icon = FIXTURE_ICON_SETTINGS; + visual->accent_rgb = 0x36d8d0u; + *title = "RESOLVING"; + break; + case FIXTURE_PRESENT_RESULT: + visual->icon = FIXTURE_ICON_OK; + *title = "RESULT"; + break; + case FIXTURE_PRESENT_NEXT: + visual->icon = FIXTURE_ICON_NEXT; + visual->accent_rgb = 0x55c7ffu; + *title = "UP NEXT"; + break; + case FIXTURE_PRESENT_AUTO: + break; + } +} + +static void apply_result(fixture_result_t result, fixture_visual_output_t *visual, + const char **title) { + switch (result) { + case FIXTURE_RESULT_PASS: + visual->icon = FIXTURE_ICON_OK; + visual->accent_rgb = 0x44d7a8u; + *title = "TEST PASSED"; + break; + case FIXTURE_RESULT_FAIL: + visual->icon = FIXTURE_ICON_CLOSE; + visual->accent_rgb = 0xff5c72u; + *title = "TEST FAILED"; + break; + case FIXTURE_RESULT_INCONCLUSIVE: + visual->icon = FIXTURE_ICON_WARNING; + visual->accent_rgb = 0xffb454u; + *title = "NEEDS REVIEW"; + break; + case FIXTURE_RESULT_NONE: + break; + } +} + +void fixture_visual_resolve(const fixture_ui_output_t *ui, + const fixture_presentation_t *presentation, + fixture_visual_output_t *visual) { + const char *title = scene_title(ui->scene); + visual->icon = scene_icon(ui->scene); + visual->accent_rgb = scene_accent(ui->scene); + visual->progress_per_mille = ui->progress_per_mille; + visual->angular_speed_milliradians = ui->scene == FIXTURE_UI_RUNNING ? 4200u : 1550u; + + if (presentation->phase != FIXTURE_PRESENT_AUTO) { + visual->progress_per_mille = presentation->progress_per_mille; + apply_phase(presentation, visual, &title); + if (presentation->title[0]) title = presentation->title; + } + apply_result(presentation->result, visual, &title); + if (ui->quality == FIXTURE_UI_PROTECTED) visual->angular_speed_milliradians = 480u; + snprintf(visual->title, sizeof(visual->title), "%s", title); +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h new file mode 100644 index 000000000..625d32159 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h @@ -0,0 +1,38 @@ +#ifndef KEYPATH_FIXTURE_VISUAL_MODEL_H +#define KEYPATH_FIXTURE_VISUAL_MODEL_H + +#include + +#include "fixture_presentation.h" +#include "fixture_ui_model.h" + +typedef enum { + FIXTURE_ICON_POWER = 0, + FIXTURE_ICON_WIFI, + FIXTURE_ICON_KEYBOARD, + FIXTURE_ICON_DOWNLOAD, + FIXTURE_ICON_WARNING, + FIXTURE_ICON_PLAY, + FIXTURE_ICON_OK, + FIXTURE_ICON_STOP, + FIXTURE_ICON_CLOSE, + FIXTURE_ICON_REFRESH, + FIXTURE_ICON_BELL, + FIXTURE_ICON_EYE, + FIXTURE_ICON_SETTINGS, + FIXTURE_ICON_NEXT, +} fixture_visual_icon_t; + +typedef struct { + fixture_visual_icon_t icon; + uint32_t accent_rgb; + uint16_t progress_per_mille; + uint16_t angular_speed_milliradians; + char title[33]; +} fixture_visual_output_t; + +void fixture_visual_resolve(const fixture_ui_output_t *ui, + const fixture_presentation_t *presentation, + fixture_visual_output_t *visual); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.c new file mode 100644 index 000000000..b8b8db430 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.c @@ -0,0 +1,22 @@ +#include "fixture_wifi_model.h" + +void fixture_wifi_model_init(fixture_wifi_model_t *model) { + model->profile_index = 0u; + model->failed_attempts = 0u; +} + +bool fixture_wifi_model_note_disconnect(fixture_wifi_model_t *model, + size_t profile_count, + unsigned int attempts_per_profile) { + if (profile_count == 0u || attempts_per_profile == 0u) return false; + model->failed_attempts++; + if (model->failed_attempts < attempts_per_profile) return false; + + model->failed_attempts = 0u; + model->profile_index = (model->profile_index + 1u) % profile_count; + return true; +} + +void fixture_wifi_model_note_connected(fixture_wifi_model_t *model) { + model->failed_attempts = 0u; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.h new file mode 100644 index 000000000..de5623190 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.h @@ -0,0 +1,18 @@ +#ifndef KEYPATH_FIXTURE_WIFI_MODEL_H +#define KEYPATH_FIXTURE_WIFI_MODEL_H + +#include +#include + +typedef struct { + size_t profile_index; + unsigned int failed_attempts; +} fixture_wifi_model_t; + +void fixture_wifi_model_init(fixture_wifi_model_t *model); +bool fixture_wifi_model_note_disconnect(fixture_wifi_model_t *model, + size_t profile_count, + unsigned int attempts_per_profile); +void fixture_wifi_model_note_connected(fixture_wifi_model_t *model); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt index f64faac78..9f9b14a41 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt @@ -1,4 +1,8 @@ -foreach(secret_name KEYPATH_WIFI_SSID KEYPATH_WIFI_PASSWORD KEYPATH_FIXTURE_TOKEN) +foreach(secret_name + KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 + KEYPATH_WIFI_SSID_2 KEYPATH_WIFI_PASSWORD_2 + KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 + KEYPATH_FIXTURE_TOKEN) if(NOT DEFINED ENV{${secret_name}} OR "$ENV{${secret_name}}" STREQUAL "") message(FATAL_ERROR "${secret_name} must be supplied in the build environment") endif() @@ -14,6 +18,15 @@ if(fixture_token_length LESS 16) endif() get_filename_component(component_dir "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) +execute_process( + COMMAND git -C "${component_dir}" rev-parse --short=12 HEAD + OUTPUT_VARIABLE KEYPATH_FIXTURE_BUILD_ID + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET +) +if(KEYPATH_FIXTURE_BUILD_ID STREQUAL "") + set(KEYPATH_FIXTURE_BUILD_ID "unknown") +endif() set(shared_source "${component_dir}/../../../src") set(generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated") file(MAKE_DIRECTORY "${generated_dir}") @@ -30,6 +43,8 @@ idf_component_register( "${shared_source}/fixture_core.c" "${shared_source}/fixture_presentation.c" "${shared_source}/fixture_ui_model.c" + "${shared_source}/fixture_visual_model.c" + "${shared_source}/fixture_wifi_model.c" INCLUDE_DIRS "." "${shared_source}" diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in index b643e0e7d..e930c86cd 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in @@ -1,9 +1,14 @@ #ifndef KEYPATH_ESP32_FIXTURE_CONFIG_H #define KEYPATH_ESP32_FIXTURE_CONFIG_H -#define KEYPATH_WIFI_SSID "@KEYPATH_WIFI_SSID_ESCAPED@" -#define KEYPATH_WIFI_PASSWORD "@KEYPATH_WIFI_PASSWORD_ESCAPED@" +#define KEYPATH_WIFI_SSID_1 "@KEYPATH_WIFI_SSID_1_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD_1 "@KEYPATH_WIFI_PASSWORD_1_ESCAPED@" +#define KEYPATH_WIFI_SSID_2 "@KEYPATH_WIFI_SSID_2_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD_2 "@KEYPATH_WIFI_PASSWORD_2_ESCAPED@" +#define KEYPATH_WIFI_SSID_3 "@KEYPATH_WIFI_SSID_3_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD_3 "@KEYPATH_WIFI_PASSWORD_3_ESCAPED@" #define KEYPATH_FIXTURE_TOKEN "@KEYPATH_FIXTURE_TOKEN_ESCAPED@" -#define KEYPATH_FIXTURE_FIRMWARE_VERSION "0.2.0-esp32s3" +#define KEYPATH_FIXTURE_FIRMWARE_VERSION "0.3.0-esp32s3" +#define KEYPATH_FIXTURE_BUILD_ID "@KEYPATH_FIXTURE_BUILD_ID@" #endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c index af04e1ea9..4537071f2 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c @@ -8,6 +8,7 @@ #include "esp_timer.h" #include "fixture_board.h" #include "fixture_runtime.h" +#include "fixture_visual_model.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "lvgl.h" @@ -42,95 +43,24 @@ typedef struct { static display_ui_t ui; -static const char *scene_name(fixture_ui_scene_t scene) { - switch (scene) { - case FIXTURE_UI_BOOT: return "WAKING UP"; - case FIXTURE_UI_CONNECTING: return "JOINING LAB"; - case FIXTURE_UI_IDLE: return "READY"; - case FIXTURE_UI_LOADED: return "SCRIPT LOADED"; - case FIXTURE_UI_ARMED: return "ARMED"; - case FIXTURE_UI_RUNNING: return "TYPING"; - case FIXTURE_UI_COMPLETE: return "RUN COMPLETE"; - case FIXTURE_UI_ABORTED: return "RUN STOPPED"; - case FIXTURE_UI_ERROR: return "ATTENTION"; - } - return "UNKNOWN"; -} - -static lv_color_t accent_for(fixture_ui_scene_t scene) { - switch (scene) { - case FIXTURE_UI_RUNNING: return lv_color_hex(0x55c7ff); - case FIXTURE_UI_COMPLETE: return lv_color_hex(0x44d7a8); - case FIXTURE_UI_ARMED: return lv_color_hex(0xffb454); - case FIXTURE_UI_ABORTED: return lv_color_hex(0x9c7cff); - case FIXTURE_UI_ERROR: return lv_color_hex(0xff5c72); - default: return lv_color_hex(0x56ddb3); - } -} - -static lv_color_t presentation_accent(const fixture_presentation_t *presentation, - fixture_ui_scene_t scene) { - if (presentation->result == FIXTURE_RESULT_PASS) return lv_color_hex(0x44d7a8); - if (presentation->result == FIXTURE_RESULT_FAIL) return lv_color_hex(0xff5c72); - if (presentation->result == FIXTURE_RESULT_INCONCLUSIVE) return lv_color_hex(0xffb454); - switch (presentation->phase) { - case FIXTURE_PRESENT_PREPARING: return lv_color_hex(0x9c7cff); - case FIXTURE_PRESENT_COUNTDOWN: return lv_color_hex(0xffb454); - case FIXTURE_PRESENT_TESTING: return lv_color_hex(0x55c7ff); - case FIXTURE_PRESENT_OBSERVING: return lv_color_hex(0xb58cff); - case FIXTURE_PRESENT_RESOLVING: return lv_color_hex(0x36d8d0); - case FIXTURE_PRESENT_NEXT: return lv_color_hex(0x55c7ff); - default: return accent_for(scene); - } -} - -static int visual_stage(const fixture_presentation_t *presentation, fixture_ui_scene_t scene) { - if (presentation->result != FIXTURE_RESULT_NONE) return 32 + (int)presentation->result; - if (presentation->phase != FIXTURE_PRESENT_AUTO) return 16 + (int)presentation->phase; - return (int)scene; -} - -static const char *symbol_for(int stage) { - if (stage == 33) return LV_SYMBOL_OK; - if (stage == 34) return LV_SYMBOL_CLOSE; - if (stage == 35) return LV_SYMBOL_WARNING; - switch (stage) { - case FIXTURE_UI_BOOT: return LV_SYMBOL_POWER; - case FIXTURE_UI_CONNECTING: return LV_SYMBOL_WIFI; - case FIXTURE_UI_IDLE: return LV_SYMBOL_KEYBOARD; - case FIXTURE_UI_LOADED: return LV_SYMBOL_DOWNLOAD; - case FIXTURE_UI_ARMED: return LV_SYMBOL_WARNING; - case FIXTURE_UI_RUNNING: return LV_SYMBOL_PLAY; - case FIXTURE_UI_COMPLETE: return LV_SYMBOL_OK; - case FIXTURE_UI_ABORTED: return LV_SYMBOL_STOP; - case FIXTURE_UI_ERROR: return LV_SYMBOL_CLOSE; - case 17: return LV_SYMBOL_REFRESH; - case 18: return LV_SYMBOL_BELL; - case 19: return LV_SYMBOL_KEYBOARD; - case 20: return LV_SYMBOL_EYE_OPEN; - case 21: return LV_SYMBOL_SETTINGS; - case 22: return LV_SYMBOL_OK; - case 23: return LV_SYMBOL_NEXT; - default: return LV_SYMBOL_POWER; - } -} - -static const char *presentation_title(const fixture_presentation_t *presentation, - fixture_ui_scene_t scene) { - if (presentation->result == FIXTURE_RESULT_PASS) return "TEST PASSED"; - if (presentation->result == FIXTURE_RESULT_FAIL) return "TEST FAILED"; - if (presentation->result == FIXTURE_RESULT_INCONCLUSIVE) return "NEEDS REVIEW"; - if (presentation->title[0]) return presentation->title; - switch (presentation->phase) { - case FIXTURE_PRESENT_PREPARING: return "PREPARING"; - case FIXTURE_PRESENT_COUNTDOWN: return "STAND BY"; - case FIXTURE_PRESENT_TESTING: return "SENDING KEYS"; - case FIXTURE_PRESENT_OBSERVING: return "OBSERVING"; - case FIXTURE_PRESENT_RESOLVING: return "RESOLVING"; - case FIXTURE_PRESENT_RESULT: return "RESULT"; - case FIXTURE_PRESENT_NEXT: return "UP NEXT"; - default: return scene_name(scene); +static const char *symbol_for(fixture_visual_icon_t icon) { + switch (icon) { + case FIXTURE_ICON_POWER: return LV_SYMBOL_POWER; + case FIXTURE_ICON_WIFI: return LV_SYMBOL_WIFI; + case FIXTURE_ICON_KEYBOARD: return LV_SYMBOL_KEYBOARD; + case FIXTURE_ICON_DOWNLOAD: return LV_SYMBOL_DOWNLOAD; + case FIXTURE_ICON_WARNING: return LV_SYMBOL_WARNING; + case FIXTURE_ICON_PLAY: return LV_SYMBOL_PLAY; + case FIXTURE_ICON_OK: return LV_SYMBOL_OK; + case FIXTURE_ICON_STOP: return LV_SYMBOL_STOP; + case FIXTURE_ICON_CLOSE: return LV_SYMBOL_CLOSE; + case FIXTURE_ICON_REFRESH: return LV_SYMBOL_REFRESH; + case FIXTURE_ICON_BELL: return LV_SYMBOL_BELL; + case FIXTURE_ICON_EYE: return LV_SYMBOL_EYE_OPEN; + case FIXTURE_ICON_SETTINGS: return LV_SYMBOL_SETTINGS; + case FIXTURE_ICON_NEXT: return LV_SYMBOL_NEXT; } + return LV_SYMBOL_WARNING; } static lv_obj_t *make_circle(lv_obj_t *parent, int size, lv_color_t color, lv_opa_t opacity) { @@ -287,17 +217,16 @@ static void announce_result(fixture_result_t result) { } static void render(const fixture_ui_output_t *output, - const fixture_presentation_t *presentation, uint64_t now_ms) { - lv_color_t accent = presentation_accent(presentation, output->scene); + const fixture_presentation_t *presentation, + const char *automatic_detail, uint64_t now_ms) { + fixture_visual_output_t visual; + fixture_visual_resolve(output, presentation, &visual); + lv_color_t accent = lv_color_hex(visual.accent_rgb); int energy = output->energy_per_mille; uint64_t elapsed_ms = ui.previous_render_ms && now_ms > ui.previous_render_ms ? now_ms - ui.previous_render_ms : output->frame_interval_ms; if (elapsed_ms > 250u) elapsed_ms = 250u; - float speed = output->scene == FIXTURE_UI_RUNNING ? 4.2f : 1.55f; - if (presentation->phase == FIXTURE_PRESENT_COUNTDOWN) speed = 2.6f; - if (presentation->phase == FIXTURE_PRESENT_TESTING) speed = 4.8f; - if (presentation->phase == FIXTURE_PRESENT_OBSERVING) speed = 1.1f; - if (output->quality == FIXTURE_UI_PROTECTED) speed = 0.48f; + float speed = (float)visual.angular_speed_milliradians / 1000.0f; #if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION speed = 0.0f; #endif @@ -316,20 +245,20 @@ static void render(const fixture_ui_output_t *output, lv_obj_set_style_bg_color(ui.screen, output->scene == FIXTURE_UI_ERROR ? lv_color_hex(0x190b13) : lv_color_hex(0x071117), 0); - lv_label_set_text(ui.state, presentation_title(presentation, output->scene)); + lv_label_set_text(ui.state, visual.title); lv_obj_set_style_text_color(ui.state, accent, 0); lv_obj_set_style_arc_color(ui.orbit, accent, LV_PART_INDICATOR); lv_obj_set_style_arc_color(ui.progress, accent, LV_PART_INDICATOR); lv_obj_set_style_border_color(ui.core, accent, 0); - uint16_t progress = presentation->phase == FIXTURE_PRESENT_AUTO - ? output->progress_per_mille : presentation->progress_per_mille; - lv_arc_set_value(ui.progress, progress); - - int stage = visual_stage(presentation, output->scene); - if (stage != ui.visual_stage) { - lv_label_set_text(ui.icon_back, symbol_for(ui.visual_stage)); - lv_label_set_text(ui.icon_front, symbol_for(stage)); - ui.visual_stage = stage; + lv_arc_set_value(ui.progress, visual.progress_per_mille); + + if ((int)visual.icon != ui.visual_stage) { + fixture_visual_icon_t previous = ui.visual_stage < 0 + ? FIXTURE_ICON_POWER + : (fixture_visual_icon_t)ui.visual_stage; + lv_label_set_text(ui.icon_back, symbol_for(previous)); + lv_label_set_text(ui.icon_front, symbol_for(visual.icon)); + ui.visual_stage = (int)visual.icon; ui.icon_transition_started_ms = now_ms; } uint64_t transition_elapsed = now_ms - ui.icon_transition_started_ms; @@ -397,7 +326,7 @@ static void render(const fixture_ui_output_t *output, } else if (presentation->phase == FIXTURE_PRESENT_NEXT && presentation->next[0]) { lv_label_set_text(ui.detail, presentation->next); } else { - lv_label_set_text_static(ui.detail, "USB + Wi-Fi control"); + lv_label_set_text(ui.detail, automatic_detail); } if (presentation->result != FIXTURE_RESULT_NONE && presentation->reports_expected > 0u) { @@ -437,12 +366,22 @@ static void display_task(void *context) { fixture_runtime_snapshot(&snapshot); uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); fixture_ui_output_t output = fixture_ui_model_step(&model, &snapshot.ui, now_ms); + char automatic_detail[64]; + if (snapshot.ui.state == FIXTURE_ERROR && snapshot.error[0]) { + snprintf(automatic_detail, sizeof(automatic_detail), "%.63s", snapshot.error); + } else if (!snapshot.ui.wifi_connected) { + snprintf(automatic_detail, sizeof(automatic_detail), "Trying %.32s", snapshot.network_name); + } else { + snprintf(automatic_detail, sizeof(automatic_detail), "%s USB %s", + snapshot.network_address, + snapshot.ui.usb_mounted ? "READY" : "WAIT"); + } if (output.completion_burst) ui.completion_started_ms = now_ms; announce_transition(&output); announce_result(snapshot.presentation.result); fixture_board_update(snapshot.ui.state == FIXTURE_ARMED || snapshot.ui.state == FIXTURE_RUNNING); if (bsp_display_lock(20u)) { - render(&output, &snapshot.presentation, now_ms); + render(&output, &snapshot.presentation, automatic_detail, now_ms); bsp_display_unlock(); } vTaskDelay(pdMS_TO_TICKS(output.frame_interval_ms)); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c index 6cb514f83..0cb93334d 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -14,6 +14,7 @@ #include "esp_wifi.h" #include "fixture_config.h" #include "fixture_runtime.h" +#include "fixture_wifi_model.h" #include "freertos/FreeRTOS.h" #include "mdns.h" #include "nvs_flash.h" @@ -24,6 +25,18 @@ static const char *TAG = "fixture_network"; static char *script_buffer; static httpd_handle_t http_server; +static fixture_wifi_model_t wifi_model; + +typedef struct { + const char *ssid; + const char *password; +} wifi_profile_t; + +static const wifi_profile_t wifi_profiles[] = { + {KEYPATH_WIFI_SSID_1, KEYPATH_WIFI_PASSWORD_1}, + {KEYPATH_WIFI_SSID_2, KEYPATH_WIFI_PASSWORD_2}, + {KEYPATH_WIFI_SSID_3, KEYPATH_WIFI_PASSWORD_3}, +}; static bool authorized(httpd_req_t *request) { char value[192]; @@ -50,29 +63,28 @@ static esp_err_t status_handler(httpd_req_t *request) { if (require_auth(request) != ESP_OK) return ESP_OK; fixture_runtime_snapshot_t snapshot; fixture_runtime_snapshot(&snapshot); - bool wifi_connected; - char address[48]; - fixture_runtime_network_snapshot(&wifi_connected, address, sizeof(address)); char body[1792]; snprintf(body, sizeof(body), - "{\"ok\":true,\"firmware\":\"%s\",\"platform\":\"waveshare-esp32-s3-touch-lcd-1.69\"," + "{\"ok\":true,\"firmware\":\"%s\",\"build\":\"%s\"," + "\"platform\":\"waveshare-esp32-s3-touch-lcd-1.69\"," "\"state\":\"%s\",\"runId\":\"%s\",\"scriptCRC32\":\"%08" PRIx32 "\"," "\"eventCount\":%" PRIu32 ",\"repeatCount\":%" PRIu32 ",\"currentRepeat\":%" PRIu32 "," "\"reportsSubmitted\":%" PRIu64 ",\"transfersCompleted\":%" PRIu64 "," "\"lateReports\":%" PRIu64 ",\"maximumLatenessUs\":%" PRId64 "," "\"submittedCRC32\":\"%08" PRIx32 "\",\"usbMounted\":%s," - "\"wifiConnected\":%s,\"address\":\"%s\",\"error\":\"%s\"," + "\"wifiConnected\":%s,\"address\":\"%s\",\"network\":\"%s\",\"error\":\"%s\"," "\"presentation\":{\"phase\":\"%s\",\"result\":\"%s\",\"progress\":%u," "\"title\":\"%s\",\"detail\":\"%s\",\"next\":\"%s\"," "\"reportsExpected\":%" PRIu32 ",\"reportsObserved\":%" PRIu32 "," "\"dropped\":%" PRIu32 ",\"duplicated\":%" PRIu32 ",\"repeated\":%" PRIu32 "," "\"latencyP95Us\":%" PRIu32 ",\"safeRelease\":%s}}\n", - KEYPATH_FIXTURE_FIRMWARE_VERSION, fixture_state_name(snapshot.ui.state), snapshot.run_id, + KEYPATH_FIXTURE_FIRMWARE_VERSION, KEYPATH_FIXTURE_BUILD_ID, + fixture_state_name(snapshot.ui.state), snapshot.run_id, snapshot.script_crc32, snapshot.ui.event_count, snapshot.ui.repeat_count, snapshot.ui.current_repeat, snapshot.ui.reports_submitted, snapshot.transfers_completed, snapshot.ui.late_reports, snapshot.ui.maximum_lateness_us, snapshot.submitted_crc32, - snapshot.ui.usb_mounted ? "true" : "false", wifi_connected ? "true" : "false", - address, snapshot.error, + snapshot.ui.usb_mounted ? "true" : "false", snapshot.ui.wifi_connected ? "true" : "false", + snapshot.network_address, snapshot.network_name, snapshot.error, fixture_presentation_phase_name(snapshot.presentation.phase), fixture_result_name(snapshot.presentation.result), snapshot.presentation.progress_per_mille, snapshot.presentation.title, snapshot.presentation.detail, snapshot.presentation.next, @@ -285,18 +297,38 @@ static esp_err_t start_http_server(void) { return ESP_OK; } +static esp_err_t select_wifi_profile(size_t index) { + wifi_model.profile_index = index % (sizeof(wifi_profiles) / sizeof(wifi_profiles[0])); + const wifi_profile_t *profile = &wifi_profiles[wifi_model.profile_index]; + wifi_config_t wifi = {0}; + snprintf((char *)wifi.sta.ssid, sizeof(wifi.sta.ssid), "%s", profile->ssid); + snprintf((char *)wifi.sta.password, sizeof(wifi.sta.password), "%s", profile->password); + wifi.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + wifi.sta.pmf_cfg.capable = true; + wifi.sta.pmf_cfg.required = false; + fixture_runtime_set_network_name(profile->ssid); + ESP_LOGI(TAG, "selecting Wi-Fi profile %u", (unsigned int)(wifi_model.profile_index + 1u)); + return esp_wifi_set_config(WIFI_IF_STA, &wifi); +} + static void wifi_event(void *argument, esp_event_base_t base, int32_t id, void *data) { (void)argument; if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { esp_wifi_connect(); } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { fixture_runtime_set_network(false, "unassigned"); + if (fixture_wifi_model_note_disconnect(&wifi_model, + sizeof(wifi_profiles) / sizeof(wifi_profiles[0]), + 2u)) { + ESP_ERROR_CHECK(select_wifi_profile(wifi_model.profile_index)); + } esp_wifi_connect(); } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t *event = data; char address[16]; snprintf(address, sizeof(address), IPSTR, IP2STR(&event->ip_info.ip)); fixture_runtime_set_network(true, address); + fixture_wifi_model_note_connected(&wifi_model); start_http_server(); } } @@ -316,16 +348,11 @@ esp_err_t fixture_network_start(void) { wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT(); ESP_RETURN_ON_ERROR(esp_wifi_init(&init), TAG, "Wi-Fi initialization failed"); + fixture_wifi_model_init(&wifi_model); ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event, NULL)); ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event, NULL)); - wifi_config_t wifi = {0}; - snprintf((char *)wifi.sta.ssid, sizeof(wifi.sta.ssid), "%s", KEYPATH_WIFI_SSID); - snprintf((char *)wifi.sta.password, sizeof(wifi.sta.password), "%s", KEYPATH_WIFI_PASSWORD); - wifi.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; - wifi.sta.pmf_cfg.capable = true; - wifi.sta.pmf_cfg.required = false; ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi)); + ESP_ERROR_CHECK(select_wifi_profile(0u)); ESP_ERROR_CHECK(mdns_init()); ESP_ERROR_CHECK(mdns_hostname_set("keypath-hid-fixture")); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c index d413cf444..d3e005f73 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c @@ -8,6 +8,7 @@ #include "esp_rom_sys.h" #include "fixture_core.h" #include "fixture_ui_model.h" +#include "fixture_visual_model.h" typedef struct { uint32_t count; @@ -58,6 +59,13 @@ static bool run_core_smoke(void) { .reports_submitted = fixture.reports_submitted, }; fixture_ui_output_t output = fixture_ui_model_step(&ui, &input, 250u); + fixture_presentation_t presentation; + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_RESULT; + presentation.result = FIXTURE_RESULT_PASS; + presentation.progress_per_mille = 1000u; + fixture_visual_output_t visual; + fixture_visual_resolve(&output, &presentation, &visual); return fixture.state == FIXTURE_COMPLETE && fixture.reports_submitted == 4u && @@ -65,7 +73,9 @@ static bool run_core_smoke(void) { reports.count == 6u && reports.final_key == 0u && output.scene == FIXTURE_UI_COMPLETE && - output.progress_per_mille == 1000u; + output.progress_per_mille == 1000u && + visual.icon == FIXTURE_ICON_OK && + visual.accent_rgb == 0x44d7a8u; } void fixture_qemu_smoke_run(void) { diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c index 4a8a777a8..eb84f1ced 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c @@ -22,6 +22,7 @@ static StaticSemaphore_t fixture_mutex_storage; static SemaphoreHandle_t fixture_mutex; static bool network_connected; static char network_address[48] = "unassigned"; +static char network_name[33] = "unassigned"; static fixture_presentation_t presentation; static void runtime_lock(void) { @@ -165,10 +166,9 @@ void fixture_runtime_set_network(bool connected, const char *address) { runtime_unlock(); } -void fixture_runtime_network_snapshot(bool *connected, char *address, size_t capacity) { +void fixture_runtime_set_network_name(const char *name) { runtime_lock(); - if (connected) *connected = network_connected; - if (address && capacity) snprintf(address, capacity, "%s", network_address); + snprintf(network_name, sizeof(network_name), "%s", name ? name : "unassigned"); runtime_unlock(); } @@ -186,6 +186,8 @@ void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot) { snapshot->ui.maximum_lateness_us = fixture.maximum_lateness_us; snprintf(snapshot->run_id, sizeof(snapshot->run_id), "%s", fixture.run_id); snprintf(snapshot->error, sizeof(snapshot->error), "%s", fixture.error); + snprintf(snapshot->network_address, sizeof(snapshot->network_address), "%s", network_address); + snprintf(snapshot->network_name, sizeof(snapshot->network_name), "%s", network_name); snapshot->script_crc32 = fixture.script_crc32; snapshot->next_event = fixture.next_event; snapshot->transfers_completed = fixture.transfers_completed; diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h index f2736496f..ac5c8c49f 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h @@ -14,6 +14,8 @@ typedef struct { fixture_ui_input_t ui; char run_id[FIXTURE_MAX_RUN_ID + 1u]; char error[128]; + char network_address[48]; + char network_name[33]; uint32_t script_crc32; uint32_t next_event; uint64_t transfers_completed; @@ -25,8 +27,8 @@ void fixture_runtime_init(void); esp_err_t fixture_runtime_start_usb(void); void fixture_runtime_start_executor(void); void fixture_runtime_set_network(bool connected, const char *address); +void fixture_runtime_set_network_name(const char *name); void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot); -void fixture_runtime_network_snapshot(bool *connected, char *address, size_t capacity); bool fixture_runtime_load(const char *body, size_t length, char *error, size_t capacity); bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity); bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity); diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index 3126239d4..ea6ea64db 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -1,6 +1,8 @@ #include "fixture_core.h" #include "fixture_presentation.h" #include "fixture_ui_model.h" +#include "fixture_visual_model.h" +#include "fixture_wifi_model.h" #include #include @@ -203,6 +205,43 @@ static void test_presentation_contract(void) { assert(!fixture_presentation_parse_phase("dancing", &presentation.phase)); } +static void test_visual_model_resolves_automatic_and_campaign_states(void) { + fixture_ui_output_t ui = { + .scene = FIXTURE_UI_IDLE, + .quality = FIXTURE_UI_SHOWCASE, + .progress_per_mille = 125u, + }; + fixture_presentation_t presentation; + fixture_presentation_init(&presentation); + fixture_visual_output_t visual; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_KEYBOARD); + assert(visual.accent_rgb == 0x56ddb3u); + assert(visual.progress_per_mille == 125u); + assert(visual.angular_speed_milliradians == 1550u); + assert(strcmp(visual.title, "READY") == 0); + + presentation.phase = FIXTURE_PRESENT_TESTING; + presentation.progress_per_mille = 640u; + snprintf(presentation.title, sizeof(presentation.title), "Swift stress"); + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_KEYBOARD); + assert(visual.accent_rgb == 0x55c7ffu); + assert(visual.progress_per_mille == 640u); + assert(visual.angular_speed_milliradians == 4800u); + assert(strcmp(visual.title, "Swift stress") == 0); + + presentation.result = FIXTURE_RESULT_FAIL; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_CLOSE); + assert(visual.accent_rgb == 0xff5c72u); + assert(strcmp(visual.title, "TEST FAILED") == 0); + + ui.quality = FIXTURE_UI_PROTECTED; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.angular_speed_milliradians == 480u); +} + static void test_ui_model_connection_error_and_counter_reset(void) { fixture_ui_model_t model; fixture_ui_model_init(&model); @@ -228,6 +267,28 @@ static void test_ui_model_connection_error_and_counter_reset(void) { assert(output.energy_per_mille <= 1000u); } +static void test_wifi_profiles_retry_in_priority_order_and_wrap(void) { + fixture_wifi_model_t model; + fixture_wifi_model_init(&model); + assert(model.profile_index == 0u); /* 529beach */ + + assert(!fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(model.profile_index == 0u); + assert(fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(model.profile_index == 1u); /* Alpern-Home */ + + assert(!fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + fixture_wifi_model_note_connected(&model); + assert(model.failed_attempts == 0u); + assert(!fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(model.profile_index == 2u); /* iPhone */ + + assert(!fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(model.profile_index == 0u); /* wrap back to 529beach */ +} + int main(void) { test_load_arm_run_and_repeat(); test_rejects_corrupt_and_unsafe_scripts(); @@ -237,6 +298,8 @@ int main(void) { test_ui_model_prioritizes_hid_and_tracks_progress(); test_ui_model_connection_error_and_counter_reset(); test_presentation_contract(); + test_visual_model_resolves_automatic_and_campaign_states(); + test_wifi_profiles_retry_in_priority_order_and_wrap(); puts("physical HID fixture core tests passed"); return 0; } diff --git a/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh index be9d89b19..f2887ad04 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh @@ -15,8 +15,12 @@ fi # shellcheck disable=SC1090 source "$idf_path/export.sh" >/dev/null -export KEYPATH_WIFI_SSID=fixture-qemu-placeholder -export KEYPATH_WIFI_PASSWORD=fixture-qemu-placeholder +export KEYPATH_WIFI_SSID_1=fixture-qemu-primary +export KEYPATH_WIFI_PASSWORD_1=fixture-qemu-placeholder +export KEYPATH_WIFI_SSID_2=fixture-qemu-fallback-one +export KEYPATH_WIFI_PASSWORD_2=fixture-qemu-placeholder +export KEYPATH_WIFI_SSID_3=fixture-qemu-fallback-two +export KEYPATH_WIFI_PASSWORD_3=fixture-qemu-placeholder export KEYPATH_FIXTURE_TOKEN=fixture-qemu-token-placeholder export KEYPATH_QEMU_SMOKE=1 diff --git a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh index 68babfa1f..3edea84ad 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh @@ -10,6 +10,8 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \ "$fixture_root/src/fixture_core.c" \ "$fixture_root/src/fixture_presentation.c" \ "$fixture_root/src/fixture_ui_model.c" \ + "$fixture_root/src/fixture_visual_model.c" \ + "$fixture_root/src/fixture_wifi_model.c" \ "$fixture_root/tests/fixture_core_tests.c" \ -o "$test_binary" "$test_binary" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index bb2cf76a4..aa4395ad9 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -276,6 +276,7 @@ python3 "$LAB_DIR/tests/macos-26-selector-scenario-tests.py" python3 "$LAB_DIR/tests/macos-27-selector-scenario-tests.py" python3 "$LAB_DIR/tests/physical-remap-session-tests.py" python3 "$LAB_DIR/tests/pico-hid-fixture-client-tests.py" +python3 "$LAB_DIR/tests/pico-hid-fixture-tool-tests.py" "$LAB_DIR/pico-hid-fixture/tests/run-tests.sh" if grep -Eq 'local[[:space:]]+status=' "$LAB_DIR/scenarios/installer-scenario"; then echo "installer scenario must not shadow zsh's read-only status parameter" >&2 diff --git a/Scripts/lab/tests/pico-hid-fixture-tool-tests.py b/Scripts/lab/tests/pico-hid-fixture-tool-tests.py new file mode 100644 index 000000000..e9f01dfda --- /dev/null +++ b/Scripts/lab/tests/pico-hid-fixture-tool-tests.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +import os +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +TOOL = ROOT / "Scripts/lab/pico-hid-fixture-tool" + + +class FixtureToolTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = pathlib.Path(self.temporary.name) + self.idf = self.directory / "esp-idf" + self.fake_bin = self.directory / "bin" + self.fake_bin.mkdir() + self.idf.mkdir() + (self.idf / "export.sh").write_text(f'export PATH="{self.fake_bin}:$PATH"\n') + self.build = self.directory / "build" + self.sdkconfig = self.directory / "sdkconfig" + self.device_dir = self.directory / "dev" + self.device_dir.mkdir() + self.environment = os.environ.copy() + self.environment.update({ + "KEYPATH_FIXTURE_HOST_OS": "Darwin", + "KEYPATH_FIXTURE_IDF_PATH": str(self.idf), + "KEYPATH_FIXTURE_BUILD_DIR": str(self.build), + "KEYPATH_FIXTURE_SDKCONFIG": str(self.sdkconfig), + "KEYPATH_FIXTURE_DEVICE_DIR": str(self.device_dir), + "KEYPATH_FIXTURE_SECRETS_FILE": str(self.directory / "missing-secrets.env"), + "KEYPATH_WIFI_SSID_1": "fixture-primary", + "KEYPATH_WIFI_PASSWORD_1": "fixture-password-one", + "KEYPATH_WIFI_SSID_2": "fixture-fallback-one", + "KEYPATH_WIFI_PASSWORD_2": "fixture-password-two", + "KEYPATH_WIFI_SSID_3": "fixture-fallback-two", + "KEYPATH_WIFI_PASSWORD_3": "fixture-password-three", + "KEYPATH_FIXTURE_TOKEN": "fixture-test-token-value", + }) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def run_tool(self, *arguments: str, environment=None) -> subprocess.CompletedProcess[str]: + return subprocess.run([str(TOOL), *arguments], text=True, capture_output=True, + env=environment or self.environment) + + def test_help_names_the_single_install_command(self) -> None: + result = self.run_tool("help") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("install [--port DEV]", result.stdout) + self.assertIn("never pass it on the command line", result.stdout) + + def test_doctor_accepts_environment_credentials_without_printing_values(self) -> None: + result = self.run_tool("doctor") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("production credentials (values hidden)", result.stdout) + self.assertIn("board not connected", result.stdout) + self.assertNotIn(self.environment["KEYPATH_WIFI_PASSWORD_1"], result.stdout + result.stderr) + self.assertNotIn(self.environment["KEYPATH_FIXTURE_TOKEN"], result.stdout + result.stderr) + + def test_doctor_rejects_missing_or_placeholder_credentials(self) -> None: + environment = self.environment.copy() + environment.pop("KEYPATH_FIXTURE_TOKEN") + result = self.run_tool("doctor", environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertRegex(result.stderr, r"sops is required|encrypted secrets file is missing") + + environment = self.environment.copy() + environment["KEYPATH_FIXTURE_TOKEN"] = "fixture-placeholder-token" + result = self.run_tool("doctor", environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("placeholder", result.stderr) + + def test_build_uses_the_configured_cache_and_checks_for_output(self) -> None: + idf_log = self.directory / "idf.log" + fake_idf = self.fake_bin / "idf.py" + fake_idf.write_text(textwrap.dedent(f"""\ + #!/bin/bash + set -eu + printf '%s\\n' "$*" >> {str(idf_log)!r} + build_dir= + while [[ $# -gt 0 ]]; do + if [[ "$1" == -B ]]; then build_dir=$2; shift 2; else shift; fi + done + mkdir -p "$build_dir" + : > "$build_dir/keypath_esp32_s3_hid_fixture.bin" + """)) + fake_idf.chmod(0o755) + + result = self.run_tool("build") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.build / "keypath_esp32_s3_hid_fixture.bin").is_file()) + invocation = idf_log.read_text() + self.assertIn(str(self.build), invocation) + self.assertIn(f"SDKCONFIG={self.sdkconfig}", invocation) + self.assertNotIn(self.environment["KEYPATH_FIXTURE_TOKEN"], invocation) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/testing/keypath-hid-fixture-first-board.md b/docs/testing/keypath-hid-fixture-first-board.md new file mode 100644 index 000000000..5bb6fdcb7 --- /dev/null +++ b/docs/testing/keypath-hid-fixture-first-board.md @@ -0,0 +1,74 @@ +# KeyPath HID fixture: first-board runbook + +This is the shortest safe path from an unopened Waveshare ESP32-S3-Touch-LCD-1.69 to a verified +KeyPath physical keyboard fixture. Run it from the KeyPath feature worktree on the Mac. + +## Before the board arrives + +```bash +Scripts/lab/pico-hid-fixture-tool configure +Scripts/lab/pico-hid-fixture-tool doctor +``` + +The secure setup stores three ordered SSID/password pairs plus the control token: + +- Profile 1: `529beach`, always attempted first. +- Profile 2: `Alpern-Home`, the non-5 GHz home fallback. +- Profile 3: `iPhone`, used when its hotspot has **Maximize Compatibility** enabled. +- `KEYPATH_FIXTURE_TOKEN`: a random value of at least 16 characters. + +Enter values only in Add Secret.app. Do not paste them into chat or pass them as command-line +arguments. Before hardware is attached, `doctor` should pass everything and report only +`wait board not connected`. + +## Install tomorrow + +1. Connect the board directly to the Mac with a known data-capable USB cable. +2. Run: + + ```bash + cd /Users/malpern/local-code/keypath-pico-hid-fixture + Scripts/lab/pico-hid-fixture-tool install + ``` + +3. If the Mac sees no serial device, hold **BOOT**, tap **RESET**, release **BOOT**, and rerun the + same command. +4. Wait for the display to progress from `WAKING UP` to `JOINING LAB` to `READY`. +5. Confirm `READY` shows an IP address and `USB READY` or `USB WAIT`. `USB WAIT` is expected until + the board is attached to a guest or host that has enumerated its HID interface. +6. Recheck at any time with: + + ```bash + Scripts/lab/pico-hid-fixture-tool status + ``` + +The returned `firmware` and `build` fields identify exactly what is running. + +## Failure routing + +| Evidence | Likely layer | Next action | +|---|---|---| +| No `/dev/cu...` device | Cable, port, or boot mode | Use a data cable; enter BOOT/RESET download mode; retry with `--port` if several devices exist. | +| Flash command cannot connect | Bootloader state | Hold BOOT while tapping RESET, release BOOT, and rerun. | +| Display stays dark | Power, board revision, or LCD initialization | Try another data cable/port; retain the flash output; do not start HID acceptance. | +| `JOINING LAB` persists | Wi-Fi credentials or 2.4 GHz reachability | Run `configure` again, rebuild/flash, and confirm the network is 2.4 GHz. | +| IP appears but health check fails | mDNS or Mac network route | Run `status`; test `keypath-hid-fixture.local`; inspect the router for the displayed IP. | +| `USB WAIT` persists | USB enumeration/ownership | Check System Information before attaching the device to a VM; confirm the cable carries data. | +| `ATTENTION` appears | Firmware safety state | Record the exact on-screen detail; query `status` and `trace`; do not repeat a run blindly. | + +Production intentionally has no USB serial console: adding one would change the device exposed to +the VM and weaken the HID-only oracle. Use the display for boot/network/USB routing and use the +authenticated status and trace APIs for runtime diagnosis. + +## Physical acceptance gate + +The software build is not hardware proof. Before calling the fixture ready: + +1. Confirm display orientation, full-frame color, icon animation, touch coordinates, and tones. +2. Confirm macOS enumerates one keyboard HID interface and no serial or storage interface. +3. Run a short baseline script and compare requested reports, transfers, CRC, received text, and + release state. +4. Repeat under CPU, memory, disk, and UI load; timing pressure may switch to `HID PRIORITY`, but + reports must remain complete and ordered. +5. Verify touch abort, button abort, Wi-Fi abort, and USB removal all finish with a released-key + report before another run can arm. From 16e86503d2cdd9fbb5ba8407e8d80bc70782e5b1 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 18:58:59 -0700 Subject: [PATCH 76/99] Add Hacker Dojo boot splash --- Scripts/lab/pico-hid-fixture/README.md | 4 + .../src/fixture_splash_model.c | 42 ++++++ .../src/fixture_splash_model.h | 21 +++ .../main/CMakeLists.txt | 1 + .../main/fixture_display.c | 134 ++++++++++++++++++ .../tests/fixture_core_tests.c | 34 +++++ .../lab/pico-hid-fixture/tests/run-tests.sh | 1 + 7 files changed, 237 insertions(+) create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_splash_model.c create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_splash_model.h diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index e968cfb09..98904429d 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -45,6 +45,10 @@ microcontroller. Its display presents the run state and timing pressure; touch o button aborts an armed/running script, and its buzzer provides sparse transition cues. Pico 2 W uses its onboard green LED instead. +On a cold boot, the display briefly presents the official Hacker Dojo torii mark before dissolving +into the live KeyPath startup scene. The mark is rendered from lightweight LVGL vector primitives, +so the splash needs no image decoder and does not delay USB, Wi-Fi, or the HID executor. + ## Safety model - USB exposes only a standard boot-keyboard HID interface. There is no USB serial control path for diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.c new file mode 100644 index 000000000..8fd206753 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.c @@ -0,0 +1,42 @@ +#include "fixture_splash_model.h" + +static uint16_t smoothstep_per_mille(uint64_t elapsed, uint64_t duration) { + if (elapsed >= duration) return 1000u; + uint64_t t = elapsed * 1000u / duration; + return (uint16_t)(t * t * (3000u - 2u * t) / 1000000u); +} + +fixture_splash_output_t fixture_splash_step(uint64_t elapsed_ms) { + fixture_splash_output_t output = { + .background_opacity = 255u, + .logo_scale = 228u, + }; + + uint16_t reveal = smoothstep_per_mille(elapsed_ms, FIXTURE_SPLASH_FADE_IN_MS); + output.foreground_opacity = (uint8_t)(255u * reveal / 1000u); + output.logo_scale = (uint16_t)(228u + 28u * reveal / 1000u); + + uint64_t wordmark_elapsed = elapsed_ms > 120u ? elapsed_ms - 120u : 0u; + uint16_t wordmark_reveal = smoothstep_per_mille(wordmark_elapsed, 300u); + output.wordmark_opacity = (uint8_t)(255u * wordmark_reveal / 1000u); + + if (elapsed_ms >= FIXTURE_SPLASH_HOLD_END_MS) { + uint64_t fade_elapsed = elapsed_ms - FIXTURE_SPLASH_HOLD_END_MS; + uint16_t fade = smoothstep_per_mille(fade_elapsed, + FIXTURE_SPLASH_TOTAL_MS - FIXTURE_SPLASH_HOLD_END_MS); + uint16_t remaining = (uint16_t)(1000u - fade); + output.foreground_opacity = (uint8_t)(255u * remaining / 1000u); + output.background_opacity = output.foreground_opacity; + output.wordmark_opacity = output.foreground_opacity; + output.logo_scale = (uint16_t)(256u + 18u * fade / 1000u); + } + + if (elapsed_ms >= FIXTURE_SPLASH_TOTAL_MS) { + output.foreground_opacity = 0u; + output.background_opacity = 0u; + output.wordmark_opacity = 0u; + output.logo_scale = 274u; + output.complete = true; + } + return output; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.h new file mode 100644 index 000000000..3693221a3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.h @@ -0,0 +1,21 @@ +#ifndef KEYPATH_FIXTURE_SPLASH_MODEL_H +#define KEYPATH_FIXTURE_SPLASH_MODEL_H + +#include +#include + +#define FIXTURE_SPLASH_FADE_IN_MS 260u +#define FIXTURE_SPLASH_HOLD_END_MS 1200u +#define FIXTURE_SPLASH_TOTAL_MS 1650u + +typedef struct { + uint8_t foreground_opacity; + uint8_t background_opacity; + uint8_t wordmark_opacity; + uint16_t logo_scale; + bool complete; +} fixture_splash_output_t; + +fixture_splash_output_t fixture_splash_step(uint64_t elapsed_ms); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt index 9f9b14a41..81318bc00 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt @@ -42,6 +42,7 @@ idf_component_register( "fixture_runtime.c" "${shared_source}/fixture_core.c" "${shared_source}/fixture_presentation.c" + "${shared_source}/fixture_splash_model.c" "${shared_source}/fixture_ui_model.c" "${shared_source}/fixture_visual_model.c" "${shared_source}/fixture_wifi_model.c" diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c index 4537071f2..182d8600d 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c @@ -8,6 +8,7 @@ #include "esp_timer.h" #include "fixture_board.h" #include "fixture_runtime.h" +#include "fixture_splash_model.h" #include "fixture_visual_model.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -16,6 +17,7 @@ #define PARTICLE_COUNT 12u #define KEY_COUNT 6u +#define DOJO_BAR_COUNT 4u typedef struct { lv_obj_t *screen; @@ -41,7 +43,18 @@ typedef struct { uint64_t completion_started_ms; } display_ui_t; +typedef struct { + lv_obj_t *root; + lv_obj_t *glow; + lv_obj_t *ring; + lv_obj_t *logo; + lv_obj_t *bars[DOJO_BAR_COUNT]; + lv_obj_t *wordmark; + lv_obj_t *location; +} display_splash_t; + static display_ui_t ui; +static display_splash_t splash; static const char *symbol_for(fixture_visual_icon_t icon) { switch (icon) { @@ -74,6 +87,19 @@ static lv_obj_t *make_circle(lv_obj_t *parent, int size, lv_color_t color, lv_op return object; } +static lv_obj_t *make_rect(lv_obj_t *parent, int x, int y, int width, int height, + lv_color_t color) { + lv_obj_t *object = lv_obj_create(parent); + lv_obj_remove_style_all(object); + lv_obj_set_pos(object, x, y); + lv_obj_set_size(object, width, height); + lv_obj_set_style_radius(object, 2, 0); + lv_obj_set_style_bg_color(object, color, 0); + lv_obj_set_style_bg_opa(object, LV_OPA_COVER, 0); + lv_obj_clear_flag(object, LV_OBJ_FLAG_CLICKABLE); + return object; +} + static void touch_event(lv_event_t *event) { if (lv_event_get_code(event) != LV_EVENT_PRESSED) return; fixture_runtime_snapshot_t snapshot; @@ -187,6 +213,112 @@ static void build_ui(void) { ui.visual_stage = -1; } +static void build_splash(void) { + lv_color_t dojo_red = lv_color_hex(0xe13838); + lv_color_t white = lv_color_hex(0xffffff); + + splash.root = lv_obj_create(ui.screen); + lv_obj_remove_style_all(splash.root); + lv_obj_set_size(splash.root, LV_PCT(100), LV_PCT(100)); + lv_obj_align(splash.root, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_bg_color(splash.root, lv_color_hex(0x080c10), 0); + lv_obj_set_style_bg_opa(splash.root, LV_OPA_COVER, 0); + lv_obj_clear_flag(splash.root, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); + + splash.glow = make_circle(splash.root, 154, dojo_red, LV_OPA_TRANSP); + lv_obj_align(splash.glow, LV_ALIGN_CENTER, 0, -27); + + splash.ring = make_circle(splash.root, 118, dojo_red, LV_OPA_TRANSP); + lv_obj_set_style_border_width(splash.ring, 2, 0); + lv_obj_set_style_border_color(splash.ring, dojo_red, 0); + lv_obj_set_style_border_opa(splash.ring, LV_OPA_TRANSP, 0); + lv_obj_align(splash.ring, LV_ALIGN_CENTER, 0, -27); + + splash.logo = lv_obj_create(splash.root); + lv_obj_remove_style_all(splash.logo); + lv_obj_set_size(splash.logo, 92, 92); + lv_obj_set_style_radius(splash.logo, 24, 0); + lv_obj_set_style_bg_color(splash.logo, dojo_red, 0); + lv_obj_set_style_bg_opa(splash.logo, LV_OPA_TRANSP, 0); + lv_obj_set_style_shadow_color(splash.logo, dojo_red, 0); + lv_obj_set_style_shadow_width(splash.logo, 26, 0); + lv_obj_set_style_shadow_opa(splash.logo, LV_OPA_TRANSP, 0); + lv_obj_align(splash.logo, LV_ALIGN_CENTER, 0, -27); + + /* Faithful, display-native reconstruction of hackerdojo.org/static/images/logo.png. */ + splash.bars[0] = make_rect(splash.logo, 20, 29, 52, 5, white); + splash.bars[1] = make_rect(splash.logo, 16, 40, 60, 5, white); + splash.bars[2] = make_rect(splash.logo, 31, 26, 5, 41, white); + splash.bars[3] = make_rect(splash.logo, 56, 26, 5, 41, white); + + splash.wordmark = lv_label_create(splash.root); + lv_label_set_text_static(splash.wordmark, "HACKER DOJO"); + lv_obj_set_style_text_font(splash.wordmark, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(splash.wordmark, white, 0); + lv_obj_set_style_text_letter_space(splash.wordmark, 3, 0); + lv_obj_set_style_text_opa(splash.wordmark, LV_OPA_TRANSP, 0); + lv_obj_align(splash.wordmark, LV_ALIGN_CENTER, 0, 48); + + splash.location = lv_label_create(splash.root); + lv_label_set_text_static(splash.location, "MOUNTAIN VIEW / SINCE 2009"); + lv_obj_set_style_text_color(splash.location, lv_color_hex(0x9aa7ad), 0); + lv_obj_set_style_text_letter_space(splash.location, 1, 0); + lv_obj_set_style_text_opa(splash.location, LV_OPA_TRANSP, 0); + lv_obj_align(splash.location, LV_ALIGN_CENTER, 0, 74); +} + +static void render_splash(const fixture_splash_output_t *output, uint64_t elapsed_ms) { + lv_opa_t foreground = (lv_opa_t)output->foreground_opacity; + lv_obj_set_style_bg_opa(splash.root, (lv_opa_t)output->background_opacity, 0); + lv_obj_set_style_bg_opa(splash.logo, foreground, 0); + lv_obj_set_style_shadow_opa(splash.logo, (lv_opa_t)(output->foreground_opacity * 42u / 255u), 0); + lv_obj_set_style_transform_scale(splash.logo, output->logo_scale, 0); + for (size_t index = 0; index < DOJO_BAR_COUNT; ++index) { + lv_obj_set_style_bg_opa(splash.bars[index], foreground, 0); + } + + lv_obj_set_style_text_opa(splash.wordmark, (lv_opa_t)output->wordmark_opacity, 0); + lv_obj_set_style_text_opa(splash.location, + (lv_opa_t)(output->wordmark_opacity * 3u / 4u), 0); + int wordmark_offset = 5 - (int)(output->wordmark_opacity * 5u / 255u); + lv_obj_set_style_translate_y(splash.wordmark, wordmark_offset, 0); + lv_obj_set_style_translate_y(splash.location, wordmark_offset, 0); + + uint16_t ring_phase = (uint16_t)(elapsed_ms % 800u); + uint16_t ring_scale = (uint16_t)(256u + ring_phase * 54u / 800u); + uint16_t ring_fade = (uint16_t)(255u - ring_phase * 255u / 800u); + lv_obj_set_style_transform_scale(splash.ring, ring_scale, 0); + lv_obj_set_style_border_opa( + splash.ring, + (lv_opa_t)(output->foreground_opacity * ring_fade * 44u / (255u * 255u)), 0); + + int glow_pulse = (int)(sinf((float)elapsed_ms / 230.0f) * 4.0f); + lv_obj_set_style_transform_scale(splash.glow, 250 + glow_pulse, 0); + lv_obj_set_style_bg_opa(splash.glow, + (lv_opa_t)(output->foreground_opacity * 24u / 255u), 0); +} + +static void play_splash(void) { + uint64_t started_ms = (uint64_t)(esp_timer_get_time() / 1000); + while (true) { + uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); + uint64_t elapsed_ms = now_ms >= started_ms ? now_ms - started_ms : 0u; + fixture_splash_output_t output = fixture_splash_step(elapsed_ms); + bool finished = false; + if (bsp_display_lock(20u)) { + render_splash(&output, elapsed_ms); + if (output.complete) { + lv_obj_delete(splash.root); + splash.root = NULL; + finished = true; + } + bsp_display_unlock(); + } + if (finished) return; + vTaskDelay(pdMS_TO_TICKS(16u)); + } +} + static void announce_transition(const fixture_ui_output_t *output) { if (output->scene == ui.previous_scene) return; switch (output->scene) { @@ -357,7 +489,9 @@ static void display_task(void *context) { ESP_ERROR_CHECK(bsp_display_brightness_set(CONFIG_KEYPATH_FIXTURE_BRIGHTNESS)); configASSERT(bsp_display_lock(0u)); build_ui(); + build_splash(); bsp_display_unlock(); + play_splash(); fixture_ui_model_t model; fixture_ui_model_init(&model); diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index ea6ea64db..3cd75acd3 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -1,5 +1,6 @@ #include "fixture_core.h" #include "fixture_presentation.h" +#include "fixture_splash_model.h" #include "fixture_ui_model.h" #include "fixture_visual_model.h" #include "fixture_wifi_model.h" @@ -289,6 +290,38 @@ static void test_wifi_profiles_retry_in_priority_order_and_wrap(void) { assert(model.profile_index == 0u); /* wrap back to 529beach */ } +static void test_splash_reveals_holds_and_fades_without_blocking_boot(void) { + fixture_splash_output_t splash = fixture_splash_step(0u); + assert(splash.foreground_opacity == 0u); + assert(splash.background_opacity == 255u); + assert(splash.wordmark_opacity == 0u); + assert(splash.logo_scale == 228u); + assert(!splash.complete); + + splash = fixture_splash_step(FIXTURE_SPLASH_FADE_IN_MS); + assert(splash.foreground_opacity == 255u); + assert(splash.logo_scale == 256u); + + splash = fixture_splash_step(500u); + assert(splash.foreground_opacity == 255u); + assert(splash.wordmark_opacity == 255u); + + splash = fixture_splash_step(FIXTURE_SPLASH_HOLD_END_MS); + assert(splash.foreground_opacity == 255u); + assert(splash.background_opacity == 255u); + + splash = fixture_splash_step(1450u); + assert(splash.foreground_opacity < 128u); + assert(splash.background_opacity == splash.foreground_opacity); + assert(splash.logo_scale > 256u); + assert(!splash.complete); + + splash = fixture_splash_step(FIXTURE_SPLASH_TOTAL_MS); + assert(splash.foreground_opacity == 0u); + assert(splash.background_opacity == 0u); + assert(splash.complete); +} + int main(void) { test_load_arm_run_and_repeat(); test_rejects_corrupt_and_unsafe_scripts(); @@ -300,6 +333,7 @@ int main(void) { test_presentation_contract(); test_visual_model_resolves_automatic_and_campaign_states(); test_wifi_profiles_retry_in_priority_order_and_wrap(); + test_splash_reveals_holds_and_fades_without_blocking_boot(); puts("physical HID fixture core tests passed"); return 0; } diff --git a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh index 3edea84ad..3da6e96c9 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh @@ -9,6 +9,7 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \ -I"$fixture_root/src" \ "$fixture_root/src/fixture_core.c" \ "$fixture_root/src/fixture_presentation.c" \ + "$fixture_root/src/fixture_splash_model.c" \ "$fixture_root/src/fixture_ui_model.c" \ "$fixture_root/src/fixture_visual_model.c" \ "$fixture_root/src/fixture_wifi_model.c" \ From f24be33f2d7bb68cdf93aeedba84139dfa888e7f Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 26 Jul 2026 21:27:57 -0700 Subject: [PATCH 77/99] Document HID fixture readiness and UX --- Scripts/lab/pico-hid-fixture/README.md | 3 + .../keypath-hid-fixture-first-board.md | 7 +- docs/testing/keypath-hid-fixture-readiness.md | 106 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 docs/testing/keypath-hid-fixture-readiness.md diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 98904429d..07b926034 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -31,6 +31,9 @@ status endpoint. If no port appears, hold **BOOT**, tap **RESET**, then release use `install --port /dev/cu...` only when more than one serial device is connected. The exact hands-on checklist and failure routing are in [`docs/testing/keypath-hid-fixture-first-board.md`](../../../docs/testing/keypath-hid-fixture-first-board.md). +The operator-facing states, dual-core allocation, UX acceptance criteria, and deferred refinements +are in +[`docs/testing/keypath-hid-fixture-readiness.md`](../../../docs/testing/keypath-hid-fixture-readiness.md). ## Hardware targets diff --git a/docs/testing/keypath-hid-fixture-first-board.md b/docs/testing/keypath-hid-fixture-first-board.md index 5bb6fdcb7..16dcd9ead 100644 --- a/docs/testing/keypath-hid-fixture-first-board.md +++ b/docs/testing/keypath-hid-fixture-first-board.md @@ -33,7 +33,8 @@ arguments. Before hardware is attached, `doctor` should pass everything and repo 3. If the Mac sees no serial device, hold **BOOT**, tap **RESET**, release **BOOT**, and rerun the same command. -4. Wait for the display to progress from `WAKING UP` to `JOINING LAB` to `READY`. +4. Confirm the brief Hacker Dojo splash is upright and clean, then wait for the display to progress + from `WAKING UP` to `JOINING LAB` to `READY`. 5. Confirm `READY` shows an IP address and `USB READY` or `USB WAIT`. `USB WAIT` is expected until the board is attached to a guest or host that has enumerated its HID interface. 6. Recheck at any time with: @@ -44,6 +45,10 @@ arguments. Before hardware is attached, `doctor` should pass everything and repo The returned `firmware` and `build` fields identify exactly what is running. +The complete screen language, core allocation, UX sign-off list, and explicitly deferred cleanup +are documented in +[`keypath-hid-fixture-readiness.md`](keypath-hid-fixture-readiness.md). + ## Failure routing | Evidence | Likely layer | Next action | diff --git a/docs/testing/keypath-hid-fixture-readiness.md b/docs/testing/keypath-hid-fixture-readiness.md new file mode 100644 index 000000000..320492271 --- /dev/null +++ b/docs/testing/keypath-hid-fixture-readiness.md @@ -0,0 +1,106 @@ +# KeyPath HID fixture readiness and on-device UX + +## Current readiness + +The firmware and Mac workflow are ready for the first physical board. Host tests, client tests, +installer tests, campaign-runner tests, the ESP32-S3 QEMU boot smoke test, and the credential-bearing +production build pass. This proves the software shape; it does not prove the delivered LCD, touch +controller, buzzer revision, native USB behavior, or report timing. + +There are no additional software or visual changes required before the first flash. The remaining +release gate is the physical acceptance checklist in +[`keypath-hid-fixture-first-board.md`](keypath-hid-fixture-first-board.md). + +## Operator journey + +### Install or update + +Connect the board to the Mac with a data-capable USB cable, then run: + +```bash +cd /Users/malpern/local-code/keypath-pico-hid-fixture +Scripts/lab/pico-hid-fixture-tool install +``` + +The command checks the Mac and encrypted credentials, runs all software tests, builds production +firmware, finds an unambiguous serial device, flashes it, and verifies the authenticated status API. +It does not ask the operator to copy credentials into a terminal. + +### Cold-boot screen sequence + +| Approximate time | Display | Operator meaning | +|---|---|---| +| 0–0.26 s | Hacker Dojo mark reveals | Display task is alive. | +| 0.26–1.20 s | Mark, `HACKER DOJO`, and `MOUNTAIN VIEW / SINCE 2009` | Short identity splash; USB, Wi-Fi, and HID initialization continue concurrently. | +| 1.20–1.65 s | Splash expands and dissolves | Transition into live fixture state. | +| After 1.65 s | `WAKING UP` or `JOINING LAB` | Runtime is starting or trying the named Wi-Fi profile. | +| Connected | `READY`, IP address, and `USB READY` or `USB WAIT` | Control plane is reachable; USB state is explicit. | + +The splash is intentionally brief and has no image-decoding work. It is built from LVGL vector +primitives and runs on the display task. It must never postpone HID, USB, or networking work. + +### Test-state language + +| Display state | Meaning | Operator action | +|---|---|---| +| `READY` | Wi-Fi is connected and no test is armed. | Safe to load a script. | +| `SCRIPT LOADED` | Script passed admission and CRC validation. | Arm only after observers are ready. | +| `ARMED` | Fixture is ready to emit reports. | Start the synchronized run or abort. | +| `TYPING` or `SENDING KEYS` | Reports are being emitted from locally timed script data. | Avoid disconnecting USB unless testing removal. | +| `HID PRIORITY` | Timing pressure was detected and display work was reduced to 8 FPS. | The test may continue; inspect trace timing afterward. | +| Pass, fail, or inconclusive result | Campaign supplied a classified outcome and metrics. | Preserve the campaign evidence. | +| `ATTENTION` | Safety or runtime error. | Record the detail, query `status` and `trace`, and do not blindly retry. | + +## Work split across both ESP32-S3 cores + +- **Core 1:** the priority-20 HID scheduler. It owns locally timed keyboard reports and uses a + short polling loop only while a script is running. +- **Core 0:** priority-18 TinyUSB service, priority-6 display, priority-5 button and buzzer work, + priority-4 HTTP control, and the ESP-IDF Wi-Fi task. +- Animation quality automatically steps from showcase to active to `HID PRIORITY`; visual fidelity + is expendable, report timing is not. + +The ESP-IDF TCP/IP task currently has no hard affinity. That is acceptable for the first-board +baseline because the HID task has higher priority, but it is a deliberate measurement item during +load acceptance. Pin TCP/IP to Core 0 only if traces show Core 1 interference; do not make that +change without comparing before-and-after timing evidence. + +## Physical UX sign-off + +Record pass, fail, or notes for each item before calling the device finished: + +- Splash is upright, centered, legible, and feels brief rather than like a boot delay. +- Splash dissolves cleanly into the live scene without a black flash or stale frame. +- `JOINING LAB` names the network currently being attempted. +- `READY` shows a readable IP address and unambiguous USB status. +- Color, icons, and motion distinguish loaded, armed, running, protected, complete, and error. +- `HID PRIORITY` visibly calms the display without making it appear frozen. +- Touch and the function button abort only armed or running tests. +- Tones are audible but not distracting; current-board revision 2 uses GPIO42. +- Pass, fail, and inconclusive remain understandable without reading the HTTP response. +- At typical viewing distance, there is no clipping, tearing, unreadably small text, or excessive + brightness. + +## Configuration knobs + +The target's `KeyPath HID fixture` menu exposes: + +- Board revision: `2` by default; use `1` only if the delivered board has the older buzzer wiring. +- Brightness: `85%` by default. +- State-change tones: enabled by default. +- Reduced motion: available without removing state, color, or progress feedback. + +Do not tune these before seeing the physical display. Capture the default result first so changes +address observed hardware behavior rather than simulator assumptions. + +## Deferred improvements, not first-flash blockers + +- Pin the TCP/IP task to Core 0 if physical traces demonstrate scheduler interference. +- Tune brightness, tone duration, touch coordinates, or splash timing from observed hardware. +- Capture a short reference video after visual acceptance so later firmware changes can be compared. +- Add factory-reset or over-the-air update UX only if repeated physical maintenance demonstrates a + real need. USB flashing remains simpler and safer for this lab-only fixture. + +Avoid adding USB serial, storage, or a composite debug interface. The device must continue to look +like one ordinary keyboard to the VM; status and trace diagnostics belong on the independent Wi-Fi +control plane. From aa2b3ca98fd77d4edfd9fcf14b88b12f2ee26bf0 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 06:49:52 -0700 Subject: [PATCH 78/99] Route CLI service control through privileged helper --- Sources/KeyPathAppKit/CLI/SystemFacade.swift | 52 ++++++-- .../Core/HelperManager+RequestHandlers.swift | 12 ++ .../KeyPathAppKit/Core/HelperProtocol.swift | 8 ++ .../KeyPathCore/KeyPathHelperContract.swift | 2 +- Sources/KeyPathHelper/HelperProtocol.swift | 8 ++ Sources/KeyPathHelper/HelperService.swift | 41 ++++++ Sources/KeyPathHelper/Info.plist | 4 +- .../Core/ServiceHealthChecker.swift | 12 ++ Tests/KeyPathTests/CLI/CLIServiceTests.swift | 122 +++++++++++++----- .../Lint/CLIPrivilegeBoundaryLintTests.swift | 69 ++++++++++ .../bugs/cli-service-control-helper-bypass.md | 39 ++++++ 11 files changed, 322 insertions(+), 47 deletions(-) create mode 100644 Tests/KeyPathTests/Lint/CLIPrivilegeBoundaryLintTests.swift create mode 100644 docs/bugs/cli-service-control-helper-bypass.md diff --git a/Sources/KeyPathAppKit/CLI/SystemFacade.swift b/Sources/KeyPathAppKit/CLI/SystemFacade.swift index b49704a67..feb3af6be 100644 --- a/Sources/KeyPathAppKit/CLI/SystemFacade.swift +++ b/Sources/KeyPathAppKit/CLI/SystemFacade.swift @@ -10,7 +10,9 @@ import KeyPathWizardCore public struct SystemFacade: Sendable { typealias RuntimeSnapshot = ServiceHealthChecker.KanataServiceRuntimeSnapshot - private let subprocessRunner: any SubprocessRunning + private let startServiceOperation: @Sendable () async throws -> Void + private let stopServiceOperation: @Sendable () async throws -> Void + private let runtimeCacheInvalidator: @Sendable () async -> Void private let runtimeSnapshotProvider: @Sendable () async -> RuntimeSnapshot private let runtimeTransitionTimeoutSeconds: TimeInterval private let pollDelayNanoseconds: UInt64 @@ -18,21 +20,45 @@ public struct SystemFacade: Sendable { public init() { self.init( - subprocessRunner: SubprocessRunner.shared, + startServiceOperation: { + try await HelperManager.shared.startKanataService() + }, + stopServiceOperation: { + try await HelperManager.shared.stopKanataService() + }, + runtimeCacheInvalidator: { + await MainActor.run { + ServiceHealthChecker.shared.invalidateHealthCache() + } + }, runtimeSnapshotProvider: { - await ServiceHealthChecker.shared.checkKanataServiceRuntimeSnapshot() + await ServiceHealthChecker.shared.checkKanataServiceRuntimeSnapshotFresh() } ) } init( - subprocessRunner: any SubprocessRunning, + startServiceOperation: @escaping @Sendable () async throws -> Void = { + try await HelperManager.shared.startKanataService() + }, + stopServiceOperation: @escaping @Sendable () async throws -> Void = { + try await HelperManager.shared.stopKanataService() + }, + runtimeCacheInvalidator: @escaping @Sendable () async -> Void = { + await MainActor.run { + ServiceHealthChecker.shared.invalidateHealthCache() + } + }, runtimeSnapshotProvider: @escaping @Sendable () async -> RuntimeSnapshot, - runtimeTransitionTimeoutSeconds: TimeInterval = 5, + // The Kanata LaunchDaemon has a 10-second ThrottleInterval. A restart + // can legitimately remain stopped for that long before launchd starts it. + runtimeTransitionTimeoutSeconds: TimeInterval = 20, pollDelayNanoseconds: UInt64 = 200_000_000, restartDelayNanoseconds: UInt64 = 500_000_000 ) { - self.subprocessRunner = subprocessRunner + self.startServiceOperation = startServiceOperation + self.stopServiceOperation = stopServiceOperation + self.runtimeCacheInvalidator = runtimeCacheInvalidator self.runtimeSnapshotProvider = runtimeSnapshotProvider self.runtimeTransitionTimeoutSeconds = runtimeTransitionTimeoutSeconds self.pollDelayNanoseconds = pollDelayNanoseconds @@ -42,9 +68,12 @@ public struct SystemFacade: Sendable { // MARK: - Service Lifecycle public func startService() async -> Bool { + await MainActor.run { + CLIRuntimeBootstrap.ensureConfigured() + } do { - let result = try await subprocessRunner.launchctl("kickstart", ["system/com.keypath.kanata"]) - guard result.exitCode == 0 else { return false } + try await startServiceOperation() + await runtimeCacheInvalidator() return await waitForRuntime(timeoutSeconds: runtimeTransitionTimeoutSeconds) { snapshot in snapshot.isRunning && snapshot.isResponding } @@ -54,9 +83,12 @@ public struct SystemFacade: Sendable { } public func stopService() async -> Bool { + await MainActor.run { + CLIRuntimeBootstrap.ensureConfigured() + } do { - let result = try await subprocessRunner.launchctl("kill", ["SIGTERM", "system/com.keypath.kanata"]) - guard result.exitCode == 0 else { return false } + try await stopServiceOperation() + await runtimeCacheInvalidator() return await waitForRuntime(timeoutSeconds: runtimeTransitionTimeoutSeconds) { snapshot in !snapshot.isRunning && !snapshot.isResponding } diff --git a/Sources/KeyPathAppKit/Core/HelperManager+RequestHandlers.swift b/Sources/KeyPathAppKit/Core/HelperManager+RequestHandlers.swift index aad226efd..d9b904a51 100644 --- a/Sources/KeyPathAppKit/Core/HelperManager+RequestHandlers.swift +++ b/Sources/KeyPathAppKit/Core/HelperManager+RequestHandlers.swift @@ -252,6 +252,18 @@ extension HelperManager { } } + func startKanataService() async throws { + try await executeXPCCall("startKanataService") { proxy, reply in + proxy.startKanataService(reply: reply) + } + } + + func stopKanataService() async throws { + try await executeXPCCall("stopKanataService") { proxy, reply in + proxy.stopKanataService(reply: reply) + } + } + // MARK: - VirtualHID Operations func activateVirtualHIDManager() async throws { diff --git a/Sources/KeyPathAppKit/Core/HelperProtocol.swift b/Sources/KeyPathAppKit/Core/HelperProtocol.swift index e3ef76f71..b3a2f0fce 100644 --- a/Sources/KeyPathAppKit/Core/HelperProtocol.swift +++ b/Sources/KeyPathAppKit/Core/HelperProtocol.swift @@ -37,6 +37,14 @@ import Foundation /// - Parameter reply: Completion handler with (success, errorMessage) func installRequiredRuntimeServices(reply: @escaping (Bool, String?) -> Void) + /// Start the fixed KeyPath Kanata LaunchDaemon. + /// - Parameter reply: Completion handler with (success, errorMessage) + func startKanataService(reply: @escaping (Bool, String?) -> Void) + + /// Stop the fixed KeyPath Kanata LaunchDaemon. + /// - Parameter reply: Completion handler with (success, errorMessage) + func stopKanataService(reply: @escaping (Bool, String?) -> Void) + // MARK: - VirtualHID Operations /// Activate the VirtualHID Manager service diff --git a/Sources/KeyPathCore/KeyPathHelperContract.swift b/Sources/KeyPathCore/KeyPathHelperContract.swift index 2a634522d..4f80fddb3 100644 --- a/Sources/KeyPathCore/KeyPathHelperContract.swift +++ b/Sources/KeyPathCore/KeyPathHelperContract.swift @@ -1,5 +1,5 @@ /// Shared identity and compatibility contract for the privileged helper. public enum KeyPathHelperContract { /// Version returned by the helper XPC service and packaged in its Info.plist. - public static let version = "1.1.0" + public static let version = "1.2.0" } diff --git a/Sources/KeyPathHelper/HelperProtocol.swift b/Sources/KeyPathHelper/HelperProtocol.swift index e3ef76f71..b3a2f0fce 100644 --- a/Sources/KeyPathHelper/HelperProtocol.swift +++ b/Sources/KeyPathHelper/HelperProtocol.swift @@ -37,6 +37,14 @@ import Foundation /// - Parameter reply: Completion handler with (success, errorMessage) func installRequiredRuntimeServices(reply: @escaping (Bool, String?) -> Void) + /// Start the fixed KeyPath Kanata LaunchDaemon. + /// - Parameter reply: Completion handler with (success, errorMessage) + func startKanataService(reply: @escaping (Bool, String?) -> Void) + + /// Stop the fixed KeyPath Kanata LaunchDaemon. + /// - Parameter reply: Completion handler with (success, errorMessage) + func stopKanataService(reply: @escaping (Bool, String?) -> Void) + // MARK: - VirtualHID Operations /// Activate the VirtualHID Manager service diff --git a/Sources/KeyPathHelper/HelperService.swift b/Sources/KeyPathHelper/HelperService.swift index 2aa7b97be..e581e1c68 100644 --- a/Sources/KeyPathHelper/HelperService.swift +++ b/Sources/KeyPathHelper/HelperService.swift @@ -13,6 +13,7 @@ class HelperService: NSObject, HelperProtocol { /// Helper version shared with the app and wizard for compatibility checks. private static let version = KeyPathHelperContract.version + private static let kanataServiceTarget = "system/com.keypath.kanata" private static let vhidRootOnlyDirectory = "/Library/Application Support/org.pqrs/tmp/rootonly" private let logger = Logger(subsystem: "com.keypath.helper", category: "service") @@ -135,6 +136,46 @@ class HelperService: NSObject, HelperProtocol { ) } + func startKanataService(reply: @escaping (Bool, String?) -> Void) { + NSLog("[KeyPathHelper] startKanataService requested") + executePrivilegedOperation( + name: "startKanataService", + operation: { + let result = Self.run( + "/bin/launchctl", + ["kickstart", Self.kanataServiceTarget], + timeout: 15 + ) + guard result.status == 0 else { + throw HelperError.operationFailed( + "Failed to start KeyPath Kanata service: \(result.out)" + ) + } + }, + reply: reply + ) + } + + func stopKanataService(reply: @escaping (Bool, String?) -> Void) { + NSLog("[KeyPathHelper] stopKanataService requested") + executePrivilegedOperation( + name: "stopKanataService", + operation: { + let result = Self.run( + "/bin/launchctl", + ["kill", "SIGTERM", Self.kanataServiceTarget], + timeout: 15 + ) + guard result.status == 0 else { + throw HelperError.operationFailed( + "Failed to stop KeyPath Kanata service: \(result.out)" + ) + } + }, + reply: reply + ) + } + // MARK: - VirtualHID Operations func activateVirtualHIDManager(reply: @escaping (Bool, String?) -> Void) { diff --git a/Sources/KeyPathHelper/Info.plist b/Sources/KeyPathHelper/Info.plist index 811e2cf52..39d555130 100644 --- a/Sources/KeyPathHelper/Info.plist +++ b/Sources/KeyPathHelper/Info.plist @@ -12,10 +12,10 @@ CFBundleShortVersionString - 1.1.0 + 1.2.0 CFBundleVersion - 2 + 3 CFBundleInfoDictionaryVersion diff --git a/Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift b/Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift index e51e04ac1..16a2dafed 100644 --- a/Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift +++ b/Sources/KeyPathInstallationWizard/Core/ServiceHealthChecker.swift @@ -620,6 +620,18 @@ public final class ServiceHealthChecker: @unchecked Sendable { return await checkKanataServiceRuntimeSnapshotUncached(tcpPort: tcpPort, timeoutMs: timeoutMs) } + /// Fresh runtime evidence for lifecycle transition postconditions. + /// + /// Tight start/stop polling must not reuse the normal two-second snapshot + /// cache: a cached pre-transition result can otherwise make a successful + /// launchd mutation look like a failure. + public nonisolated func checkKanataServiceRuntimeSnapshotFresh( + tcpPort: Int = KeyPathConstants.Networking.defaultTCPPort, + timeoutMs: Int = 300 + ) async -> KanataServiceRuntimeSnapshot { + await checkKanataServiceRuntimeSnapshotUncached(tcpPort: tcpPort, timeoutMs: timeoutMs) + } + private nonisolated func checkKanataServiceRuntimeSnapshotUncached( tcpPort: Int, timeoutMs: Int diff --git a/Tests/KeyPathTests/CLI/CLIServiceTests.swift b/Tests/KeyPathTests/CLI/CLIServiceTests.swift index 1cbc13617..9ad97db8a 100644 --- a/Tests/KeyPathTests/CLI/CLIServiceTests.swift +++ b/Tests/KeyPathTests/CLI/CLIServiceTests.swift @@ -28,20 +28,9 @@ final class CLIServiceTests: XCTestCase { // MARK: - service lifecycle - func testStopServiceReturnsFalseWhenLaunchctlExitsNonZero() async { - let fakeRunner = SubprocessRunnerFake.shared - await fakeRunner.reset() - await fakeRunner.configureLaunchctlResult { _, _ in - ProcessResult( - exitCode: 112, - stdout: "", - stderr: "Not privileged to signal service.", - duration: 0.1 - ) - } - + func testStopServiceReturnsFalseWhenPrivilegedHelperFails() async { let facade = SystemFacade( - subprocessRunner: fakeRunner, + stopServiceOperation: { throw ServiceOperationError.failed }, runtimeSnapshotProvider: { Self.runtimeSnapshot(running: true, responding: true) }, runtimeTransitionTimeoutSeconds: 0.05, pollDelayNanoseconds: 0 @@ -52,40 +41,33 @@ final class CLIServiceTests: XCTestCase { XCTAssertFalse(stopped) } - func testStopServiceWaitsForStoppedRuntimeAfterLaunchctlSuccess() async { - let fakeRunner = SubprocessRunnerFake.shared - await fakeRunner.reset() + func testStopServiceWaitsForStoppedRuntimeAfterHelperSuccess() async { let snapshots = RuntimeSnapshotSequence([ Self.runtimeSnapshot(running: true, responding: true), Self.runtimeSnapshot(running: false, responding: false) ]) + let operations = ServiceOperationRecorder() let facade = SystemFacade( - subprocessRunner: fakeRunner, + stopServiceOperation: { await operations.recordStop() }, runtimeSnapshotProvider: { await snapshots.next() }, runtimeTransitionTimeoutSeconds: 0.05, pollDelayNanoseconds: 0 ) let stopped = await facade.stopService() + let stopCount = await operations.stopCount XCTAssertTrue(stopped) + XCTAssertEqual(stopCount, 1) } func testRestartServiceDoesNotReportSuccessWhenStopFails() async { - let fakeRunner = SubprocessRunnerFake.shared - await fakeRunner.reset() - await fakeRunner.configureLaunchctlResult { subcommand, _ in - ProcessResult( - exitCode: subcommand == "kill" ? 112 : 0, - stdout: "", - stderr: subcommand == "kill" ? "Not privileged to signal service." : "", - duration: 0.1 - ) - } + let operations = ServiceOperationRecorder() let facade = SystemFacade( - subprocessRunner: fakeRunner, + startServiceOperation: { await operations.recordStart() }, + stopServiceOperation: { throw ServiceOperationError.failed }, runtimeSnapshotProvider: { Self.runtimeSnapshot(running: true, responding: true) }, runtimeTransitionTimeoutSeconds: 0.05, pollDelayNanoseconds: 0, @@ -93,18 +75,15 @@ final class CLIServiceTests: XCTestCase { ) let restarted = await facade.restartService() - let commands = await fakeRunner.executedCommands + let startCount = await operations.startCount XCTAssertFalse(restarted) - XCTAssertEqual(commands.compactMap(\.args.first), ["kill"]) + XCTAssertEqual(startCount, 0) } func testStartServiceReturnsFalseWhenRuntimeNeverBecomesHealthy() async { - let fakeRunner = SubprocessRunnerFake.shared - await fakeRunner.reset() - let facade = SystemFacade( - subprocessRunner: fakeRunner, + startServiceOperation: {}, runtimeSnapshotProvider: { Self.runtimeSnapshot(running: true, responding: false) }, runtimeTransitionTimeoutSeconds: 0.05, pollDelayNanoseconds: 0 @@ -115,6 +94,51 @@ final class CLIServiceTests: XCTestCase { XCTAssertFalse(started) } + func testStartServiceWaitsForHealthyRuntimeAfterHelperSuccess() async { + let snapshots = RuntimeSnapshotSequence([ + Self.runtimeSnapshot(running: false, responding: false), + Self.runtimeSnapshot(running: true, responding: true) + ]) + let operations = ServiceOperationRecorder() + let facade = SystemFacade( + startServiceOperation: { await operations.recordStart() }, + runtimeSnapshotProvider: { await snapshots.next() }, + runtimeTransitionTimeoutSeconds: 0.05, + pollDelayNanoseconds: 0 + ) + + let started = await facade.startService() + let startCount = await operations.startCount + + XCTAssertTrue(started) + XCTAssertEqual(startCount, 1) + } + + func testServiceOperationsInvalidateRuntimeHealthCacheBeforePolling() async { + let invalidations = SynchronousCallRecorder() + let stoppedFacade = SystemFacade( + stopServiceOperation: {}, + runtimeCacheInvalidator: { invalidations.record() }, + runtimeSnapshotProvider: { Self.runtimeSnapshot(running: false, responding: false) }, + runtimeTransitionTimeoutSeconds: 0.05, + pollDelayNanoseconds: 0 + ) + let startedFacade = SystemFacade( + startServiceOperation: {}, + runtimeCacheInvalidator: { invalidations.record() }, + runtimeSnapshotProvider: { Self.runtimeSnapshot(running: true, responding: true) }, + runtimeTransitionTimeoutSeconds: 0.05, + pollDelayNanoseconds: 0 + ) + + let stopped = await stoppedFacade.stopService() + let started = await startedFacade.startService() + + XCTAssertTrue(stopped) + XCTAssertTrue(started) + XCTAssertEqual(invalidations.count, 2) + } + private nonisolated static func runtimeSnapshot( running: Bool, responding: Bool @@ -132,6 +156,36 @@ final class CLIServiceTests: XCTestCase { } } +private enum ServiceOperationError: Error { + case failed +} + +private actor ServiceOperationRecorder { + private(set) var startCount = 0 + private(set) var stopCount = 0 + + func recordStart() { + startCount += 1 + } + + func recordStop() { + stopCount += 1 + } +} + +private final class SynchronousCallRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedCount = 0 + + var count: Int { + lock.withLock { recordedCount } + } + + func record() { + lock.withLock { recordedCount += 1 } + } +} + private actor RuntimeSnapshotSequence { private var snapshots: [ServiceHealthChecker.KanataServiceRuntimeSnapshot] diff --git a/Tests/KeyPathTests/Lint/CLIPrivilegeBoundaryLintTests.swift b/Tests/KeyPathTests/Lint/CLIPrivilegeBoundaryLintTests.swift new file mode 100644 index 000000000..14d25a252 --- /dev/null +++ b/Tests/KeyPathTests/Lint/CLIPrivilegeBoundaryLintTests.swift @@ -0,0 +1,69 @@ +import Foundation +@preconcurrency import XCTest + +/// The CLI is an authorized client of KeyPath's narrowly scoped XPC helper. +/// It must not grow a second privileged execution path through launchctl, sudo, +/// osascript, or the generic admin-command executor. +final class CLIPrivilegeBoundaryLintTests: KeyPathTestCase { + func testCLIUsesBrokeredOrHelperBackedPrivilegedOperations() throws { + let root = cliPrivilegeBoundaryRepositoryRoot() + let sourceRoots = [ + "Sources/KeyPathCLI", + "Sources/KeyPathCLISupport", + "Sources/KeyPathAppKit/CLI", + ] + let forbidden = [ + ".launchctl(", + "/bin/launchctl", + "/usr/bin/sudo", + "/usr/bin/osascript", + "AdminCommandExecutor", + "PrivilegedCommandRunner", + ] + var violations: [String] = [] + + for relativeRoot in sourceRoots { + let directory = root.appendingPathComponent(relativeRoot) + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil + ) else { + XCTFail("Could not enumerate \(relativeRoot)") + continue + } + + for case let file as URL in enumerator where file.pathExtension == "swift" { + let source = try String(contentsOf: file, encoding: .utf8) + for (lineIndex, line) in source + .split(separator: "\n", omittingEmptySubsequences: false) + .enumerated() + { + for token in forbidden where line.contains(token) { + let relativePath = file.path.replacingOccurrences( + of: root.path + "/", + with: "" + ) + violations.append("\(relativePath):\(lineIndex + 1): \(token)") + } + } + } + } + + XCTAssertTrue( + violations.isEmpty, + """ + CLI privileged operations must use the explicit helper API or the + installer privilege broker. Direct privileged execution found: + \(violations.joined(separator: "\n")) + """ + ) + } +} + +private func cliPrivilegeBoundaryRepositoryRoot(file: StaticString = #filePath) -> URL { + URL(fileURLWithPath: "\(file)") + .deletingLastPathComponent() // Lint + .deletingLastPathComponent() // KeyPathTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repository root +} diff --git a/docs/bugs/cli-service-control-helper-bypass.md b/docs/bugs/cli-service-control-helper-bypass.md new file mode 100644 index 000000000..292fcf8ee --- /dev/null +++ b/docs/bugs/cli-service-control-helper-bypass.md @@ -0,0 +1,39 @@ +# CLI service control bypassed the privileged helper + +## Symptom + +`keypath-cli service stop` failed for a normal user with `Not privileged to signal service`. +`service start` had the same authorization defect, and `service restart` composed both paths. + +## Root cause + +`SystemFacade` invoked `launchctl kill` and `launchctl kickstart` directly even though the +bundled CLI is an explicitly trusted client of KeyPath's privileged XPC helper. The commands +therefore depended on the caller already being root instead of using KeyPath's authorization +boundary. + +## Resolution + +The helper protocol now exposes narrowly scoped start and stop operations for the fixed +`system/com.keypath.kanata` service target. `SystemFacade` calls those operations and retains +its independent runtime postcondition checks. The helper contract version was advanced so an +older helper cannot be mistaken for a compatible one. + +Lifecycle commands now bootstrap the same runtime dependencies as `service status`, invalidate +pre-transition health evidence, and poll fresh snapshots. Startup verification allows for the +LaunchDaemon's 10-second throttle interval. Without those pieces, the privileged operation could +succeed while the CLI falsely reported failure because it had no launchctl targets or reused a +pre-transition snapshot. + +`CLIPrivilegeBoundaryLintTests` scans the CLI source surfaces for direct `launchctl`, `sudo`, +`osascript`, and generic privileged-command execution. Installer, repair, and uninstall remain +on the existing installer privilege broker. + +## Verification + +- The supported CLI lifecycle suite passes. +- The privilege-boundary lint passes. +- The installed normal-user CLI completes `service restart --json` without `sudo` and reports + `{"restarted": true}`. +- The installed app returns to an operational state with a fresh helper, running Kanata, and + healthy VirtualHID. From 120063ad3af42bdee0f63ca685663326c816ee13 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 06:50:19 -0700 Subject: [PATCH 79/99] Add fail-closed physical HID capture matrix --- Scripts/lab/hid-capture-jig-client | 115 ++ Scripts/lab/hid-capture-jig-tool | 79 ++ Scripts/lab/hid-capture-jig/Package.swift | 24 + .../lab/hid-capture-jig/Resources/Info.plist | 28 + .../Resources/generate_jig_icon.swift | 191 +++ .../HIDCaptureCore/CaptureBrandMotion.swift | 51 + .../HIDCaptureCore/CaptureLayout.swift | 35 + .../HIDCaptureCore/CaptureSession.swift | 285 +++++ .../HIDCaptureCore/KeycapBurstModel.swift | 153 +++ .../HIDCaptureCore/SystemReadiness.swift | 178 +++ .../Sources/HIDCaptureJig/main.swift | 1077 +++++++++++++++++ .../CaptureBrandMotionTests.swift | 68 ++ .../CaptureLayoutTests.swift | 24 + .../CaptureSessionTests.swift | 148 +++ .../KeycapBurstModelTests.swift | 129 ++ .../SystemReadinessTests.swift | 69 ++ Scripts/lab/hid-capture-jig/build-app.sh | 21 + Scripts/lab/physical-hid-capture-run | 556 +++++++++ Scripts/lab/physical-hid-shift-matrix | 272 +++++ Scripts/lab/pico-hid-fixture-client | 95 +- Scripts/lab/pico-hid-fixture-tool | 68 +- Scripts/lab/pico-hid-fixture/README.md | 130 +- .../src/fixture_button_feedback.c | 46 + .../src/fixture_button_feedback.h | 28 + .../lab/pico-hid-fixture/src/fixture_core.c | 23 + .../lab/pico-hid-fixture/src/fixture_core.h | 2 + .../src/fixture_presentation.h | 1 + .../src/fixture_visual_model.c | 8 + .../src/fixture_visual_model.h | 6 + .../main/CMakeLists.txt | 76 +- .../main/Kconfig.projbuild | 8 + .../main/app_main.c | 36 + .../main/fixture_board.c | 57 +- .../main/fixture_board.h | 11 + .../main/fixture_config.h.in | 2 + .../main/fixture_display.c | 253 +++- .../main/fixture_display.h | 13 + .../main/fixture_http.c | 206 +++- .../main/fixture_http.h | 3 + .../main/fixture_runtime.c | 100 +- .../main/fixture_runtime.h | 5 + .../partitions.csv | 6 + .../sdkconfig.defaults | 8 +- .../sdkconfig.qemu.defaults | 3 + .../tests/fixture_core_tests.c | 99 ++ .../tests/fixtures/baseline.txt | 1 + .../pico-hid-fixture/tests/fixtures/burst.txt | 1 + .../tests/fixtures/shift-and-symbols.txt | 1 + .../tests/run-esp32-qemu-smoke.sh | 12 + .../lab/pico-hid-fixture/tests/run-tests.sh | 1 + .../lab/tests/hid-capture-jig-client-tests.py | 113 ++ .../tests/physical-hid-capture-run-tests.py | 435 +++++++ .../tests/physical-hid-shift-matrix-tests.py | 92 ++ .../tests/pico-hid-fixture-client-tests.py | 61 +- .../lab/tests/pico-hid-fixture-tool-tests.py | 43 + .../keypath-hid-fixture-first-board.md | 28 +- .../keypath-hid-fixture-physical-results.md | 134 ++ 57 files changed, 5641 insertions(+), 77 deletions(-) create mode 100755 Scripts/lab/hid-capture-jig-client create mode 100755 Scripts/lab/hid-capture-jig-tool create mode 100644 Scripts/lab/hid-capture-jig/Package.swift create mode 100644 Scripts/lab/hid-capture-jig/Resources/Info.plist create mode 100644 Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift create mode 100644 Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureBrandMotion.swift create mode 100644 Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureLayout.swift create mode 100644 Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift create mode 100644 Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift create mode 100644 Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift create mode 100644 Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift create mode 100644 Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift create mode 100644 Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureLayoutTests.swift create mode 100644 Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift create mode 100644 Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift create mode 100644 Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift create mode 100755 Scripts/lab/hid-capture-jig/build-app.sh create mode 100755 Scripts/lab/physical-hid-capture-run create mode 100755 Scripts/lab/physical-hid-shift-matrix create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.c create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.h create mode 100644 Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/partitions.csv create mode 100644 Scripts/lab/pico-hid-fixture/tests/fixtures/baseline.txt create mode 100644 Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt create mode 100644 Scripts/lab/pico-hid-fixture/tests/fixtures/shift-and-symbols.txt create mode 100755 Scripts/lab/tests/hid-capture-jig-client-tests.py create mode 100644 Scripts/lab/tests/physical-hid-capture-run-tests.py create mode 100644 Scripts/lab/tests/physical-hid-shift-matrix-tests.py create mode 100644 docs/testing/keypath-hid-fixture-physical-results.md diff --git a/Scripts/lab/hid-capture-jig-client b/Scripts/lab/hid-capture-jig-client new file mode 100755 index 000000000..1bd1bc70c --- /dev/null +++ b/Scripts/lab/hid-capture-jig-client @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Control the KeyPath HID Capture Jig through its local file-RPC channel.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import sys +import time +import uuid + + +def state_directory() -> pathlib.Path: + override = os.environ.get("KEYPATH_CAPTURE_JIG_STATE_DIR") + if override: + return pathlib.Path(override) + return pathlib.Path.home() / ".local/state/keypath-hid-capture-jig" + + +def atomic_json_write(path: pathlib.Path, value: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + temporary.chmod(0o600) + os.replace(temporary, path) + + +def send(action: str, **fields: object) -> dict[str, object]: + directory = state_directory() + request_id = str(uuid.uuid4()) + command = {"id": request_id, "action": action} + command.update({key: value for key, value in fields.items() if value is not None}) + atomic_json_write(directory / "command.json", command) + + timeout = float(os.environ.get("KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT", "5")) + deadline = time.monotonic() + timeout + response_path = directory / "response.json" + while time.monotonic() < deadline: + try: + response = json.loads(response_path.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + time.sleep(0.025) + continue + if response.get("id") == request_id: + return response + time.sleep(0.025) + raise RuntimeError( + f"HID Capture Jig did not answer within {timeout:g}s; launch it with " + "Scripts/lab/hid-capture-jig-tool open" + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("status") + commands.add_parser("focus") + commands.add_parser("reset") + commands.add_parser("finalize") + commands.add_parser("quit") + + arm = commands.add_parser("arm") + arm.add_argument("--run-id", required=True) + arm.add_argument("--expected", required=True, type=pathlib.Path) + arm.add_argument("--timeout-ms", type=int, default=10_000) + arm.add_argument("--settle-ms", type=int, default=250) + + wait = commands.add_parser("wait") + wait.add_argument("--timeout-seconds", type=float, default=15.0) + wait.add_argument("--poll-ms", type=int, default=100) + return parser + + +def print_response(response: dict[str, object]) -> None: + print(json.dumps(response, indent=2, sort_keys=True)) + + +def main() -> int: + arguments = build_parser().parse_args() + try: + if arguments.command == "arm": + if not arguments.expected.is_file(): + raise RuntimeError(f"expected-output file does not exist: {arguments.expected}") + response = send( + "arm", + runID=arguments.run_id, + expected=arguments.expected.read_text(), + timeoutMs=arguments.timeout_ms, + settleMs=arguments.settle_ms, + ) + elif arguments.command == "wait": + deadline = time.monotonic() + arguments.timeout_seconds + response = send("status") + while time.monotonic() < deadline: + state = response.get("snapshot", {}).get("state") + if state in {"passed", "failed"}: + print_response(response) + return 0 if state == "passed" else 1 + time.sleep(arguments.poll_ms / 1000) + response = send("status") + response = send("finalize") + else: + response = send(arguments.command) + except (OSError, ValueError, RuntimeError) as error: + print(f"hid-capture-jig-client: {error}", file=sys.stderr) + return 2 + + print_response(response) + return 0 if response.get("ok") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/hid-capture-jig-tool b/Scripts/lab/hid-capture-jig-tool new file mode 100755 index 000000000..9dee29b53 --- /dev/null +++ b/Scripts/lab/hid-capture-jig-tool @@ -0,0 +1,79 @@ +#!/bin/bash +set -euo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +package_dir="$script_dir/hid-capture-jig" +client="$script_dir/hid-capture-jig-client" +app_path=${KEYPATH_CAPTURE_JIG_APP:-"$HOME/.cache/keypath-hid-capture-jig/KeyPath HID Capture Jig.app"} + +usage() { + printf '%s\n' \ + "Usage: Scripts/lab/hid-capture-jig-tool COMMAND" \ + "" \ + "Commands:" \ + " build Build and ad-hoc sign the isolated capture app" \ + " test Run capture-core and control-client tests" \ + " open Build, open, focus, and verify the capture app" \ + " status Query the running capture app" \ + " close Ask the capture app to quit" +} + +case ${1:-} in + build) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + "$package_dir/build-app.sh" + ;; + test) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + swift test --package-path "$package_dir" --jobs "${KEYPATH_CAPTURE_JIG_BUILD_JOBS:-4}" + python3 "$script_dir/tests/hid-capture-jig-client-tests.py" + ;; + open) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + if pgrep -x HIDCaptureJig >/dev/null; then + "$client" quit >/dev/null 2>&1 || true + for _ in {1..50}; do + pgrep -x HIDCaptureJig >/dev/null || break + sleep 0.1 + done + if pgrep -x HIDCaptureJig >/dev/null; then + printf '%s\n' "hid-capture-jig-tool: existing capture app did not quit" >&2 + exit 1 + fi + fi + "$package_dir/build-app.sh" + open "$app_path" + result=$(mktemp -t keypath-hid-capture-jig-status.XXXXXX) + trap 'rm -f "$result"' EXIT + for _ in {1..50}; do + if KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT=0.2 "$client" status >"$result" 2>/dev/null; then + cat "$result" + exit 0 + fi + sleep 0.1 + done + printf '%s\n' "hid-capture-jig-tool: app opened but did not become controllable" >&2 + exit 1 + ;; + status) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + "$client" status + ;; + close) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + "$client" quit + for _ in {1..50}; do + pgrep -x HIDCaptureJig >/dev/null || exit 0 + sleep 0.1 + done + printf '%s\n' "hid-capture-jig-tool: capture app did not quit" >&2 + exit 1 + ;; + help|-h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/Scripts/lab/hid-capture-jig/Package.swift b/Scripts/lab/hid-capture-jig/Package.swift new file mode 100644 index 000000000..068af15d7 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "KeyPathHIDCaptureJig", + platforms: [.macOS(.v14)], + products: [ + .library(name: "HIDCaptureCore", targets: ["HIDCaptureCore"]), + .executable(name: "HIDCaptureJig", targets: ["HIDCaptureJig"]), + ], + targets: [ + .target(name: "HIDCaptureCore"), + .executableTarget( + name: "HIDCaptureJig", + dependencies: ["HIDCaptureCore"] + ), + .testTarget( + name: "HIDCaptureCoreTests", + dependencies: ["HIDCaptureCore"] + ), + ], + swiftLanguageModes: [.v5] +) diff --git a/Scripts/lab/hid-capture-jig/Resources/Info.plist b/Scripts/lab/hid-capture-jig/Resources/Info.plist new file mode 100644 index 000000000..4701b41e7 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + HIDCaptureJig + CFBundleIdentifier + com.keypath.lab.HIDCaptureJig + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + KeyPath HID Capture Jig + CFBundleIconFile + AppIcon + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + + diff --git a/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift b/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift new file mode 100644 index 000000000..7c1aff79d --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift @@ -0,0 +1,191 @@ +import AppKit +import Foundation + +guard CommandLine.arguments.count == 2 else { + fputs("usage: generate_jig_icon.swift OUTPUT.iconset\n", stderr) + exit(2) +} + +let outputDirectory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) +try FileManager.default.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true +) + +struct IconVariant { + let points: Int + let scale: Int + + var filename: String { + scale == 1 + ? "icon_\(points)x\(points).png" + : "icon_\(points)x\(points)@\(scale)x.png" + } +} + +let variants = [ + IconVariant(points: 16, scale: 1), + IconVariant(points: 16, scale: 2), + IconVariant(points: 32, scale: 1), + IconVariant(points: 32, scale: 2), + IconVariant(points: 128, scale: 1), + IconVariant(points: 128, scale: 2), + IconVariant(points: 256, scale: 1), + IconVariant(points: 256, scale: 2), + IconVariant(points: 512, scale: 1), + IconVariant(points: 512, scale: 2), +] + +func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = 1) -> NSColor { + NSColor(calibratedRed: red, green: green, blue: blue, alpha: alpha) +} + +func roundedRect(_ rect: NSRect, radius: CGFloat, fill: NSColor, stroke: NSColor? = nil, + lineWidth: CGFloat = 1) { + let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + fill.setFill() + path.fill() + if let stroke { + stroke.setStroke() + path.lineWidth = lineWidth + path.stroke() + } +} + +func render(size: CGFloat) -> NSImage { + let image = NSImage(size: NSSize(width: size, height: size)) + image.lockFocus() + defer { image.unlockFocus() } + + NSGraphicsContext.current?.imageInterpolation = .high + let canvas = NSRect(x: 0, y: 0, width: size, height: size) + NSColor.clear.setFill() + canvas.fill() + + let tile = canvas.insetBy(dx: size * 0.045, dy: size * 0.045) + let tilePath = NSBezierPath( + roundedRect: tile, + xRadius: size * 0.205, + yRadius: size * 0.205 + ) + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.42) + shadow.shadowBlurRadius = size * 0.055 + shadow.shadowOffset = NSSize(width: 0, height: -size * 0.025) + shadow.set() + color(0.035, 0.075, 0.095).setFill() + tilePath.fill() + NSGraphicsContext.restoreGraphicsState() + + let background = NSGradient(colors: [ + color(0.16, 0.28, 0.32), + color(0.025, 0.07, 0.09), + ])! + background.draw(in: tilePath, angle: -72) + color(0.42, 0.60, 0.62, 0.45).setStroke() + tilePath.lineWidth = max(1, size * 0.009) + tilePath.stroke() + + // Machined base plate and its four registration bolts. + let plate = NSRect( + x: size * 0.17, y: size * 0.20, + width: size * 0.66, height: size * 0.49 + ) + roundedRect( + plate, + radius: size * 0.075, + fill: color(0.60, 0.70, 0.70), + stroke: color(0.84, 0.91, 0.89, 0.78), + lineWidth: size * 0.012 + ) + let insetPlate = plate.insetBy(dx: size * 0.035, dy: size * 0.035) + roundedRect( + insetPlate, + radius: size * 0.055, + fill: color(0.10, 0.18, 0.20), + stroke: color(0.03, 0.07, 0.08), + lineWidth: size * 0.008 + ) + for point in [ + NSPoint(x: plate.minX + size * 0.055, y: plate.minY + size * 0.055), + NSPoint(x: plate.maxX - size * 0.055, y: plate.minY + size * 0.055), + NSPoint(x: plate.minX + size * 0.055, y: plate.maxY - size * 0.055), + NSPoint(x: plate.maxX - size * 0.055, y: plate.maxY - size * 0.055), + ] { + let bolt = NSRect( + x: point.x - size * 0.018, y: point.y - size * 0.018, + width: size * 0.036, height: size * 0.036 + ) + color(0.82, 0.91, 0.89).setFill() + NSBezierPath(ovalIn: bolt).fill() + } + + // Opposing clamp jaws make this read as a fixture instead of a keyboard app. + let jawColor = color(0.76, 0.84, 0.82) + let jawEdge = color(0.38, 0.52, 0.53) + let leftPost = NSRect(x: size * 0.22, y: size * 0.31, width: size * 0.13, height: size * 0.27) + let rightPost = NSRect(x: size * 0.65, y: size * 0.31, width: size * 0.13, height: size * 0.27) + roundedRect(leftPost, radius: size * 0.035, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.01) + roundedRect(rightPost, radius: size * 0.035, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.01) + roundedRect( + NSRect(x: size * 0.29, y: size * 0.39, width: size * 0.14, height: size * 0.10), + radius: size * 0.025, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.008 + ) + roundedRect( + NSRect(x: size * 0.57, y: size * 0.39, width: size * 0.14, height: size * 0.10), + radius: size * 0.025, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.008 + ) + + // The amber test key is intentionally generic: it is not the KeyPath product mark. + let keyBase = NSRect(x: size * 0.37, y: size * 0.31, width: size * 0.26, height: size * 0.27) + roundedRect( + keyBase, + radius: size * 0.055, + fill: color(0.88, 0.29, 0.055), + stroke: color(1.0, 0.62, 0.13), + lineWidth: size * 0.012 + ) + let keyFace = NSRect(x: size * 0.395, y: size * 0.365, width: size * 0.21, height: size * 0.16) + roundedRect( + keyFace, + radius: size * 0.038, + fill: color(1.0, 0.88, 0.58), + stroke: color(1.0, 0.67, 0.19), + lineWidth: size * 0.008 + ) + + // A measurement probe completes the test-jig metaphor. + let probe = NSBezierPath() + probe.move(to: NSPoint(x: size * 0.50, y: size * 0.81)) + probe.line(to: NSPoint(x: size * 0.50, y: size * 0.61)) + color(0.86, 0.95, 0.92).setStroke() + probe.lineWidth = max(1.5, size * 0.026) + probe.lineCapStyle = .round + probe.stroke() + let collar = NSRect(x: size * 0.43, y: size * 0.72, width: size * 0.14, height: size * 0.09) + roundedRect(collar, radius: size * 0.025, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.009) + let probeTip = NSRect(x: size * 0.475, y: size * 0.575, width: size * 0.05, height: size * 0.05) + color(0.34, 0.88, 0.70).setFill() + NSBezierPath(ovalIn: probeTip).fill() + + return image +} + +func writePNG(_ image: NSImage, to url: URL) throws { + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let data = bitmap.representation(using: .png, properties: [:]) else { + throw NSError( + domain: "KeyPathJigIcon", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Could not encode icon PNG"] + ) + } + try data.write(to: url, options: .atomic) +} + +for variant in variants { + let pixels = CGFloat(variant.points * variant.scale) + try writePNG(render(size: pixels), to: outputDirectory.appendingPathComponent(variant.filename)) +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureBrandMotion.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureBrandMotion.swift new file mode 100644 index 000000000..0e6f892b2 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureBrandMotion.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A tiny, deterministic motion model shared by the Jig's branded drawing. +/// Keeping animation math out of AppKit makes it testable and ensures visual +/// feedback never changes capture state or timing. +public struct CaptureBrandMotion: Equatable, Sendable { + public let completion: Double + public let breath: Double + public let eventPulse: Double + public let glintPosition: Double + + public static func resolve( + snapshot: CaptureSnapshot, + nowNs: UInt64, + reduceMotion: Bool + ) -> CaptureBrandMotion { + let expectedCount = snapshot.expected.count + let completion = expectedCount == 0 + ? 0 + : min(1, Double(snapshot.received.count) / Double(expectedCount)) + + if reduceMotion { + return CaptureBrandMotion( + completion: completion, + breath: 0.5, + eventPulse: 0, + glintPosition: completion + ) + } + + let cycleNs: UInt64 = snapshot.state == .capturing ? 1_900_000_000 : 3_800_000_000 + let phase = Double(nowNs % cycleNs) / Double(cycleNs) + let breath = (sin(phase * .pi * 2 - .pi / 2) + 1) / 2 + + let eventPulse: Double + if let lastEventAtNs = snapshot.lastEventAtNs, nowNs >= lastEventAtNs { + let elapsed = Double(nowNs - lastEventAtNs) / 420_000_000 + let remaining = max(0, 1 - min(1, elapsed)) + eventPulse = remaining * remaining * (3 - 2 * remaining) + } else { + eventPulse = 0 + } + + return CaptureBrandMotion( + completion: completion, + breath: breath, + eventPulse: eventPulse, + glintPosition: phase + ) + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureLayout.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureLayout.swift new file mode 100644 index 000000000..62706e345 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureLayout.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum CaptureLayoutMode: String, Sendable { + case regular + case compact + case tiny +} + +public struct CaptureLayoutMetrics: Equatable, Sendable { + public let mode: CaptureLayoutMode + public let padding: Double + public let headerHeight: Double + public let stateFontSize: Double + public let fieldHeight: Double + public let showsFooter: Bool + + public static func resolve(width: Double, height: Double) -> CaptureLayoutMetrics { + if width >= 700, height >= 540 { + return CaptureLayoutMetrics( + mode: .regular, padding: 26, headerHeight: 54, stateFontSize: 38, + fieldHeight: 34, showsFooter: true + ) + } + if width >= 480, height >= 360 { + return CaptureLayoutMetrics( + mode: .compact, padding: 18, headerHeight: 44, stateFontSize: 30, + fieldHeight: 30, showsFooter: true + ) + } + return CaptureLayoutMetrics( + mode: .tiny, padding: 10, headerHeight: 32, stateFontSize: 23, + fieldHeight: 26, showsFooter: false + ) + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift new file mode 100644 index 000000000..cd6cca85c --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift @@ -0,0 +1,285 @@ +import Foundation + +public enum CaptureRunState: String, Codable, Sendable { + case idle + case armed + case capturing + case passed + case failed +} + +public enum CapturedEventPhase: String, Codable, Sendable { + case down + case up + case flagsChanged +} + +public struct CapturedKeyEvent: Codable, Equatable, Sendable { + public let sequence: Int + public let phase: CapturedEventPhase + public let keyCode: UInt16 + public let characters: String + public let modifiers: UInt + public let isRepeat: Bool + public let timestampNs: UInt64 + + public init( + sequence: Int, + phase: CapturedEventPhase, + keyCode: UInt16, + characters: String, + modifiers: UInt, + isRepeat: Bool, + timestampNs: UInt64 + ) { + self.sequence = sequence + self.phase = phase + self.keyCode = keyCode + self.characters = characters + self.modifiers = modifiers + self.isRepeat = isRepeat + self.timestampNs = timestampNs + } +} + +public struct CaptureSnapshot: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let runID: String + public let state: CaptureRunState + public let focused: Bool + public let expected: String + public let received: String + public let issues: [String] + public let events: [CapturedKeyEvent] + public let pressedKeyCodes: [UInt16] + public let activeModifiers: UInt + public let repeatEvents: Int + public let duplicateDownEvents: Int + public let unmatchedUpEvents: Int + public let startedAtNs: UInt64? + public let firstEventAtNs: UInt64? + public let lastEventAtNs: UInt64? + public let deadlineAtNs: UInt64? + public let settledForMs: UInt64 + + public var exactMatch: Bool { + received == expected + } + + public var allKeysReleased: Bool { + pressedKeyCodes.isEmpty && activeModifiers == 0 + } +} + +public final class CaptureSession { + private var runID = "" + private var state: CaptureRunState = .idle + private var focused = false + private var expected = "" + private var received = "" + private var events: [CapturedKeyEvent] = [] + private var pressedKeyCodes: Set = [] + private var activeModifiers: UInt = 0 + private var repeatEvents = 0 + private var duplicateDownEvents = 0 + private var unmatchedUpEvents = 0 + private var focusLost = false + private var finalized = false + private var terminalFailure = false + private var startedAtNs: UInt64? + private var firstEventAtNs: UInt64? + private var lastEventAtNs: UInt64? + private var deadlineAtNs: UInt64? + private var settleNs: UInt64 = 250_000_000 + + public init() {} + + public func reset() { + runID = "" + state = .idle + focused = false + expected = "" + received = "" + events.removeAll(keepingCapacity: true) + pressedKeyCodes.removeAll(keepingCapacity: true) + activeModifiers = 0 + repeatEvents = 0 + duplicateDownEvents = 0 + unmatchedUpEvents = 0 + focusLost = false + finalized = false + terminalFailure = false + startedAtNs = nil + firstEventAtNs = nil + lastEventAtNs = nil + deadlineAtNs = nil + } + + @discardableResult + public func arm( + runID: String, + expected: String, + timeoutMs: UInt64, + settleMs: UInt64, + focused: Bool, + nowNs: UInt64 + ) -> Bool { + reset() + guard !runID.isEmpty, runID.count <= 80, !expected.isEmpty, + timeoutMs >= 250, timeoutMs <= 300_000, + settleMs >= 50, settleMs <= 5_000, + focused + else { + self.runID = runID + self.expected = expected + self.focused = focused + state = .failed + return false + } + self.runID = runID + self.expected = Self.normalize(expected) + self.focused = true + startedAtNs = nowNs + deadlineAtNs = nowNs &+ timeoutMs &* 1_000_000 + settleNs = settleMs &* 1_000_000 + state = .armed + return true + } + + public func noteFocus(_ focused: Bool, nowNs: UInt64) { + self.focused = focused + if !focused, state == .capturing { + focusLost = true + terminalFailure = true + state = .failed + lastEventAtNs = nowNs + } + } + + public func record( + phase: CapturedEventPhase, + keyCode: UInt16, + characters: String, + modifiers: UInt, + isRepeat: Bool, + nowNs: UInt64 + ) { + guard state != .idle, !runID.isEmpty else { return } + let normalized = phase == .down ? Self.normalize(characters) : "" + events.append(CapturedKeyEvent( + sequence: events.count + 1, + phase: phase, + keyCode: keyCode, + characters: normalized, + modifiers: modifiers, + isRepeat: isRepeat, + timestampNs: nowNs + )) + if firstEventAtNs == nil { firstEventAtNs = nowNs } + lastEventAtNs = nowNs + activeModifiers = modifiers + + switch phase { + case .down: + if !pressedKeyCodes.insert(keyCode).inserted { duplicateDownEvents += 1 } + if isRepeat { repeatEvents += 1 } + received.append(normalized) + case .up: + if pressedKeyCodes.remove(keyCode) == nil { unmatchedUpEvents += 1 } + case .flagsChanged: + break + } + + if state != .failed { state = .capturing } + evaluate(nowNs: nowNs) + } + + public func finalize(nowNs: UInt64) { + guard state != .idle else { return } + finalized = true + evaluate(nowNs: nowNs) + } + + public func snapshot(nowNs: UInt64) -> CaptureSnapshot { + evaluate(nowNs: nowNs) + let settledForNs = lastEventAtNs.map { nowNs >= $0 ? nowNs - $0 : 0 } ?? 0 + return CaptureSnapshot( + schemaVersion: 1, + runID: runID, + state: state, + focused: focused, + expected: expected, + received: received, + issues: issues(nowNs: nowNs), + events: events, + pressedKeyCodes: pressedKeyCodes.sorted(), + activeModifiers: activeModifiers, + repeatEvents: repeatEvents, + duplicateDownEvents: duplicateDownEvents, + unmatchedUpEvents: unmatchedUpEvents, + startedAtNs: startedAtNs, + firstEventAtNs: firstEventAtNs, + lastEventAtNs: lastEventAtNs, + deadlineAtNs: deadlineAtNs, + settledForMs: settledForNs / 1_000_000 + ) + } + + private func evaluate(nowNs: UInt64) { + guard state != .idle else { return } + if terminalFailure { + state = .failed + return + } + if focusLost || repeatEvents > 0 || duplicateDownEvents > 0 || unmatchedUpEvents > 0 || + mismatchIndex() != nil || received.count > expected.count { + terminalFailure = true + state = .failed + return + } + let timedOut = deadlineAtNs.map { nowNs >= $0 } ?? false + if timedOut || finalized { + if received == expected && pressedKeyCodes.isEmpty && activeModifiers == 0 { + state = .passed + } else { + terminalFailure = true + state = .failed + } + return + } + if received == expected, pressedKeyCodes.isEmpty, activeModifiers == 0, + let lastEventAtNs, nowNs >= lastEventAtNs, + nowNs - lastEventAtNs >= settleNs { + state = .passed + } + } + + private func issues(nowNs: UInt64) -> [String] { + var result: [String] = [] + if focusLost { result.append("capture focus was lost") } + if repeatEvents > 0 { result.append("received \(repeatEvents) repeated key-down event(s)") } + if duplicateDownEvents > 0 { result.append("received \(duplicateDownEvents) key-down event(s) before release") } + if unmatchedUpEvents > 0 { result.append("received \(unmatchedUpEvents) key-up event(s) without a matching key-down") } + if let index = mismatchIndex() { result.append("received output differs from expected output at character \(index)") } + if received.count > expected.count { result.append("received \(received.count - expected.count) extra character(s)") } + let terminal = finalized || (deadlineAtNs.map { nowNs >= $0 } ?? false) + if terminal, !focused, firstEventAtNs == nil { result.append("capture was not focused before input arrived") } + if terminal, received.count < expected.count { result.append("missing \(expected.count - received.count) expected character(s)") } + if terminal, !pressedKeyCodes.isEmpty { result.append("unreleased key code(s): \(pressedKeyCodes.sorted())") } + if terminal, activeModifiers != 0 { result.append("unreleased modifier flags: \(activeModifiers)") } + return result + } + + private func mismatchIndex() -> Int? { + let actual = Array(received) + let target = Array(expected) + for index in 0 ..< min(actual.count, target.count) where actual[index] != target[index] { + return index + } + return nil + } + + private static func normalize(_ value: String) -> String { + value.replacingOccurrences(of: "\r", with: "\n") + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift new file mode 100644 index 000000000..c533791ff --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift @@ -0,0 +1,153 @@ +import Foundation + +public struct KeycapBurstItem: Equatable, Sendable { + public let sequence: Int + public let keyCode: UInt16 + public let label: String + public let pressDepth: Double + public let opacity: Double + public let xOffset: Double + public let yOffset: Double + public let scale: Double + public let isPressed: Bool + public let isRepeat: Bool +} + +public struct KeycapBurstOutput: Equatable, Sendable { + public let items: [KeycapBurstItem] + public let totalPresses: Int + public let presentedPresses: Int + public let recentPresses: Int + public let intensity: Double + public let isAnimating: Bool +} + +public enum KeycapBurstModel { + private static let visibleLifetimeNs: UInt64 = 900_000_000 + private static let pressReleaseNs: UInt64 = 160_000_000 + private static let burstWindowNs: UInt64 = 250_000_000 + private static let presentationCadenceNs: UInt64 = 36_000_000 + + public static func resolve( + events: [CapturedKeyEvent], + pressedKeyCodes: [UInt16], + nowNs: UInt64, + reduceMotion: Bool, + limit: Int = 10 + ) -> KeycapBurstOutput { + let downEvents = events.filter { $0.phase == .down } + var lastPresentationNs: UInt64 = 0 + let presented = downEvents.map { event -> (event: CapturedKeyEvent, timestampNs: UInt64) in + let earliest = lastPresentationNs == 0 + ? event.timestampNs + : lastPresentationNs &+ (reduceMotion ? 0 : presentationCadenceNs) + let timestamp = max(event.timestampNs, earliest) + lastPresentationNs = timestamp + return (event, timestamp) + } + let activeEvents = presented.filter { + nowNs >= $0.timestampNs && nowNs - $0.timestampNs <= visibleLifetimeNs + } + let visible = Array(activeEvents.suffix(max(0, limit))) + let pressed = Set(pressedKeyCodes) + let recentRealPresses = downEvents.reduce(into: 0) { count, event in + if nowNs >= event.timestampNs, nowNs - event.timestampNs <= burstWindowNs { + count += 1 + } + } + let recentPresentedPresses = presented.reduce(into: 0) { count, item in + if nowNs >= item.timestampNs, nowNs - item.timestampNs <= burstWindowNs { + count += 1 + } + } + let recentPresses = max(recentRealPresses, recentPresentedPresses) + let intensity = min(1, Double(recentPresses) / 8) + let presentedPresses = presented.reduce(into: 0) { count, item in + if nowNs >= item.timestampNs { count += 1 } + } + let isAnimating = lastPresentationNs > 0 && + (nowNs < lastPresentationNs || nowNs - lastPresentationNs <= visibleLifetimeNs) + + let items = visible.enumerated().map { index, presentedEvent in + let event = presentedEvent.event + let ageNs = nowNs - presentedEvent.timestampNs + let age = min(1, Double(ageNs) / Double(visibleLifetimeNs)) + let pressDepth: Double + let opacity: Double + let xOffset: Double + let yOffset: Double + let scale: Double + + if reduceMotion { + pressDepth = 0 + opacity = 1 - 0.42 * age + xOffset = Double(index - visible.count / 2) * 2 + yOffset = Double(index) * 2.5 + scale = 0.96 + Double(index) * 0.004 + } else { + let release = min(1, Double(ageNs) / Double(pressReleaseNs)) + pressDepth = pow(1 - release, 2) + let fadeStart = 0.42 + let fade = age <= fadeStart ? 0 : (age - fadeStart) / (1 - fadeStart) + opacity = 1 - 0.82 * fade * fade + let arrival = 1 - pow(1 - release, 3) + let fan = [-9.0, 6.0, -4.0, 10.0, -7.0, 3.0][event.sequence % 6] + xOffset = fan * arrival * (0.45 + intensity * 0.55) + yOffset = Double(index) * (3.3 + intensity * 1.8) * arrival + scale = (0.94 + Double(index) * 0.005) * (1 - pressDepth * 0.035) + } + + return KeycapBurstItem( + sequence: event.sequence, + keyCode: event.keyCode, + label: label(for: event), + pressDepth: pressDepth, + opacity: max(0, min(1, opacity)), + xOffset: xOffset, + yOffset: yOffset, + scale: scale, + isPressed: pressed.contains(event.keyCode), + isRepeat: event.isRepeat + ) + } + + return KeycapBurstOutput( + items: items, + totalPresses: downEvents.count, + presentedPresses: presentedPresses, + recentPresses: recentPresses, + intensity: intensity, + isAnimating: isAnimating + ) + } + + public static func label(for event: CapturedKeyEvent) -> String { + switch event.characters { + case " ": return "SPACE" + case "\r", "\n": return "RETURN" + case "\t": return "TAB" + case "\u{7f}", "\u{8}": return "DELETE" + case "\u{1b}": return "ESC" + case "\u{f700}": return "UP" + case "\u{f701}": return "DOWN" + case "\u{f702}": return "LEFT" + case "\u{f703}": return "RIGHT" + case "": + switch event.keyCode { + case 36, 76: return "RETURN" + case 48: return "TAB" + case 49: return "SPACE" + case 51, 117: return "DELETE" + case 53: return "ESC" + case 123: return "LEFT" + case 124: return "RIGHT" + case 125: return "DOWN" + case 126: return "UP" + default: return "K\(event.keyCode)" + } + default: + let value = event.characters.uppercased() + return value.count <= 6 ? value : String(value.prefix(5)) + "…" + } + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift new file mode 100644 index 000000000..dfd86d9f8 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift @@ -0,0 +1,178 @@ +import Foundation + +public struct SystemResourceSample: Codable, Equatable, Sendable { + public let timestampNs: UInt64 + public let cpuUtilization: Double? + public let loadAveragePerCore: Double + public let availableMemoryBytes: UInt64 + public let physicalMemoryBytes: UInt64 + public let threadCount: Int + public let logicalProcessorCount: Int + public let memoryPressureLevel: Int + public let thermalState: String + + public init( + timestampNs: UInt64, + cpuUtilization: Double?, + loadAveragePerCore: Double, + availableMemoryBytes: UInt64, + physicalMemoryBytes: UInt64, + threadCount: Int, + logicalProcessorCount: Int, + memoryPressureLevel: Int, + thermalState: String + ) { + self.timestampNs = timestampNs + self.cpuUtilization = cpuUtilization + self.loadAveragePerCore = loadAveragePerCore + self.availableMemoryBytes = availableMemoryBytes + self.physicalMemoryBytes = physicalMemoryBytes + self.threadCount = threadCount + self.logicalProcessorCount = logicalProcessorCount + self.memoryPressureLevel = memoryPressureLevel + self.thermalState = thermalState + } +} + +public enum SystemReadinessState: String, Codable, Sendable { + case calibrating + case ready + case waiting +} + +public struct SystemReadinessAssessment: Codable, Equatable, Sendable { + public let state: SystemReadinessState + public let canProceed: Bool + public let summary: String + public let detail: String + public let issues: [String] + public let suggestions: [String] + public let stableSamples: Int + public let requiredStableSamples: Int + + public static let calibrating = SystemReadinessAssessment( + state: .calibrating, + canProceed: false, + summary: "Measuring CPU, load, memory, and temperature", + detail: "Keep this window open; readiness normally takes three seconds.", + issues: [], + suggestions: ["Wait a few seconds while the Jig establishes a stable baseline."], + stableSamples: 0, + requiredStableSamples: 3 + ) +} + +public enum SystemReadinessModel { + public static let requiredStableSamples = 3 + private static let maximumCPUUtilization = 0.80 + private static let maximumLoadPerCore = 0.90 + private static let minimumAvailableMemoryBytes: UInt64 = 2 * 1_024 * 1_024 * 1_024 + private static let maximumThreadsPerCore = 900 + + public static func resolve( + samples: [SystemResourceSample], + requiredSamples: Int = requiredStableSamples + ) -> SystemReadinessAssessment { + guard requiredSamples > 0, let latest = samples.last else { return .calibrating } + let summary = metricSummary(latest) + let latestIssues = issues(for: latest) + if !latestIssues.isEmpty { + return SystemReadinessAssessment( + state: .waiting, + canProceed: false, + summary: summary, + detail: latestIssues.joined(separator: " · "), + issues: latestIssues, + suggestions: suggestions(for: latestIssues), + stableSamples: 0, + requiredStableSamples: requiredSamples + ) + } + + let recent = Array(samples.suffix(requiredSamples)) + let cleanCount = recent.reversed().prefix { issues(for: $0).isEmpty }.count + guard recent.count == requiredSamples, cleanCount == requiredSamples else { + return SystemReadinessAssessment( + state: .calibrating, + canProceed: false, + summary: summary, + detail: "Conditions are improving; confirming a stable window (\(cleanCount)/\(requiredSamples)).", + issues: [], + suggestions: ["Wait a few seconds; the Jig will recheck automatically."], + stableSamples: cleanCount, + requiredStableSamples: requiredSamples + ) + } + + return SystemReadinessAssessment( + state: .ready, + canProceed: true, + summary: summary, + detail: "Resources have stayed within the capture envelope for \(requiredSamples) checks.", + issues: [], + suggestions: [], + stableSamples: requiredSamples, + requiredStableSamples: requiredSamples + ) + } + + private static func issues(for sample: SystemResourceSample) -> [String] { + var issues: [String] = [] + if let cpu = sample.cpuUtilization, cpu >= maximumCPUUtilization { + issues.append("CPU \(percent(cpu)) (limit \(percent(maximumCPUUtilization)))") + } + if sample.loadAveragePerCore >= maximumLoadPerCore { + issues.append(String( + format: "competing work %.1f×/core (limit %.1f×)", + sample.loadAveragePerCore, maximumLoadPerCore + )) + } + let proportionalMemoryFloor = UInt64(Double(sample.physicalMemoryBytes) * 0.08) + let memoryFloor = max(minimumAvailableMemoryBytes, proportionalMemoryFloor) + if sample.availableMemoryBytes < memoryFloor { + issues.append("memory \(gigabytes(sample.availableMemoryBytes)) available (need \(gigabytes(memoryFloor)))") + } + if sample.memoryPressureLevel > 1 { + issues.append("macOS memory pressure elevated") + } + if sample.thermalState == "serious" || sample.thermalState == "critical" { + issues.append("thermal state is \(sample.thermalState)") + } + let processorCount = max(1, sample.logicalProcessorCount) + if sample.threadCount > processorCount * maximumThreadsPerCore { + issues.append("system thread inventory is unusually high (\(sample.threadCount))") + } + return issues + } + + private static func suggestions(for issues: [String]) -> [String] { + var result: [String] = [] + if issues.contains(where: { $0.contains("CPU") || $0.contains("competing work") }) { + result.append("Pause builds, exports, VMs, or other CPU-heavy jobs.") + } + if issues.contains(where: { $0.contains("memory") || $0.contains("thread inventory") }) { + result.append("Close unused apps or VMs to free memory and background workers.") + } + if issues.contains(where: { $0.contains("thermal") }) { + result.append("Let the Mac cool and keep its vents clear before retrying.") + } + result.append("The Jig will recheck automatically; no restart is required.") + return result + } + + private static func metricSummary(_ sample: SystemResourceSample) -> String { + let cpu = sample.cpuUtilization.map { "\(percent($0)) CPU" } ?? "sampling CPU" + return String( + format: "%@ · load %.1f× · %@ available", + cpu, sample.loadAveragePerCore, gigabytes(sample.availableMemoryBytes) + ) + } + + private static func percent(_ value: Double) -> String { + "\(Int((value * 100).rounded()))%" + } + + private static func gigabytes(_ bytes: UInt64) -> String { + String(format: "%.1f GB", Double(bytes) / 1_073_741_824) + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift new file mode 100644 index 000000000..e0841e63b --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -0,0 +1,1077 @@ +import AppKit +import Darwin +import Foundation +import HIDCaptureCore + +private struct ControlCommand: Codable { + let id: String + let action: String + let runID: String? + let expected: String? + let timeoutMs: UInt64? + let settleMs: UInt64? +} + +private struct ControlResponse: Codable { + let id: String + let ok: Bool + let message: String + let processID: Int32 + let snapshot: CaptureSnapshot + let systemReadiness: SystemReadinessAssessment +} + +private func monotonicNow() -> UInt64 { + DispatchTime.now().uptimeNanoseconds +} + +private final class SystemResourceMonitor { + private var samples: [SystemResourceSample] = [] + private var lastSampleNs: UInt64 = 0 + private var previousCPUTicks: (busy: UInt64, total: UInt64)? + private(set) var assessment = SystemReadinessAssessment.calibrating + + @discardableResult + func sampleIfNeeded(nowNs: UInt64, force: Bool = false) -> Bool { + guard force || lastSampleNs == 0 || nowNs - lastSampleNs >= 1_000_000_000 else { + return false + } + lastSampleNs = nowNs + samples.append(capture(nowNs: nowNs)) + if samples.count > 12 { samples.removeFirst(samples.count - 12) } + assessment = SystemReadinessModel.resolve(samples: samples) + return true + } + + private func capture(nowNs: UInt64) -> SystemResourceSample { + let processors = max(1, ProcessInfo.processInfo.activeProcessorCount) + var averages = [Double](repeating: 0, count: 3) + let averageCount = averages.withUnsafeMutableBufferPointer { + getloadavg($0.baseAddress, Int32($0.count)) + } + let oneMinuteLoad = averageCount > 0 ? averages[0] : 0 + return SystemResourceSample( + timestampNs: nowNs, + cpuUtilization: cpuUtilization(), + loadAveragePerCore: max(0, oneMinuteLoad / Double(processors)), + availableMemoryBytes: availableMemoryBytes(), + physicalMemoryBytes: ProcessInfo.processInfo.physicalMemory, + threadCount: integerSysctl("kern.num_taskthreads"), + logicalProcessorCount: processors, + memoryPressureLevel: integerSysctl("kern.memorystatus_vm_pressure_level"), + thermalState: thermalStateName(ProcessInfo.processInfo.thermalState) + ) + } + + private func cpuUtilization() -> Double? { + var info = host_cpu_load_info() + var count = mach_msg_type_number_t( + MemoryLayout.size(ofValue: info) / MemoryLayout.size + ) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count) + } + } + guard result == KERN_SUCCESS else { return nil } + let busy = UInt64(info.cpu_ticks.0) + UInt64(info.cpu_ticks.1) + UInt64(info.cpu_ticks.3) + let total = busy + UInt64(info.cpu_ticks.2) + defer { previousCPUTicks = (busy, total) } + guard let previousCPUTicks, total > previousCPUTicks.total else { return nil } + let totalDelta = total - previousCPUTicks.total + let busyDelta = busy >= previousCPUTicks.busy ? busy - previousCPUTicks.busy : 0 + return min(1, max(0, Double(busyDelta) / Double(totalDelta))) + } + + private func availableMemoryBytes() -> UInt64 { + var info = vm_statistics64() + var count = mach_msg_type_number_t( + MemoryLayout.size(ofValue: info) / MemoryLayout.size + ) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard result == KERN_SUCCESS else { return 0 } + var pageSize: vm_size_t = 0 + guard host_page_size(mach_host_self(), &pageSize) == KERN_SUCCESS else { return 0 } + let reclaimable = UInt64(info.free_count) + UInt64(info.inactive_count) + + UInt64(info.speculative_count) + UInt64(info.purgeable_count) + return reclaimable * UInt64(pageSize) + } + + private func integerSysctl(_ name: String) -> Int { + var value: Int32 = 0 + var size = MemoryLayout.size + let result = name.withCString { + sysctlbyname($0, &value, &size, nil, 0) + } + return result == 0 ? Int(value) : 0 + } + + private func thermalStateName(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: return "nominal" + case .fair: return "fair" + case .serious: return "serious" + case .critical: return "critical" + @unknown default: return "unknown" + } + } +} + +private final class CaptureCanvas: NSView { + let session: CaptureSession + var onFocusChange: ((Bool) -> Void)? + var systemReadiness = SystemReadinessAssessment.calibrating + private var fontCache: [String: NSFont] = [:] + private var paragraphCache: [Int: NSParagraphStyle] = [:] + private let brandAmber = NSColor( + calibratedRed: 0.95, green: 0.56, blue: 0.12, alpha: 1 + ) + private let brandOrange = NSColor( + calibratedRed: 0.86, green: 0.31, blue: 0.07, alpha: 1 + ) + private let brandLight = NSColor( + calibratedRed: 1.0, green: 0.96, blue: 0.83, alpha: 1 + ) + private lazy var keyPathLogo: NSImage? = { + guard let url = Bundle.main.url(forResource: "KeyPathLogo", withExtension: "icns") else { + return nil + } + return NSImage(contentsOf: url) + }() + + init(session: CaptureSession) { + self.session = session + super.init(frame: .zero) + wantsLayer = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { true } + override var isOpaque: Bool { true } + + override func becomeFirstResponder() -> Bool { + onFocusChange?(true) + needsDisplay = true + return true + } + + override func resignFirstResponder() -> Bool { + onFocusChange?(false) + needsDisplay = true + return true + } + + override func keyDown(with event: NSEvent) { + session.record( + phase: .down, + keyCode: event.keyCode, + characters: event.characters ?? "", + modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue, + isRepeat: event.isARepeat, + nowNs: monotonicNow() + ) + needsDisplay = true + } + + override func keyUp(with event: NSEvent) { + session.record( + phase: .up, + keyCode: event.keyCode, + characters: "", + modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue, + isRepeat: false, + nowNs: monotonicNow() + ) + needsDisplay = true + } + + override func flagsChanged(with event: NSEvent) { + session.record( + phase: .flagsChanged, + keyCode: event.keyCode, + characters: "", + modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue, + isRepeat: false, + nowNs: monotonicNow() + ) + needsDisplay = true + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let nowNs = monotonicNow() + let snapshot = session.snapshot(nowNs: nowNs) + let motion = CaptureBrandMotion.resolve( + snapshot: snapshot, + nowNs: nowNs, + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + ) + let layout = CaptureLayoutMetrics.resolve( + width: Double(bounds.width), height: Double(bounds.height) + ) + let padding = CGFloat(layout.padding) + let inset = bounds.insetBy(dx: padding, dy: padding) + let opaqueBackground = ( + NSColor.windowBackgroundColor.usingColorSpace(.deviceRGB) ?? + NSColor(calibratedWhite: 0.97, alpha: 1) + ).withAlphaComponent(1) + opaqueBackground.setFill() + bounds.fill() + + let accent = systemReadiness.state == .waiting && snapshot.state == .idle + ? brandOrange : color(for: snapshot.state) + let headerHeight = CGFloat(layout.headerHeight) + let header = NSRect( + x: inset.minX, y: inset.maxY - headerHeight, + width: inset.width, height: headerHeight + ) + let headerPath = NSBezierPath( + roundedRect: header, + xRadius: layout.mode == .tiny ? 10 : 14, + yRadius: layout.mode == .tiny ? 10 : 14 + ) + brandAmber.withAlphaComponent(0.08 + motion.breath * 0.025).setFill() + headerPath.fill() + brandAmber.withAlphaComponent(0.22).setStroke() + headerPath.lineWidth = 1 + headerPath.stroke() + + let markSize: CGFloat = layout.mode == .tiny ? 22 : 34 + let markFrame = NSRect( + x: header.minX + (layout.mode == .tiny ? 8 : 14), + y: header.midY - markSize / 2, + width: markSize, + height: markSize + ) + drawKeyPathLogo(in: markFrame) + + let focusWidth: CGFloat = layout.mode == .tiny ? 72 : 130 + let titleX = markFrame.maxX + (layout.mode == .tiny ? 6 : 10) + drawText( + layout.mode == .tiny ? "KEYPATH HID JIG" : "KEYPATH / HID CAPTURE JIG", + rect: NSRect( + x: titleX, + y: header.midY - 10, + width: max(40, header.maxX - focusWidth - titleX - 14), height: 22 + ), + font: font( + size: layout.mode == .tiny ? 10 : 14, weight: .semibold, + monospaced: true + ), + color: accent + ) + drawText( + snapshot.focused ? (layout.mode == .tiny ? "FOCUS" : "FOCUSED") : "NO FOCUS", + rect: NSRect( + x: header.maxX - focusWidth - (layout.mode == .tiny ? 10 : 18), + y: header.midY - 10, width: focusWidth, height: 22 + ), + font: font( + size: layout.mode == .tiny ? 9 : 12, weight: .bold, + monospaced: true + ), + color: snapshot.focused ? NSColor.systemGreen : NSColor.systemRed, + alignment: .right + ) + drawActivityRail(in: header, motion: motion, stateAccent: accent) + + var cursor = header.minY - (layout.mode == .tiny ? 5 : 12) + let stateHeight: CGFloat = layout.mode == .tiny ? 28 : 48 + let visibleState: String + if snapshot.state == .idle, systemReadiness.state == .waiting { + visibleState = layout.mode == .tiny ? "MAC BUSY" : "PAUSED · MAC BUSY" + } else if snapshot.state == .idle, systemReadiness.state == .calibrating { + visibleState = layout.mode == .tiny ? "CHECKING" : "CHECKING THE MAC" + } else { + visibleState = snapshot.state.rawValue.uppercased() + } + drawText( + visibleState, + rect: NSRect( + x: inset.minX, y: cursor - stateHeight, + width: inset.width, height: stateHeight + ), + font: font(size: CGFloat(layout.stateFontSize), weight: .bold), + color: accent + ) + cursor -= stateHeight + let runHeight: CGFloat = layout.mode == .tiny ? 14 : 22 + let runSummary = snapshot.runID.isEmpty ? systemReadiness.summary : snapshot.runID + drawText( + runSummary, + rect: NSRect(x: inset.minX, y: cursor - runHeight, width: inset.width, height: runHeight), + font: font( + size: layout.mode == .tiny ? 9 : 14, weight: .regular, + monospaced: true + ), + color: NSColor.secondaryLabelColor + ) + cursor -= runHeight + (layout.mode == .tiny ? 4 : 10) + + let labelHeight: CGFloat = layout.mode == .tiny ? 11 : 16 + let fieldHeight = CGFloat(layout.fieldHeight) + let fieldBlockHeight = labelHeight + fieldHeight + (layout.mode == .tiny ? 4 : 8) + drawField( + label: "EXPECTED", value: snapshot.expected, + frame: NSRect( + x: inset.minX, y: cursor - labelHeight - fieldHeight, + width: inset.width, height: fieldHeight + ), + labelHeight: labelHeight, compact: layout.mode != .regular + ) + cursor -= fieldBlockHeight + drawField( + label: "RECEIVED", value: snapshot.received, + frame: NSRect( + x: inset.minX, y: cursor - labelHeight - fieldHeight, + width: inset.width, height: fieldHeight + ), + labelHeight: labelHeight, compact: layout.mode != .regular, + valueColor: snapshot.received == snapshot.expected && !snapshot.received.isEmpty + ? NSColor.systemGreen : NSColor.labelColor + ) + cursor -= fieldBlockHeight + + let issueText: String + if !snapshot.issues.isEmpty { + issueText = snapshot.issues.joined(separator: " • ") + } else if snapshot.state == .idle, systemReadiness.state != .ready { + issueText = ([systemReadiness.detail] + systemReadiness.suggestions).joined(separator: " • ") + } else if snapshot.state == .idle { + issueText = "System resources are stable · ready to arm" + } else { + issueText = "No anomalies detected" + } + let resourceBlocked = snapshot.state == .idle && systemReadiness.state == .waiting + let issueHeight: CGFloat = resourceBlocked + ? (layout.mode == .regular ? 68 : (layout.mode == .compact ? 58 : 24)) + : (layout.mode == .tiny ? 18 : 28) + let issueFrame = NSRect( + x: inset.minX, y: cursor - issueHeight, width: inset.width, height: issueHeight + ) + if resourceBlocked, layout.mode != .tiny { + let why = systemReadiness.issues.prefix(2).joined(separator: " · ") + let help = systemReadiness.suggestions.first ?? "Wait; the Jig will recheck automatically." + drawWrappedText( + "WHY \(why)\nHELP \(help)", + rect: issueFrame, + font: font(size: layout.mode == .regular ? 13 : 11, weight: .medium), + color: brandOrange + ) + } else { + drawText( + issueText, + rect: issueFrame, + font: font(size: layout.mode == .tiny ? 9 : 13, weight: .medium), + color: !snapshot.issues.isEmpty ? NSColor.systemRed : NSColor.secondaryLabelColor + ) + } + cursor -= issueHeight + (layout.mode == .regular ? 12 : 6) + + let footerClearance: CGFloat = layout.showsFooter ? 28 : 0 + let burstFrame = NSRect( + x: inset.minX, + y: inset.minY + footerClearance, + width: inset.width, + height: max(54, cursor - inset.minY - footerClearance) + ) + drawKeycapBurst( + snapshot: snapshot, + nowNs: nowNs, + frame: burstFrame, + mode: layout.mode, + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + ) + + if layout.showsFooter { + drawText( + "Click this window to restore focus. Tests fail closed if focus moves elsewhere.", + rect: NSRect(x: inset.minX, y: inset.minY, width: inset.width, height: 20), + font: font( + size: layout.mode == .regular ? 12 : 10, weight: .regular + ), + color: NSColor.tertiaryLabelColor + ) + } + } + + private func drawField( + label: String, value: String, frame: NSRect, labelHeight: CGFloat, + compact: Bool, valueColor: NSColor = .labelColor + ) { + drawText( + label, + rect: NSRect( + x: frame.minX, y: frame.maxY + 1, + width: frame.width, height: labelHeight + ), + font: font(size: compact ? 9 : 11, weight: .semibold, monospaced: true), + color: NSColor.tertiaryLabelColor + ) + NSColor.controlBackgroundColor.setFill() + NSBezierPath( + roundedRect: frame, xRadius: compact ? 6 : 8, yRadius: compact ? 6 : 8 + ).fill() + drawText( + value.isEmpty ? "—" : value.debugDescription, + rect: NSRect( + x: frame.minX + (compact ? 8 : 12), y: frame.minY + (compact ? 5 : 8), + width: frame.width - (compact ? 16 : 24), height: frame.height - 8 + ), + font: font(size: compact ? 11 : 15, weight: .medium, monospaced: true), + color: valueColor + ) + } + + private func drawKeycapBurst( + snapshot: CaptureSnapshot, + nowNs: UInt64, + frame: NSRect, + mode: CaptureLayoutMode, + reduceMotion: Bool + ) { + let output = KeycapBurstModel.resolve( + events: snapshot.events, + pressedKeyCodes: snapshot.pressedKeyCodes, + nowNs: nowNs, + reduceMotion: reduceMotion + ) + let panel = NSBezierPath( + roundedRect: frame, + xRadius: mode == .tiny ? 10 : 16, + yRadius: mode == .tiny ? 10 : 16 + ) + NSColor.controlBackgroundColor.withAlphaComponent(0.62).setFill() + panel.fill() + brandAmber.withAlphaComponent(0.12 + 0.13 * output.intensity).setStroke() + panel.lineWidth = 1 + panel.stroke() + + let titleHeight: CGFloat = mode == .tiny ? 14 : 20 + let titleInset: CGFloat = mode == .tiny ? 9 : 14 + drawText( + mode == .tiny ? "LIVE KEYS" : "LIVE KEYCAP STACK", + rect: NSRect( + x: frame.minX + titleInset, + y: frame.maxY - titleHeight - (mode == .tiny ? 5 : 9), + width: frame.width * 0.5, + height: titleHeight + ), + font: font(size: mode == .tiny ? 8 : 11, weight: .semibold, monospaced: true), + color: NSColor.tertiaryLabelColor + ) + let evidence: String + if output.totalPresses == 0 { + evidence = "WAITING FOR KEY-DOWN" + } else if output.presentedPresses < output.totalPresses { + evidence = "PLAYING \(output.presentedPresses)/\(output.totalPresses) · BURST \(output.recentPresses)" + } else if output.recentPresses > 0 { + evidence = "\(output.recentPresses) NOW · \(output.totalPresses) TOTAL" + } else { + evidence = "\(output.totalPresses) PRESSES CAPTURED" + } + drawText( + evidence, + rect: NSRect( + x: frame.midX, + y: frame.maxY - titleHeight - (mode == .tiny ? 5 : 9), + width: frame.width * 0.5 - titleInset, + height: titleHeight + ), + font: font(size: mode == .tiny ? 8 : 10, weight: .medium, monospaced: true), + color: output.recentPresses > 0 ? brandOrange : NSColor.tertiaryLabelColor, + alignment: .right + ) + + let stageTop = frame.maxY - titleHeight - (mode == .tiny ? 8 : 16) + let stage = NSRect( + x: frame.minX + titleInset, + y: frame.minY + (mode == .tiny ? 5 : 10), + width: frame.width - titleInset * 2, + height: max(28, stageTop - frame.minY - (mode == .tiny ? 7 : 12)) + ) + + guard !output.items.isEmpty else { + drawEmptyKeycapStack(in: stage, mode: mode) + return + } + + let maxWidth = mode == .regular ? 118.0 : (mode == .compact ? 96.0 : 66.0) + let capWidth = min(CGFloat(maxWidth), stage.width * (mode == .tiny ? 0.28 : 0.30)) + let capHeight = min( + capWidth * 0.68, + stage.height * (mode == .tiny ? 0.72 : 0.62) + ) + let center = NSPoint(x: stage.midX, y: stage.midY - capHeight * 0.08) + for item in output.items { + drawKeycap(item, center: center, size: NSSize(width: capWidth, height: capHeight)) + } + } + + private func drawEmptyKeycapStack(in frame: NSRect, mode: CaptureLayoutMode) { + let capWidth = min(mode == .tiny ? 54 : 82, frame.width * 0.28) + let capHeight = capWidth * 0.64 + for index in 0..<3 { + let cap = NSRect( + x: frame.midX - capWidth / 2 + CGFloat(index - 1) * 3, + y: frame.midY - capHeight / 2 + CGFloat(index) * 3, + width: capWidth, + height: capHeight + ) + let path = NSBezierPath(roundedRect: cap, xRadius: 11, yRadius: 11) + brandAmber.withAlphaComponent(0.08 + CGFloat(index) * 0.035).setFill() + path.fill() + brandAmber.withAlphaComponent(0.18).setStroke() + path.lineWidth = 1 + path.stroke() + } + if mode != .tiny { + drawText( + "physical input appears here", + rect: NSRect( + x: frame.minX, + y: max(frame.minY, frame.midY - capHeight / 2 - 28), + width: frame.width, + height: 18 + ), + font: font(size: 11, weight: .regular, monospaced: true), + color: NSColor.tertiaryLabelColor, + alignment: .center + ) + } + } + + private func drawKeycap(_ item: KeycapBurstItem, center: NSPoint, size: NSSize) { + let width = size.width * CGFloat(item.scale) + let height = size.height * CGFloat(item.scale) + let x = center.x - width / 2 + CGFloat(item.xOffset) + let y = center.y - height / 2 + CGFloat(item.yOffset) + let opacity = CGFloat(item.opacity) + let baseDepth = max(4, height * 0.12) + let radius = max(8, width * 0.13) + + let base = NSRect(x: x, y: y, width: width, height: height) + let basePath = NSBezierPath(roundedRect: base, xRadius: radius, yRadius: radius) + (item.isRepeat ? NSColor.systemRed : brandOrange).withAlphaComponent(opacity * 0.88).setFill() + basePath.fill() + + let compression = CGFloat(item.pressDepth) * baseDepth * 0.76 + let face = NSRect( + x: x + width * 0.045, + y: y + baseDepth - compression, + width: width * 0.91, + height: height - baseDepth - height * 0.055 + compression * 0.24 + ) + let facePath = NSBezierPath( + roundedRect: face, + xRadius: max(7, radius * 0.76), + yRadius: max(7, radius * 0.76) + ) + let pressedGlow = item.isPressed || item.pressDepth > 0.15 + let faceColor = pressedGlow + ? brandLight.blended(withFraction: 0.16, of: brandAmber) ?? brandLight + : brandLight + faceColor.withAlphaComponent(opacity).setFill() + facePath.fill() + brandAmber.withAlphaComponent(opacity * (pressedGlow ? 0.95 : 0.60)).setStroke() + facePath.lineWidth = pressedGlow ? 2 : 1 + facePath.stroke() + + let fontSize = min(width * (item.label.count > 2 ? 0.16 : 0.30), height * 0.38) + drawText( + item.label, + rect: NSRect( + x: face.minX + 4, + y: face.midY - fontSize * 0.63, + width: face.width - 8, + height: fontSize * 1.35 + ), + font: font(size: max(8, fontSize), weight: .bold, monospaced: true), + color: NSColor(calibratedWhite: 0.14, alpha: opacity), + alignment: .center + ) + } + + private func drawText( + _ text: String, + rect: NSRect, + font: NSFont, + color: NSColor, + alignment: NSTextAlignment = .left + ) { + let paragraph: NSParagraphStyle + if let cached = paragraphCache[alignment.rawValue] { + paragraph = cached + } else { + let value = NSMutableParagraphStyle() + value.alignment = alignment + value.lineBreakMode = .byTruncatingTail + paragraph = value.copy() as! NSParagraphStyle + paragraphCache[alignment.rawValue] = paragraph + } + (text as NSString).draw(in: rect, withAttributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ]) + } + + private func drawWrappedText(_ text: String, rect: NSRect, font: NSFont, color: NSColor) { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byWordWrapping + paragraph.lineSpacing = 2 + (text as NSString).draw( + with: rect, + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + ) + } + + private func font( + size: CGFloat, weight: NSFont.Weight, monospaced: Bool = false + ) -> NSFont { + let key = "\(monospaced ? "mono" : "system"):\(size):\(weight.rawValue)" + if let cached = fontCache[key] { return cached } + let value = monospaced + ? NSFont.monospacedSystemFont(ofSize: size, weight: weight) + : NSFont.systemFont(ofSize: size, weight: weight) + fontCache[key] = value + return value + } + + private func color(for state: CaptureRunState) -> NSColor { + switch state { + case .idle: return brandAmber + case .armed: return brandOrange + case .capturing: return brandAmber + case .passed: return NSColor.systemGreen + case .failed: return NSColor.systemRed + } + } + + private func drawKeyPathLogo(in frame: NSRect) { + guard let keyPathLogo else { + drawText( + "KP", + rect: frame, + font: font(size: frame.height * 0.42, weight: .bold, monospaced: true), + color: brandAmber, + alignment: .center + ) + return + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current?.imageInterpolation = .high + keyPathLogo.draw( + in: frame, + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high] + ) + NSGraphicsContext.restoreGraphicsState() + } + + private func drawActivityRail( + in header: NSRect, + motion: CaptureBrandMotion, + stateAccent: NSColor + ) { + let rail = NSRect(x: header.minX + 12, y: header.minY + 3, width: header.width - 24, height: 2) + NSColor.labelColor.withAlphaComponent(0.07).setFill() + NSBezierPath(roundedRect: rail, xRadius: 1, yRadius: 1).fill() + + if motion.completion > 0 { + let fill = NSRect( + x: rail.minX, y: rail.minY, + width: max(2, rail.width * CGFloat(motion.completion)), height: rail.height + ) + stateAccent.withAlphaComponent(0.72).setFill() + NSBezierPath(roundedRect: fill, xRadius: 1, yRadius: 1).fill() + } + + let travel = rail.minX + rail.width * CGFloat(motion.glintPosition) + drawGlint( + center: NSPoint(x: travel, y: rail.midY), + size: 5 + CGFloat(motion.eventPulse * 4), + color: brandLight.withAlphaComponent(0.55 + motion.eventPulse * 0.4) + ) + } + + private func drawGlint(center: NSPoint, size: CGFloat, color: NSColor) { + let half = size / 2 + let waist = max(0.6, size * 0.12) + let path = NSBezierPath() + path.move(to: NSPoint(x: center.x, y: center.y + half)) + path.line(to: NSPoint(x: center.x + waist, y: center.y + waist)) + path.line(to: NSPoint(x: center.x + half, y: center.y)) + path.line(to: NSPoint(x: center.x + waist, y: center.y - waist)) + path.line(to: NSPoint(x: center.x, y: center.y - half)) + path.line(to: NSPoint(x: center.x - waist, y: center.y - waist)) + path.line(to: NSPoint(x: center.x - half, y: center.y)) + path.line(to: NSPoint(x: center.x - waist, y: center.y + waist)) + path.close() + color.setFill() + path.fill() + } +} + +private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + private let session = CaptureSession() + private let resourceMonitor = SystemResourceMonitor() + private var window: NSWindow! + private var canvas: CaptureCanvas! + private var timer: Timer? + private var lastCommandID = "" + private var persistedTerminalKey = "" + private var runActive = false + private var lastRenderedState: CaptureRunState? + private var ambientFrame = 0 + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() + + private lazy var stateDirectory: URL = { + if let override = ProcessInfo.processInfo.environment["KEYPATH_CAPTURE_JIG_STATE_DIR"], !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/state/keypath-hid-capture-jig", isDirectory: true) + }() + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + buildWindow() + resourceMonitor.sampleIfNeeded(nowNs: monotonicNow(), force: true) + canvas.systemReadiness = resourceMonitor.assessment + prepareStateDirectory() + writeReadyFile() + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + self?.tick() + } + RunLoop.main.add(timer!, forMode: .common) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(canvas) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + func windowDidBecomeKey(_ notification: Notification) { + window.makeFirstResponder(canvas) + session.noteFocus(true, nowNs: monotonicNow()) + canvas.needsDisplay = true + } + + func windowDidResignKey(_ notification: Notification) { + session.noteFocus(false, nowNs: monotonicNow()) + canvas.needsDisplay = true + } + + private func buildWindow() { + canvas = CaptureCanvas(session: session) + canvas.onFocusChange = { [weak self] focused in + self?.session.noteFocus(focused, nowNs: monotonicNow()) + } + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 860, height: 660), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "KeyPath HID Capture Jig" + window.minSize = NSSize(width: 380, height: 280) + let frameAutosaveName = "KeyPathHIDCaptureJigWindow" + if !window.setFrameUsingName(frameAutosaveName) { + window.center() + } + window.setFrameAutosaveName(frameAutosaveName) + window.delegate = self + window.contentView = canvas + } + + private func prepareStateDirectory() { + try? FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: stateDirectory.path) + let artifacts = stateDirectory.appendingPathComponent("artifacts", isDirectory: true) + try? FileManager.default.createDirectory(at: artifacts, withIntermediateDirectories: true) + } + + private func writeReadyFile() { + let value: [String: Any] = [ + "schemaVersion": 1, + "processID": ProcessInfo.processInfo.processIdentifier, + "status": "ready", + "stateDirectory": stateDirectory.path, + ] + guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]) else { return } + writeAtomically(data, to: stateDirectory.appendingPathComponent("ready.json")) + } + + private func tick() { + processCommandIfNeeded() + let nowNs = monotonicNow() + if resourceMonitor.sampleIfNeeded(nowNs: nowNs) { + canvas.systemReadiness = resourceMonitor.assessment + canvas.needsDisplay = true + } + let snapshot = session.snapshot(nowNs: nowNs) + let burstAnimating = KeycapBurstModel.resolve( + events: snapshot.events, + pressedKeyCodes: snapshot.pressedKeyCodes, + nowNs: nowNs, + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + ).isAnimating + if !runActive { + ambientFrame = (ambientFrame + 1) % 2 + if burstAnimating || + (!NSWorkspace.shared.accessibilityDisplayShouldReduceMotion && ambientFrame == 0) { + canvas.needsDisplay = true + } + lastRenderedState = snapshot.state + return + } + if snapshot.state == .armed, !snapshot.focused { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(canvas) + } + if snapshot.state == .armed || snapshot.state == .capturing || + snapshot.state != lastRenderedState { + canvas.needsDisplay = true + } + lastRenderedState = snapshot.state + persistTerminalResultIfNeeded(snapshot) + if snapshot.state == .passed || snapshot.state == .failed { + runActive = false + } + } + + private func processCommandIfNeeded() { + let commandURL = stateDirectory.appendingPathComponent("command.json") + guard let data = try? Data(contentsOf: commandURL), + let command = try? JSONDecoder().decode(ControlCommand.self, from: data), + command.id != lastCommandID + else { return } + lastCommandID = command.id + if command.action == "arm" { + beginArm(command, attempt: 0) + return + } + if command.action == "focus" { + beginFocus(command, attempt: 0) + return + } + let response = handle(command) + write(response) + } + + private func beginFocus(_ command: ControlCommand, attempt: Int) { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(canvas) + let focused = window.isKeyWindow && window.firstResponder === canvas + if !focused, attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.beginFocus(command, attempt: attempt + 1) + } + return + } + write(ControlResponse( + id: command.id, + ok: focused, + message: focused ? "capture jig focused" : "capture jig could not acquire keyboard focus", + processID: ProcessInfo.processInfo.processIdentifier, + snapshot: session.snapshot(nowNs: monotonicNow()), + systemReadiness: resourceMonitor.assessment + )) + } + + private func beginArm(_ command: ControlCommand, attempt: Int) { + let readiness = resourceMonitor.assessment + guard readiness.canProceed else { + runActive = false + canvas.systemReadiness = readiness + canvas.needsDisplay = true + let guidance = readiness.suggestions.first.map { " \($0)" } ?? "" + write(ControlResponse( + id: command.id, + ok: false, + message: "capture paused: \(readiness.detail)\(guidance)", + processID: ProcessInfo.processInfo.processIdentifier, + snapshot: session.snapshot(nowNs: monotonicNow()), + systemReadiness: readiness + )) + return + } + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(canvas) + let focused = window.isKeyWindow && window.firstResponder === canvas + if !focused, attempt < 10 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.beginArm(command, attempt: attempt + 1) + } + return + } + let ok = session.arm( + runID: command.runID ?? "", + expected: command.expected ?? "", + timeoutMs: command.timeoutMs ?? 10_000, + settleMs: command.settleMs ?? 250, + focused: focused, + nowNs: monotonicNow() + ) + persistedTerminalKey = "" + runActive = ok + lastRenderedState = nil + canvas.needsDisplay = true + write(ControlResponse( + id: command.id, + ok: ok, + message: ok ? "capture armed and focused" : "capture refused: focus or command bounds invalid", + processID: ProcessInfo.processInfo.processIdentifier, + snapshot: session.snapshot(nowNs: monotonicNow()), + systemReadiness: readiness + )) + } + + private func write(_ response: ControlResponse) { + guard let encoded = try? encoder.encode(response) else { return } + writeAtomically(encoded, to: stateDirectory.appendingPathComponent("response.json")) + } + + private func handle(_ command: ControlCommand) -> ControlResponse { + var ok = true + var message = "status captured" + switch command.action { + case "status": + break + case "reset": + session.reset() + persistedTerminalKey = "" + runActive = false + lastRenderedState = nil + message = "capture session reset" + case "finalize": + session.finalize(nowNs: monotonicNow()) + message = "capture finalized" + case "quit": + message = "capture jig quitting" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { NSApp.terminate(nil) } + default: + ok = false + message = "unknown action: \(command.action)" + } + canvas.needsDisplay = true + let snapshot = session.snapshot(nowNs: monotonicNow()) + return ControlResponse( + id: command.id, + ok: ok, + message: message, + processID: ProcessInfo.processInfo.processIdentifier, + snapshot: snapshot, + systemReadiness: resourceMonitor.assessment + ) + } + + private func persistTerminalResultIfNeeded(_ snapshot: CaptureSnapshot) { + guard snapshot.state == .passed || snapshot.state == .failed, !snapshot.runID.isEmpty else { return } + let key = "\(snapshot.runID):\(snapshot.state.rawValue):\(snapshot.events.count)" + guard key != persistedTerminalKey, let data = try? encoder.encode(snapshot) else { return } + persistedTerminalKey = key + let safeRunID = snapshot.runID.map { $0.isLetter || $0.isNumber || "-_.".contains($0) ? $0 : "_" } + let filename = "\(String(safeRunID))-\(snapshot.state.rawValue).json" + writeAtomically(data, to: stateDirectory.appendingPathComponent("artifacts/\(filename)")) + } + + private func writeAtomically(_ data: Data, to url: URL) { + do { + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + NSLog("HID Capture Jig could not write %@: %@", url.path, error.localizedDescription) + } + } +} + +@main +private enum HIDCaptureJigMain { + static func main() { + let application = NSApplication.shared + installMainMenu(on: application) + let delegate = AppDelegate() + application.delegate = delegate + application.run() + withExtendedLifetime(delegate) {} + } + + private static func installMainMenu(on application: NSApplication) { + let applicationName = "KeyPath HID Capture Jig" + let mainMenu = NSMenu() + let applicationMenuItem = NSMenuItem() + let applicationMenu = NSMenu(title: applicationName) + + applicationMenu.addItem( + withTitle: "About \(applicationName)", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "" + ) + applicationMenu.addItem(.separator()) + + let servicesMenu = NSMenu(title: "Services") + let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") + servicesItem.submenu = servicesMenu + applicationMenu.addItem(servicesItem) + application.servicesMenu = servicesMenu + + applicationMenu.addItem(.separator()) + applicationMenu.addItem( + withTitle: "Hide \(applicationName)", + action: #selector(NSApplication.hide(_:)), + keyEquivalent: "h" + ) + + let hideOthersItem = applicationMenu.addItem( + withTitle: "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h" + ) + hideOthersItem.keyEquivalentModifierMask = [.command, .option] + applicationMenu.addItem( + withTitle: "Show All", + action: #selector(NSApplication.unhideAllApplications(_:)), + keyEquivalent: "" + ) + + applicationMenu.addItem(.separator()) + applicationMenu.addItem( + withTitle: "Quit \(applicationName)", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + + applicationMenuItem.submenu = applicationMenu + mainMenu.addItem(applicationMenuItem) + application.mainMenu = mainMenu + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift new file mode 100644 index 000000000..1f82f7363 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift @@ -0,0 +1,68 @@ +import HIDCaptureCore +import XCTest + +final class CaptureBrandMotionTests: XCTestCase { + private let start: UInt64 = 1_000_000_000 + + func testCompletionAndMotionStayBounded() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "brand", expected: "abcd", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + session.record( + phase: .down, keyCode: 0, characters: "a", modifiers: 0, + isRepeat: false, nowNs: start + 10 + ) + let snapshot = session.snapshot(nowNs: start + 20) + let motion = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start + 20, reduceMotion: false + ) + + XCTAssertEqual(motion.completion, 0.25, accuracy: 0.001) + XCTAssertTrue((0 ... 1).contains(motion.breath)) + XCTAssertTrue((0 ... 1).contains(motion.eventPulse)) + XCTAssertTrue((0 ... 1).contains(motion.glintPosition)) + } + + func testEventPulseDecaysWithoutInventingProgress() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "pulse", expected: "ab", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + session.record( + phase: .down, keyCode: 0, characters: "a", modifiers: 0, + isRepeat: false, nowNs: start + 10 + ) + let snapshot = session.snapshot(nowNs: start + 20) + let immediate = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start + 10, reduceMotion: false + ) + let settled = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start + 500_000_000, reduceMotion: false + ) + + XCTAssertEqual(immediate.eventPulse, 1, accuracy: 0.001) + XCTAssertEqual(settled.eventPulse, 0, accuracy: 0.001) + XCTAssertEqual(settled.completion, 0.5, accuracy: 0.001) + } + + func testReduceMotionProducesStableEvidenceDrivenState() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "reduced", expected: "ab", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + let snapshot = session.snapshot(nowNs: start) + let first = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start, reduceMotion: true + ) + let later = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start + 9_000_000_000, reduceMotion: true + ) + + XCTAssertEqual(first, later) + XCTAssertEqual(first.eventPulse, 0) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureLayoutTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureLayoutTests.swift new file mode 100644 index 000000000..59a036d53 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureLayoutTests.swift @@ -0,0 +1,24 @@ +import HIDCaptureCore +import XCTest + +final class CaptureLayoutTests: XCTestCase { + func testRegularLayoutKeepsFullCaptureStage() { + let layout = CaptureLayoutMetrics.resolve(width: 860, height: 660) + XCTAssertEqual(layout.mode, .regular) + XCTAssertEqual(layout.fieldHeight, 34) + XCTAssertTrue(layout.showsFooter) + } + + func testCompactLayoutPreservesCoreState() { + let layout = CaptureLayoutMetrics.resolve(width: 620, height: 460) + XCTAssertEqual(layout.mode, .compact) + XCTAssertGreaterThan(layout.fieldHeight, 0) + } + + func testTinyLayoutFitsMinimumWindow() { + let layout = CaptureLayoutMetrics.resolve(width: 360, height: 250) + XCTAssertEqual(layout.mode, .tiny) + XCTAssertFalse(layout.showsFooter) + XCTAssertLessThan(layout.padding, 18) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift new file mode 100644 index 000000000..af3f20914 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift @@ -0,0 +1,148 @@ +import HIDCaptureCore +import XCTest + +final class CaptureSessionTests: XCTestCase { + private let start: UInt64 = 1_000_000_000 + + func testExactSequencePassesOnlyAfterReleaseAndSettle() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "exact", expected: "qw", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 12, characters: "q", modifiers: 0, isRepeat: false, nowNs: start + 10) + session.record(phase: .up, keyCode: 12, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 20) + session.record(phase: .down, keyCode: 13, characters: "w", modifiers: 0, isRepeat: false, nowNs: start + 30) + session.record(phase: .up, keyCode: 13, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 40) + + XCTAssertEqual(session.snapshot(nowNs: start + 50).state, .capturing) + let result = session.snapshot(nowNs: start + 100_000_041) + XCTAssertEqual(result.state, .passed) + XCTAssertEqual(result.received, "qw") + XCTAssertTrue(result.allKeysReleased) + XCTAssertTrue(result.issues.isEmpty) + } + + func testDuplicateAndRepeatFailWithExactEvidence() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "repeat", expected: "aa", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 10) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: true, nowNs: start + 20) + let result = session.snapshot(nowNs: start + 30) + XCTAssertEqual(result.state, .failed) + XCTAssertEqual(result.repeatEvents, 1) + XCTAssertEqual(result.duplicateDownEvents, 1) + XCTAssertEqual(result.pressedKeyCodes, [0]) + } + + func testWrongOrderFailsAtFirstMismatchedCharacter() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "order", expected: "abc", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 10) + session.record(phase: .up, keyCode: 0, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 20) + session.record(phase: .down, keyCode: 2, characters: "c", modifiers: 0, isRepeat: false, nowNs: start + 30) + let result = session.snapshot(nowNs: start + 40) + XCTAssertEqual(result.state, .failed) + XCTAssertTrue(result.issues.contains { $0.contains("character 1") }) + } + + func testTimeoutReportsMissingAndStuckKeys() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "timeout", expected: "ab", timeoutMs: 250, + settleMs: 50, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 10) + let result = session.snapshot(nowNs: start + 250_000_001) + XCTAssertEqual(result.state, .failed) + XCTAssertTrue(result.issues.contains { $0.contains("missing 1") }) + XCTAssertTrue(result.issues.contains { $0.contains("unreleased key") }) + } + + func testLateInputCannotTurnTimedOutFailureIntoPass() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "late", expected: "a", timeoutMs: 250, + settleMs: 50, focused: true, nowNs: start + )) + XCTAssertEqual(session.snapshot(nowNs: start + 250_000_001).state, .failed) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 300_000_000) + session.record(phase: .up, keyCode: 0, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 300_000_010) + let result = session.snapshot(nowNs: start + 400_000_000) + XCTAssertEqual(result.state, .failed) + XCTAssertEqual(result.received, "a") + } + + func testFocusLossFailsClosed() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "focus", expected: "a", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 5) + session.noteFocus(false, nowNs: start + 10) + let result = session.snapshot(nowNs: start + 20) + XCTAssertEqual(result.state, .failed) + XCTAssertEqual(result.issues, ["capture focus was lost"]) + } + + func testArmedSessionCanRecoverFocusBeforeFirstInput() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "focus-recovery", expected: "a", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + session.noteFocus(false, nowNs: start + 10) + XCTAssertEqual(session.snapshot(nowNs: start + 20).state, .armed) + session.noteFocus(true, nowNs: start + 30) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 40) + session.record(phase: .up, keyCode: 0, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 50) + XCTAssertEqual(session.snapshot(nowNs: start + 100_000_051).state, .passed) + } + + func testArmRejectsMissingFocusAndUnsafeBounds() { + let session = CaptureSession() + XCTAssertFalse(session.arm( + runID: "not-focused", expected: "a", timeoutMs: 2_000, + settleMs: 100, focused: false, nowNs: start + )) + XCTAssertEqual(session.snapshot(nowNs: start).state, .failed) + XCTAssertFalse(session.arm( + runID: "empty", expected: "", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + } + + func testPassWaitsForModifierRelease() { + let session = CaptureSession() + let shift: UInt = 1 << 17 + XCTAssertTrue(session.arm( + runID: "modifier-release", expected: "A", timeoutMs: 2_000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .flagsChanged, keyCode: 56, characters: "", modifiers: shift, + isRepeat: false, nowNs: start + 10) + session.record(phase: .down, keyCode: 0, characters: "A", modifiers: shift, + isRepeat: false, nowNs: start + 20) + session.record(phase: .up, keyCode: 0, characters: "", modifiers: shift, + isRepeat: false, nowNs: start + 30) + + var result = session.snapshot(nowNs: start + 200_000_000) + XCTAssertEqual(result.state, .capturing) + XCTAssertEqual(result.activeModifiers, shift) + XCTAssertFalse(result.allKeysReleased) + + session.record(phase: .flagsChanged, keyCode: 56, characters: "", modifiers: 0, + isRepeat: false, nowNs: start + 200_000_010) + result = session.snapshot(nowNs: start + 300_000_011) + XCTAssertEqual(result.state, .passed) + XCTAssertEqual(result.activeModifiers, 0) + XCTAssertTrue(result.allKeysReleased) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift new file mode 100644 index 000000000..1ff2ec27f --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift @@ -0,0 +1,129 @@ +import HIDCaptureCore +import XCTest + +final class KeycapBurstModelTests: XCTestCase { + func testOnlyKeyDownEventsBecomeKeycaps() { + let events = [ + event(1, .down, "q", at: 100), + event(2, .up, at: 110), + event(3, .flagsChanged, at: 120), + ] + + let output = KeycapBurstModel.resolve( + events: events, pressedKeyCodes: [12], nowNs: 130, reduceMotion: false + ) + + XCTAssertEqual(output.items.map(\.label), ["Q"]) + XCTAssertEqual(output.totalPresses, 1) + } + + func testDenseBurstIsCappedAndRaisesIntensity() { + let events = (1...30).map { index in + event(index, .down, "x", at: 1_000_000_000 + UInt64(index) * 4_000_000) + } + + let output = KeycapBurstModel.resolve( + events: events, + pressedKeyCodes: [], + nowNs: 2_050_000_000, + reduceMotion: false, + limit: 10 + ) + + XCTAssertEqual(output.items.count, 10) + XCTAssertEqual(output.items.map(\.sequence), Array(21...30)) + XCTAssertGreaterThanOrEqual(output.intensity, 0.85) + XCTAssertEqual(output.presentedPresses, 30) + XCTAssertTrue(output.isAnimating) + } + + func testFasterThanDisplayInputIsPresentedInOrderAtReadableCadence() { + let events = (1...5).map { index in + event(index, .down, String(index), at: 1_000_000_000 + UInt64(index)) + } + + let early = KeycapBurstModel.resolve( + events: events, + pressedKeyCodes: [], + nowNs: 1_050_000_000, + reduceMotion: false + ) + let later = KeycapBurstModel.resolve( + events: events, + pressedKeyCodes: [], + nowNs: 1_160_000_000, + reduceMotion: false + ) + + XCTAssertEqual(early.presentedPresses, 2) + XCTAssertEqual(early.items.map(\.label), ["1", "2"]) + XCTAssertEqual(later.presentedPresses, 5) + XCTAssertEqual(later.items.map(\.label), ["1", "2", "3", "4", "5"]) + } + + func testPressCompressesImmediatelyThenReleases() { + let input = [event(1, .down, "a", at: 1_000_000_000)] + let initial = KeycapBurstModel.resolve( + events: input, pressedKeyCodes: [12], nowNs: 1_000_000_000, reduceMotion: false + ) + let released = KeycapBurstModel.resolve( + events: input, pressedKeyCodes: [], nowNs: 1_180_000_000, reduceMotion: false + ) + + XCTAssertEqual(initial.items[0].pressDepth, 1, accuracy: 0.001) + XCTAssertTrue(initial.items[0].isPressed) + XCTAssertEqual(released.items[0].pressDepth, 0, accuracy: 0.001) + XCTAssertFalse(released.items[0].isPressed) + } + + func testOldKeycapsAgeOut() { + let output = KeycapBurstModel.resolve( + events: [event(1, .down, "a", at: 1_000_000_000)], + pressedKeyCodes: [], + nowNs: 1_900_000_001, + reduceMotion: false + ) + + XCTAssertTrue(output.items.isEmpty) + } + + func testReducedMotionKeepsStackPositionStableWhileItIsVisible() { + let input = [event(1, .down, "a", at: 1_000_000_000)] + let early = KeycapBurstModel.resolve( + events: input, pressedKeyCodes: [12], nowNs: 1_020_000_000, reduceMotion: true + ) + let later = KeycapBurstModel.resolve( + events: input, pressedKeyCodes: [], nowNs: 1_400_000_000, reduceMotion: true + ) + + XCTAssertEqual(early.items[0].xOffset, later.items[0].xOffset) + XCTAssertEqual(early.items[0].yOffset, later.items[0].yOffset) + XCTAssertEqual(early.items[0].scale, later.items[0].scale) + XCTAssertEqual(early.items[0].pressDepth, 0) + } + + func testSpecialKeyLabelsAreReadable() { + XCTAssertEqual(KeycapBurstModel.label(for: event(1, .down, " ", at: 0)), "SPACE") + XCTAssertEqual(KeycapBurstModel.label(for: event(1, .down, "\r", at: 0)), "RETURN") + XCTAssertEqual(KeycapBurstModel.label(for: event(1, .down, "\u{7f}", at: 0)), "DELETE") + XCTAssertEqual(KeycapBurstModel.label(for: event(1, .down, "", at: 0, keyCode: 53)), "ESC") + } + + private func event( + _ sequence: Int, + _ phase: CapturedEventPhase, + _ characters: String = "", + at timestampNs: UInt64, + keyCode: UInt16 = 12 + ) -> CapturedKeyEvent { + CapturedKeyEvent( + sequence: sequence, + phase: phase, + keyCode: keyCode, + characters: characters, + modifiers: 0, + isRepeat: false, + timestampNs: timestampNs + ) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift new file mode 100644 index 000000000..31a82f4d7 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift @@ -0,0 +1,69 @@ +import HIDCaptureCore +import XCTest + +final class SystemReadinessTests: XCTestCase { + private func sample( + at timestamp: UInt64, + cpu: Double? = 0.25, + load: Double = 0.35, + availableGB: UInt64 = 8, + pressure: Int = 1, + thermal: String = "nominal", + threads: Int = 3_000 + ) -> SystemResourceSample { + SystemResourceSample( + timestampNs: timestamp, + cpuUtilization: cpu, + loadAveragePerCore: load, + availableMemoryBytes: availableGB * 1_024 * 1_024 * 1_024, + physicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + threadCount: threads, + logicalProcessorCount: 10, + memoryPressureLevel: pressure, + thermalState: thermal + ) + } + + func testRequiresStableWindowBeforeProceeding() { + let first = SystemReadinessModel.resolve(samples: [sample(at: 1)]) + XCTAssertEqual(first.state, .calibrating) + XCTAssertFalse(first.canProceed) + + let ready = SystemReadinessModel.resolve(samples: [ + sample(at: 1), sample(at: 2), sample(at: 3), + ]) + XCTAssertEqual(ready.state, .ready) + XCTAssertTrue(ready.canProceed) + XCTAssertEqual(ready.stableSamples, 3) + } + + func testBusyCPUAndLoadFailWithActionableGuidance() { + let assessment = SystemReadinessModel.resolve(samples: [ + sample(at: 1), sample(at: 2), sample(at: 3, cpu: 0.91, load: 1.4), + ]) + XCTAssertEqual(assessment.state, .waiting) + XCTAssertFalse(assessment.canProceed) + XCTAssertTrue(assessment.issues.contains { $0.contains("CPU") }) + XCTAssertTrue(assessment.issues.contains { $0.contains("competing work") }) + XCTAssertTrue(assessment.suggestions.contains { $0.contains("Pause builds") }) + } + + func testMemoryThermalAndThreadPressureFailClosed() { + let assessment = SystemReadinessModel.resolve(samples: [sample( + at: 1, availableGB: 1, pressure: 2, thermal: "serious", threads: 9_100 + )]) + XCTAssertFalse(assessment.canProceed) + XCTAssertTrue(assessment.issues.contains { $0.contains("memory") && $0.contains("available") }) + XCTAssertTrue(assessment.issues.contains { $0.contains("memory pressure") }) + XCTAssertTrue(assessment.issues.contains { $0.contains("thermal") }) + XCTAssertTrue(assessment.issues.contains { $0.contains("thread inventory") }) + } + + func testRecentBusySampleMustAgeOutOfWindow() { + let samples = [ + sample(at: 1, cpu: 0.95), sample(at: 2), sample(at: 3), sample(at: 4), + ] + XCTAssertTrue(SystemReadinessModel.resolve(samples: samples).canProceed) + XCTAssertFalse(SystemReadinessModel.resolve(samples: Array(samples.dropLast())).canProceed) + } +} diff --git a/Scripts/lab/hid-capture-jig/build-app.sh b/Scripts/lab/hid-capture-jig/build-app.sh new file mode 100755 index 000000000..76e8ec8d7 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/build-app.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +app_path=${KEYPATH_CAPTURE_JIG_APP:-"$HOME/.cache/keypath-hid-capture-jig/KeyPath HID Capture Jig.app"} +icon_work=$(mktemp -d "${TMPDIR:-/tmp}/keypath-hid-jig-icon.XXXXXX") +trap 'rm -rf "$icon_work"' EXIT + +swift build --package-path "$script_dir" --product HIDCaptureJig --jobs "${KEYPATH_CAPTURE_JIG_BUILD_JOBS:-4}" +bin_path=$(swift build --package-path "$script_dir" --show-bin-path) + +mkdir -p "$app_path/Contents/MacOS" +mkdir -p "$app_path/Contents/Resources" +install -m 755 "$bin_path/HIDCaptureJig" "$app_path/Contents/MacOS/HIDCaptureJig" +install -m 644 "$script_dir/Resources/Info.plist" "$app_path/Contents/Info.plist" +xcrun swift "$script_dir/Resources/generate_jig_icon.swift" "$icon_work/JigIcon.iconset" +iconutil -c icns "$icon_work/JigIcon.iconset" -o "$app_path/Contents/Resources/AppIcon.icns" +install -m 644 "$script_dir/../../../Sources/KeyPathApp/Resources/AppIcon.icns" \ + "$app_path/Contents/Resources/KeyPathLogo.icns" +codesign --force --sign - --timestamp=none "$app_path" >/dev/null +printf '%s\n' "$app_path" diff --git a/Scripts/lab/physical-hid-capture-run b/Scripts/lab/physical-hid-capture-run new file mode 100755 index 000000000..c87c01e25 --- /dev/null +++ b/Scripts/lab/physical-hid-capture-run @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +"""Run one ESP32 HID script against the native macOS capture oracle.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import threading +import time +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +FIXTURE_CLIENT = pathlib.Path(os.environ.get( + "KEYPATH_FIXTURE_CLIENT", ROOT / "Scripts/lab/pico-hid-fixture-client" +)) +CAPTURE_CLIENT = pathlib.Path(os.environ.get( + "KEYPATH_CAPTURE_JIG_CLIENT", ROOT / "Scripts/lab/hid-capture-jig-client" +)) + + +def load_fixture_token() -> str: + if token := os.environ.get("KEYPATH_FIXTURE_TOKEN"): + return token + secrets = pathlib.Path(os.environ.get( + "KEYPATH_FIXTURE_SECRETS_FILE", pathlib.Path.home() / "dotfiles/secrets.env" + )) + try: + decrypted = subprocess.run( + ["sops", "-d", str(secrets)], text=True, capture_output=True, check=True + ).stdout + except (FileNotFoundError, subprocess.CalledProcessError) as error: + raise RuntimeError("could not load KEYPATH_FIXTURE_TOKEN from encrypted secrets") from error + for line in decrypted.splitlines(): + name, separator, value = line.partition("=") + if separator and name == "KEYPATH_FIXTURE_TOKEN" and value: + return value + raise RuntimeError("KEYPATH_FIXTURE_TOKEN is not configured") + + +def run_json( + command: list[str], environment: dict[str, str], allowed_returncodes: tuple[int, ...] = (0,) +) -> dict[str, Any]: + result = subprocess.run(command, text=True, capture_output=True, env=environment) + if result.returncode not in allowed_returncodes: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + raise RuntimeError(f"{' '.join(command[:3])} failed: {detail}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError(f"{' '.join(command[:3])} returned invalid JSON") from error + + +def run_checked(command: list[str], environment: dict[str, str]) -> None: + result = subprocess.run(command, text=True, capture_output=True, env=environment) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + raise RuntimeError(f"{' '.join(command[:3])} failed: {detail}") + + +def fixture_command(host: str, action: str, *arguments: str) -> list[str]: + return [str(FIXTURE_CLIENT), "--host", host, action, *arguments] + + +def capture_command(action: str, *arguments: str) -> list[str]: + return [str(CAPTURE_CLIENT), action, *arguments] + + +def resolve_fixture_host(initial_host: str, environment: dict[str, str]) -> tuple[str, dict[str, Any]]: + status = run_json(fixture_command(initial_host, "status"), environment) + address = str(status.get("address", "")) + try: + ipaddress.ip_address(address) + except ValueError: + return initial_host, status + return address, status + + +def safe_run_id(value: str) -> str: + return "".join(character if character.isalnum() or character in "-_." else "_" for character in value) + + +def wait_for_fixture_terminal( + host: str, environment: dict[str, str], timeout_seconds: float +) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + while True: + status = run_json(fixture_command(host, "status"), environment) + if status.get("state") not in {"armed", "running"}: + return status + if time.monotonic() >= deadline: + return status + time.sleep(0.05) + + +def wait_for_capture_settle( + environment: dict[str, str], settle_ms: int, timeout_seconds: float +) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + while True: + response = run_json(capture_command("status"), environment) + snapshot = response.get("snapshot", {}) + settled_for_ms = snapshot.get("settledForMs") + if settled_for_ms is None or ( + settled_for_ms >= settle_ms and + not snapshot.get("pressedKeyCodes") and + snapshot.get("activeModifiers", 0) == 0 + ): + return response + if time.monotonic() >= deadline: + return response + time.sleep(0.05) + + +def fetch_fixture_trace(host: str, environment: dict[str, str]) -> list[dict[str, Any]]: + """Fetch the fixture's bounded trace in chronological order after a run.""" + traces: list[dict[str, Any]] = [] + start = 0 + available: int | None = None + while available is None or start < available: + response = run_json(fixture_command(host, "trace", "--from", str(start), "--limit", "8"), environment) + if not isinstance(response, list) or not response or not isinstance(response[0], dict): + raise RuntimeError("fixture trace returned an invalid batch") + header = response[0] + available = int(header.get("available", 0)) + batch = [entry for entry in response[1:] if isinstance(entry, dict)] + traces.extend(batch) + if not batch: + break + start += len(batch) + return traces + + +class ControlledCPULoad: + """Bounded synthetic CPU pressure with samples suitable for test artifacts.""" + + def __init__(self, workers: int, sample_ms: int): + self.workers = workers + self.sample_seconds = sample_ms / 1000 + self.logical_cpus = max(1, os.cpu_count() or 1) + self.processes: list[subprocess.Popen] = [] + self.samples: list[dict[str, float]] = [] + self.stop_event = threading.Event() + self.sampler: threading.Thread | None = None + self.started_at = 0.0 + + def start(self) -> None: + self.started_at = time.time() + self.processes = [ + subprocess.Popen( + ["/usr/bin/yes"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + for _ in range(self.workers) + ] + self.sampler = threading.Thread(target=self._sample_loop, daemon=True) + self.sampler.start() + + def stop(self) -> dict[str, Any]: + self.stop_event.set() + for process in self.processes: + process.terminate() + for process in self.processes: + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=1) + if self.sampler is not None: + self.sampler.join(timeout=2) + cpu_values = [sample["cpuPercent"] for sample in self.samples] + return { + "workers": self.workers, + "logicalCPUs": self.logical_cpus, + "sampleIntervalMs": round(self.sample_seconds * 1000), + "startedAtEpoch": self.started_at, + "averageCPUPercent": round(sum(cpu_values) / len(cpu_values), 1) if cpu_values else None, + "maximumCPUPercent": round(max(cpu_values), 1) if cpu_values else None, + "samples": self.samples, + } + + def _sample_loop(self) -> None: + while not self.stop_event.is_set(): + try: + result = subprocess.run( + ["/bin/ps", "-A", "-o", "%cpu="], + text=True, capture_output=True, check=True, timeout=2, + ) + process_cpu = sum(float(value) for value in result.stdout.split()) + cpu_percent = min(100.0, process_cpu / self.logical_cpus) + load_1m, load_5m, load_15m = os.getloadavg() + self.samples.append({ + "elapsedMs": round((time.time() - self.started_at) * 1000), + "cpuPercent": round(cpu_percent, 1), + "load1m": round(load_1m, 2), + "load5m": round(load_5m, 2), + "load15m": round(load_15m, 2), + }) + except (OSError, ValueError, subprocess.SubprocessError): + pass + self.stop_event.wait(self.sample_seconds) + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + root.add_argument("--run-id", required=True) + root.add_argument("--text", required=True, type=pathlib.Path) + root.add_argument("--fixture-host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + root.add_argument("--interval-ms", type=int, default=50) + root.add_argument("--hold-ms", type=int, default=12) + root.add_argument("--shift-lead-ms", type=int, default=0) + root.add_argument("--shift-release-lag-ms", type=int, default=0) + root.add_argument("--repeat", type=int, default=1) + root.add_argument("--cycle-gap-ms", type=int, default=500) + root.add_argument("--delay-ms", type=int, default=1200) + root.add_argument("--settle-ms", type=int, default=300) + root.add_argument("--timeout-ms", type=int) + root.add_argument("--max-late-reports", type=int, default=0) + root.add_argument("--max-lateness-us", type=int, default=0) + root.add_argument( + "--cpu-load-workers", type=int, + help="start this many bounded CPU workers after capture preflight and record CPU samples", + ) + root.add_argument("--cpu-sample-ms", type=int, default=250) + interruption = root.add_mutually_exclusive_group() + interruption.add_argument( + "--abort-after-ms", type=int, + help="abort after start returns and verify a safe, released prefix instead of full output", + ) + interruption.add_argument( + "--expect-external-abort-ms", type=int, + help="wait for a physical button or touch abort for this many milliseconds", + ) + interruption.add_argument( + "--expect-usb-cycle-ms", type=int, + help="wait for physical USB removal and reconnection for this many milliseconds", + ) + root.add_argument("--output", type=pathlib.Path) + return root + + +def main() -> int: + arguments = parser().parse_args() + if not arguments.text.is_file(): + print(f"physical-hid-capture-run: text file does not exist: {arguments.text}", file=sys.stderr) + return 2 + environment = os.environ.copy() + fixture_host = "" + fixture_mutated = False + capture_armed = False + cpu_load: ControlledCPULoad | None = None + cpu_load_result: dict[str, Any] | None = None + try: + if arguments.abort_after_ms is not None and arguments.abort_after_ms < 1: + raise RuntimeError("--abort-after-ms must be positive") + if arguments.expect_external_abort_ms is not None and arguments.expect_external_abort_ms < 1: + raise RuntimeError("--expect-external-abort-ms must be positive") + if arguments.expect_usb_cycle_ms is not None and arguments.expect_usb_cycle_ms < 1: + raise RuntimeError("--expect-usb-cycle-ms must be positive") + if arguments.max_late_reports < 0 or arguments.max_lateness_us < 0: + raise RuntimeError("lateness budgets cannot be negative") + if arguments.max_late_reports > 0 and arguments.max_lateness_us == 0: + raise RuntimeError("--max-lateness-us is required when late reports are allowed") + logical_cpus = max(1, os.cpu_count() or 1) + if arguments.cpu_load_workers is not None and not 0 <= arguments.cpu_load_workers <= logical_cpus: + raise RuntimeError(f"--cpu-load-workers must be between 0 and {logical_cpus}") + if not 100 <= arguments.cpu_sample_ms <= 5_000: + raise RuntimeError("--cpu-sample-ms must be between 100 and 5000") + environment["KEYPATH_FIXTURE_TOKEN"] = load_fixture_token() + host, initial_status = resolve_fixture_host(arguments.fixture_host, environment) + fixture_host = host + capture_preflight = run_json(capture_command("status"), environment) + readiness = capture_preflight.get("systemReadiness") + if not isinstance(readiness, dict): + raise RuntimeError( + "capture Jig does not report system readiness; rebuild and reopen it before testing" + ) + if not readiness.get("canProceed"): + detail = str(readiness.get("detail", "the Mac is not inside the capture resource envelope")) + suggestions = readiness.get("suggestions", []) + help_text = " ".join(str(value) for value in suggestions if value) + raise RuntimeError(f"capture Jig paused the run: {detail}. {help_text}".strip()) + text = arguments.text.read_text() + expected = text * arguments.repeat + estimated_ms = arguments.delay_ms + arguments.repeat * ( + len(text) * arguments.interval_ms + arguments.cycle_gap_ms + ) + # WindowServer can queue valid HID events while the capture app is starved by a + # concurrent compile. Preserve enough wall-clock margin to drain that queue before + # the fail-closed capture timeout freezes the evidence snapshot. + timeout_margin_ms = max(10_000, estimated_ms // 2) + timeout_ms = arguments.timeout_ms or max(15_000, estimated_ms + timeout_margin_ms) + + with tempfile.TemporaryDirectory(prefix="keypath-hid-capture-") as temporary: + script = pathlib.Path(temporary) / "fixture-script.txt" + expected_path = pathlib.Path(temporary) / "expected.txt" + expected_path.write_text(expected) + run_checked([ + str(FIXTURE_CLIENT), "compile-text", "--run-id", arguments.run_id, + "--text", str(arguments.text), "--interval-ms", str(arguments.interval_ms), + "--hold-ms", str(arguments.hold_ms), "--repeat", str(arguments.repeat), + "--cycle-gap-ms", str(arguments.cycle_gap_ms), + "--shift-lead-ms", str(arguments.shift_lead_ms), + "--shift-release-lag-ms", str(arguments.shift_release_lag_ms), + "--output", str(script), + ], environment) + header = script.read_text().splitlines()[0].split() + expected_reports = int(header[2]) * int(header[3]) + + load = run_json(fixture_command(host, "load-script", str(script)), environment) + fixture_mutated = True + fixture_arm = run_json(fixture_command(host, "arm", arguments.run_id), environment) + presentation_testing = run_json(fixture_command( + host, "present", "--phase", "testing", "--result", "none", + "--progress", "0", "--title", arguments.run_id[:32], + "--detail", "CAPTURE JIG ARMED", "--next", "VERIFY OUTPUT", + "--reports-expected", str(expected_reports), "--reports-observed", "0", + ), environment) + # Acquire focus at the last possible moment. Keeping the Jig focused while + # readiness checks or compilation run would steal the user's own typing. + capture_focus = run_json(capture_command("focus"), environment) + capture_arm = run_json(capture_command( + "arm", "--run-id", arguments.run_id, "--expected", str(expected_path), + "--timeout-ms", str(timeout_ms), "--settle-ms", str(arguments.settle_ms), + ), environment) + capture_armed = True + if arguments.cpu_load_workers is not None: + cpu_load = ControlledCPULoad( + workers=arguments.cpu_load_workers, sample_ms=arguments.cpu_sample_ms + ) + cpu_load.start() + start = run_json(fixture_command( + host, "start", arguments.run_id, "--delay-ms", str(arguments.delay_ms) + ), environment) + abort_response = None + saw_usb_unmounted = False + interruption_mode = ( + "remote-abort" if arguments.abort_after_ms is not None else + "external-abort" if arguments.expect_external_abort_ms is not None else + "usb-cycle" if arguments.expect_usb_cycle_ms is not None else + "completion" + ) + if interruption_mode == "completion": + capture_initial_terminal = run_json(capture_command( + "wait", "--timeout-seconds", f"{timeout_ms / 1000 + 2:g}", "--poll-ms", "100" + ), environment, (0, 1)) + fixture_status = wait_for_fixture_terminal( + host, environment, timeout_seconds=timeout_ms / 1000 + 2 + ) + capture_response = wait_for_capture_settle( + environment, settle_ms=arguments.settle_ms, + timeout_seconds=timeout_margin_ms / 1000, + ) + elif interruption_mode == "remote-abort": + capture_initial_terminal = None + time.sleep(arguments.abort_after_ms / 1000) + abort_response = run_json(fixture_command(host, "abort"), environment) + release_deadline = time.monotonic() + 2 + while True: + fixture_status = run_json(fixture_command(host, "status"), environment) + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + if fixture_status.get("state") == "aborted" and completed >= submitted + 1: + break + if time.monotonic() >= release_deadline: + break + time.sleep(0.025) + capture_response = run_json(capture_command("finalize"), environment, (0, 1)) + elif interruption_mode == "external-abort": + capture_initial_terminal = None + external_deadline = time.monotonic() + arguments.expect_external_abort_ms / 1000 + while True: + fixture_status = run_json(fixture_command(host, "status"), environment) + if fixture_status.get("state") == "aborted": + break + if time.monotonic() >= external_deadline: + break + time.sleep(0.05) + abort_response = fixture_status + release_deadline = time.monotonic() + 2 + while fixture_status.get("state") == "aborted": + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + if completed >= submitted + 1: + break + if time.monotonic() >= release_deadline: + break + time.sleep(0.025) + fixture_status = run_json(fixture_command(host, "status"), environment) + capture_response = run_json(capture_command("finalize"), environment, (0, 1)) + else: + capture_initial_terminal = None + usb_deadline = time.monotonic() + arguments.expect_usb_cycle_ms / 1000 + while True: + fixture_status = run_json(fixture_command(host, "status"), environment) + if fixture_status.get("state") == "error" and not fixture_status.get("usbMounted", True): + saw_usb_unmounted = True + if saw_usb_unmounted and fixture_status.get("usbMounted"): + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + if completed >= submitted + 1: + break + if time.monotonic() >= usb_deadline: + break + time.sleep(0.05) + capture_response = run_json(capture_command("finalize"), environment, (0, 1)) + + if cpu_load is not None: + cpu_load_result = cpu_load.stop() + cpu_load = None + fixture_trace = fetch_fixture_trace(host, environment) + capture = capture_response["snapshot"] + late_reports = int(fixture_status.get("lateReports", 0)) + maximum_lateness_us = int(fixture_status.get("maximumLatenessUs", 0)) + lateness_within_budget = ( + late_reports <= arguments.max_late_reports and + (late_reports == 0 or maximum_lateness_us <= arguments.max_lateness_us) + ) + if interruption_mode == "completion": + checks = { + "capturePassed": capture.get("state") == "passed", + "exactOutput": capture.get("received") == expected, + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + "noCaptureIssues": not capture.get("issues"), + "fixtureCompleted": fixture_status.get("state") == "complete", + "allReportsSubmitted": fixture_status.get("reportsSubmitted") == expected_reports, + "latenessWithinBudget": lateness_within_budget, + } + elif interruption_mode in {"remote-abort", "external-abort"}: + received = str(capture.get("received", "")) + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + checks = { + "fixtureAborted": fixture_status.get("state") == "aborted", + "reportsStoppedEarly": 0 < submitted < expected_reports, + "receivedSafePrefix": bool(received) and expected.startswith(received), + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + "releaseTransferCompleted": completed >= submitted + 1, + "noDuplicateDownEvents": capture.get("duplicateDownEvents", 0) == 0, + "noRepeatEvents": capture.get("repeatEvents", 0) == 0, + "noUnmatchedUpEvents": capture.get("unmatchedUpEvents", 0) == 0, + } + else: + received = str(capture.get("received", "")) + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + checks = { + "fixtureErroredOnUnmount": fixture_status.get("state") == "error", + "usbUnmountObserved": saw_usb_unmounted, + "usbRemounted": fixture_status.get("usbMounted") is True, + "reportsStoppedEarly": 0 < submitted < expected_reports, + "receivedSafePrefix": bool(received) and expected.startswith(received), + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + "releaseTransferCompleted": completed >= submitted + 1, + "noDuplicateDownEvents": capture.get("duplicateDownEvents", 0) == 0, + "noRepeatEvents": capture.get("repeatEvents", 0) == 0, + "noUnmatchedUpEvents": capture.get("unmatchedUpEvents", 0) == 0, + } + passed = all(checks.values()) + presentation_result = run_json(fixture_command( + host, "present", "--phase", "result", "--result", "pass" if passed else "fail", + "--progress", "1000", "--title", arguments.run_id[:32], + "--detail", f"{len(capture.get('received', ''))} / {len(expected)} CHARS", + "--next", "NEXT MATRIX CASE", "--reports-expected", str(expected_reports), + "--reports-observed", str(fixture_status.get("reportsSubmitted", 0)), + "--latency-p95-us", str(fixture_status.get("maximumLatenessUs", 0)), + "--safe-release", + ), environment) + artifact = { + "schemaVersion": 1, + "runID": arguments.run_id, + "status": "passed" if passed else "failed", + "fixtureHost": host, + "expectedReports": expected_reports, + "interruptionMode": interruption_mode, + "timingBudget": { + "maxLateReports": arguments.max_late_reports, + "maxLatenessUs": arguments.max_lateness_us, + }, + "modifierTiming": { + "shiftLeadMs": arguments.shift_lead_ms, + "keyHoldMs": arguments.hold_ms, + "shiftReleaseLagMs": arguments.shift_release_lag_ms, + }, + "checks": checks, + "control": { + "initialFixtureStatus": initial_status, + "capturePreflight": capture_preflight, + "load": load, + "fixtureArm": fixture_arm, + "captureFocus": capture_focus, + "captureArm": capture_arm, + "captureInitialTerminal": capture_initial_terminal, + "presentationTesting": presentation_testing, + "presentationResult": presentation_result, + "start": start, + "abort": abort_response, + }, + "fixture": fixture_status, + "fixtureTrace": fixture_trace, + "capture": capture, + "cpuLoad": cpu_load_result, + } + output = arguments.output or ( + pathlib.Path.home() / ".local/state/keypath-hid-capture-jig/combined" / + f"{safe_run_id(arguments.run_id)}.json" + ) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") + output.chmod(0o600) + summary = { + "runID": arguments.run_id, + "status": artifact["status"], + "artifact": str(output), + "fixtureHost": host, + "expectedReports": expected_reports, + "submittedReports": fixture_status.get("reportsSubmitted"), + "capturedEvents": len(capture.get("events", [])), + "fixtureTraceReports": len(fixture_trace), + "receivedCharacters": len(capture.get("received", "")), + "checks": checks, + } + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 if passed else 1 + except (OSError, ValueError, RuntimeError, KeyError) as error: + cleanup: list[str] = [] + if fixture_mutated and fixture_host: + try: + run_json(fixture_command(fixture_host, "abort"), environment) + cleanup.append("fixture aborted with all-keys-released queued") + except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: + cleanup.append(f"fixture abort failed: {cleanup_error}") + if capture_armed: + try: + run_json(capture_command("finalize"), environment, (0, 1)) + cleanup.append("capture finalized") + except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: + cleanup.append(f"capture finalize failed: {cleanup_error}") + suffix = f"; cleanup: {'; '.join(cleanup)}" if cleanup else "" + print(f"physical-hid-capture-run: {error}{suffix}", file=sys.stderr) + return 2 + finally: + if cpu_load is not None: + cpu_load.stop() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/physical-hid-shift-matrix b/Scripts/lab/physical-hid-shift-matrix new file mode 100755 index 000000000..ff86c4602 --- /dev/null +++ b/Scripts/lab/physical-hid-shift-matrix @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Run a focused physical-HID Shift lead/hold/release timing matrix.""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import pathlib +import subprocess +import sys +import time +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RUNNER = pathlib.Path(os.environ.get( + "KEYPATH_PHYSICAL_HID_RUNNER", ROOT / "Scripts/lab/physical-hid-capture-run" +)) +JIG_TOOL = pathlib.Path(os.environ.get( + "KEYPATH_CAPTURE_JIG_TOOL", ROOT / "Scripts/lab/hid-capture-jig-tool" +)) +DEFAULT_TEXT = ROOT / "Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt" +SHIFT_DEMOTIONS = { + **{shifted: plain for shifted, plain in zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")}, + **dict(zip("!@#$%^&*()_+{}|:\"~<>?", "1234567890-=[]\\;'`,./")), +} + + +def integer_list(value: str) -> list[int]: + try: + values = [int(part) for part in value.split(",")] + except ValueError as error: + raise argparse.ArgumentTypeError("timing values must be comma-separated integers") from error + if not values or any(item < 0 for item in values) or len(set(values)) != len(values): + raise argparse.ArgumentTypeError("timing values must be unique non-negative integers") + return values + + +def safe_name(value: str) -> str: + return "".join(character if character.isalnum() or character in "-_." else "_" for character in value) + + +def analyze_artifact(artifact: dict[str, Any]) -> dict[str, Any]: + capture = artifact.get("capture", {}) + expected = str(capture.get("expected", "")) + received = str(capture.get("received", "")) + paired_mismatches = [ + {"offset": index, "expected": wanted, "received": actual} + for index, (wanted, actual) in enumerate(zip(expected, received)) + if wanted != actual + ] + shift_demotions = sum( + 1 for mismatch in paired_mismatches + if SHIFT_DEMOTIONS.get(mismatch["expected"]) == mismatch["received"] + ) + wrong_characters = len(paired_mismatches) + abs(len(expected) - len(received)) + fixture = artifact.get("fixture", {}) + return { + "runID": artifact.get("runID"), + "runnerStatus": artifact.get("status"), + "shiftLeadMs": artifact.get("modifierTiming", {}).get("shiftLeadMs"), + "keyHoldMs": artifact.get("modifierTiming", {}).get("keyHoldMs"), + "shiftReleaseLagMs": artifact.get("modifierTiming", {}).get("shiftReleaseLagMs"), + "expectedCharacters": len(expected), + "receivedCharacters": len(received), + "wrongCharacters": wrong_characters, + "shiftDemotions": shift_demotions, + "focusValid": bool(capture.get("focused")), + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + "lateReports": fixture.get("lateReports"), + "maximumLatenessUs": fixture.get("maximumLatenessUs"), + "fixtureTraceReports": len(artifact.get("fixtureTrace", [])), + "firstMismatches": paired_mismatches[:12], + } + + +def classify(cases: list[dict[str, Any]]) -> str: + valid = [case for case in cases if case["focusValid"] and case["allKeysReleased"] and + case["allModifiersReleased"]] + if not valid: + return "harness-invalid" + baseline = next((case for case in valid if case["shiftLeadMs"] == 0 and + case["shiftReleaseLagMs"] == 0), None) + exact = [case for case in valid if case["wrongCharacters"] == 0] + if exact and baseline and baseline["wrongCharacters"] > 0: + return "modifier-timing-sensitive" + if exact: + return "exact-output-observed" + if any(case["shiftDemotions"] for case in valid): + return "shift-demotion-persists" + return "output-corruption-persists" + + +def ensure_jig_running() -> tuple[bool, str]: + """Preserve a responsive Jig process without stealing keyboard focus.""" + status = subprocess.run([str(JIG_TOOL), "status"], text=True, capture_output=True) + if status.returncode == 0: + detail = status.stderr.strip() or status.stdout.strip() or "capture Jig is responsive" + return True, detail + result = subprocess.run([str(JIG_TOOL), "open"], text=True, capture_output=True) + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + return result.returncode == 0, detail + + +def wait_for_jig_readiness(timeout_seconds: float) -> tuple[bool, str]: + """Wait for a stable resource window without activating the Jig.""" + deadline = time.monotonic() + timeout_seconds + detail = "capture Jig readiness is unavailable" + while True: + result = subprocess.run([str(JIG_TOOL), "status"], text=True, capture_output=True) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + else: + try: + response = json.loads(result.stdout) + readiness = response.get("systemReadiness", {}) + detail = str(readiness.get("detail", "resource readiness is unavailable")) + if readiness.get("canProceed"): + return True, detail + except (json.JSONDecodeError, AttributeError): + detail = "capture Jig returned invalid readiness JSON" + if time.monotonic() >= deadline: + return False, detail + time.sleep(1) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + result.add_argument("--run-id-prefix", default=f"shift-timing-{stamp}") + result.add_argument("--text", type=pathlib.Path, default=DEFAULT_TEXT) + result.add_argument("--lead-values", type=integer_list, default=integer_list("0,2,4,8,12")) + result.add_argument("--release-values", type=integer_list, default=integer_list("0,2,4,8,12")) + result.add_argument("--interval-ms", type=int, default=50) + result.add_argument("--hold-ms", type=int, default=8) + result.add_argument("--repeat", type=int, default=5) + result.add_argument("--cycle-gap-ms", type=int, default=250) + result.add_argument("--delay-ms", type=int, default=1000) + result.add_argument("--readiness-timeout-seconds", type=float, default=60) + result.add_argument("--fixture-host", default=os.environ.get( + "KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + result.add_argument("--output-directory", type=pathlib.Path) + result.add_argument("--continue-after-infrastructure-error", action="store_true") + return result + + +def main() -> int: + arguments = parser().parse_args() + if not arguments.text.is_file(): + print(f"physical-hid-shift-matrix: text file does not exist: {arguments.text}", file=sys.stderr) + return 2 + maximum_timing = max(arguments.lead_values) + arguments.hold_ms + max(arguments.release_values) + if maximum_timing >= arguments.interval_ms: + print("physical-hid-shift-matrix: lead + hold + release must fit inside the interval", file=sys.stderr) + return 2 + if arguments.readiness_timeout_seconds < 0: + print("physical-hid-shift-matrix: readiness timeout cannot be negative", file=sys.stderr) + return 2 + + output_directory = arguments.output_directory or ( + pathlib.Path.home() / ".local/state/keypath-hid-capture-jig/modifier-matrix" / + safe_name(arguments.run_id_prefix) + ) + output_directory.mkdir(parents=True, exist_ok=True) + output_directory.chmod(0o700) + plan = { + "schemaVersion": 1, + "runIDPrefix": arguments.run_id_prefix, + "text": str(arguments.text), + "intervalMs": arguments.interval_ms, + "holdMs": arguments.hold_ms, + "repeat": arguments.repeat, + "leadValuesMs": arguments.lead_values, + "releaseValuesMs": arguments.release_values, + "caseCount": len(arguments.lead_values) * len(arguments.release_values), + } + (output_directory / "plan.json").write_text(json.dumps(plan, indent=2, sort_keys=True) + "\n") + (output_directory / "plan.json").chmod(0o600) + + jig_ready, detail = ensure_jig_running() + if not jig_ready: + print(f"physical-hid-shift-matrix: could not open capture Jig: {detail}", file=sys.stderr) + return 2 + + cases: list[dict[str, Any]] = [] + infrastructure_errors: list[dict[str, Any]] = [] + for lead_ms in arguments.lead_values: + for release_ms in arguments.release_values: + run_id = safe_name(f"{arguments.run_id_prefix}-l{lead_ms}-r{release_ms}")[:48] + artifact_path = output_directory / f"{run_id}.json" + jig_ready, detail = ensure_jig_running() + if not jig_ready: + infrastructure_errors.append({ + "runID": run_id, "shiftLeadMs": lead_ms, + "shiftReleaseLagMs": release_ms, "returnCode": 2, + "detail": f"capture Jig focus failed: {detail}", + }) + if not arguments.continue_after_infrastructure_error: + break + continue + resource_ready, detail = wait_for_jig_readiness(arguments.readiness_timeout_seconds) + if not resource_ready: + infrastructure_errors.append({ + "runID": run_id, "shiftLeadMs": lead_ms, + "shiftReleaseLagMs": release_ms, "returnCode": 2, + "detail": f"capture Jig resource gate timed out: {detail}", + }) + if not arguments.continue_after_infrastructure_error: + break + continue + command = [ + str(RUNNER), "--run-id", run_id, "--text", str(arguments.text), + "--fixture-host", arguments.fixture_host, + "--interval-ms", str(arguments.interval_ms), "--hold-ms", str(arguments.hold_ms), + "--shift-lead-ms", str(lead_ms), "--shift-release-lag-ms", str(release_ms), + "--repeat", str(arguments.repeat), "--cycle-gap-ms", str(arguments.cycle_gap_ms), + "--delay-ms", str(arguments.delay_ms), "--output", str(artifact_path), + ] + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode == 2 or not artifact_path.is_file(): + error = { + "runID": run_id, "shiftLeadMs": lead_ms, "shiftReleaseLagMs": release_ms, + "returnCode": result.returncode, + "detail": result.stderr.strip() or result.stdout.strip() or "artifact missing", + } + infrastructure_errors.append(error) + if not arguments.continue_after_infrastructure_error: + break + continue + artifact = json.loads(artifact_path.read_text()) + case = analyze_artifact(artifact) + case["artifact"] = str(artifact_path) + cases.append(case) + print(json.dumps({key: case[key] for key in ( + "runID", "shiftLeadMs", "shiftReleaseLagMs", "wrongCharacters", + "shiftDemotions", "focusValid", "lateReports" + )}, sort_keys=True), flush=True) + if infrastructure_errors and not arguments.continue_after_infrastructure_error: + break + + ranked = sorted( + (case for case in cases if case["focusValid"] and case["allKeysReleased"] and + case["allModifiersReleased"]), + key=lambda case: (case["wrongCharacters"], case["shiftDemotions"], + case["shiftLeadMs"] + case["shiftReleaseLagMs"]), + ) + summary = { + "schemaVersion": 1, + "status": "blocked" if infrastructure_errors else "completed", + "classification": classify(cases), + "plan": plan, + "completedCases": len(cases), + "infrastructureErrors": infrastructure_errors, + "bestCases": ranked[:5], + "cases": cases, + } + summary_path = output_directory / "summary.json" + summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + summary_path.chmod(0o600) + print(json.dumps({ + "status": summary["status"], "classification": summary["classification"], + "completedCases": len(cases), "plannedCases": plan["caseCount"], + "summary": str(summary_path), "bestCases": ranked[:3], + }, indent=2, sort_keys=True)) + return 2 if infrastructure_errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/pico-hid-fixture-client b/Scripts/lab/pico-hid-fixture-client index 68a374126..7961c1fcd 100755 --- a/Scripts/lab/pico-hid-fixture-client +++ b/Scripts/lab/pico-hid-fixture-client @@ -4,10 +4,13 @@ from __future__ import annotations import argparse +import hashlib +import hmac import json import os import pathlib import sys +import time import urllib.error import urllib.request import zlib @@ -34,15 +37,22 @@ USAGE.update({ def compile_text(run_id: str, text: str, interval_ms: int, hold_ms: int, - repeat_count: int, cycle_gap_ms: int) -> str: + repeat_count: int, cycle_gap_ms: int, shift_lead_ms: int = 0, + shift_release_lag_ms: int = 0) -> str: if not run_id or len(run_id) > 48 or any(not (ch.isalnum() or ch in "-_.") for ch in run_id): raise ValueError("run id must contain 1-48 letters, digits, dots, underscores, or hyphens") if not text: raise ValueError("input text is empty") if not 1 <= repeat_count <= 10_000: raise ValueError("repeat count must be between 1 and 10000") + if interval_ms < 4: + raise ValueError("report interval must be at least 4 ms for deterministic full-speed USB delivery") if not 2 <= hold_ms < interval_ms: raise ValueError("hold duration must be at least 2 ms and shorter than the interval") + if shift_lead_ms < 0 or shift_release_lag_ms < 0: + raise ValueError("Shift lead and release lag cannot be negative") + if shift_lead_ms + hold_ms + shift_release_lag_ms >= interval_ms: + raise ValueError("Shift lead, hold, and release lag must fit inside the character interval") events: list[str] = [] at_us = 0 for index, character in enumerate(text): @@ -50,8 +60,17 @@ def compile_text(run_id: str, text: str, interval_ms: int, hold_ms: int, usage, modifiers = USAGE[character] except KeyError as error: raise ValueError(f"unsupported US keyboard character at offset {index}: {character!r}") from error - events.append(f"{at_us} {modifiers} {usage} 0 0 0 0 0") - events.append(f"{at_us + hold_ms * 1000} 0 0 0 0 0 0 0") + key_down_us = at_us + if modifiers and shift_lead_ms: + events.append(f"{at_us} {modifiers} 0 0 0 0 0 0") + key_down_us += shift_lead_ms * 1000 + events.append(f"{key_down_us} {modifiers} {usage} 0 0 0 0 0") + key_up_us = key_down_us + hold_ms * 1000 + if modifiers and shift_release_lag_ms: + events.append(f"{key_up_us} {modifiers} 0 0 0 0 0 0") + events.append(f"{key_up_us + shift_release_lag_ms * 1000} 0 0 0 0 0 0 0") + else: + events.append(f"{key_up_us} 0 0 0 0 0 0 0") at_us += interval_ms * 1000 cycle_us = at_us + cycle_gap_ms * 1000 event_payload = "\n".join(events) + "\n" @@ -70,12 +89,16 @@ class FixtureClient: self.timeout = timeout def request(self, method: str, path: str, body: bytes | None = None, - content_type: str = "text/plain; charset=us-ascii") -> str: + content_type: str = "text/plain; charset=us-ascii", + headers: dict[str, str] | None = None) -> str: + request_headers = {"Authorization": f"Bearer {self.token}"} + if headers: + request_headers.update(headers) request = urllib.request.Request( self.base_url + path, data=body, method=method, - headers={"Authorization": f"Bearer {self.token}"}, + headers=request_headers, ) if body is not None: request.add_header("Content-Type", content_type) @@ -109,6 +132,51 @@ class FixtureClient: lines = self.request("GET", f"/v1/trace?from={start}&limit={limit}").splitlines() return [json.loads(line) for line in lines] + def update_firmware(self, firmware: bytes, expected_build: str, + wait_seconds: float = 90.0) -> dict[str, object]: + if not firmware: + raise ValueError("firmware image is empty") + if not expected_build or len(expected_build) > 32 or not expected_build.isalnum(): + raise ValueError("expected build must contain 1-32 letters or digits") + if wait_seconds <= 0: + raise ValueError("update wait must be positive") + before = self.status() + if before.get("updateReady") is not True: + raise RuntimeError("fixture is not safely idle for a firmware update") + current_slot = before.get("otaSlot") + if current_slot not in ("ota_0", "ota_1"): + raise RuntimeError("fixture needs the one-time USB dual-slot migration before Wi-Fi updates") + target_slot = "ota_1" if current_slot == "ota_0" else "ota_0" + digest = hashlib.sha256(firmware).hexdigest() + signature = hmac.new(self.token.encode("utf-8"), firmware, hashlib.sha256).hexdigest() + try: + response = json.loads(self.request( + "POST", "/v1/firmware", firmware, "application/octet-stream", + {"X-KeyPath-SHA256": digest, "X-KeyPath-HMAC-SHA256": signature, + "X-KeyPath-Expected-Build": expected_build}, + )) + except (urllib.error.URLError, ConnectionError, TimeoutError): + # The board may close the socket while restarting. The build check below is authoritative. + response = {"ok": True, "message": "board disconnected while restarting", "rebooting": True} + + deadline = time.monotonic() + wait_seconds + last_error = "fixture did not reconnect" + while time.monotonic() < deadline: + try: + status = self.status() + if (status.get("build") == expected_build and status.get("wifiConnected") is True and + status.get("displayHealthy") is True and status.get("splashComplete") is True and + status.get("otaSlot") == target_slot and status.get("otaState") == "valid"): + return {"upload": response, "status": status, "verifiedBuild": expected_build, + "previousSlot": current_slot, "verifiedSlot": target_slot} + last_error = (f"fixture reported build {status.get('build', 'unknown')!r}, " + f"slot {status.get('otaSlot', 'unknown')!r}, " + f"state {status.get('otaState', 'unknown')!r}") + except (urllib.error.URLError, ConnectionError, TimeoutError, RuntimeError, json.JSONDecodeError) as error: + last_error = str(error) + time.sleep(1.0) + raise RuntimeError(f"firmware update was not verified: {last_error}") + def token_from_environment() -> str: token = os.environ.get("KEYPATH_FIXTURE_TOKEN", "") @@ -131,6 +199,8 @@ def parser() -> argparse.ArgumentParser: compile_command.add_argument("--hold-ms", type=int, default=30) compile_command.add_argument("--repeat", type=int, default=1) compile_command.add_argument("--cycle-gap-ms", type=int, default=500) + compile_command.add_argument("--shift-lead-ms", type=int, default=0) + compile_command.add_argument("--shift-release-lag-ms", type=int, default=0) compile_command.add_argument("--output", type=pathlib.Path) load_command = commands.add_parser("load-script") load_command.add_argument("script", type=pathlib.Path) @@ -140,6 +210,10 @@ def parser() -> argparse.ArgumentParser: start_command.add_argument("run_id") start_command.add_argument("--delay-ms", type=int, default=2000) commands.add_parser("abort") + update_command = commands.add_parser("update-firmware", help="install and verify an authenticated OTA image") + update_command.add_argument("firmware", type=pathlib.Path) + update_command.add_argument("--expected-build", required=True) + update_command.add_argument("--wait-seconds", type=float, default=90.0) trace_command = commands.add_parser("trace") trace_command.add_argument("--from", dest="start", type=int, default=0) trace_command.add_argument("--limit", type=int, default=8) @@ -166,6 +240,8 @@ def parser() -> argparse.ArgumentParser: run_command.add_argument("--hold-ms", type=int, default=30) run_command.add_argument("--repeat", type=int, default=1) run_command.add_argument("--cycle-gap-ms", type=int, default=500) + run_command.add_argument("--shift-lead-ms", type=int, default=0) + run_command.add_argument("--shift-release-lag-ms", type=int, default=0) run_command.add_argument("--delay-ms", type=int, default=2000) return root @@ -178,7 +254,8 @@ def main() -> int: arguments = parser().parse_args() if arguments.command == "compile-text": script = compile_text(arguments.run_id, arguments.text.read_text(), arguments.interval_ms, - arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms) + arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms, + arguments.shift_lead_ms, arguments.shift_release_lag_ms) if arguments.output: arguments.output.write_text(script) print(arguments.output) @@ -192,6 +269,9 @@ def main() -> int: elif arguments.command == "arm": print_json(client.arm(arguments.run_id)) elif arguments.command == "start": print_json(client.start(arguments.run_id, arguments.delay_ms)) elif arguments.command == "abort": print_json(client.abort()) + elif arguments.command == "update-firmware": + print_json(client.update_firmware(arguments.firmware.read_bytes(), arguments.expected_build, + arguments.wait_seconds)) elif arguments.command == "trace": print_json(client.trace(arguments.start, arguments.limit)) elif arguments.command == "present": print_json(client.present({ @@ -204,7 +284,8 @@ def main() -> int: })) elif arguments.command == "run-text": script = compile_text(arguments.run_id, arguments.text.read_text(), arguments.interval_ms, - arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms) + arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms, + arguments.shift_lead_ms, arguments.shift_release_lag_ms) print_json({"load": client.load_script(script), "arm": client.arm(arguments.run_id), "start": client.start(arguments.run_id, arguments.delay_ms)}) return 0 diff --git a/Scripts/lab/pico-hid-fixture-tool b/Scripts/lab/pico-hid-fixture-tool index 32414c302..1bf595d7d 100755 --- a/Scripts/lab/pico-hid-fixture-tool +++ b/Scripts/lab/pico-hid-fixture-tool @@ -6,12 +6,13 @@ fixture_root="$script_dir/pico-hid-fixture" target="$fixture_root/targets/waveshare-esp32-s3-touch-lcd-1.69" idf_path=${KEYPATH_FIXTURE_IDF_PATH:-"$HOME/.cache/keypath-esp32/esp-idf"} build_dir=${KEYPATH_FIXTURE_BUILD_DIR:-"$HOME/.cache/keypath-esp32/keypath-fixture-build"} -sdkconfig=${KEYPATH_FIXTURE_SDKCONFIG:-"$HOME/.cache/keypath-esp32/keypath-fixture-sdkconfig"} +sdkconfig=${KEYPATH_FIXTURE_SDKCONFIG:-"$HOME/.cache/keypath-esp32/keypath-fixture-sdkconfig-ota-v2"} secrets_file=${KEYPATH_FIXTURE_SECRETS_FILE:-"$HOME/dotfiles/secrets.env"} device_dir=${KEYPATH_FIXTURE_DEVICE_DIR:-/dev} add_secret_app=${KEYPATH_FIXTURE_ADD_SECRET_APP:-"/Applications/Add Secret.app"} +fixture_client=${KEYPATH_FIXTURE_CLIENT:-"$script_dir/pico-hid-fixture-client"} -required_secrets="KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 KEYPATH_WIFI_SSID_2 KEYPATH_WIFI_PASSWORD_2 KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 KEYPATH_FIXTURE_TOKEN" +required_secrets="KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 KEYPATH_WIFI_SSID_2 KEYPATH_WIFI_PASSWORD_2 KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 KEYPATH_WIFI_SSID_4 KEYPATH_WIFI_PASSWORD_4 KEYPATH_FIXTURE_TOKEN" usage() { printf '%s\n' \ @@ -24,6 +25,7 @@ usage() { " build Build production firmware with real credentials" \ " flash [--port DEV] Build and flash the connected Waveshare board" \ " install [--port DEV] Run checks, tests, build, flash, and health check" \ + " update Build, install, and verify firmware over authenticated Wi-Fi OTA" \ " status Query the running fixture over Wi-Fi" \ "" \ "The fixture token is read from the environment or sops; never pass it on the command line." @@ -55,7 +57,7 @@ load_credentials() { [[ -f "$secrets_file" ]] || fail "encrypted secrets file is missing: $secrets_file" while IFS='=' read -r secret_name secret_value; do case "$secret_name" in - KEYPATH_WIFI_SSID_1|KEYPATH_WIFI_PASSWORD_1|KEYPATH_WIFI_SSID_2|KEYPATH_WIFI_PASSWORD_2|KEYPATH_WIFI_SSID_3|KEYPATH_WIFI_PASSWORD_3|KEYPATH_FIXTURE_TOKEN) + KEYPATH_WIFI_SSID_1|KEYPATH_WIFI_PASSWORD_1|KEYPATH_WIFI_SSID_2|KEYPATH_WIFI_PASSWORD_2|KEYPATH_WIFI_SSID_3|KEYPATH_WIFI_PASSWORD_3|KEYPATH_WIFI_SSID_4|KEYPATH_WIFI_PASSWORD_4|KEYPATH_FIXTURE_TOKEN) printf -v "$secret_name" '%s' "$secret_value" export "$secret_name" ;; @@ -77,6 +79,15 @@ validate_credentials() { idf_activate() { [[ -f "$idf_path/export.sh" ]] || fail "ESP-IDF is missing at $idf_path" + if [[ -z "${IDF_PYTHON_ENV_PATH:-}" ]]; then + for candidate in "$HOME"/.espressif/python_env/idf5.5_py*_env; do + if [[ -x "$candidate/bin/python" ]]; then + IDF_PYTHON_ENV_PATH=$candidate + export IDF_PYTHON_ENV_PATH + break + fi + done + fi # shellcheck disable=SC1090 source "$idf_path/export.sh" >/dev/null } @@ -161,6 +172,8 @@ command_test() { python3 "$script_dir/tests/pico-hid-fixture-client-tests.py" python3 "$script_dir/tests/pico-hid-fixture-tool-tests.py" python3 "$script_dir/tests/scenario-matrix-runner-tests.py" + python3 "$script_dir/tests/physical-hid-capture-run-tests.py" + python3 "$script_dir/tests/physical-hid-shift-matrix-tests.py" "$fixture_root/tests/run-esp32-qemu-smoke.sh" } @@ -197,7 +210,39 @@ command_flash() { command_status() { validate_credentials - "$script_dir/pico-hid-fixture-client" status + "$fixture_client" status +} + +firmware_build_id() { + generated_config="$build_dir/esp-idf/main/generated/fixture_config.h" + [[ -f "$generated_config" ]] || fail "generated build identity is missing: $generated_config" + build_id=$(sed -n 's/^#define KEYPATH_FIXTURE_BUILD_ID "\([[:alnum:]]*\)"$/\1/p' "$generated_config") + [[ -n "$build_id" ]] || fail "could not read the expected build identity" + printf '%s\n' "$build_id" +} + +command_update() { + command_build + expected_build=$(firmware_build_id) + firmware="$build_dir/keypath_esp32_s3_hid_fixture.bin" + current_status=$(command_status) || fail "fixture is not reachable; use USB flash recovery" + python3 - "$current_status" <<'PY' || fail "fixture is not safely idle; abort or finish the current test before updating" +import json +import sys + +status = json.loads(sys.argv[1]) +if not status.get("updateReady"): + raise SystemExit(1) +if not str(status.get("otaSlot", "")).startswith("ota_"): + raise SystemExit(1) +PY + printf 'updating build %s over authenticated Wi-Fi\n' "$expected_build" + # A production image is about 1.3 MB. The ESP32 writes and hashes it while + # receiving, so the generic 10-second control request timeout can close the + # socket mid-image on a healthy but busy board. + "$fixture_client" --timeout 120 update-firmware "$firmware" \ + --expected-build "$expected_build" --wait-seconds 90 + printf 'verified OTA build %s is healthy over Wi-Fi\n' "$expected_build" } command_install() { @@ -209,12 +254,22 @@ command_install() { else command_flash fi + expected_build=$(firmware_build_id) printf 'waiting for Wi-Fi health check' attempt=0 while [[ "$attempt" -lt 30 ]]; do - if command_status >/dev/null 2>&1; then + if installed_status=$(command_status 2>/dev/null) && + python3 - "$installed_status" "$expected_build" <<'PY' +import json +import sys + +status = json.loads(sys.argv[1]) +raise SystemExit(0 if status.get("build") == sys.argv[2] and + str(status.get("otaSlot", "")).startswith("ota_") else 1) +PY + then printf '\ninstallation verified over Wi-Fi\n' - command_status + printf '%s\n' "$installed_status" return fi printf '.' @@ -235,6 +290,7 @@ case "$command" in build) [[ $# -eq 0 ]] || fail "build takes no options"; command_build ;; flash) command_flash "$@" ;; install) command_install "$@" ;; + update) [[ $# -eq 0 ]] || fail "update takes no options"; command_update ;; status) [[ $# -eq 0 ]] || fail "status takes no options"; command_status ;; help|-h|--help) usage ;; *) usage >&2; fail "unknown command: $command" ;; diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 07b926034..103d20a3e 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -9,7 +9,7 @@ The fixture is deliberately not part of any KeyPath product target. ## First board: one-command install from a Mac The checked-in `pico-hid-fixture-tool` owns setup, tests, builds, flashing, and the first Wi-Fi -health check. Before the board arrives, configure the three ordered Wi-Fi profiles and control +health check. Before the board arrives, configure the four Wi-Fi profiles and control token without putting their values in a shell history or chat: ```bash @@ -63,10 +63,22 @@ so the splash needs no image decoder and does not delay USB, Wi-Fi, or the HID e - Scripts are bounded by event, repeat, duration, request-size, and trace limits. - Uploaded event bytes are CRC32-verified before the fixture can arm. - HTTP endpoints require a bearer token. Credentials are build inputs and are never committed. +- Firmware updates are accepted only while the fixture is safely idle, are authenticated with a + token-bound HMAC-SHA256, and are SHA-256 checked before ESP-IDF validates the application image. +- Two application slots preserve the currently working image. A newly installed image must bring + its Wi-Fi control plane up within 60 seconds or the bootloader rolls back automatically. - Control traffic is HTTP rather than TLS, so operate it only on the isolated, WPA2-protected lab Wi-Fi; the bearer token is defense in depth, not a substitute for network isolation. - The fixture schedules every report locally. Wi-Fi jitter can shift the start acknowledgement but cannot alter inter-key timing after the script starts. +- The deterministic physical timing contract starts at a 4 ms character interval with a 2 ms key + hold. That is already a 250 Hz key cadence. A physical 3 ms diagnostic preserved output but + missed USB deadlines because its alternating 2 ms/1 ms press-release gaps leave no scheduling + margin on the full-speed interrupt endpoint, so the compiler rejects intervals below 4 ms. +- Combined physical runs are strict by default: any report more than 1 ms late fails the run. A + load case may opt into both `--max-late-reports` and `--max-lateness-us` to classify bounded + timing pressure separately from correctness. Exact output, report count, ordering, and + all-keys-released remain mandatory regardless of that budget. - On ESP32-S3, the HID scheduler owns core 1 at high priority. USB service, network control, sound, and display work remain on core 0. The motion governor drops the display from 30 to 20 to 8 FPS and removes expensive particle layers before animation can compete with HID timing. @@ -100,6 +112,8 @@ export KEYPATH_WIFI_SSID_2='fallback-network-one' export KEYPATH_WIFI_PASSWORD_2='...' export KEYPATH_WIFI_SSID_3='fallback-network-two' export KEYPATH_WIFI_PASSWORD_3='...' +export KEYPATH_WIFI_SSID_4='current-location-network' +export KEYPATH_WIFI_PASSWORD_4='...' export KEYPATH_FIXTURE_TOKEN='at-least-16-random-characters' idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 build ``` @@ -122,6 +136,98 @@ colored icons instead of hard cuts. A visible `HID PRIORITY` mode keeps a restra while shedding most particles, so the display still communicates life without competing with keyboard delivery. +Secure OTA uses the same KeyPath visual language as the host capture Jig: the opened amber keycap, +illuminated center, and four-point glint. The key light fills from the authenticated image's real +receive/validation progress; the orbiting glint communicates liveness without implying extra +progress. + +The device UI uses LVGL's native Montserrat bitmaps at 12, 14, 16, 20, and 24 px rather than scaling +a single size. Small telemetry stays crisp, the 24 px state and splash wordmark carry the hierarchy, +and icons retain the symbol-complete 20 px cut. These fonts are compiled into flash and require no +runtime rasterization, allocation, or work on the HID core. + +The host Jig keeps product and tool identity separate. Its in-window header bundles and renders the +real KeyPath application artwork, while its Dock/Finder icon depicts a physical test fixture—base +plate, opposing clamps, test key, and measurement probe—and does not reuse the KeyPath app icon. + +## Host capture Jig resource preflight + +### Foreground ownership requirement + +A physical completion run needs exclusive keyboard focus on the macOS desktop under test. USB HID +reports are delivered to the foreground application; they cannot be addressed to a background Jig. +The runner therefore leaves the Jig unfocused during resource checks, script compilation, and fixture +setup, then focuses it immediately before the atomic arm step. Losing focus after arming invalidates +the run. Do not type or switch applications until the runner returns. + +This does not require an otherwise empty Mac. Normal background services are allowed when the +resource gate accepts them, and deliberate load cases add their own bounded workers after arming. +It does require reserving the active desktop for the test. For unattended runs while the host remains +usable, pass the fixture USB device through to a dedicated macOS VM and keep the Jig focused inside +that guest; retain a smaller bare-metal acceptance set because VM USB scheduling is not identical to +physical macOS. + +The focused Shift diagnostic is split into a short admission run and a full matrix: + +```bash +# About 10 minutes including admission, evidence inspection, and one retry allowance. +Scripts/lab/physical-hid-shift-matrix \ + --lead-values 0,4,8 --release-values 0 --repeat 2 + +# Reserve 20-30 minutes. Runs the complete 5x5 lead/release grid. +Scripts/lab/physical-hid-shift-matrix +``` + +The matrix waits up to 60 seconds for a stable Jig resource window before each cell. It does not +focus the Jig while waiting. Each artifact combines exact expected/received text, focus and release +state, macOS event history, fixture timing totals, and the bounded ESP32 report trace. + +The native AppKit HID Capture Jig samples the Mac once per second and will not arm until three +consecutive checks are inside the baseline capture envelope. It evaluates instantaneous CPU use, +one-minute runnable load normalized per logical core, reclaimable memory, macOS memory pressure, +thermal state, and an extreme system-thread ceiling. Current thresholds are centralized and tested +in `hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift`: + +- CPU below 80% +- normalized load below 0.9× per logical core +- at least 2 GB or 8% of physical memory reclaimable, whichever is larger +- normal macOS memory pressure +- no serious/critical thermal state +- fewer than 900 system threads per logical core + +While blocked, the Jig remains idle and shows `PAUSED · MAC BUSY`, live measurements, the specific +reason, and the best first operator action. The file-RPC response contains the same structured +`systemReadiness` assessment. `physical-hid-capture-run` checks it before loading or arming the +fixture, so a busy host cannot mutate the USB test state and then fail late. The Jig rechecks +automatically; no app or machine restart is required. + +During capture, every real key-down is also rendered as a labeled KeyPath keycap at a shared focal +point. The face compresses immediately, then joins a short, fanned stack of the most recent presses; +dense input widens the fan and raises the live burst count without drawing a full keyboard. The model +uses capture timestamps, caps the visible stack at ten keycaps, and gives faster-than-display bursts a +36 ms minimum presentation cadence so each genuine key-down remains perceptible and in order. Each cap +ages out in 900 ms, while the complete, unmodified event ledger remains in the JSON artifact. Reduce +Motion removes travel, compression, and paced playback while preserving live labels and evidence. + +This is the baseline reliability policy. Deliberate load-matrix cases use the runner's bounded CPU +envelope rather than bypassing the gate with an unstructured "ignore busy" switch. The runner +performs the normal calm preflight first, then starts the requested number of tracked CPU workers +only after the Jig and fixture are armed: + +```bash +Scripts/lab/physical-hid-capture-run \ + --run-id shifted-high-load \ + --text Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt \ + --interval-ms 50 --hold-ms 12 --repeat 20 --cycle-gap-ms 50 \ + --cpu-load-workers 6 --cpu-sample-ms 250 +``` + +`--cpu-load-workers` accepts zero through the host's logical CPU count. Every worker is owned and +terminated by the runner, including error paths. The combined artifact records worker count, +logical CPUs, average/maximum CPU, and timestamped CPU/load samples. If capture fails early, the +runner still waits for the fixture to finish and for the macOS event queue to settle, preserving the +complete event ledger instead of a first-mismatch prefix. + ## Pico 2 W build Install CMake, an Arm embedded compiler, and the Raspberry Pi Pico SDK. Keep Wi-Fi credentials and @@ -175,6 +281,7 @@ The fixture advertises `keypath-hid-fixture.local` over mDNS on port 8080. Every | POST | `/v1/abort` | Empty | | GET | `/v1/trace?from=N&limit=8` | — | | POST | `/v1/presentation` | Bounded JSON phase, result, progress, labels, and metrics | +| POST | `/v1/firmware` | Binary ESP32 application image plus SHA-256 and HMAC headers | Commands are state-checked. A script cannot start unless the same run ID was loaded and armed. The start delay must be 100–60000 ms, giving the guest observer time to become ready. @@ -206,6 +313,24 @@ Scripts/lab/pico-hid-fixture-client present --phase result --result pass --progr stage explicitly so the VM can prove USB admission, arm its independent observers, and verify the requested load threshold before `start`. +## Safe Wi-Fi firmware updates + +The initial dual-slot partition layout must be installed once over USB with the normal BOOT/RESET +flash path. After that migration, routine application updates are one command and do not change +the HID-only USB interface: + +```bash +Scripts/lab/pico-hid-fixture-tool update +``` + +The tool builds an exact source-identified image, confirms the fixture is reachable and update-safe, +authenticates the image with the fixture token, streams it into the inactive slot, and waits through +the reboot. Success requires the board to reconnect over Wi-Fi on the exact expected build. The +device shows receive/validation progress while keeping HID execution disabled. Never remove power +during the update. If the new image cannot restore its Wi-Fi control plane within 60 seconds, the +bootloader returns to the previous slot. USB BOOT/RESET remains the recovery path if both application +slots or the network configuration are unusable. + The matrix campaign executor mirrors live phases and its final classified result to the display when invoked with `--hid-presentation`. This path is intentionally presentation-only: display delivery failures are logged to `hid-fixture-presentation.log` and never change test evidence or @@ -221,7 +346,8 @@ Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh These tests cover CRC and script admission, timing/repeat execution, trace ordering, lateness metrics, boot/abort/unmount releases, US-keyboard compilation, bearer authentication, endpoint -selection, secure installer behavior, presentation/result resolution, NDJSON trace decoding, and +selection, authenticated OTA image construction and build verification, secure installer behavior, +presentation/result resolution, NDJSON trace decoding, and the adaptive UI model. The QEMU test boots an ESP32-S3 image and executes the real parser, scheduler, trace logic, and UI state model on the emulated Xtensa cores. QEMU does not emulate the Waveshare LCD/touch/buzzer or the ESP32-S3 native USB device diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.c b/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.c new file mode 100644 index 000000000..23bfb0fb9 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.c @@ -0,0 +1,46 @@ +#include "fixture_button_feedback.h" + +fixture_button_feedback_output_t fixture_button_feedback_resolve( + fixture_button_event_t event, bool boot_held, bool download_hint, + uint64_t elapsed_ms) { + fixture_button_feedback_output_t output = {0}; + bool sustained_boot_feedback = event == FIXTURE_BUTTON_BOOT && boot_held; + if (event == FIXTURE_BUTTON_NONE || + (!sustained_boot_feedback && elapsed_ms >= FIXTURE_BUTTON_FEEDBACK_DURATION_MS)) { + return output; + } + + output.active = true; + switch (event) { + case FIXTURE_BUTTON_POWER: + output.title = "POWER BUTTON"; + output.detail = "TOP button detected"; + output.accent_rgb = 0xffb454u; + break; + case FIXTURE_BUTTON_BOOT: + if (boot_held && download_hint) { + output.title = "BOOT HELD"; + output.detail = "KEEP HOLDING + TAP BOTTOM RESET"; + } else if (download_hint) { + output.title = "BOOT RELEASED"; + output.detail = "Hold it while tapping RESET"; + } else { + output.title = "BOOT BUTTON"; + output.detail = "Test abort requested"; + } + output.accent_rgb = 0x55c7ffu; + break; + case FIXTURE_BUTTON_RESET: + output.title = "RESET / START"; + output.detail = "BOTTOM RST or power cycle"; + output.accent_rgb = 0xff6b6bu; + break; + case FIXTURE_BUTTON_NONE: + return output; + } + + uint64_t phase_ms = elapsed_ms % 360u; + uint64_t triangle_ms = phase_ms <= 180u ? phase_ms : 360u - phase_ms; + output.pulse_per_mille = (uint16_t)(triangle_ms * 1000u / 180u); + return output; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.h b/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.h new file mode 100644 index 000000000..187a1f7f4 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.h @@ -0,0 +1,28 @@ +#ifndef KEYPATH_FIXTURE_BUTTON_FEEDBACK_H +#define KEYPATH_FIXTURE_BUTTON_FEEDBACK_H + +#include +#include + +#define FIXTURE_BUTTON_FEEDBACK_DURATION_MS 1200u + +typedef enum { + FIXTURE_BUTTON_NONE = 0, + FIXTURE_BUTTON_POWER, + FIXTURE_BUTTON_BOOT, + FIXTURE_BUTTON_RESET, +} fixture_button_event_t; + +typedef struct { + bool active; + const char *title; + const char *detail; + uint32_t accent_rgb; + uint16_t pulse_per_mille; +} fixture_button_feedback_output_t; + +fixture_button_feedback_output_t fixture_button_feedback_resolve( + fixture_button_event_t event, bool boot_held, bool download_hint, + uint64_t elapsed_ms); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.c b/Scripts/lab/pico-hid-fixture/src/fixture_core.c index 78337caf2..e11534731 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_core.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.c @@ -252,10 +252,33 @@ void fixture_abort(fixture_t *fixture, const char *reason) { snprintf(fixture->error, sizeof(fixture->error), "%s", reason ? reason : "aborted"); } +bool fixture_abort_if_active(fixture_t *fixture, const char *reason) { + if (!fixture || (fixture->state != FIXTURE_ARMED && fixture->state != FIXTURE_RUNNING)) { + return false; + } + fixture_abort(fixture, reason); + return true; +} + void fixture_note_transfer_complete(fixture_t *fixture) { if (fixture) fixture->transfers_completed++; } +uint32_t fixture_time_until_next_action_us(const fixture_t *fixture, uint64_t now_us) { + if (!fixture || fixture->pending_release) return 0u; + if (fixture->state != FIXTURE_RUNNING || fixture->event_count == 0u || + fixture->next_event >= fixture->event_count) { + return UINT32_MAX; + } + + uint64_t scheduled = fixture->start_at_us + + (uint64_t)fixture->current_repeat * fixture->cycle_us + + fixture->events[fixture->next_event].at_us; + if (now_us >= scheduled) return 0u; + uint64_t remaining = scheduled - now_us; + return remaining > UINT32_MAX ? UINT32_MAX : (uint32_t)remaining; +} + static void trace_report(fixture_t *fixture, uint64_t scheduled, uint64_t submitted, uint8_t modifiers, const uint8_t keys[6]) { uint32_t slot = fixture->trace_head; diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.h b/Scripts/lab/pico-hid-fixture/src/fixture_core.h index edbbfb3a6..ba0b4c9b3 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_core.h +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.h @@ -75,7 +75,9 @@ bool fixture_arm(fixture_t *fixture, const char *run_id, char *error, size_t err bool fixture_start(fixture_t *fixture, const char *run_id, uint32_t delay_ms, uint64_t now_us, char *error, size_t error_capacity); void fixture_abort(fixture_t *fixture, const char *reason); +bool fixture_abort_if_active(fixture_t *fixture, const char *reason); void fixture_note_transfer_complete(fixture_t *fixture); +uint32_t fixture_time_until_next_action_us(const fixture_t *fixture, uint64_t now_us); void fixture_poll(fixture_t *fixture, uint64_t now_us, bool usb_mounted, bool usb_ready, fixture_send_report_fn send_report, void *send_context); diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h index 53adcc420..ed0c1e46f 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h +++ b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h @@ -26,6 +26,7 @@ typedef enum { typedef struct { fixture_presentation_phase_t phase; fixture_result_t result; + bool branded_firmware_update; uint16_t progress_per_mille; uint32_t reports_expected; uint32_t reports_observed; diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c index dbc5e56de..92b5025c6 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c @@ -116,6 +116,9 @@ void fixture_visual_resolve(const fixture_ui_output_t *ui, fixture_visual_output_t *visual) { const char *title = scene_title(ui->scene); visual->icon = scene_icon(ui->scene); + visual->variant = presentation->branded_firmware_update + ? FIXTURE_VISUAL_KEYPATH_UPDATE + : FIXTURE_VISUAL_STANDARD; visual->accent_rgb = scene_accent(ui->scene); visual->progress_per_mille = ui->progress_per_mille; visual->angular_speed_milliradians = ui->scene == FIXTURE_UI_RUNNING ? 4200u : 1550u; @@ -125,6 +128,11 @@ void fixture_visual_resolve(const fixture_ui_output_t *ui, apply_phase(presentation, visual, &title); if (presentation->title[0]) title = presentation->title; } + if (presentation->branded_firmware_update && presentation->result == FIXTURE_RESULT_NONE) { + visual->icon = FIXTURE_ICON_DOWNLOAD; + visual->accent_rgb = 0xf3a128u; + visual->angular_speed_milliradians = 2500u; + } apply_result(presentation->result, visual, &title); if (ui->quality == FIXTURE_UI_PROTECTED) visual->angular_speed_milliradians = 480u; snprintf(visual->title, sizeof(visual->title), "%s", title); diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h index 625d32159..d355f59e8 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h @@ -23,8 +23,14 @@ typedef enum { FIXTURE_ICON_NEXT, } fixture_visual_icon_t; +typedef enum { + FIXTURE_VISUAL_STANDARD = 0, + FIXTURE_VISUAL_KEYPATH_UPDATE, +} fixture_visual_variant_t; + typedef struct { fixture_visual_icon_t icon; + fixture_visual_variant_t variant; uint32_t accent_rgb; uint16_t progress_per_mille; uint16_t angular_speed_milliradians; diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt index 81318bc00..f9dda8b7c 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt @@ -2,6 +2,7 @@ foreach(secret_name KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 KEYPATH_WIFI_SSID_2 KEYPATH_WIFI_PASSWORD_2 KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 + KEYPATH_WIFI_SSID_4 KEYPATH_WIFI_PASSWORD_4 KEYPATH_FIXTURE_TOKEN) if(NOT DEFINED ENV{${secret_name}} OR "$ENV{${secret_name}}" STREQUAL "") message(FATAL_ERROR "${secret_name} must be supplied in the build environment") @@ -18,45 +19,76 @@ if(fixture_token_length LESS 16) endif() get_filename_component(component_dir "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) -execute_process( - COMMAND git -C "${component_dir}" rev-parse --short=12 HEAD - OUTPUT_VARIABLE KEYPATH_FIXTURE_BUILD_ID - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET -) -if(KEYPATH_FIXTURE_BUILD_ID STREQUAL "") - set(KEYPATH_FIXTURE_BUILD_ID "unknown") -endif() set(shared_source "${component_dir}/../../../src") +set(fixture_sources + "${component_dir}/app_main.c" + "${component_dir}/fixture_board.c" + "${component_dir}/fixture_display.c" + "${component_dir}/fixture_http.c" + "${component_dir}/fixture_qemu_smoke.c" + "${component_dir}/fixture_runtime.c" + "${shared_source}/fixture_button_feedback.c" + "${shared_source}/fixture_core.c" + "${shared_source}/fixture_presentation.c" + "${shared_source}/fixture_splash_model.c" + "${shared_source}/fixture_ui_model.c" + "${shared_source}/fixture_visual_model.c" + "${shared_source}/fixture_wifi_model.c" +) +file(GLOB fixture_headers + "${component_dir}/*.h" + "${shared_source}/*.h" +) +list(SORT fixture_headers) + +# Identify the exact source state, including uncommitted work, without embedding secrets. +set(fixture_source_fingerprint "") +foreach(source_path IN LISTS fixture_sources) + file(SHA256 "${source_path}" source_hash) + string(APPEND fixture_source_fingerprint "${source_hash}\n") +endforeach() +foreach(header_path IN LISTS fixture_headers) + file(SHA256 "${header_path}" header_hash) + string(APPEND fixture_source_fingerprint "${header_hash}\n") +endforeach() +foreach(configuration_path + "${component_dir}/../sdkconfig.defaults" + "${component_dir}/../partitions.csv" + "${component_dir}/CMakeLists.txt" + "${component_dir}/fixture_config.h.in") + file(SHA256 "${configuration_path}" configuration_hash) + string(APPEND fixture_source_fingerprint "${configuration_hash}\n") +endforeach() + +# The source hashes are evaluated at configure time. Tell CMake that edits to +# any fingerprinted input must trigger a reconfigure so OTA verification never +# mistakes a newly built image for the previous source revision. +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${fixture_sources} + ${fixture_headers} +) +string(SHA256 fixture_source_hash "${fixture_source_fingerprint}") +string(SUBSTRING "${fixture_source_hash}" 0 12 KEYPATH_FIXTURE_BUILD_ID) + set(generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated") file(MAKE_DIRECTORY "${generated_dir}") configure_file("${component_dir}/fixture_config.h.in" "${generated_dir}/fixture_config.h" @ONLY) idf_component_register( - SRCS - "app_main.c" - "fixture_board.c" - "fixture_display.c" - "fixture_http.c" - "fixture_qemu_smoke.c" - "fixture_runtime.c" - "${shared_source}/fixture_core.c" - "${shared_source}/fixture_presentation.c" - "${shared_source}/fixture_splash_model.c" - "${shared_source}/fixture_ui_model.c" - "${shared_source}/fixture_visual_model.c" - "${shared_source}/fixture_wifi_model.c" + SRCS ${fixture_sources} INCLUDE_DIRS "." "${shared_source}" "${generated_dir}" REQUIRES + app_update esp_event esp_http_server esp_netif esp_timer esp_wifi json + mbedtls mdns nvs_flash ) diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild index 75b10f866..4f486953d 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild @@ -29,4 +29,12 @@ menu "KeyPath HID fixture" Keep state, color, and progress feedback while disabling orbital movement, pulsing geometry, and the completion particle flourish. + config KEYPATH_FIXTURE_BOOT_SPLASH + bool "Enable the Hacker Dojo boot splash" + default y + help + Show the short display-native identity splash before the live + fixture state. The splash renderer uses bounded redraw regions, + and OTA validation requires its display heartbeat to complete. + endmenu diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c index 82bd0c1f2..8e6935478 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c @@ -1,4 +1,9 @@ #include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_system.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "fixture_board.h" #include "fixture_display.h" @@ -8,6 +13,35 @@ static const char *TAG = "keypath_fixture"; +static void validate_ota_boot(void *context) { + (void)context; + const esp_partition_t *running = esp_ota_get_running_partition(); + esp_ota_img_states_t state; + if (esp_ota_get_state_partition(running, &state) != ESP_OK || + state != ESP_OTA_IMG_PENDING_VERIFY) { + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "new OTA image pending validation; waiting for control-plane health"); + for (unsigned int attempt = 0u; attempt < 600u; ++attempt) { + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + if (snapshot.ui.wifi_connected && fixture_network_control_ready() && + fixture_display_is_healthy()) { + ESP_ERROR_CHECK(esp_ota_mark_app_valid_cancel_rollback()); + ESP_LOGI(TAG, "OTA image marked valid after Wi-Fi control plane became healthy"); + vTaskDelete(NULL); + return; + } + vTaskDelay(pdMS_TO_TICKS(100)); + } + + ESP_LOGE(TAG, "new OTA image failed its 60-second control-plane health window; rolling back"); + ESP_ERROR_CHECK(esp_ota_mark_app_invalid_rollback_and_reboot()); + esp_restart(); +} + void app_main(void) { #ifdef KEYPATH_QEMU_SMOKE fixture_qemu_smoke_run(); @@ -19,5 +53,7 @@ void app_main(void) { fixture_runtime_start_executor(); fixture_display_start(); ESP_ERROR_CHECK(fixture_network_start()); + configASSERT(xTaskCreatePinnedToCore(validate_ota_boot, "fixture_ota_validate", 3072, + NULL, 3, NULL, 0) == pdPASS); ESP_LOGI(TAG, "KeyPath ESP32-S3 physical HID fixture ready"); } diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c index c20872b9f..79f8d0970 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c @@ -10,7 +10,8 @@ #include "freertos/task.h" #include "sdkconfig.h" -#define FIXTURE_BUTTON GPIO_NUM_0 +#define FIXTURE_BOOT_BUTTON GPIO_NUM_0 +#define FIXTURE_POWER_BUTTON GPIO_NUM_40 #if CONFIG_KEYPATH_FIXTURE_BOARD_REVISION == 1 #define FIXTURE_BUZZER GPIO_NUM_33 #else @@ -24,10 +25,27 @@ typedef struct { static QueueHandle_t tone_queue; static volatile bool button_enabled; +static portMUX_TYPE feedback_lock = portMUX_INITIALIZER_UNLOCKED; +static fixture_board_feedback_t latest_feedback; + +static void publish_feedback(fixture_button_event_t event) { + taskENTER_CRITICAL(&feedback_lock); + latest_feedback.event = event; + latest_feedback.sequence++; + taskEXIT_CRITICAL(&feedback_lock); +} + +static void publish_boot_state(bool held, bool download_hint) { + taskENTER_CRITICAL(&feedback_lock); + latest_feedback.boot_held = held; + if (held) latest_feedback.download_hint = download_hint; + taskEXIT_CRITICAL(&feedback_lock); +} static void board_task(void *context) { (void)context; - bool previous_pressed = false; + bool previous_boot_pressed = false; + bool previous_power_pressed = false; while (true) { tone_request_t tone; if (xQueueReceive(tone_queue, &tone, pdMS_TO_TICKS(10)) == pdTRUE) { @@ -39,18 +57,32 @@ static void board_task(void *context) { ledc_stop(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_2, 0u); #endif } - bool pressed = gpio_get_level(FIXTURE_BUTTON) == 0; - if (button_enabled && pressed && !previous_pressed) { - fixture_runtime_abort("physical button abort"); - fixture_board_tone(220u, 90u); + bool boot_pressed = gpio_get_level(FIXTURE_BOOT_BUTTON) == 0; + bool power_pressed = gpio_get_level(FIXTURE_POWER_BUTTON) == 0; + if (boot_pressed != previous_boot_pressed) { + publish_boot_state(boot_pressed, !button_enabled); } - previous_pressed = pressed; + if (boot_pressed && !previous_boot_pressed) { + publish_feedback(FIXTURE_BUTTON_BOOT); + if (button_enabled) { + fixture_runtime_abort("physical button abort"); + fixture_board_tone(220u, 90u); + } else { + fixture_board_tone(760u, 55u); + } + } + if (power_pressed && !previous_power_pressed) { + publish_feedback(FIXTURE_BUTTON_POWER); + fixture_board_tone(520u, 55u); + } + previous_boot_pressed = boot_pressed; + previous_power_pressed = power_pressed; } } void fixture_board_init(void) { gpio_config_t button = { - .pin_bit_mask = 1ULL << FIXTURE_BUTTON, + .pin_bit_mask = (1ULL << FIXTURE_BOOT_BUTTON) | (1ULL << FIXTURE_POWER_BUTTON), .mode = GPIO_MODE_INPUT, .pull_up_en = GPIO_PULLUP_ENABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, @@ -79,6 +111,8 @@ void fixture_board_init(void) { #endif tone_queue = xQueueCreate(4u, sizeof(tone_request_t)); configASSERT(tone_queue); + /* CHIP_PU reset cannot draw before the CPU stops, so acknowledge it after every restart. */ + publish_feedback(FIXTURE_BUTTON_RESET); configASSERT(xTaskCreatePinnedToCore(board_task, "fixture_board", 3072, NULL, 5, NULL, 0) == pdPASS); } @@ -91,3 +125,10 @@ void fixture_board_tone(unsigned int frequency_hz, unsigned int duration_ms) { void fixture_board_update(bool armed_or_running) { button_enabled = armed_or_running; } + +void fixture_board_feedback_snapshot(fixture_board_feedback_t *feedback) { + if (!feedback) return; + taskENTER_CRITICAL(&feedback_lock); + *feedback = latest_feedback; + taskEXIT_CRITICAL(&feedback_lock); +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h index db55b68f5..2c7b8fa1c 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h @@ -2,9 +2,20 @@ #define KEYPATH_ESP32_FIXTURE_BOARD_H #include +#include + +#include "fixture_button_feedback.h" + +typedef struct { + fixture_button_event_t event; + uint32_t sequence; + bool boot_held; + bool download_hint; +} fixture_board_feedback_t; void fixture_board_init(void); void fixture_board_tone(unsigned int frequency_hz, unsigned int duration_ms); void fixture_board_update(bool armed_or_running); +void fixture_board_feedback_snapshot(fixture_board_feedback_t *feedback); #endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in index e930c86cd..111915055 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in @@ -7,6 +7,8 @@ #define KEYPATH_WIFI_PASSWORD_2 "@KEYPATH_WIFI_PASSWORD_2_ESCAPED@" #define KEYPATH_WIFI_SSID_3 "@KEYPATH_WIFI_SSID_3_ESCAPED@" #define KEYPATH_WIFI_PASSWORD_3 "@KEYPATH_WIFI_PASSWORD_3_ESCAPED@" +#define KEYPATH_WIFI_SSID_4 "@KEYPATH_WIFI_SSID_4_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD_4 "@KEYPATH_WIFI_PASSWORD_4_ESCAPED@" #define KEYPATH_FIXTURE_TOKEN "@KEYPATH_FIXTURE_TOKEN_ESCAPED@" #define KEYPATH_FIXTURE_FIRMWARE_VERSION "0.3.0-esp32s3" #define KEYPATH_FIXTURE_BUILD_ID "@KEYPATH_FIXTURE_BUILD_ID@" diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c index 182d8600d..b50856cda 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c @@ -18,6 +18,8 @@ #define PARTICLE_COUNT 12u #define KEY_COUNT 6u #define DOJO_BAR_COUNT 4u +#define SPLASH_FRAME_INTERVAL_MS 33u +#define DISPLAY_HEARTBEAT_MAX_AGE_MS 2000u typedef struct { lv_obj_t *screen; @@ -26,6 +28,12 @@ typedef struct { lv_obj_t *orbit; lv_obj_t *progress; lv_obj_t *core; + lv_obj_t *brand_keycap; + lv_obj_t *brand_light; + lv_obj_t *brand_fill; + lv_obj_t *brand_lid; + lv_obj_t *brand_glint_horizontal; + lv_obj_t *brand_glint_vertical; lv_obj_t *icon_front; lv_obj_t *icon_back; lv_obj_t *keys[KEY_COUNT]; @@ -41,6 +49,9 @@ typedef struct { uint64_t previous_render_ms; uint64_t icon_transition_started_ms; uint64_t completion_started_ms; + uint64_t button_feedback_started_ms; + uint32_t button_feedback_sequence; + fixture_button_event_t button_feedback_event; } display_ui_t; typedef struct { @@ -55,6 +66,29 @@ typedef struct { static display_ui_t ui; static display_splash_t splash; +static portMUX_TYPE display_health_lock = portMUX_INITIALIZER_UNLOCKED; +static fixture_display_health_t display_health; + +static void note_display_frame(uint64_t now_ms) { + taskENTER_CRITICAL(&display_health_lock); + display_health.frame_sequence++; + display_health.last_frame_ms = now_ms; + taskEXIT_CRITICAL(&display_health_lock); +} + +static void note_display_initialized(bool splash_enabled, bool splash_complete) { + taskENTER_CRITICAL(&display_health_lock); + display_health.initialized = true; + display_health.splash_enabled = splash_enabled; + display_health.splash_complete = splash_complete; + taskEXIT_CRITICAL(&display_health_lock); +} + +static void note_splash_complete(void) { + taskENTER_CRITICAL(&display_health_lock); + display_health.splash_complete = true; + taskEXIT_CRITICAL(&display_health_lock); +} static const char *symbol_for(fixture_visual_icon_t icon) { switch (icon) { @@ -100,6 +134,14 @@ static lv_obj_t *make_rect(lv_obj_t *parent, int x, int y, int width, int height return object; } +static void set_hidden(lv_obj_t *object, bool hidden) { + if (hidden) { + lv_obj_add_flag(object, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_clear_flag(object, LV_OBJ_FLAG_HIDDEN); + } +} + static void touch_event(lv_event_t *event) { if (lv_event_get_code(event) != LV_EVENT_PRESSED) return; fixture_runtime_snapshot_t snapshot; @@ -121,6 +163,7 @@ static void build_ui(void) { ui.eyebrow = lv_label_create(ui.screen); lv_label_set_text_static(ui.eyebrow, "KEYPATH / HID ORACLE"); + lv_obj_set_style_text_font(ui.eyebrow, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(ui.eyebrow, lv_color_hex(0x71909d), 0); lv_obj_set_style_text_letter_space(ui.eyebrow, 2, 0); lv_obj_align(ui.eyebrow, LV_ALIGN_TOP_MID, 0, 15); @@ -182,6 +225,62 @@ static void build_ui(void) { lv_obj_set_pos(ui.keys[index], 8 + column * 21, 17 + row * 19); } + /* Display-native reconstruction of KeyPath's opened, illuminated keycap. */ + ui.brand_keycap = lv_obj_create(ui.screen); + lv_obj_remove_style_all(ui.brand_keycap); + lv_obj_set_size(ui.brand_keycap, 78, 62); + lv_obj_set_style_radius(ui.brand_keycap, 17, 0); + lv_obj_set_style_bg_color(ui.brand_keycap, lv_color_hex(0xd95b18), 0); + lv_obj_set_style_bg_opa(ui.brand_keycap, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(ui.brand_keycap, lv_color_hex(0xf3a128), 0); + lv_obj_set_style_shadow_width(ui.brand_keycap, 18, 0); + lv_obj_set_style_shadow_opa(ui.brand_keycap, LV_OPA_20, 0); + lv_obj_align(ui.brand_keycap, LV_ALIGN_CENTER, 0, 5); + lv_obj_clear_flag(ui.brand_keycap, LV_OBJ_FLAG_CLICKABLE); + + ui.brand_light = lv_obj_create(ui.screen); + lv_obj_remove_style_all(ui.brand_light); + lv_obj_set_size(ui.brand_light, 54, 38); + lv_obj_set_style_radius(ui.brand_light, 12, 0); + lv_obj_set_style_bg_color(ui.brand_light, lv_color_hex(0xfff2c9), 0); + lv_obj_set_style_bg_opa(ui.brand_light, LV_OPA_COVER, 0); + lv_obj_align(ui.brand_light, LV_ALIGN_CENTER, 0, 2); + lv_obj_clear_flag(ui.brand_light, LV_OBJ_FLAG_CLICKABLE); + + ui.brand_fill = lv_obj_create(ui.brand_light); + lv_obj_remove_style_all(ui.brand_fill); + lv_obj_set_size(ui.brand_fill, 54, 1); + lv_obj_set_style_radius(ui.brand_fill, 10, 0); + lv_obj_set_style_bg_color(ui.brand_fill, lv_color_hex(0xf3a128), 0); + lv_obj_set_style_bg_opa(ui.brand_fill, LV_OPA_60, 0); + lv_obj_align(ui.brand_fill, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_clear_flag(ui.brand_fill, LV_OBJ_FLAG_CLICKABLE); + + ui.brand_lid = lv_obj_create(ui.screen); + lv_obj_remove_style_all(ui.brand_lid); + lv_obj_set_size(ui.brand_lid, 68, 27); + lv_obj_set_style_radius(ui.brand_lid, 10, 0); + lv_obj_set_style_bg_color(ui.brand_lid, lv_color_hex(0xf3a128), 0); + lv_obj_set_style_bg_opa(ui.brand_lid, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(ui.brand_lid, lv_color_hex(0xffd36a), 0); + lv_obj_set_style_shadow_width(ui.brand_lid, 12, 0); + lv_obj_set_style_shadow_opa(ui.brand_lid, LV_OPA_20, 0); + lv_obj_align(ui.brand_lid, LV_ALIGN_CENTER, 0, -29); + lv_obj_clear_flag(ui.brand_lid, LV_OBJ_FLAG_CLICKABLE); + + ui.brand_glint_horizontal = make_rect( + ui.screen, 0, 0, 12, 2, lv_color_hex(0xfff7df)); + ui.brand_glint_vertical = make_rect( + ui.screen, 0, 0, 2, 12, lv_color_hex(0xfff7df)); + lv_obj_set_style_radius(ui.brand_glint_horizontal, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_radius(ui.brand_glint_vertical, LV_RADIUS_CIRCLE, 0); + + set_hidden(ui.brand_keycap, true); + set_hidden(ui.brand_light, true); + set_hidden(ui.brand_lid, true); + set_hidden(ui.brand_glint_horizontal, true); + set_hidden(ui.brand_glint_vertical, true); + for (size_t index = 0; index < PARTICLE_COUNT; ++index) { ui.particles[index] = make_circle(ui.screen, index % 3u == 0u ? 6 : 4, lv_color_hex(0x56ddb3), LV_OPA_50); @@ -189,7 +288,7 @@ static void build_ui(void) { ui.state = lv_label_create(ui.screen); lv_label_set_text_static(ui.state, "WAKING UP"); - lv_obj_set_style_text_font(ui.state, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_font(ui.state, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(ui.state, lv_color_hex(0xe9f7f4), 0); lv_obj_set_width(ui.state, 220); lv_label_set_long_mode(ui.state, LV_LABEL_LONG_DOT); @@ -198,6 +297,7 @@ static void build_ui(void) { ui.detail = lv_label_create(ui.screen); lv_label_set_text_static(ui.detail, "USB + Wi-Fi control"); + lv_obj_set_style_text_font(ui.detail, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(ui.detail, lv_color_hex(0x78909a), 0); lv_obj_set_width(ui.detail, 220); lv_label_set_long_mode(ui.detail, LV_LABEL_LONG_DOT); @@ -206,6 +306,7 @@ static void build_ui(void) { ui.quality = lv_label_create(ui.screen); lv_label_set_text_static(ui.quality, "CINEMATIC"); + lv_obj_set_style_text_font(ui.quality, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(ui.quality, lv_color_hex(0x52727e), 0); lv_obj_set_style_text_letter_space(ui.quality, 1, 0); lv_obj_align(ui.quality, LV_ALIGN_TOP_RIGHT, -10, 38); @@ -225,7 +326,12 @@ static void build_splash(void) { lv_obj_set_style_bg_opa(splash.root, LV_OPA_COVER, 0); lv_obj_clear_flag(splash.root, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); - splash.glow = make_circle(splash.root, 154, dojo_red, LV_OPA_TRANSP); + /* + * Keep the glow inside a bounded redraw region. Transforming the previous + * 154 px object at 60 FPS invalidated most of the 240 x 280 display and + * could starve the LVGL flush task on the physical SPI panel. + */ + splash.glow = make_circle(splash.root, 132, dojo_red, LV_OPA_TRANSP); lv_obj_align(splash.glow, LV_ALIGN_CENTER, 0, -27); splash.ring = make_circle(splash.root, 118, dojo_red, LV_OPA_TRANSP); @@ -240,9 +346,6 @@ static void build_splash(void) { lv_obj_set_style_radius(splash.logo, 24, 0); lv_obj_set_style_bg_color(splash.logo, dojo_red, 0); lv_obj_set_style_bg_opa(splash.logo, LV_OPA_TRANSP, 0); - lv_obj_set_style_shadow_color(splash.logo, dojo_red, 0); - lv_obj_set_style_shadow_width(splash.logo, 26, 0); - lv_obj_set_style_shadow_opa(splash.logo, LV_OPA_TRANSP, 0); lv_obj_align(splash.logo, LV_ALIGN_CENTER, 0, -27); /* Faithful, display-native reconstruction of hackerdojo.org/static/images/logo.png. */ @@ -253,7 +356,7 @@ static void build_splash(void) { splash.wordmark = lv_label_create(splash.root); lv_label_set_text_static(splash.wordmark, "HACKER DOJO"); - lv_obj_set_style_text_font(splash.wordmark, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_font(splash.wordmark, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(splash.wordmark, white, 0); lv_obj_set_style_text_letter_space(splash.wordmark, 3, 0); lv_obj_set_style_text_opa(splash.wordmark, LV_OPA_TRANSP, 0); @@ -261,6 +364,7 @@ static void build_splash(void) { splash.location = lv_label_create(splash.root); lv_label_set_text_static(splash.location, "MOUNTAIN VIEW / SINCE 2009"); + lv_obj_set_style_text_font(splash.location, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(splash.location, lv_color_hex(0x9aa7ad), 0); lv_obj_set_style_text_letter_space(splash.location, 1, 0); lv_obj_set_style_text_opa(splash.location, LV_OPA_TRANSP, 0); @@ -269,10 +373,13 @@ static void build_splash(void) { static void render_splash(const fixture_splash_output_t *output, uint64_t elapsed_ms) { lv_opa_t foreground = (lv_opa_t)output->foreground_opacity; - lv_obj_set_style_bg_opa(splash.root, (lv_opa_t)output->background_opacity, 0); + /* + * Do not animate opacity on the full-screen root. The logo and wordmark + * still fade out before the root is deleted, but every frame now redraws + * only the splash artwork rather than the entire panel. + */ + lv_obj_set_style_bg_opa(splash.root, LV_OPA_COVER, 0); lv_obj_set_style_bg_opa(splash.logo, foreground, 0); - lv_obj_set_style_shadow_opa(splash.logo, (lv_opa_t)(output->foreground_opacity * 42u / 255u), 0); - lv_obj_set_style_transform_scale(splash.logo, output->logo_scale, 0); for (size_t index = 0; index < DOJO_BAR_COUNT; ++index) { lv_obj_set_style_bg_opa(splash.bars[index], foreground, 0); } @@ -285,17 +392,15 @@ static void render_splash(const fixture_splash_output_t *output, uint64_t elapse lv_obj_set_style_translate_y(splash.location, wordmark_offset, 0); uint16_t ring_phase = (uint16_t)(elapsed_ms % 800u); - uint16_t ring_scale = (uint16_t)(256u + ring_phase * 54u / 800u); uint16_t ring_fade = (uint16_t)(255u - ring_phase * 255u / 800u); - lv_obj_set_style_transform_scale(splash.ring, ring_scale, 0); lv_obj_set_style_border_opa( splash.ring, (lv_opa_t)(output->foreground_opacity * ring_fade * 44u / (255u * 255u)), 0); int glow_pulse = (int)(sinf((float)elapsed_ms / 230.0f) * 4.0f); - lv_obj_set_style_transform_scale(splash.glow, 250 + glow_pulse, 0); lv_obj_set_style_bg_opa(splash.glow, - (lv_opa_t)(output->foreground_opacity * 24u / 255u), 0); + (lv_opa_t)(output->foreground_opacity * + (uint32_t)(22 + glow_pulse) / 255u), 0); } static void play_splash(void) { @@ -307,15 +412,17 @@ static void play_splash(void) { bool finished = false; if (bsp_display_lock(20u)) { render_splash(&output, elapsed_ms); + note_display_frame(now_ms); if (output.complete) { lv_obj_delete(splash.root); splash.root = NULL; + note_splash_complete(); finished = true; } bsp_display_unlock(); } if (finished) return; - vTaskDelay(pdMS_TO_TICKS(16u)); + vTaskDelay(pdMS_TO_TICKS(SPLASH_FRAME_INTERVAL_MS)); } } @@ -348,6 +455,41 @@ static void announce_result(fixture_result_t result) { ui.previous_result = result; } +static void render_button_feedback(uint64_t now_ms) { + fixture_board_feedback_t feedback; + fixture_board_feedback_snapshot(&feedback); + if (feedback.sequence != ui.button_feedback_sequence) { + ui.button_feedback_sequence = feedback.sequence; + ui.button_feedback_event = feedback.event; + ui.button_feedback_started_ms = now_ms; + } + + uint64_t elapsed_ms = now_ms >= ui.button_feedback_started_ms + ? now_ms - ui.button_feedback_started_ms : 0u; + fixture_button_feedback_output_t output = + fixture_button_feedback_resolve(ui.button_feedback_event, feedback.boot_held, + feedback.download_hint, elapsed_ms); + if (!output.active) return; + + lv_color_t accent = lv_color_hex(output.accent_rgb); + lv_label_set_text(ui.state, output.title); + lv_label_set_text(ui.detail, output.detail); + lv_label_set_text_static(ui.quality, "BUTTON INPUT"); + lv_obj_set_style_text_color(ui.state, accent, 0); + lv_obj_set_style_text_color(ui.quality, accent, 0); + lv_obj_set_style_arc_color(ui.orbit, accent, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(ui.progress, accent, LV_PART_INDICATOR); + lv_obj_set_style_border_color(ui.core, accent, 0); + lv_obj_set_style_bg_color(ui.halo_inner, accent, 0); + lv_obj_set_style_bg_color(ui.halo_outer, accent, 0); + lv_obj_set_style_bg_opa(ui.halo_inner, + (lv_opa_t)(24u + output.pulse_per_mille * 40u / 1000u), 0); + lv_obj_set_style_bg_opa(ui.halo_outer, + (lv_opa_t)(12u + output.pulse_per_mille * 24u / 1000u), 0); + lv_obj_set_style_transform_scale(ui.state, + 256 + (int)(output.pulse_per_mille * 10u / 1000u), 0); +} + static void render(const fixture_ui_output_t *output, const fixture_presentation_t *presentation, const char *automatic_detail, uint64_t now_ms) { @@ -377,6 +519,7 @@ static void render(const fixture_ui_output_t *output, lv_obj_set_style_bg_color(ui.screen, output->scene == FIXTURE_UI_ERROR ? lv_color_hex(0x190b13) : lv_color_hex(0x071117), 0); + lv_obj_set_style_transform_scale(ui.state, 256, 0); lv_label_set_text(ui.state, visual.title); lv_obj_set_style_text_color(ui.state, accent, 0); lv_obj_set_style_arc_color(ui.orbit, accent, LV_PART_INDICATOR); @@ -384,6 +527,48 @@ static void render(const fixture_ui_output_t *output, lv_obj_set_style_border_color(ui.core, accent, 0); lv_arc_set_value(ui.progress, visual.progress_per_mille); + bool branded_update = visual.variant == FIXTURE_VISUAL_KEYPATH_UPDATE; + lv_label_set_text_static( + ui.eyebrow, branded_update ? "KEYPATH / SECURE UPDATE" : "KEYPATH / HID ORACLE"); + set_hidden(ui.core, branded_update); + set_hidden(ui.icon_front, branded_update); + set_hidden(ui.icon_back, branded_update); + set_hidden(ui.brand_keycap, !branded_update); + set_hidden(ui.brand_light, !branded_update); + set_hidden(ui.brand_lid, !branded_update); + set_hidden(ui.brand_glint_horizontal, !branded_update); + set_hidden(ui.brand_glint_vertical, !branded_update); + + if (branded_update) { + int fill_height = 2 + (int)(visual.progress_per_mille * 34u / 1000u); + lv_obj_set_height(ui.brand_fill, fill_height); + lv_obj_align(ui.brand_fill, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(ui.brand_fill, accent, 0); + + int key_light = (int)((sinf(ui.phase * 1.45f) + 1.0f) * 18.0f); + int lid_lift = 2 + (int)((sinf(ui.phase * 1.45f) + 1.0f) * 2.0f); +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + key_light = 18; + lid_lift = 3; +#endif + lv_obj_set_style_bg_opa(ui.brand_light, (lv_opa_t)(205 + key_light), 0); + lv_obj_set_style_shadow_opa(ui.brand_keycap, (lv_opa_t)(18 + key_light / 2), 0); + lv_obj_set_style_translate_y(ui.brand_lid, -lid_lift, 0); + + float glint_angle = ui.phase; +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + glint_angle = (float)visual.progress_per_mille / 1000.0f * 6.2831853f; +#endif + int glint_x = (int)(cosf(glint_angle) * 67.0f); + int glint_y = (int)(sinf(glint_angle) * 67.0f) - 2; + lv_obj_align(ui.brand_glint_horizontal, LV_ALIGN_CENTER, glint_x, glint_y); + lv_obj_align(ui.brand_glint_vertical, LV_ALIGN_CENTER, glint_x, glint_y); + lv_obj_set_style_bg_color(ui.brand_glint_horizontal, lv_color_hex(0xfff7df), 0); + lv_obj_set_style_bg_color(ui.brand_glint_vertical, lv_color_hex(0xfff7df), 0); + lv_obj_set_style_bg_opa(ui.brand_glint_horizontal, LV_OPA_80, 0); + lv_obj_set_style_bg_opa(ui.brand_glint_vertical, LV_OPA_80, 0); + } + if ((int)visual.icon != ui.visual_stage) { fixture_visual_icon_t previous = ui.visual_stage < 0 ? FIXTURE_ICON_POWER @@ -470,7 +655,10 @@ static void render(const fixture_ui_output_t *output, lv_obj_set_style_text_color(ui.quality, accent, 0); } else - if (output->quality == FIXTURE_UI_PROTECTED) { + if (branded_update) { + lv_label_set_text_static(ui.quality, "SECURE OTA"); + lv_obj_set_style_text_color(ui.quality, accent, 0); + } else if (output->quality == FIXTURE_UI_PROTECTED) { lv_label_set_text_static(ui.quality, "HID PRIORITY"); lv_obj_set_style_text_color(ui.quality, lv_color_hex(0xffb454), 0); } else if (output->quality == FIXTURE_UI_ACTIVE) { @@ -480,6 +668,7 @@ static void render(const fixture_ui_output_t *output, lv_label_set_text_static(ui.quality, "CINEMATIC"); lv_obj_set_style_text_color(ui.quality, lv_color_hex(0x52727e), 0); } + render_button_feedback(now_ms); } static void display_task(void *context) { @@ -488,10 +677,25 @@ static void display_task(void *context) { configASSERT(display); ESP_ERROR_CHECK(bsp_display_brightness_set(CONFIG_KEYPATH_FIXTURE_BRIGHTNESS)); configASSERT(bsp_display_lock(0u)); - build_ui(); +#if CONFIG_KEYPATH_FIXTURE_BOOT_SPLASH + ui.screen = lv_screen_active(); + lv_obj_remove_style_all(ui.screen); + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x080c10), 0); + lv_obj_set_style_bg_opa(ui.screen, LV_OPA_COVER, 0); build_splash(); +#else + build_ui(); +#endif bsp_display_unlock(); +#if CONFIG_KEYPATH_FIXTURE_BOOT_SPLASH + note_display_initialized(true, false); play_splash(); + configASSERT(bsp_display_lock(100u)); + build_ui(); + bsp_display_unlock(); +#else + note_display_initialized(false, true); +#endif fixture_ui_model_t model; fixture_ui_model_init(&model); @@ -516,6 +720,7 @@ static void display_task(void *context) { fixture_board_update(snapshot.ui.state == FIXTURE_ARMED || snapshot.ui.state == FIXTURE_RUNNING); if (bsp_display_lock(20u)) { render(&output, &snapshot.presentation, automatic_detail, now_ms); + note_display_frame(now_ms); bsp_display_unlock(); } vTaskDelay(pdMS_TO_TICKS(output.frame_interval_ms)); @@ -525,3 +730,19 @@ static void display_task(void *context) { void fixture_display_start(void) { configASSERT(xTaskCreatePinnedToCore(display_task, "fixture_display", 8192, NULL, 6, NULL, 0) == pdPASS); } + +void fixture_display_health_snapshot(fixture_display_health_t *health) { + if (!health) return; + taskENTER_CRITICAL(&display_health_lock); + *health = display_health; + taskEXIT_CRITICAL(&display_health_lock); +} + +bool fixture_display_is_healthy(void) { + fixture_display_health_t health; + fixture_display_health_snapshot(&health); + uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); + uint64_t age_ms = now_ms >= health.last_frame_ms ? now_ms - health.last_frame_ms : UINT64_MAX; + return health.initialized && health.splash_complete && health.frame_sequence > 0u && + age_ms <= DISPLAY_HEARTBEAT_MAX_AGE_MS; +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h index 0a9322a7a..19fc8693b 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h @@ -1,6 +1,19 @@ #ifndef KEYPATH_ESP32_FIXTURE_DISPLAY_H #define KEYPATH_ESP32_FIXTURE_DISPLAY_H +#include +#include + +typedef struct { + bool initialized; + bool splash_enabled; + bool splash_complete; + uint64_t frame_sequence; + uint64_t last_frame_ms; +} fixture_display_health_t; + void fixture_display_start(void); +void fixture_display_health_snapshot(fixture_display_health_t *health); +bool fixture_display_is_healthy(void); #endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c index 0cb93334d..923ca5890 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -11,21 +11,30 @@ #include "esp_http_server.h" #include "esp_log.h" #include "esp_netif.h" +#include "esp_ota_ops.h" +#include "esp_system.h" #include "esp_wifi.h" #include "fixture_config.h" +#include "fixture_display.h" #include "fixture_runtime.h" #include "fixture_wifi_model.h" #include "freertos/FreeRTOS.h" #include "mdns.h" +#include "mbedtls/md.h" +#include "mbedtls/sha256.h" #include "nvs_flash.h" #include "sdkconfig.h" #define SCRIPT_CAPACITY (96u * 1024u) +#define OTA_CHUNK_SIZE 4096u +#define SHA256_SIZE 32u +#define SHA256_HEX_SIZE 64u static const char *TAG = "fixture_network"; static char *script_buffer; static httpd_handle_t http_server; static fixture_wifi_model_t wifi_model; +static volatile bool control_plane_ready; typedef struct { const char *ssid; @@ -33,9 +42,10 @@ typedef struct { } wifi_profile_t; static const wifi_profile_t wifi_profiles[] = { + {KEYPATH_WIFI_SSID_4, KEYPATH_WIFI_PASSWORD_4}, {KEYPATH_WIFI_SSID_1, KEYPATH_WIFI_PASSWORD_1}, - {KEYPATH_WIFI_SSID_2, KEYPATH_WIFI_PASSWORD_2}, {KEYPATH_WIFI_SSID_3, KEYPATH_WIFI_PASSWORD_3}, + {KEYPATH_WIFI_SSID_2, KEYPATH_WIFI_PASSWORD_2}, }; static bool authorized(httpd_req_t *request) { @@ -59,11 +69,185 @@ static esp_err_t require_auth(httpd_req_t *request) { return ESP_FAIL; } +static bool decode_hex(const char *hex, uint8_t *bytes, size_t byte_count) { + if (!hex || strlen(hex) != byte_count * 2u) return false; + for (size_t index = 0u; index < byte_count; ++index) { + uint8_t value = 0u; + for (size_t nibble = 0u; nibble < 2u; ++nibble) { + char character = hex[index * 2u + nibble]; + uint8_t digit; + if (character >= '0' && character <= '9') digit = (uint8_t)(character - '0'); + else if (character >= 'a' && character <= 'f') digit = (uint8_t)(character - 'a' + 10); + else if (character >= 'A' && character <= 'F') digit = (uint8_t)(character - 'A' + 10); + else return false; + value = (uint8_t)((value << 4u) | digit); + } + bytes[index] = value; + } + return true; +} + +static bool constant_time_equal(const uint8_t *left, const uint8_t *right, size_t length) { + uint8_t difference = 0u; + for (size_t index = 0u; index < length; ++index) difference |= left[index] ^ right[index]; + return difference == 0u; +} + +static const char *ota_state_name(esp_ota_img_states_t state) { + switch (state) { + case ESP_OTA_IMG_NEW: return "new"; + case ESP_OTA_IMG_PENDING_VERIFY: return "pending-verify"; + case ESP_OTA_IMG_VALID: return "valid"; + case ESP_OTA_IMG_INVALID: return "invalid"; + case ESP_OTA_IMG_ABORTED: return "aborted"; + case ESP_OTA_IMG_UNDEFINED: return "undefined"; + default: return "unknown"; + } +} + +static esp_err_t firmware_update_failure(httpd_req_t *request, const char *status, + const char *message) { + fixture_runtime_end_firmware_update(false, message); + char body[224]; + snprintf(body, sizeof(body), "{\"ok\":false,\"message\":\"%s\"}\n", message); + return send_json(request, status, body); +} + +static esp_err_t firmware_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + + char error[128]; + if (!fixture_runtime_begin_firmware_update(error, sizeof(error))) { + char body[192]; + snprintf(body, sizeof(body), "{\"ok\":false,\"message\":\"%s\"}\n", error); + return send_json(request, "409 Conflict", body); + } + + const esp_partition_t *partition = esp_ota_get_next_update_partition(NULL); + if (!partition || request->content_len <= 0 || (size_t)request->content_len > partition->size) { + return firmware_update_failure(request, "413 Payload Too Large", + "image does not fit the inactive application slot"); + } + + char sha_hex[SHA256_HEX_SIZE + 1u]; + char hmac_hex[SHA256_HEX_SIZE + 1u]; + uint8_t expected_sha[SHA256_SIZE]; + uint8_t expected_hmac[SHA256_SIZE]; + if (httpd_req_get_hdr_value_str(request, "X-KeyPath-SHA256", sha_hex, sizeof(sha_hex)) != ESP_OK || + httpd_req_get_hdr_value_str(request, "X-KeyPath-HMAC-SHA256", hmac_hex, sizeof(hmac_hex)) != ESP_OK || + !decode_hex(sha_hex, expected_sha, sizeof(expected_sha)) || + !decode_hex(hmac_hex, expected_hmac, sizeof(expected_hmac))) { + return firmware_update_failure(request, "400 Bad Request", + "valid SHA-256 and HMAC-SHA256 headers are required"); + } + + uint8_t *chunk = heap_caps_malloc(OTA_CHUNK_SIZE, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (!chunk) return firmware_update_failure(request, "503 Service Unavailable", + "not enough internal memory for update buffer"); + + mbedtls_sha256_context sha; + mbedtls_md_context_t hmac; + mbedtls_sha256_init(&sha); + mbedtls_md_init(&hmac); + const mbedtls_md_info_t *sha256 = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + esp_ota_handle_t ota_handle = 0u; + bool ota_started = false; + esp_err_t ota_result = ESP_OK; + if (!sha256 || mbedtls_sha256_starts(&sha, 0) != 0 || + mbedtls_md_setup(&hmac, sha256, 1) != 0 || + mbedtls_md_hmac_starts(&hmac, (const unsigned char *)KEYPATH_FIXTURE_TOKEN, + strlen(KEYPATH_FIXTURE_TOKEN)) != 0) { + ota_result = ESP_FAIL; + } else { + ota_result = esp_ota_begin(partition, (size_t)request->content_len, &ota_handle); + ota_started = ota_result == ESP_OK; + } + + size_t received = 0u; + uint16_t last_progress = 0u; + while (ota_result == ESP_OK && received < (size_t)request->content_len) { + size_t remaining = (size_t)request->content_len - received; + size_t wanted = remaining < OTA_CHUNK_SIZE ? remaining : OTA_CHUNK_SIZE; + int count = httpd_req_recv(request, (char *)chunk, wanted); + if (count == HTTPD_SOCK_ERR_TIMEOUT) continue; + if (count <= 0) { + ota_result = ESP_ERR_INVALID_RESPONSE; + break; + } + if (mbedtls_sha256_update(&sha, chunk, (size_t)count) != 0 || + mbedtls_md_hmac_update(&hmac, chunk, (size_t)count) != 0) { + ota_result = ESP_FAIL; + break; + } + ota_result = esp_ota_write(ota_handle, chunk, (size_t)count); + received += (size_t)count; + uint16_t progress = (uint16_t)((received * 900u) / (size_t)request->content_len); + if (progress >= last_progress + 10u || received == (size_t)request->content_len) { + char detail[49]; + snprintf(detail, sizeof(detail), "RECEIVING IMAGE %u%%", progress / 10u); + fixture_runtime_set_firmware_update_progress(progress, detail); + last_progress = progress; + } + } + + uint8_t actual_sha[SHA256_SIZE] = {0}; + uint8_t actual_hmac[SHA256_SIZE] = {0}; + if (ota_result == ESP_OK && + (mbedtls_sha256_finish(&sha, actual_sha) != 0 || + mbedtls_md_hmac_finish(&hmac, actual_hmac) != 0)) { + ota_result = ESP_FAIL; + } + mbedtls_sha256_free(&sha); + mbedtls_md_free(&hmac); + free(chunk); + + if (ota_result != ESP_OK || received != (size_t)request->content_len || + !constant_time_equal(actual_sha, expected_sha, sizeof(actual_sha)) || + !constant_time_equal(actual_hmac, expected_hmac, sizeof(actual_hmac))) { + if (ota_started) esp_ota_abort(ota_handle); + return firmware_update_failure(request, "400 Bad Request", + "image transfer or cryptographic verification failed"); + } + + fixture_runtime_set_firmware_update_progress(950u, "VALIDATING APPLICATION"); + ota_result = esp_ota_end(ota_handle); + ota_started = false; + if (ota_result != ESP_OK) { + return firmware_update_failure(request, "400 Bad Request", + "ESP-IDF rejected the application image"); + } + ota_result = esp_ota_set_boot_partition(partition); + if (ota_result != ESP_OK) { + return firmware_update_failure(request, "500 Internal Server Error", + "could not select the verified application slot"); + } + + fixture_runtime_end_firmware_update(true, "RESTARTING"); + httpd_resp_set_hdr(request, "Connection", "close"); + char response_body[192]; + snprintf(response_body, sizeof(response_body), + "{\"ok\":true,\"message\":\"image verified; restarting\"," + "\"rebooting\":true,\"targetSlot\":\"%s\"}\n", partition->label); + esp_err_t response = send_json(request, "202 Accepted", response_body); + vTaskDelay(pdMS_TO_TICKS(300)); + esp_restart(); + return response; +} + static esp_err_t status_handler(httpd_req_t *request) { if (require_auth(request) != ESP_OK) return ESP_OK; fixture_runtime_snapshot_t snapshot; fixture_runtime_snapshot(&snapshot); - char body[1792]; + fixture_display_health_t display_health; + fixture_display_health_snapshot(&display_health); + const esp_partition_t *running_partition = esp_ota_get_running_partition(); + esp_ota_img_states_t ota_state = ESP_OTA_IMG_UNDEFINED; + const char *ota_state_value = esp_ota_get_state_partition(running_partition, &ota_state) == ESP_OK + ? ota_state_name(ota_state) : "unavailable"; + bool update_ready = !snapshot.pending_release && !snapshot.firmware_update_in_progress && + (snapshot.ui.state == FIXTURE_IDLE || snapshot.ui.state == FIXTURE_COMPLETE || + snapshot.ui.state == FIXTURE_ABORTED || snapshot.ui.state == FIXTURE_ERROR); + char body[2048]; snprintf(body, sizeof(body), "{\"ok\":true,\"firmware\":\"%s\",\"build\":\"%s\"," "\"platform\":\"waveshare-esp32-s3-touch-lcd-1.69\"," @@ -72,6 +256,9 @@ static esp_err_t status_handler(httpd_req_t *request) { "\"reportsSubmitted\":%" PRIu64 ",\"transfersCompleted\":%" PRIu64 "," "\"lateReports\":%" PRIu64 ",\"maximumLatenessUs\":%" PRId64 "," "\"submittedCRC32\":\"%08" PRIx32 "\",\"usbMounted\":%s," + "\"displayHealthy\":%s,\"displayFrame\":%" PRIu64 "," + "\"displayLastFrameMs\":%" PRIu64 ",\"splashEnabled\":%s,\"splashComplete\":%s," + "\"updateReady\":%s,\"updateInProgress\":%s,\"otaSlot\":\"%s\",\"otaState\":\"%s\"," "\"wifiConnected\":%s,\"address\":\"%s\",\"network\":\"%s\",\"error\":\"%s\"," "\"presentation\":{\"phase\":\"%s\",\"result\":\"%s\",\"progress\":%u," "\"title\":\"%s\",\"detail\":\"%s\",\"next\":\"%s\"," @@ -83,7 +270,12 @@ static esp_err_t status_handler(httpd_req_t *request) { snapshot.script_crc32, snapshot.ui.event_count, snapshot.ui.repeat_count, snapshot.ui.current_repeat, snapshot.ui.reports_submitted, snapshot.transfers_completed, snapshot.ui.late_reports, snapshot.ui.maximum_lateness_us, snapshot.submitted_crc32, - snapshot.ui.usb_mounted ? "true" : "false", snapshot.ui.wifi_connected ? "true" : "false", + snapshot.ui.usb_mounted ? "true" : "false", + fixture_display_is_healthy() ? "true" : "false", display_health.frame_sequence, + display_health.last_frame_ms, display_health.splash_enabled ? "true" : "false", + display_health.splash_complete ? "true" : "false", update_ready ? "true" : "false", + snapshot.firmware_update_in_progress ? "true" : "false", running_partition->label, + ota_state_value, snapshot.ui.wifi_connected ? "true" : "false", snapshot.network_address, snapshot.network_name, snapshot.error, fixture_presentation_phase_name(snapshot.presentation.phase), fixture_result_name(snapshot.presentation.result), snapshot.presentation.progress_per_mille, @@ -276,7 +468,7 @@ static esp_err_t start_http_server(void) { if (http_server) return ESP_OK; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = CONFIG_KEYPATH_FIXTURE_HTTP_PORT; - config.max_uri_handlers = 7; + config.max_uri_handlers = 8; config.stack_size = 8192; config.core_id = 0; config.task_priority = 4; @@ -289,14 +481,20 @@ static esp_err_t start_http_server(void) { {.uri = "/v1/abort", .method = HTTP_POST, .handler = abort_handler}, {.uri = "/v1/trace", .method = HTTP_GET, .handler = trace_handler}, {.uri = "/v1/presentation", .method = HTTP_POST, .handler = presentation_handler}, + {.uri = "/v1/firmware", .method = HTTP_POST, .handler = firmware_handler}, }; for (size_t index = 0; index < sizeof(handlers) / sizeof(handlers[0]); ++index) { ESP_RETURN_ON_ERROR(httpd_register_uri_handler(http_server, &handlers[index]), TAG, "URI registration failed"); } + control_plane_ready = true; return ESP_OK; } +bool fixture_network_control_ready(void) { + return control_plane_ready; +} + static esp_err_t select_wifi_profile(size_t index) { wifi_model.profile_index = index % (sizeof(wifi_profiles) / sizeof(wifi_profiles[0])); const wifi_profile_t *profile = &wifi_profiles[wifi_model.profile_index]; diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h index fa56b74de..8229bc80a 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h @@ -1,8 +1,11 @@ #ifndef KEYPATH_ESP32_FIXTURE_HTTP_H #define KEYPATH_ESP32_FIXTURE_HTTP_H +#include + #include "esp_err.h" esp_err_t fixture_network_start(void); +bool fixture_network_control_ready(void); #endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c index eb84f1ced..f9df42267 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c @@ -24,6 +24,7 @@ static bool network_connected; static char network_address[48] = "unassigned"; static char network_name[33] = "unassigned"; static fixture_presentation_t presentation; +static bool firmware_update_in_progress; static void runtime_lock(void) { configASSERT(xSemaphoreTake(fixture_mutex, portMAX_DELAY) == pdTRUE); @@ -114,16 +115,28 @@ static void executor_task(void *context) { (void)context; while (true) { bool running; + uint32_t wait_us; runtime_lock(); - fixture_poll(&fixture, (uint64_t)esp_timer_get_time(), tud_mounted(), tud_hid_ready(), + uint64_t now_us = (uint64_t)esp_timer_get_time(); + fixture_poll(&fixture, now_us, tud_mounted(), tud_hid_ready(), send_keyboard_report, NULL); running = fixture.state == FIXTURE_RUNNING; + wait_us = fixture_time_until_next_action_us(&fixture, now_us); runtime_unlock(); - if (running) { - esp_rom_delay_us(25u); + if (!running) { + /* USB mount and safety-release state do not need a 1 kHz idle poll. */ + vTaskDelay(pdMS_TO_TICKS(5)); + } else if (wait_us > 1500u) { + /* + * Leave at least 1 ms for the precision spin, but yield frequently enough + * that touch, HTTP abort, and status snapshots can acquire the state lock. + */ + uint32_t sleep_ms = (wait_us - 1000u) / 1000u; + if (sleep_ms > 10u) sleep_ms = 10u; + vTaskDelay(pdMS_TO_TICKS(sleep_ms)); } else { - vTaskDelay(pdMS_TO_TICKS(1)); + esp_rom_delay_us(25u); } } } @@ -161,6 +174,9 @@ void fixture_runtime_start_executor(void) { void fixture_runtime_set_network(bool connected, const char *address) { runtime_lock(); + if (network_connected && !connected) { + fixture_abort_if_active(&fixture, "Wi-Fi disconnected during active run"); + } network_connected = connected; snprintf(network_address, sizeof(network_address), "%s", address ? address : "unassigned"); runtime_unlock(); @@ -192,27 +208,44 @@ void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot) { snapshot->next_event = fixture.next_event; snapshot->transfers_completed = fixture.transfers_completed; snapshot->submitted_crc32 = fixture.submitted_crc32; + snapshot->pending_release = fixture.pending_release; + snapshot->firmware_update_in_progress = firmware_update_in_progress; snapshot->presentation = presentation; runtime_unlock(); } bool fixture_runtime_load(const char *body, size_t length, char *error, size_t capacity) { runtime_lock(); - bool ok = fixture_load_script(&fixture, body, length, error, capacity); + bool ok = false; + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update in progress"); + } else { + ok = fixture_load_script(&fixture, body, length, error, capacity); + } runtime_unlock(); return ok; } bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity) { runtime_lock(); - bool ok = fixture_arm(&fixture, run_id, error, capacity); + bool ok = false; + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update in progress"); + } else { + ok = fixture_arm(&fixture, run_id, error, capacity); + } runtime_unlock(); return ok; } bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity) { runtime_lock(); - bool ok = fixture_start(&fixture, run_id, delay_ms, (uint64_t)esp_timer_get_time(), error, capacity); + bool ok = false; + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update in progress"); + } else { + ok = fixture_start(&fixture, run_id, delay_ms, (uint64_t)esp_timer_get_time(), error, capacity); + } runtime_unlock(); return ok; } @@ -229,6 +262,59 @@ void fixture_runtime_set_presentation(const fixture_presentation_t *value) { runtime_unlock(); } +bool fixture_runtime_begin_firmware_update(char *error, size_t capacity) { + runtime_lock(); + bool terminal = fixture.state == FIXTURE_IDLE || fixture.state == FIXTURE_COMPLETE || + fixture.state == FIXTURE_ABORTED || fixture.state == FIXTURE_ERROR; + bool safe = terminal && !fixture.pending_release && !firmware_update_in_progress; + if (!safe) { + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update already in progress"); + } else if (fixture.pending_release) { + snprintf(error, capacity, "waiting for the all-keys-released safety report"); + } else { + snprintf(error, capacity, "fixture must be idle or in a terminal state before updating"); + } + } else { + firmware_update_in_progress = true; + fixture_presentation_init(&presentation); + presentation.branded_firmware_update = true; + presentation.phase = FIXTURE_PRESENT_PREPARING; + snprintf(presentation.title, sizeof(presentation.title), "FIRMWARE UPDATE"); + snprintf(presentation.detail, sizeof(presentation.detail), "AUTHENTICATING IMAGE"); + snprintf(presentation.next, sizeof(presentation.next), "KEEP POWER CONNECTED"); + } + runtime_unlock(); + return safe; +} + +void fixture_runtime_set_firmware_update_progress(uint16_t progress_per_mille, const char *detail) { + runtime_lock(); + if (firmware_update_in_progress) { + presentation.phase = FIXTURE_PRESENT_PREPARING; + presentation.progress_per_mille = progress_per_mille > 1000u ? 1000u : progress_per_mille; + snprintf(presentation.detail, sizeof(presentation.detail), "%s", detail ? detail : "UPDATING"); + } + runtime_unlock(); +} + +void fixture_runtime_end_firmware_update(bool success, const char *detail) { + runtime_lock(); + if (firmware_update_in_progress) { + presentation.phase = FIXTURE_PRESENT_RESULT; + presentation.result = success ? FIXTURE_RESULT_PASS : FIXTURE_RESULT_FAIL; + presentation.progress_per_mille = success ? 1000u : presentation.progress_per_mille; + snprintf(presentation.title, sizeof(presentation.title), "%s", + success ? "UPDATE VERIFIED" : "UPDATE REJECTED"); + snprintf(presentation.detail, sizeof(presentation.detail), "%s", + detail ? detail : (success ? "RESTARTING" : "IMAGE NOT INSTALLED")); + snprintf(presentation.next, sizeof(presentation.next), "%s", + success ? "RESTARTING SAFELY" : "CURRENT FIRMWARE SAFE"); + firmware_update_in_progress = false; + } + runtime_unlock(); +} + uint32_t fixture_runtime_trace_count(void) { runtime_lock(); uint32_t count = fixture_trace_count(&fixture); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h index ac5c8c49f..8457ee768 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h @@ -20,6 +20,8 @@ typedef struct { uint32_t next_event; uint64_t transfers_completed; uint32_t submitted_crc32; + bool pending_release; + bool firmware_update_in_progress; fixture_presentation_t presentation; } fixture_runtime_snapshot_t; @@ -34,6 +36,9 @@ bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity); bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity); void fixture_runtime_abort(const char *reason); void fixture_runtime_set_presentation(const fixture_presentation_t *presentation); +bool fixture_runtime_begin_firmware_update(char *error, size_t capacity); +void fixture_runtime_set_firmware_update_progress(uint16_t progress_per_mille, const char *detail); +void fixture_runtime_end_firmware_update(bool success, const char *detail); uint32_t fixture_runtime_trace_count(void); bool fixture_runtime_trace_at(uint32_t index, fixture_trace_t *trace); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/partitions.csv b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/partitions.csv new file mode 100644 index 000000000..d7597038d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/partitions.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +ota_0, app, ota_0, 0x10000, 0x300000, +ota_1, app, ota_1, 0x310000, 0x300000, +coredump, data, coredump,0x610000, 0x10000, diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults index 5069d61ae..2714dc3b7 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults @@ -1,6 +1,8 @@ CONFIG_IDF_TARGET="esp32s3" CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y -CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_OCT=y CONFIG_SPIRAM_SPEED_80M=y @@ -12,8 +14,12 @@ CONFIG_LV_COLOR_DEPTH_16=y CONFIG_LV_CONF_MINIMAL=y CONFIG_LV_USE_ARC=y CONFIG_LV_USE_LABEL=y +CONFIG_LV_FONT_MONTSERRAT_12=y CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_16=y CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_24=y +CONFIG_KEYPATH_FIXTURE_BOOT_SPLASH=y CONFIG_LV_BUILD_EXAMPLES=n CONFIG_LV_BUILD_DEMOS=n CONFIG_BSP_DISPLAY_LVGL_BUF_HEIGHT=40 diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults index 850598058..df40057f1 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults @@ -13,8 +13,11 @@ CONFIG_LV_COLOR_DEPTH_16=y CONFIG_LV_CONF_MINIMAL=y CONFIG_LV_USE_ARC=y CONFIG_LV_USE_LABEL=y +CONFIG_LV_FONT_MONTSERRAT_12=y CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_16=y CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_24=y CONFIG_LV_BUILD_EXAMPLES=n CONFIG_LV_BUILD_DEMOS=n CONFIG_BSP_DISPLAY_LVGL_BUF_HEIGHT=40 diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index 3cd75acd3..d2f48907a 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -1,3 +1,4 @@ +#include "fixture_button_feedback.h" #include "fixture_core.h" #include "fixture_presentation.h" #include "fixture_splash_model.h" @@ -134,6 +135,24 @@ static void test_abort_and_unmount_force_release(void) { assert(reports.keys[reports.count - 1u][0] == 0u); } +static void test_control_plane_disconnect_aborts_only_active_runs(void) { + fixture_t fixture; + fixture_init(&fixture); + assert(!fixture_abort_if_active(&fixture, "Wi-Fi disconnected")); + assert(fixture.state == FIXTURE_IDLE); + + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "network", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(!fixture_abort_if_active(&fixture, "Wi-Fi disconnected")); + assert(fixture_arm(&fixture, "network", error, sizeof(error))); + assert(fixture_abort_if_active(&fixture, "Wi-Fi disconnected")); + assert(fixture.state == FIXTURE_ABORTED); + assert(fixture.pending_release); + assert(strstr(fixture.error, "Wi-Fi")); +} + static void test_lateness_metrics(void) { fixture_t fixture; reports_t reports = {0}; @@ -151,6 +170,30 @@ static void test_lateness_metrics(void) { assert(fixture.maximum_lateness_us == 3000); } +static void test_next_action_deadline_supports_cooperative_waiting(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + assert(fixture_time_until_next_action_us(&fixture, 0u) == 0u); + drain_initial_release(&fixture, &reports); + assert(fixture_time_until_next_action_us(&fixture, 0u) == UINT32_MAX); + + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "cooperative", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture_arm(&fixture, "cooperative", error, sizeof(error))); + assert(fixture_time_until_next_action_us(&fixture, 0u) == 0u); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + + assert(fixture_start(&fixture, "cooperative", 100u, 1000000u, error, sizeof(error))); + assert(fixture_time_until_next_action_us(&fixture, 1000000u) == 100000u); + assert(fixture_time_until_next_action_us(&fixture, 1099000u) == 1000u); + assert(fixture_time_until_next_action_us(&fixture, 1100000u) == 0u); + fixture_poll(&fixture, 1100000u, true, true, capture_report, &reports); + assert(fixture_time_until_next_action_us(&fixture, 1100000u) == 50000u); +} + static void test_ui_model_prioritizes_hid_and_tracks_progress(void) { fixture_ui_model_t model; fixture_ui_model_init(&model); @@ -232,6 +275,19 @@ static void test_visual_model_resolves_automatic_and_campaign_states(void) { assert(visual.angular_speed_milliradians == 4800u); assert(strcmp(visual.title, "Swift stress") == 0); + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_PREPARING; + presentation.branded_firmware_update = true; + presentation.progress_per_mille = 420u; + snprintf(presentation.title, sizeof(presentation.title), "FIRMWARE UPDATE"); + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.variant == FIXTURE_VISUAL_KEYPATH_UPDATE); + assert(visual.icon == FIXTURE_ICON_DOWNLOAD); + assert(visual.accent_rgb == 0xf3a128u); + assert(visual.progress_per_mille == 420u); + assert(visual.angular_speed_milliradians == 2500u); + assert(strcmp(visual.title, "FIRMWARE UPDATE") == 0); + presentation.result = FIXTURE_RESULT_FAIL; fixture_visual_resolve(&ui, &presentation, &visual); assert(visual.icon == FIXTURE_ICON_CLOSE); @@ -322,18 +378,61 @@ static void test_splash_reveals_holds_and_fades_without_blocking_boot(void) { assert(splash.complete); } +static void test_button_feedback_identifies_physical_positions_and_expires(void) { + fixture_button_feedback_output_t feedback = + fixture_button_feedback_resolve(FIXTURE_BUTTON_POWER, false, false, 0u); + assert(feedback.active); + assert(strcmp(feedback.title, "POWER BUTTON") == 0); + assert(strcmp(feedback.detail, "TOP button detected") == 0); + assert(feedback.accent_rgb == 0xffb454u); + + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_BOOT, true, true, 180u); + assert(feedback.active); + assert(strcmp(feedback.title, "BOOT HELD") == 0); + assert(strstr(feedback.detail, "TAP BOTTOM RESET")); + assert(feedback.pulse_per_mille == 1000u); + + feedback = fixture_button_feedback_resolve( + FIXTURE_BUTTON_BOOT, true, true, FIXTURE_BUTTON_FEEDBACK_DURATION_MS * 4u); + assert(feedback.active); + assert(strcmp(feedback.title, "BOOT HELD") == 0); + + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_BOOT, false, true, 200u); + assert(feedback.active); + assert(strcmp(feedback.title, "BOOT RELEASED") == 0); + assert(strstr(feedback.detail, "while tapping RESET")); + + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_BOOT, true, false, 200u); + assert(feedback.active); + assert(strcmp(feedback.detail, "Test abort requested") == 0); + + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_RESET, false, false, 600u); + assert(feedback.active); + assert(strcmp(feedback.title, "RESET / START") == 0); + assert(strstr(feedback.detail, "BOTTOM RST")); + + feedback = fixture_button_feedback_resolve( + FIXTURE_BUTTON_BOOT, false, true, FIXTURE_BUTTON_FEEDBACK_DURATION_MS); + assert(!feedback.active); + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_NONE, false, false, 0u); + assert(!feedback.active); +} + int main(void) { test_load_arm_run_and_repeat(); test_rejects_corrupt_and_unsafe_scripts(); test_failed_replacement_invalidates_previous_script(); test_abort_and_unmount_force_release(); + test_control_plane_disconnect_aborts_only_active_runs(); test_lateness_metrics(); + test_next_action_deadline_supports_cooperative_waiting(); test_ui_model_prioritizes_hid_and_tracks_progress(); test_ui_model_connection_error_and_counter_reset(); test_presentation_contract(); test_visual_model_resolves_automatic_and_campaign_states(); test_wifi_profiles_retry_in_priority_order_and_wrap(); test_splash_reveals_holds_and_fades_without_blocking_boot(); + test_button_feedback_identifies_physical_positions_and_expires(); puts("physical HID fixture core tests passed"); return 0; } diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/baseline.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/baseline.txt new file mode 100644 index 000000000..3a078b945 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/baseline.txt @@ -0,0 +1 @@ +keypath-fixture-baseline-20260727 diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt new file mode 100644 index 000000000..b2ba30591 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt @@ -0,0 +1 @@ +aZ9!?-_=+[] diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/shift-and-symbols.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/shift-and-symbols.txt new file mode 100644 index 000000000..04bfaec9d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/shift-and-symbols.txt @@ -0,0 +1 @@ +KeyPath! Shift_Test: []{} <>? 12345 diff --git a/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh index f2887ad04..7292bb582 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh @@ -13,6 +13,16 @@ if [[ ! -f "$idf_path/export.sh" ]]; then exit 1 fi +if [[ -z "${IDF_PYTHON_ENV_PATH:-}" ]]; then + for candidate in "$HOME"/.espressif/python_env/idf5.5_py*_env; do + if [[ -x "$candidate/bin/python" ]]; then + IDF_PYTHON_ENV_PATH=$candidate + export IDF_PYTHON_ENV_PATH + break + fi + done +fi + # shellcheck disable=SC1090 source "$idf_path/export.sh" >/dev/null export KEYPATH_WIFI_SSID_1=fixture-qemu-primary @@ -21,6 +31,8 @@ export KEYPATH_WIFI_SSID_2=fixture-qemu-fallback-one export KEYPATH_WIFI_PASSWORD_2=fixture-qemu-placeholder export KEYPATH_WIFI_SSID_3=fixture-qemu-fallback-two export KEYPATH_WIFI_PASSWORD_3=fixture-qemu-placeholder +export KEYPATH_WIFI_SSID_4=fixture-qemu-current-location +export KEYPATH_WIFI_PASSWORD_4=fixture-qemu-placeholder export KEYPATH_FIXTURE_TOKEN=fixture-qemu-token-placeholder export KEYPATH_QEMU_SMOKE=1 diff --git a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh index 3da6e96c9..14811968d 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh @@ -7,6 +7,7 @@ trap 'rm -f "$test_binary"' EXIT HUP INT TERM cc -std=c11 -Wall -Wextra -Werror -pedantic \ -I"$fixture_root/src" \ + "$fixture_root/src/fixture_button_feedback.c" \ "$fixture_root/src/fixture_core.c" \ "$fixture_root/src/fixture_presentation.c" \ "$fixture_root/src/fixture_splash_model.c" \ diff --git a/Scripts/lab/tests/hid-capture-jig-client-tests.py b/Scripts/lab/tests/hid-capture-jig-client-tests.py new file mode 100755 index 000000000..d8336222b --- /dev/null +++ b/Scripts/lab/tests/hid-capture-jig-client-tests.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import threading +import time +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +CLIENT = ROOT / "lab/hid-capture-jig-client" + + +class FakeJig: + def __init__(self, directory: pathlib.Path): + self.directory = directory + self.commands: list[dict[str, object]] = [] + self.stop = False + self.thread = threading.Thread(target=self.run, daemon=True) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *_): + self.stop = True + self.thread.join(timeout=1) + + def run(self): + last_id = None + while not self.stop: + try: + command = json.loads((self.directory / "command.json").read_text()) + except (FileNotFoundError, json.JSONDecodeError): + time.sleep(0.005) + continue + if command["id"] == last_id: + time.sleep(0.005) + continue + last_id = command["id"] + self.commands.append(command) + snapshot = { + "state": "armed" if command["action"] == "arm" else "idle", + "expected": command.get("expected", ""), + "received": "", + } + response = { + "id": command["id"], "ok": True, "message": "fake response", + "processID": 42, "snapshot": snapshot, + } + temporary = self.directory / ".response.tmp" + temporary.write_text(json.dumps(response)) + os.replace(temporary, self.directory / "response.json") + + +class HIDCaptureJigClientTests(unittest.TestCase): + def run_client(self, directory: pathlib.Path, *arguments: str, timeout: str = "1"): + environment = os.environ.copy() + environment["KEYPATH_CAPTURE_JIG_STATE_DIR"] = str(directory) + environment["KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT"] = timeout + return subprocess.run( + [str(CLIENT), *arguments], text=True, capture_output=True, + env=environment, timeout=5, + ) + + def test_arm_transports_expected_text_by_file(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + expected = directory / "expected.txt" + expected.write_text("q becomes w") + with FakeJig(directory) as jig: + result = self.run_client( + directory, "arm", "--run-id", "physical-1", "--expected", str(expected), + "--timeout-ms", "7000", "--settle-ms", "300", + ) + self.assertEqual(result.returncode, 0, result.stderr) + response = json.loads(result.stdout) + self.assertEqual(response["snapshot"]["expected"], "q becomes w") + self.assertEqual(jig.commands[0]["runID"], "physical-1") + self.assertEqual(jig.commands[0]["timeoutMs"], 7000) + self.assertEqual(jig.commands[0]["settleMs"], 300) + + def test_focus_uses_a_distinct_control_action(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + with FakeJig(directory) as jig: + result = self.run_client(directory, "focus") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(jig.commands[0]["action"], "focus") + + def test_missing_app_fails_with_actionable_message(self): + with tempfile.TemporaryDirectory() as temporary: + result = self.run_client(pathlib.Path(temporary), "status", timeout="0.1") + self.assertEqual(result.returncode, 2) + self.assertIn("hid-capture-jig-tool open", result.stderr) + + def test_missing_expected_file_is_rejected_before_command(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + result = self.run_client( + directory, "arm", "--run-id", "missing", "--expected", str(directory / "none"), + ) + self.assertEqual(result.returncode, 2) + self.assertIn("does not exist", result.stderr) + self.assertFalse((directory / "command.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/physical-hid-capture-run-tests.py b/Scripts/lab/tests/physical-hid-capture-run-tests.py new file mode 100644 index 000000000..8defce743 --- /dev/null +++ b/Scripts/lab/tests/physical-hid-capture-run-tests.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +"""Contract tests for the combined physical HID capture runner.""" + +from __future__ import annotations + +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import pathlib +import tempfile +import unittest +from unittest import mock + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +RUNNER = ROOT / "Scripts/lab/physical-hid-capture-run" + +READY_PREFLIGHT = { + "canProceed": True, + "state": "ready", + "detail": "Resources stable", + "suggestions": [], +} + +TRACE_BATCH = [ + {"runId": "test-run", "from": 0, "available": 2}, + {"sequence": 1, "modifiers": 0, "keys": [4, 0, 0, 0, 0, 0]}, + {"sequence": 2, "modifiers": 0, "keys": [0, 0, 0, 0, 0, 0]}, +] + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader("physical_hid_capture_run", str(RUNNER)) + spec = importlib.util.spec_from_loader(loader.name, loader) + assert spec is not None + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +def action(command: list[str]) -> tuple[str, str]: + if "hid-capture-jig-client" in command[0]: + return "capture", command[1] + return "fixture", command[3] + + +class PhysicalHIDCaptureRunTests(unittest.TestCase): + def setUp(self) -> None: + self.runner = load_runner() + + @staticmethod + def compile_script(command: list[str], environment: dict[str, str]) -> None: + del environment + output = pathlib.Path(command[command.index("--output") + 1]) + output.write_text("KPHID1 test-run 2 1 100000 00000000\n0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n") + + def test_capture_preflight_happens_before_fixture_mutation(self) -> None: + calls: list[tuple[str, str]] = [] + fixture_status_calls = 0 + capture_status_calls = 0 + capture_arm_command: list[str] | None = None + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls, capture_status_calls, capture_arm_command + target, operation = action(command) + calls.append((target, operation)) + if (target, operation) == ("fixture", "trace"): + return TRACE_BATCH + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + return { + "state": "complete", "reportsSubmitted": 2, + "lateReports": 1, "maximumLatenessUs": 2_000, + } + if (target, operation) == ("capture", "status"): + capture_status_calls += 1 + if capture_status_calls > 1: + return {"ok": True, "snapshot": { + "state": "passed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "issues": [], "events": [{}, {}], + }} + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "arm"): + capture_arm_command = command + return {"ok": True} + if (target, operation) == ("capture", "wait"): + return {"snapshot": { + "state": "passed", "received": "a", "pressedKeyCodes": [], + "issues": [], "events": [{}, {}], + }} + return {"ok": True} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("a") + arguments = [ + str(RUNNER), "--run-id", "test-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--output", str(output_path), + "--max-late-reports", "2", "--max-lateness-us", "3000", + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 0) + + self.assertLess(calls.index(("capture", "status")), calls.index(("fixture", "load-script"))) + self.assertEqual( + calls.index(("capture", "focus")) + 1, + calls.index(("capture", "arm")), + ) + self.assertIsNotNone(capture_arm_command) + assert capture_arm_command is not None + self.assertEqual(capture_arm_command[capture_arm_command.index("--timeout-ms") + 1], "15000") + artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["control"]["capturePreflight"]["snapshot"]["state"], "idle") + self.assertTrue(artifact["checks"]["latenessWithinBudget"]) + self.assertEqual(artifact["timingBudget"]["maxLateReports"], 2) + + def test_busy_capture_preflight_stops_before_fixture_mutation(self) -> None: + calls: list[tuple[str, str]] = [] + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + target, operation = action(command) + calls.append((target, operation)) + if (target, operation) == ("fixture", "status"): + return {"address": "10.0.0.47", "state": "idle"} + if (target, operation) == ("capture", "status"): + return { + "ok": True, + "snapshot": {"state": "idle"}, + "systemReadiness": { + "canProceed": False, + "detail": "CPU 93% (limit 80%)", + "suggestions": ["Pause builds or VMs."], + }, + } + raise AssertionError(f"unexpected mutation: {target} {operation}") + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + input_path.write_text("a") + arguments = [ + str(RUNNER), "--run-id", "busy-run", "--text", str(input_path), + "--fixture-host", "fixture.local", + ] + error = io.StringIO() + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stderr(error): + self.assertEqual(self.runner.main(), 2) + + self.assertIn("CPU 93%", error.getvalue()) + self.assertIn("Pause builds or VMs", error.getvalue()) + self.assertNotIn(("fixture", "load-script"), calls) + self.assertNotIn(("fixture", "arm"), calls) + + def test_failed_capture_waits_for_fixture_and_persists_complete_evidence(self) -> None: + fixture_status_calls = 0 + capture_status_calls = 0 + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls, capture_status_calls + target, operation = action(command) + if (target, operation) == ("fixture", "trace"): + return TRACE_BATCH + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + if fixture_status_calls == 2: + return {"state": "running", "reportsSubmitted": 1} + return { + "state": "complete", "reportsSubmitted": 2, + "lateReports": 0, "maximumLatenessUs": 0, + } + if (target, operation) == ("capture", "status"): + capture_status_calls += 1 + if capture_status_calls == 1: + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + return {"ok": True, "snapshot": { + "state": "failed", "received": "ab", "pressedKeyCodes": [], + "activeModifiers": 0, "issues": ["mismatch"], + "events": [{}, {}, {}, {}], + }} + if (target, operation) == ("capture", "wait"): + return {"snapshot": { + "state": "failed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "issues": ["mismatch"], "events": [{}, {}], + }} + return {"ok": True} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("aa") + arguments = [ + str(RUNNER), "--run-id", "complete-failure", "--text", str(input_path), + "--fixture-host", "fixture.local", "--output", str(output_path), + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.time, "sleep"), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 1) + + artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["fixture"]["state"], "complete") + self.assertEqual(artifact["fixture"]["reportsSubmitted"], 2) + self.assertEqual(artifact["capture"]["received"], "ab") + self.assertEqual(len(artifact["capture"]["events"]), 4) + self.assertEqual( + artifact["control"]["captureInitialTerminal"]["snapshot"]["received"], "a" + ) + self.assertTrue(artifact["checks"]["fixtureCompleted"]) + self.assertTrue(artifact["checks"]["allReportsSubmitted"]) + + def test_capture_arm_failure_aborts_mutated_fixture(self) -> None: + calls: list[tuple[str, str]] = [] + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + target, operation = action(command) + calls.append((target, operation)) + if (target, operation) == ("fixture", "status"): + return {"address": "10.0.0.47", "state": "idle"} + if (target, operation) == ("capture", "status"): + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "arm"): + raise RuntimeError("capture app exited") + return {"ok": True} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + input_path.write_text("a") + arguments = [ + str(RUNNER), "--run-id", "test-run", "--text", str(input_path), + "--fixture-host", "fixture.local", + ] + error = io.StringIO() + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stderr(error): + self.assertEqual(self.runner.main(), 2) + + self.assertIn(("fixture", "abort"), calls) + self.assertIn("fixture aborted with all-keys-released queued", error.getvalue()) + + def test_abort_mode_verifies_released_prefix(self) -> None: + calls: list[tuple[str, str]] = [] + fixture_status_calls = 0 + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls + target, operation = action(command) + calls.append((target, operation)) + if (target, operation) == ("fixture", "trace"): + return TRACE_BATCH + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + return { + "state": "aborted", "reportsSubmitted": 1, + "transfersCompleted": 2, "lateReports": 0, + "maximumLatenessUs": 0, + } + if (target, operation) == ("capture", "status"): + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "finalize"): + return {"snapshot": { + "state": "failed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "duplicateDownEvents": 0, + "repeatEvents": 0, "unmatchedUpEvents": 0, + "issues": ["received output differs from expected output"], + "events": [{}, {}], + }} + return {"ok": True} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("aa") + arguments = [ + str(RUNNER), "--run-id", "abort-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--abort-after-ms", "10", + "--output", str(output_path), + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.time, "sleep"), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 0) + + artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["status"], "passed") + self.assertTrue(all(artifact["checks"].values())) + self.assertIn(("fixture", "abort"), calls) + self.assertIn(("capture", "finalize"), calls) + + def test_late_report_allowance_requires_a_lateness_ceiling(self) -> None: + arguments = [ + str(RUNNER), "--run-id", "invalid-budget", "--text", str(RUNNER), + "--max-late-reports", "1", + ] + error = io.StringIO() + with mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stderr(error): + self.assertEqual(self.runner.main(), 2) + self.assertIn("--max-lateness-us is required", error.getvalue()) + + def test_external_abort_mode_waits_for_physical_abort(self) -> None: + fixture_status_calls = 0 + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls + target, operation = action(command) + if (target, operation) == ("fixture", "trace"): + return TRACE_BATCH + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + return {"state": "aborted", "reportsSubmitted": 1, "transfersCompleted": 2} + if (target, operation) == ("capture", "status"): + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "finalize"): + return {"snapshot": { + "state": "failed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "duplicateDownEvents": 0, + "repeatEvents": 0, "unmatchedUpEvents": 0, "issues": [], "events": [], + }} + return {"ok": True} + + self._run_interruption_case( + fake_run_json, ["--expect-external-abort-ms", "100"], "external-abort" + ) + + def test_usb_cycle_mode_waits_for_release_after_remount(self) -> None: + fixture_status_calls = 0 + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls + target, operation = action(command) + if (target, operation) == ("fixture", "trace"): + return TRACE_BATCH + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + if fixture_status_calls == 2: + return { + "state": "error", "usbMounted": False, + "reportsSubmitted": 1, "transfersCompleted": 1, + } + return { + "state": "error", "usbMounted": True, + "reportsSubmitted": 1, "transfersCompleted": 2, + } + if (target, operation) == ("capture", "status"): + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "finalize"): + return {"snapshot": { + "state": "failed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "duplicateDownEvents": 0, + "repeatEvents": 0, "unmatchedUpEvents": 0, "issues": [], "events": [], + }} + return {"ok": True} + + self._run_interruption_case( + fake_run_json, ["--expect-usb-cycle-ms", "100"], "usb-cycle" + ) + + def _run_interruption_case(self, fake_run_json, mode_arguments, expected_mode) -> None: + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("aa") + arguments = [ + str(RUNNER), "--run-id", "manual-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--output", str(output_path), + *mode_arguments, + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.time, "sleep"), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 0) + artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["status"], "passed") + self.assertEqual(artifact["interruptionMode"], expected_mode) + self.assertTrue(all(artifact["checks"].values())) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/physical-hid-shift-matrix-tests.py b/Scripts/lab/tests/physical-hid-shift-matrix-tests.py new file mode 100644 index 000000000..582eba3ae --- /dev/null +++ b/Scripts/lab/tests/physical-hid-shift-matrix-tests.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import importlib.machinery +import importlib.util +import pathlib +import subprocess +import unittest +from unittest import mock + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "Scripts/lab/physical-hid-shift-matrix" +LOADER = importlib.machinery.SourceFileLoader("physical_hid_shift_matrix", str(SCRIPT)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class ShiftMatrixTests(unittest.TestCase): + def artifact(self, expected="A!", received="a1", focused=True): + return { + "runID": "case", + "status": "failed", + "modifierTiming": {"shiftLeadMs": 0, "keyHoldMs": 8, "shiftReleaseLagMs": 0}, + "fixture": {"lateReports": 0, "maximumLatenessUs": 0}, + "fixtureTrace": [{"sequence": 1}, {"sequence": 2}], + "capture": { + "expected": expected, "received": received, "focused": focused, + "pressedKeyCodes": [], "activeModifiers": 0, + }, + } + + def test_analysis_counts_shift_demotions(self): + case = MODULE.analyze_artifact(self.artifact()) + self.assertEqual(case["wrongCharacters"], 2) + self.assertEqual(case["shiftDemotions"], 2) + self.assertEqual(case["fixtureTraceReports"], 2) + + def test_classification_finds_timing_sensitive_exact_case(self): + baseline = MODULE.analyze_artifact(self.artifact()) + improved_artifact = self.artifact(expected="A!", received="A!") + improved_artifact["modifierTiming"]["shiftLeadMs"] = 4 + improved = MODULE.analyze_artifact(improved_artifact) + self.assertEqual(MODULE.classify([baseline, improved]), "modifier-timing-sensitive") + + def test_classification_fails_closed_on_focus_loss(self): + invalid = MODULE.analyze_artifact(self.artifact(focused=False)) + self.assertEqual(MODULE.classify([invalid]), "harness-invalid") + + def test_ready_jig_is_reused_without_focus_or_reopen(self): + responses = [subprocess.CompletedProcess([], 0, stdout="{}", stderr="")] + with mock.patch.object(MODULE.subprocess, "run", side_effect=responses) as run: + ready, _ = MODULE.ensure_jig_running() + self.assertTrue(ready) + self.assertEqual(len(run.call_args_list), 1) + self.assertEqual(run.call_args_list[0].args[0][-1], "status") + + def test_missing_jig_is_opened(self): + responses = [ + subprocess.CompletedProcess([], 2, stdout="", stderr="not running"), + subprocess.CompletedProcess([], 0, stdout='{"snapshot":{"focused":true}}', stderr=""), + ] + with mock.patch.object(MODULE.subprocess, "run", side_effect=responses) as run: + ready, _ = MODULE.ensure_jig_running() + self.assertTrue(ready) + self.assertEqual(run.call_args_list[1].args[0][-1], "open") + + def test_readiness_wait_does_not_request_focus(self): + response = subprocess.CompletedProcess( + [], 0, stdout='{"systemReadiness":{"canProceed":true,"detail":"stable"}}', stderr="" + ) + with mock.patch.object(MODULE.subprocess, "run", return_value=response) as run: + ready, detail = MODULE.wait_for_jig_readiness(0) + self.assertTrue(ready) + self.assertEqual(detail, "stable") + self.assertEqual(run.call_args.args[0][-1], "status") + + def test_readiness_wait_times_out_closed(self): + response = subprocess.CompletedProcess( + [], 0, + stdout='{"systemReadiness":{"canProceed":false,"detail":"memory pressure"}}', + stderr="", + ) + with mock.patch.object(MODULE.subprocess, "run", return_value=response): + ready, detail = MODULE.wait_for_jig_readiness(0) + self.assertFalse(ready) + self.assertEqual(detail, "memory pressure") + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/pico-hid-fixture-client-tests.py b/Scripts/lab/tests/pico-hid-fixture-client-tests.py index 643fa67b5..2030d27dc 100755 --- a/Scripts/lab/tests/pico-hid-fixture-client-tests.py +++ b/Scripts/lab/tests/pico-hid-fixture-client-tests.py @@ -2,6 +2,8 @@ import importlib.util import importlib.machinery +import hashlib +import hmac import json import pathlib import threading @@ -20,6 +22,9 @@ class RecordingHandler(BaseHTTPRequestHandler): requests = [] + received_headers = [] + reported_build = "old-build" + reported_slot = "ota_0" def log_message(self, _format, *_arguments): pass @@ -28,8 +33,16 @@ def _handle(self): length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(length) self.__class__.requests.append((self.command, self.path, self.headers.get("Authorization"), body)) + self.__class__.received_headers.append({name.lower(): value for name, value in self.headers.items()}) if self.path == "/v1/status": - payload = {"ok": True, "state": "idle"} + payload = {"ok": True, "state": "idle", "build": self.__class__.reported_build, + "wifiConnected": True, "updateReady": True, + "displayHealthy": True, "splashComplete": True, + "otaSlot": self.__class__.reported_slot, "otaState": "valid"} + elif self.path == "/v1/firmware": + self.__class__.reported_build = self.headers["X-KeyPath-Expected-Build"] + self.__class__.reported_slot = "ota_1" if self.__class__.reported_slot == "ota_0" else "ota_0" + payload = {"ok": True, "rebooting": True, "targetSlot": self.__class__.reported_slot} elif self.path.startswith("/v1/trace"): encoded = b'{"runId":"r","from":0,"available":1}\n{"sequence":1}\n' self.send_response(200) @@ -65,6 +78,9 @@ def tearDownClass(cls): def setUp(self): RecordingHandler.requests.clear() + RecordingHandler.received_headers.clear() + RecordingHandler.reported_build = "old-build" + RecordingHandler.reported_slot = "ota_0" self.client = CLIENT.FixtureClient("127.0.0.1", "test-token", self.server.server_port) def test_compile_text_emits_complete_reports_and_crc(self): @@ -81,11 +97,31 @@ def test_compile_text_emits_complete_reports_and_crc(self): def test_compile_rejects_unsupported_text_and_unsafe_timing(self): with self.assertRaisesRegex(ValueError, "unsupported"): CLIENT.compile_text("run", "🙂", 80, 30, 1, 0) + with self.assertRaisesRegex(ValueError, "at least 4 ms"): + CLIENT.compile_text("run", "a", 3, 2, 1, 0) with self.assertRaisesRegex(ValueError, "hold duration"): CLIENT.compile_text("run", "a", 20, 20, 1, 0) with self.assertRaisesRegex(ValueError, "timeout"): CLIENT.FixtureClient("fixture", "token", timeout=0) + def test_compile_text_can_separate_shift_press_and_release(self): + script = CLIENT.compile_text("shift-order", "A!", 50, 8, 1, 100, 4, 6) + header, payload = script.split("\n", 1) + fields = header.split() + self.assertEqual(fields[:5], ["KPHID1", "shift-order", "8", "1", "200000"]) + self.assertEqual(payload.splitlines(), [ + "0 2 0 0 0 0 0 0", + "4000 2 4 0 0 0 0 0", + "12000 2 0 0 0 0 0 0", + "18000 0 0 0 0 0 0 0", + "50000 2 0 0 0 0 0 0", + "54000 2 30 0 0 0 0 0", + "62000 2 0 0 0 0 0 0", + "68000 0 0 0 0 0 0 0", + ]) + with self.assertRaisesRegex(ValueError, "fit inside"): + CLIENT.compile_text("shift-order", "A", 20, 10, 1, 0, 5, 5) + def test_client_authenticates_and_uses_expected_endpoints(self): self.assertEqual(self.client.status()["state"], "idle") self.client.arm("run-2") @@ -111,6 +147,29 @@ def test_presentation_uses_bounded_json_channel(self): self.assertEqual(payload["result"], "pass") self.assertEqual(payload["reportsObserved"], 40) + def test_firmware_update_authenticates_image_and_verifies_reconnected_build(self): + firmware = b"esp32-application-image" + result = self.client.update_firmware(firmware, "a1b2c3d4", wait_seconds=2) + + method, path, auth, body = RecordingHandler.requests[1] + headers = RecordingHandler.received_headers[1] + self.assertEqual((method, path, auth, body), + ("POST", "/v1/firmware", "Bearer test-token", firmware)) + self.assertEqual(headers["x-keypath-sha256"], hashlib.sha256(firmware).hexdigest()) + self.assertEqual(headers["x-keypath-hmac-sha256"], + hmac.new(b"test-token", firmware, hashlib.sha256).hexdigest()) + self.assertEqual(headers["x-keypath-expected-build"], "a1b2c3d4") + self.assertEqual(result["verifiedBuild"], "a1b2c3d4") + self.assertEqual(result["previousSlot"], "ota_0") + self.assertEqual(result["verifiedSlot"], "ota_1") + self.assertEqual(result["status"]["otaSlot"], "ota_1") + + def test_firmware_update_rejects_empty_images_and_ambiguous_builds(self): + with self.assertRaisesRegex(ValueError, "empty"): + self.client.update_firmware(b"", "a1") + with self.assertRaisesRegex(ValueError, "expected build"): + self.client.update_firmware(b"image", "not-a-build") + if __name__ == "__main__": unittest.main() diff --git a/Scripts/lab/tests/pico-hid-fixture-tool-tests.py b/Scripts/lab/tests/pico-hid-fixture-tool-tests.py index e9f01dfda..57b4449b5 100644 --- a/Scripts/lab/tests/pico-hid-fixture-tool-tests.py +++ b/Scripts/lab/tests/pico-hid-fixture-tool-tests.py @@ -32,6 +32,7 @@ def setUp(self) -> None: "KEYPATH_FIXTURE_BUILD_DIR": str(self.build), "KEYPATH_FIXTURE_SDKCONFIG": str(self.sdkconfig), "KEYPATH_FIXTURE_DEVICE_DIR": str(self.device_dir), + "KEYPATH_FIXTURE_CLIENT": str(self.directory / "fixture-client"), "KEYPATH_FIXTURE_SECRETS_FILE": str(self.directory / "missing-secrets.env"), "KEYPATH_WIFI_SSID_1": "fixture-primary", "KEYPATH_WIFI_PASSWORD_1": "fixture-password-one", @@ -39,6 +40,8 @@ def setUp(self) -> None: "KEYPATH_WIFI_PASSWORD_2": "fixture-password-two", "KEYPATH_WIFI_SSID_3": "fixture-fallback-two", "KEYPATH_WIFI_PASSWORD_3": "fixture-password-three", + "KEYPATH_WIFI_SSID_4": "fixture-current-location", + "KEYPATH_WIFI_PASSWORD_4": "fixture-password-four", "KEYPATH_FIXTURE_TOKEN": "fixture-test-token-value", }) @@ -101,6 +104,46 @@ def test_build_uses_the_configured_cache_and_checks_for_output(self) -> None: self.assertIn(f"SDKCONFIG={self.sdkconfig}", invocation) self.assertNotIn(self.environment["KEYPATH_FIXTURE_TOKEN"], invocation) + def test_update_builds_authenticates_and_verifies_without_exposing_token(self) -> None: + idf_log = self.directory / "idf.log" + fake_idf = self.fake_bin / "idf.py" + fake_idf.write_text(textwrap.dedent(f"""\ + #!/bin/bash + set -eu + printf '%s\\n' "$*" >> {str(idf_log)!r} + build_dir= + while [[ $# -gt 0 ]]; do + if [[ "$1" == -B ]]; then build_dir=$2; shift 2; else shift; fi + done + mkdir -p "$build_dir/esp-idf/main/generated" + : > "$build_dir/keypath_esp32_s3_hid_fixture.bin" + printf '%s\\n' '#define KEYPATH_FIXTURE_BUILD_ID "a1b2c3d4e5f6"' \\ + > "$build_dir/esp-idf/main/generated/fixture_config.h" + """)) + fake_idf.chmod(0o755) + client_log = self.directory / "client.log" + fake_client = pathlib.Path(self.environment["KEYPATH_FIXTURE_CLIENT"]) + fake_client.write_text(textwrap.dedent(f"""\ + #!/bin/bash + set -eu + printf '%s\\n' "$*" >> {str(client_log)!r} + if [[ "$1" == status ]]; then + printf '%s\\n' '{{"ok":true,"updateReady":true,"otaSlot":"ota_0"}}' + else + printf '%s\\n' '{{"ok":true,"verifiedBuild":"a1b2c3d4e5f6"}}' + fi + """)) + fake_client.chmod(0o755) + + result = self.run_tool("update") + + self.assertEqual(result.returncode, 0, result.stderr) + invocation = client_log.read_text() + self.assertIn("update-firmware", invocation) + self.assertIn("--timeout 120", invocation) + self.assertIn("--expected-build a1b2c3d4e5f6", invocation) + self.assertNotIn(self.environment["KEYPATH_FIXTURE_TOKEN"], result.stdout + result.stderr + invocation) + if __name__ == "__main__": unittest.main() diff --git a/docs/testing/keypath-hid-fixture-first-board.md b/docs/testing/keypath-hid-fixture-first-board.md index 16dcd9ead..dec57ae10 100644 --- a/docs/testing/keypath-hid-fixture-first-board.md +++ b/docs/testing/keypath-hid-fixture-first-board.md @@ -10,11 +10,13 @@ Scripts/lab/pico-hid-fixture-tool configure Scripts/lab/pico-hid-fixture-tool doctor ``` -The secure setup stores three ordered SSID/password pairs plus the control token: +The secure setup stores four SSID/password pairs plus the control token. Connection priority is +the current-location network first, followed by the established `1, 3, 2` fallback order: -- Profile 1: `529beach`, always attempted first. -- Profile 2: `Alpern-Home`, the non-5 GHz home fallback. +- Profile 4: `beachFi`, attempted first at the current location. +- Profile 1: `529beach`, the primary lab fallback. - Profile 3: `iPhone`, used when its hotspot has **Maximize Compatibility** enabled. +- Profile 2: `Alpern-Home`, the non-5 GHz home fallback. - `KEYPATH_FIXTURE_TOKEN`: a random value of at least 16 characters. Enter values only in Add Secret.app. Do not paste them into chat or pass them as command-line @@ -33,6 +35,10 @@ arguments. Before hardware is attached, `doctor` should pass everything and repo 3. If the Mac sees no serial device, hold **BOOT**, tap **RESET**, release **BOOT**, and rerun the same command. + + While the application is still running, holding the middle **BOOT** button shows `BOOT HELD` + and `KEEP HOLDING + TAP BOTTOM RESET`. The display freezes when RESET transfers control to the + ROM downloader; that is expected because the fixture application is no longer running. 4. Confirm the brief Hacker Dojo splash is upright and clean, then wait for the display to progress from `WAKING UP` to `JOINING LAB` to `READY`. 5. Confirm `READY` shows an IP address and `USB READY` or `USB WAIT`. `USB WAIT` is expected until @@ -45,9 +51,23 @@ arguments. Before hardware is attached, `doctor` should pass everything and repo The returned `firmware` and `build` fields identify exactly what is running. +The current installation command also lays down the two-slot firmware partition table. This is the +last update that inherently requires BOOT/RESET. Once `status` reports an `otaSlot` beginning with +`ota_`, future application firmware can be installed and verified without touching the board: + +```bash +Scripts/lab/pico-hid-fixture-tool update +``` + +An OTA result is not accepted merely because upload completed: the board must reboot, restore Wi-Fi, +and report the exact newly built identity. A new image that cannot restore the control plane within +60 seconds rolls back to the previous slot automatically. + The complete screen language, core allocation, UX sign-off list, and explicitly deferred cleanup are documented in [`keypath-hid-fixture-readiness.md`](keypath-hid-fixture-readiness.md). +Live first-board evidence and the remaining manual rows are tracked in +[`keypath-hid-fixture-physical-results.md`](keypath-hid-fixture-physical-results.md). ## Failure routing @@ -60,6 +80,8 @@ are documented in | IP appears but health check fails | mDNS or Mac network route | Run `status`; test `keypath-hid-fixture.local`; inspect the router for the displayed IP. | | `USB WAIT` persists | USB enumeration/ownership | Check System Information before attaching the device to a VM; confirm the cable carries data. | | `ATTENTION` appears | Firmware safety state | Record the exact on-screen detail; query `status` and `trace`; do not repeat a run blindly. | +| OTA command says the fixture is not update-safe | A test is loaded/running or a release is pending | Finish or abort the run, wait for the release report, then retry. | +| OTA upload succeeds but the new build never appears | New image failed health validation or networking | Query `status` for the prior build; it should have rolled back. Use USB BOOT/RESET recovery if unreachable. | Production intentionally has no USB serial console: adding one would change the device exposed to the VM and weaken the HID-only oracle. Use the display for boot/network/USB routing and use the diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md new file mode 100644 index 000000000..8acfa9cbf --- /dev/null +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -0,0 +1,134 @@ +# KeyPath HID fixture physical acceptance results + +## 2026-07-27 first-board session + +Hardware: Waveshare ESP32-S3-Touch-LCD-1.69 revision 2. The fixture reported firmware +`0.3.0-esp32s3`, build `f24be33f2d7b`, Wi-Fi `529beach`, and a mounted native USB HID interface. +The host oracle was the isolated AppKit HID Capture Jig on macOS 27.0. + +Combined artifacts are stored with mode `0600` under +`~/.local/state/keypath-hid-capture-jig/combined/`. An interrupted attempt named +`physical-jig-burst-10ms-1` is excluded; its fixture run was remotely aborted and it is not test +evidence. + +## Automated physical results + +| Case | Result | Characters | Reports | Timing evidence | +|---|---:|---:|---:|---| +| Cooperative baseline | Pass | 34 / 34 | 68 / 68 | 0 late; 32 us maximum lateness | +| Shift and symbols | Pass | 36 / 36 | 72 / 72 | 0 late; modifiers released | +| 10 ms burst | Pass | 240 / 240 | 480 / 480 | 0 late; 40 us maximum | +| 5 ms burst | Pass | 600 / 600 | 1,200 / 1,200 | 0 late; 48 us maximum | +| 4 ms boundary | Pass | 1,200 / 1,200 | 2,400 / 2,400 | 0 late; 44 us maximum | +| 4 ms with 8 saturated host cores | Pass | 1,200 / 1,200 | 2,400 / 2,400 | 0 late; 48 us maximum | +| 5 ms with CPU and storage contention | Pass | 1,200 / 1,200 | 2,400 / 2,400 | 0 late; 65 us maximum | +| Sustained 5 ms with 8 saturated host cores | Pass | 3,600 / 3,600 | 7,200 / 7,200 | 3 late within the explicit 5-report / 5,000 us stress budget; 2,165 us maximum | +| Remote abort during active typing | Pass | Correct 10-character prefix | Stopped at 20 / 6,800 | Final release transfer completed; no stuck key or modifier | +| Post-flash cooperative baseline | Pass | 34 / 34 | 68 / 68 | Safety/button-feedback build; exact output and final release | +| Physical BOOT abort | Pass | Correct 1,332-character prefix | Stopped at 2,663 / 6,800 | Final release completed; no repeat, duplicate-down, or unmatched-up events | +| Physical touchscreen abort | Pass | Correct 668-character prefix | Stopped at 1,335 / 6,800 | Final release completed; no repeat, duplicate-down, or unmatched-up events | + +Every passing completion case had exact output, the full requested report count, no duplicate +downs, no AppKit repeat events, no unmatched ups, and an empty final key/modifier set. + +## Boundaries and defects found + +- A 3 ms diagnostic delivered all 1,200 characters and all 2,400 reports in order, but 1,601 + reports missed the 1 ms deadline and the worst miss was 6,727 us. The compiler now rejects + intervals below 4 ms instead of promising a cadence the full-speed USB path cannot guarantee. +- A 4 ms CPU-plus-storage boundary run delivered exact output but recorded three deadline misses. + The zero-lateness stress floor is therefore 5 ms; load runs can use explicit, recorded timing + budgets without weakening output, ordering, count, or release checks. +- The first sustained load attempt exposed two HID Capture Jig crashes in text drawing. The jig + was snapshotting and redrawing on every event and the launcher could leave multiple instances + racing on one RPC directory. Drawing is now bounded to 20 FPS, fonts and paragraph styles are + cached, terminal snapshots stop refreshing, and the launcher enforces one process. The sustained + rerun passed and produced no new crash report; completed-jig CPU returned below 1%. +- Load-aware capture timeouts now include a 50% or 10-second drain margin so WindowServer can + deliver queued events before the oracle freezes its fail-closed result. +- Wi-Fi loss previously rotated profiles without stopping an active script, contradicting the + safety checklist. The new firmware aborts only armed/running scripts on a connected-to-disconnected + transition and queues the same all-keys-released report as other abort paths. + +## Remaining manual gate + +The following checks require a person to manipulate or observe the board and are not yet physical +proof: + +- During active typing, unplug and reconnect USB; verify the unmount error, remount, and final + release report before another run arms. +- The held-BOOT overlay is proven: it remained visible and instructed `KEEP HOLDING + TAP BOTTOM + RESET`. The POWER overlay and tone are proven, including restoration of the prior `TEST PASSED` + state. The post-reset overlay is also visually confirmed, followed by a normal return to `READY`. + Confirm the protected-motion state remains legible. +- Where the lab network can be intentionally interrupted, verify physical Wi-Fi loss aborts the + active run and safely releases the keyboard. + +Do not call the first-board gate complete until these manual rows are recorded. + +The `physical-jig-button-abort-post-flash-1` attempt is excluded: its 30-second external-action +window expired without a physical button event, after which the fixture completed normally. It is +not evidence of either a passing or failing physical-button abort. + +## 2026-07-27 shifted-key CPU matrix + +Firmware build `ccd910cb18d9` ran the same 20-cycle shifted corpus +`aZ9!?-_=+[]\n` at a 50 ms character interval under four controlled host CPU levels. The Jig +performed its normal three-sample calm admission before each case; bounded CPU workers started only +after capture was armed. Artifacts are under +`~/.local/state/keypath-hid-capture-jig/load-matrix/20260727-1925/`. + +| CPU workers | Measured CPU avg / max | Received | Wrong characters | Oracle state | +|---:|---:|---:|---:|---| +| 0 (calm control) | 29.8% / 67.1% | 240 / 240 | 12 | Focus retained; released | +| 2 (moderate) | 40.0% / 63.3% | 240 / 240 | 5 | Focus retained; released | +| 6 (high) | 70.6% / 81.1% | 240 / 240 | 4 | Focus retained; released | +| 10 (saturated) | 90.9% / 96.2% | 37 / 240 | 204 including missing tail | Focus lost; Shift release unobserved | + +The fixture submitted all 480 requested reports plus its final release transfer in every case, with +zero device-side late reports. In the calm-through-high cases, every corruption was an unshifted +version of the expected character (`Z→z`, `!→1`, `?→/`, `_→-`, or `+→=`). The error rate did not +increase with CPU load: the calm control had the most errors and the high case the fewest. This is +evidence that CPU load up to the measured 81.1% peak is not required for, and did not amplify, the +modifier-order defect in this matrix. + +Full saturation crossed a different boundary: the AppKit oracle became unresponsive long enough to +lose focus and did not observe most input or the final Shift release. Because the independent oracle +lost validity, that row is a system/harness starvation result, not clean KeyPath product attribution. +After all ten workers were terminated, a calm recovery baseline passed 34 / 34 characters and 68 / +68 reports with no stuck key or modifier. KeyPath, Kanata, VHID, the Jig, Wi-Fi, and USB all remained +healthy. + +### Kanata-off isolation attempt + +The installed `keypath-cli service stop` could not stop the system service because its +`SystemFacade` calls `launchctl kill SIGTERM system/com.keypath.kanata` directly as the unprivileged +user instead of routing through KeyPath's privileged helper. The native **Stop KeyPath Runtime…** +menu command did use the privileged path and successfully stopped Kanata without disabling VHID. + +With Kanata confirmed stopped, the fixture submitted all 480 reports for the same 20-cycle shifted +corpus with zero device-side lateness, but the Jig received 0 / 240 characters. This Mac therefore +does not currently route the physical fixture directly to applications when Kanata is absent, so +the result cannot distinguish a Kanata modifier-order defect from device admission/routing below +Kanata. It does establish that the prior corruptions came through the working Kanata/VirtualHID +delivery path. KeyPath was relaunched afterward, and a recovery baseline passed 34 / 34 characters +and 68 / 68 reports with all keys and modifiers released. + +## 2026-07-28 Shift-matrix readiness + +The fixture and harness are ready for the next dedicated physical window, but no new KeyPath result +is claimed yet. Firmware build `4d1cb1cb54c4` is running from a valid OTA slot on `beachFi`; +authenticated status reports mounted USB, healthy live display frames, and a completed boot splash. +The complete host, core, client, and QEMU suite passes. + +The diagnostic compiler now independently varies Shift lead and release lag around a fixed key hold. +The combined runner persists the firmware's exact report trace alongside Jig focus, event, output, +release, and timing evidence. A three-cell smoke attempt was excluded before HID execution: the first +attempt could not acquire Jig focus after reopening the app, and the second was rejected by elevated +macOS memory pressure. These are fail-closed harness admissions, not KeyPath test outcomes. + +Focus orchestration now preserves an existing healthy Jig, waits for resources without activating it, +and requests focus only immediately before arm. Physical runs require exclusive use of the active +desktop because real USB keyboard input cannot target a background application. Reserve about 10 +minutes for the three-cell smoke matrix; after it is valid, reserve another 20-30 minutes for the full +5x5 Shift lead/release matrix. From acf07f25c949cbb205ebcca803f7ec577688fed2 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 06:57:20 -0700 Subject: [PATCH 80/99] Apply pinned Swift formatting --- .../Resources/generate_jig_icon.swift | 6 +- .../HIDCaptureCore/CaptureSession.swift | 70 +++++++++++----- .../HIDCaptureCore/KeycapBurstModel.swift | 4 +- .../HIDCaptureCore/SystemReadiness.swift | 2 +- .../Sources/HIDCaptureJig/main.swift | 82 +++++++++++-------- .../CaptureBrandMotionTests.swift | 6 +- .../CaptureSessionTests.swift | 16 ++-- .../KeycapBurstModelTests.swift | 6 +- .../SystemReadinessTests.swift | 8 +- .../Core/PrivilegedOperationsRouter.swift | 28 +++++-- .../System/SystemUninstallCommand.swift | 6 +- .../Core/ServiceBootstrapper.swift | 4 +- 12 files changed, 150 insertions(+), 88 deletions(-) diff --git a/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift b/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift index 7c1aff79d..9373517e6 100644 --- a/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift +++ b/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift @@ -41,7 +41,8 @@ func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = } func roundedRect(_ rect: NSRect, radius: CGFloat, fill: NSColor, stroke: NSColor? = nil, - lineWidth: CGFloat = 1) { + lineWidth: CGFloat = 1) +{ let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) fill.setFill() path.fill() @@ -175,7 +176,8 @@ func render(size: CGFloat) -> NSImage { func writePNG(_ image: NSImage, to url: URL) throws { guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff), - let data = bitmap.representation(using: .png, properties: [:]) else { + let data = bitmap.representation(using: .png, properties: [:]) + else { throw NSError( domain: "KeyPathJigIcon", code: 1, diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift index cd6cca85c..1a75feedb 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift @@ -127,7 +127,7 @@ public final class CaptureSession { reset() guard !runID.isEmpty, runID.count <= 80, !expected.isEmpty, timeoutMs >= 250, timeoutMs <= 300_000, - settleMs >= 50, settleMs <= 5_000, + settleMs >= 50, settleMs <= 5000, focused else { self.runID = runID @@ -175,22 +175,32 @@ public final class CaptureSession { isRepeat: isRepeat, timestampNs: nowNs )) - if firstEventAtNs == nil { firstEventAtNs = nowNs } + if firstEventAtNs == nil { + firstEventAtNs = nowNs + } lastEventAtNs = nowNs activeModifiers = modifiers switch phase { case .down: - if !pressedKeyCodes.insert(keyCode).inserted { duplicateDownEvents += 1 } - if isRepeat { repeatEvents += 1 } + if !pressedKeyCodes.insert(keyCode).inserted { + duplicateDownEvents += 1 + } + if isRepeat { + repeatEvents += 1 + } received.append(normalized) case .up: - if pressedKeyCodes.remove(keyCode) == nil { unmatchedUpEvents += 1 } + if pressedKeyCodes.remove(keyCode) == nil { + unmatchedUpEvents += 1 + } case .flagsChanged: break } - if state != .failed { state = .capturing } + if state != .failed { + state = .capturing + } evaluate(nowNs: nowNs) } @@ -232,14 +242,15 @@ public final class CaptureSession { return } if focusLost || repeatEvents > 0 || duplicateDownEvents > 0 || unmatchedUpEvents > 0 || - mismatchIndex() != nil || received.count > expected.count { + mismatchIndex() != nil || received.count > expected.count + { terminalFailure = true state = .failed return } let timedOut = deadlineAtNs.map { nowNs >= $0 } ?? false if timedOut || finalized { - if received == expected && pressedKeyCodes.isEmpty && activeModifiers == 0 { + if received == expected, pressedKeyCodes.isEmpty, activeModifiers == 0 { state = .passed } else { terminalFailure = true @@ -249,24 +260,45 @@ public final class CaptureSession { } if received == expected, pressedKeyCodes.isEmpty, activeModifiers == 0, let lastEventAtNs, nowNs >= lastEventAtNs, - nowNs - lastEventAtNs >= settleNs { + nowNs - lastEventAtNs >= settleNs + { state = .passed } } private func issues(nowNs: UInt64) -> [String] { var result: [String] = [] - if focusLost { result.append("capture focus was lost") } - if repeatEvents > 0 { result.append("received \(repeatEvents) repeated key-down event(s)") } - if duplicateDownEvents > 0 { result.append("received \(duplicateDownEvents) key-down event(s) before release") } - if unmatchedUpEvents > 0 { result.append("received \(unmatchedUpEvents) key-up event(s) without a matching key-down") } - if let index = mismatchIndex() { result.append("received output differs from expected output at character \(index)") } - if received.count > expected.count { result.append("received \(received.count - expected.count) extra character(s)") } + if focusLost { + result.append("capture focus was lost") + } + if repeatEvents > 0 { + result.append("received \(repeatEvents) repeated key-down event(s)") + } + if duplicateDownEvents > 0 { + result.append("received \(duplicateDownEvents) key-down event(s) before release") + } + if unmatchedUpEvents > 0 { + result.append("received \(unmatchedUpEvents) key-up event(s) without a matching key-down") + } + if let index = mismatchIndex() { + result.append("received output differs from expected output at character \(index)") + } + if received.count > expected.count { + result.append("received \(received.count - expected.count) extra character(s)") + } let terminal = finalized || (deadlineAtNs.map { nowNs >= $0 } ?? false) - if terminal, !focused, firstEventAtNs == nil { result.append("capture was not focused before input arrived") } - if terminal, received.count < expected.count { result.append("missing \(expected.count - received.count) expected character(s)") } - if terminal, !pressedKeyCodes.isEmpty { result.append("unreleased key code(s): \(pressedKeyCodes.sorted())") } - if terminal, activeModifiers != 0 { result.append("unreleased modifier flags: \(activeModifiers)") } + if terminal, !focused, firstEventAtNs == nil { + result.append("capture was not focused before input arrived") + } + if terminal, received.count < expected.count { + result.append("missing \(expected.count - received.count) expected character(s)") + } + if terminal, !pressedKeyCodes.isEmpty { + result.append("unreleased key code(s): \(pressedKeyCodes.sorted())") + } + if terminal, activeModifiers != 0 { + result.append("unreleased modifier flags: \(activeModifiers)") + } return result } diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift index c533791ff..9472a3f99 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift @@ -63,7 +63,9 @@ public enum KeycapBurstModel { let recentPresses = max(recentRealPresses, recentPresentedPresses) let intensity = min(1, Double(recentPresses) / 8) let presentedPresses = presented.reduce(into: 0) { count, item in - if nowNs >= item.timestampNs { count += 1 } + if nowNs >= item.timestampNs { + count += 1 + } } let isAnimating = lastPresentationNs > 0 && (nowNs < lastPresentationNs || nowNs - lastPresentationNs <= visibleLifetimeNs) diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift index dfd86d9f8..d579adafa 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift @@ -66,7 +66,7 @@ public enum SystemReadinessModel { public static let requiredStableSamples = 3 private static let maximumCPUUtilization = 0.80 private static let maximumLoadPerCore = 0.90 - private static let minimumAvailableMemoryBytes: UInt64 = 2 * 1_024 * 1_024 * 1_024 + private static let minimumAvailableMemoryBytes: UInt64 = 2 * 1024 * 1024 * 1024 private static let maximumThreadsPerCore = 900 public static func resolve( diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift index e0841e63b..e8fc118c9 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -38,7 +38,9 @@ private final class SystemResourceMonitor { } lastSampleNs = nowNs samples.append(capture(nowNs: nowNs)) - if samples.count > 12 { samples.removeFirst(samples.count - 12) } + if samples.count > 12 { + samples.removeFirst(samples.count - 12) + } assessment = SystemReadinessModel.resolve(samples: samples) return true } @@ -150,12 +152,17 @@ private final class CaptureCanvas: NSView { } @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - override var acceptsFirstResponder: Bool { true } - override var isOpaque: Bool { true } + override var acceptsFirstResponder: Bool { + true + } + + override var isOpaque: Bool { + true + } override func becomeFirstResponder() -> Bool { onFocusChange?(true) @@ -205,7 +212,7 @@ private final class CaptureCanvas: NSView { needsDisplay = true } - override func mouseDown(with event: NSEvent) { + override func mouseDown(with _: NSEvent) { window?.makeFirstResponder(self) } @@ -289,13 +296,12 @@ private final class CaptureCanvas: NSView { var cursor = header.minY - (layout.mode == .tiny ? 5 : 12) let stateHeight: CGFloat = layout.mode == .tiny ? 28 : 48 - let visibleState: String - if snapshot.state == .idle, systemReadiness.state == .waiting { - visibleState = layout.mode == .tiny ? "MAC BUSY" : "PAUSED · MAC BUSY" + let visibleState: String = if snapshot.state == .idle, systemReadiness.state == .waiting { + layout.mode == .tiny ? "MAC BUSY" : "PAUSED · MAC BUSY" } else if snapshot.state == .idle, systemReadiness.state == .calibrating { - visibleState = layout.mode == .tiny ? "CHECKING" : "CHECKING THE MAC" + layout.mode == .tiny ? "CHECKING" : "CHECKING THE MAC" } else { - visibleState = snapshot.state.rawValue.uppercased() + snapshot.state.rawValue.uppercased() } drawText( visibleState, @@ -344,15 +350,14 @@ private final class CaptureCanvas: NSView { ) cursor -= fieldBlockHeight - let issueText: String - if !snapshot.issues.isEmpty { - issueText = snapshot.issues.joined(separator: " • ") + let issueText: String = if !snapshot.issues.isEmpty { + snapshot.issues.joined(separator: " • ") } else if snapshot.state == .idle, systemReadiness.state != .ready { - issueText = ([systemReadiness.detail] + systemReadiness.suggestions).joined(separator: " • ") + ([systemReadiness.detail] + systemReadiness.suggestions).joined(separator: " • ") } else if snapshot.state == .idle { - issueText = "System resources are stable · ready to arm" + "System resources are stable · ready to arm" } else { - issueText = "No anomalies detected" + "No anomalies detected" } let resourceBlocked = snapshot.state == .idle && systemReadiness.state == .waiting let issueHeight: CGFloat = resourceBlocked @@ -472,15 +477,14 @@ private final class CaptureCanvas: NSView { font: font(size: mode == .tiny ? 8 : 11, weight: .semibold, monospaced: true), color: NSColor.tertiaryLabelColor ) - let evidence: String - if output.totalPresses == 0 { - evidence = "WAITING FOR KEY-DOWN" + let evidence = if output.totalPresses == 0 { + "WAITING FOR KEY-DOWN" } else if output.presentedPresses < output.totalPresses { - evidence = "PLAYING \(output.presentedPresses)/\(output.totalPresses) · BURST \(output.recentPresses)" + "PLAYING \(output.presentedPresses)/\(output.totalPresses) · BURST \(output.recentPresses)" } else if output.recentPresses > 0 { - evidence = "\(output.recentPresses) NOW · \(output.totalPresses) TOTAL" + "\(output.recentPresses) NOW · \(output.totalPresses) TOTAL" } else { - evidence = "\(output.totalPresses) PRESSES CAPTURED" + "\(output.totalPresses) PRESSES CAPTURED" } drawText( evidence, @@ -523,7 +527,7 @@ private final class CaptureCanvas: NSView { private func drawEmptyKeycapStack(in frame: NSRect, mode: CaptureLayoutMode) { let capWidth = min(mode == .tiny ? 54 : 82, frame.width * 0.28) let capHeight = capWidth * 0.64 - for index in 0..<3 { + for index in 0 ..< 3 { let cap = NSRect( x: frame.midX - capWidth / 2 + CGFloat(index - 1) * 3, y: frame.midY - capHeight / 2 + CGFloat(index) * 3, @@ -647,7 +651,9 @@ private final class CaptureCanvas: NSView { size: CGFloat, weight: NSFont.Weight, monospaced: Bool = false ) -> NSFont { let key = "\(monospaced ? "mono" : "system"):\(size):\(weight.rawValue)" - if let cached = fontCache[key] { return cached } + if let cached = fontCache[key] { + return cached + } let value = monospaced ? NSFont.monospacedSystemFont(ofSize: size, weight: weight) : NSFont.systemFont(ofSize: size, weight: weight) @@ -657,11 +663,11 @@ private final class CaptureCanvas: NSView { private func color(for state: CaptureRunState) -> NSColor { switch state { - case .idle: return brandAmber - case .armed: return brandOrange - case .capturing: return brandAmber - case .passed: return NSColor.systemGreen - case .failed: return NSColor.systemRed + case .idle: brandAmber + case .armed: brandOrange + case .capturing: brandAmber + case .passed: NSColor.systemGreen + case .failed: NSColor.systemRed } } @@ -758,7 +764,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega .appendingPathComponent(".local/state/keypath-hid-capture-jig", isDirectory: true) }() - func applicationDidFinishLaunching(_ notification: Notification) { + func applicationDidFinishLaunching(_: Notification) { NSApp.setActivationPolicy(.regular) buildWindow() resourceMonitor.sampleIfNeeded(nowNs: monotonicNow(), force: true) @@ -774,15 +780,17 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega window.makeFirstResponder(canvas) } - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } - func windowDidBecomeKey(_ notification: Notification) { + func windowDidBecomeKey(_: Notification) { window.makeFirstResponder(canvas) session.noteFocus(true, nowNs: monotonicNow()) canvas.needsDisplay = true } - func windowDidResignKey(_ notification: Notification) { + func windowDidResignKey(_: Notification) { session.noteFocus(false, nowNs: monotonicNow()) canvas.needsDisplay = true } @@ -844,7 +852,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega if !runActive { ambientFrame = (ambientFrame + 1) % 2 if burstAnimating || - (!NSWorkspace.shared.accessibilityDisplayShouldReduceMotion && ambientFrame == 0) { + (!NSWorkspace.shared.accessibilityDisplayShouldReduceMotion && ambientFrame == 0) + { canvas.needsDisplay = true } lastRenderedState = snapshot.state @@ -856,7 +865,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega window.makeFirstResponder(canvas) } if snapshot.state == .armed || snapshot.state == .capturing || - snapshot.state != lastRenderedState { + snapshot.state != lastRenderedState + { canvas.needsDisplay = true } lastRenderedState = snapshot.state @@ -936,7 +946,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega let ok = session.arm( runID: command.runID ?? "", expected: command.expected ?? "", - timeoutMs: command.timeoutMs ?? 10_000, + timeoutMs: command.timeoutMs ?? 10000, settleMs: command.settleMs ?? 250, focused: focused, nowNs: monotonicNow() diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift index 1f82f7363..be06bb1a8 100644 --- a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift @@ -7,7 +7,7 @@ final class CaptureBrandMotionTests: XCTestCase { func testCompletionAndMotionStayBounded() { let session = CaptureSession() XCTAssertTrue(session.arm( - runID: "brand", expected: "abcd", timeoutMs: 2_000, + runID: "brand", expected: "abcd", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) session.record( @@ -28,7 +28,7 @@ final class CaptureBrandMotionTests: XCTestCase { func testEventPulseDecaysWithoutInventingProgress() { let session = CaptureSession() XCTAssertTrue(session.arm( - runID: "pulse", expected: "ab", timeoutMs: 2_000, + runID: "pulse", expected: "ab", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) session.record( @@ -51,7 +51,7 @@ final class CaptureBrandMotionTests: XCTestCase { func testReduceMotionProducesStableEvidenceDrivenState() { let session = CaptureSession() XCTAssertTrue(session.arm( - runID: "reduced", expected: "ab", timeoutMs: 2_000, + runID: "reduced", expected: "ab", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) let snapshot = session.snapshot(nowNs: start) diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift index af3f20914..4775458e7 100644 --- a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift @@ -7,7 +7,7 @@ final class CaptureSessionTests: XCTestCase { func testExactSequencePassesOnlyAfterReleaseAndSettle() { let session = CaptureSession() XCTAssertTrue(session.arm( - runID: "exact", expected: "qw", timeoutMs: 2_000, + runID: "exact", expected: "qw", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) session.record(phase: .down, keyCode: 12, characters: "q", modifiers: 0, isRepeat: false, nowNs: start + 10) @@ -26,7 +26,7 @@ final class CaptureSessionTests: XCTestCase { func testDuplicateAndRepeatFailWithExactEvidence() { let session = CaptureSession() XCTAssertTrue(session.arm( - runID: "repeat", expected: "aa", timeoutMs: 2_000, + runID: "repeat", expected: "aa", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 10) @@ -41,7 +41,7 @@ final class CaptureSessionTests: XCTestCase { func testWrongOrderFailsAtFirstMismatchedCharacter() { let session = CaptureSession() XCTAssertTrue(session.arm( - runID: "order", expected: "abc", timeoutMs: 2_000, + runID: "order", expected: "abc", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 10) @@ -82,7 +82,7 @@ final class CaptureSessionTests: XCTestCase { func testFocusLossFailsClosed() { let session = CaptureSession() XCTAssertTrue(session.arm( - runID: "focus", expected: "a", timeoutMs: 2_000, + runID: "focus", expected: "a", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 5) @@ -95,7 +95,7 @@ final class CaptureSessionTests: XCTestCase { func testArmedSessionCanRecoverFocusBeforeFirstInput() { let session = CaptureSession() XCTAssertTrue(session.arm( - runID: "focus-recovery", expected: "a", timeoutMs: 2_000, + runID: "focus-recovery", expected: "a", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) session.noteFocus(false, nowNs: start + 10) @@ -109,12 +109,12 @@ final class CaptureSessionTests: XCTestCase { func testArmRejectsMissingFocusAndUnsafeBounds() { let session = CaptureSession() XCTAssertFalse(session.arm( - runID: "not-focused", expected: "a", timeoutMs: 2_000, + runID: "not-focused", expected: "a", timeoutMs: 2000, settleMs: 100, focused: false, nowNs: start )) XCTAssertEqual(session.snapshot(nowNs: start).state, .failed) XCTAssertFalse(session.arm( - runID: "empty", expected: "", timeoutMs: 2_000, + runID: "empty", expected: "", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) } @@ -123,7 +123,7 @@ final class CaptureSessionTests: XCTestCase { let session = CaptureSession() let shift: UInt = 1 << 17 XCTAssertTrue(session.arm( - runID: "modifier-release", expected: "A", timeoutMs: 2_000, + runID: "modifier-release", expected: "A", timeoutMs: 2000, settleMs: 100, focused: true, nowNs: start )) session.record(phase: .flagsChanged, keyCode: 56, characters: "", modifiers: shift, diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift index 1ff2ec27f..791d06e4d 100644 --- a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift @@ -18,7 +18,7 @@ final class KeycapBurstModelTests: XCTestCase { } func testDenseBurstIsCappedAndRaisesIntensity() { - let events = (1...30).map { index in + let events = (1 ... 30).map { index in event(index, .down, "x", at: 1_000_000_000 + UInt64(index) * 4_000_000) } @@ -31,14 +31,14 @@ final class KeycapBurstModelTests: XCTestCase { ) XCTAssertEqual(output.items.count, 10) - XCTAssertEqual(output.items.map(\.sequence), Array(21...30)) + XCTAssertEqual(output.items.map(\.sequence), Array(21 ... 30)) XCTAssertGreaterThanOrEqual(output.intensity, 0.85) XCTAssertEqual(output.presentedPresses, 30) XCTAssertTrue(output.isAnimating) } func testFasterThanDisplayInputIsPresentedInOrderAtReadableCadence() { - let events = (1...5).map { index in + let events = (1 ... 5).map { index in event(index, .down, String(index), at: 1_000_000_000 + UInt64(index)) } diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift index 31a82f4d7..72af87b48 100644 --- a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift @@ -9,14 +9,14 @@ final class SystemReadinessTests: XCTestCase { availableGB: UInt64 = 8, pressure: Int = 1, thermal: String = "nominal", - threads: Int = 3_000 + threads: Int = 3000 ) -> SystemResourceSample { SystemResourceSample( timestampNs: timestamp, cpuUtilization: cpu, loadAveragePerCore: load, - availableMemoryBytes: availableGB * 1_024 * 1_024 * 1_024, - physicalMemoryBytes: 32 * 1_024 * 1_024 * 1_024, + availableMemoryBytes: availableGB * 1024 * 1024 * 1024, + physicalMemoryBytes: 32 * 1024 * 1024 * 1024, threadCount: threads, logicalProcessorCount: 10, memoryPressureLevel: pressure, @@ -50,7 +50,7 @@ final class SystemReadinessTests: XCTestCase { func testMemoryThermalAndThreadPressureFailClosed() { let assessment = SystemReadinessModel.resolve(samples: [sample( - at: 1, availableGB: 1, pressure: 2, thermal: "serious", threads: 9_100 + at: 1, availableGB: 1, pressure: 2, thermal: "serious", threads: 9100 )]) XCTAssertFalse(assessment.canProceed) XCTAssertTrue(assessment.issues.contains { $0.contains("memory") && $0.contains("available") }) diff --git a/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift b/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift index 410959819..71ea3649a 100644 --- a/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift +++ b/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift @@ -907,7 +907,9 @@ public final class PrivilegedOperationsRouter { let deadline = Date().addingTimeInterval(Self.kanataStopVerifyTimeoutSeconds) repeat { let pids = await SystemStateProvider.shared.processIDs(matching: "kanata.*--cfg") - if pids.isEmpty { return true } + if pids.isEmpty { + return true + } do { try await Task.sleep(for: Self.postconditionPollInterval) } catch { return false } } while Date() < deadline @@ -935,13 +937,17 @@ public final class PrivilegedOperationsRouter { } else { false } - if staleOnEntry { return .staleRegistration } + if staleOnEntry { + return .staleRegistration + } let deadline = Date().addingTimeInterval(Self.kanataReadinessTimeout) var launchctlNotFoundSamples: [Bool] = [] while Date() < deadline { - if Task.isCancelled { return .timedOut } + if Task.isCancelled { + return .timedOut + } let remaining = deadline.timeIntervalSince(Date()) let timeoutMs = max(50, min(300, Int(remaining * 1000))) @@ -982,7 +988,9 @@ public final class PrivilegedOperationsRouter { } } - if await detectKanataTCPPortConflict() { return .tcpPortInUse } + if await detectKanataTCPPortConflict() { + return .tcpPortInUse + } return .timedOut } @@ -1026,7 +1034,9 @@ public final class PrivilegedOperationsRouter { let vhidManager = VHIDDeviceManager() let start = Date() while Date().timeIntervalSince(start) < Self.vhidVerifyTimeoutSeconds { - if await vhidManager.detectRunning() { return true } + if await vhidManager.detectRunning() { + return true + } try await Task.sleep(for: Self.postconditionPollInterval) } @@ -1078,7 +1088,9 @@ public final class PrivilegedOperationsRouter { let vhidManager = VHIDDeviceManager() let startTime = Date() while Date().timeIntervalSince(startTime) < Self.vhidVerifyTimeoutSeconds { - if await vhidManager.detectRunning() { return true } + if await vhidManager.detectRunning() { + return true + } try await Task.sleep(for: Self.postconditionPollInterval) } @@ -1089,7 +1101,9 @@ public final class PrivilegedOperationsRouter { let now = Date() if let last = lastSMAppApprovalNotice, now.timeIntervalSince(last) < smAppApprovalNoticeThrottle - { return } + { + return + } lastSMAppApprovalNotice = now NotificationCenter.default.post(name: .smAppServiceApprovalRequired, object: nil) } diff --git a/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift b/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift index 622cb62e1..bc83e3fe6 100644 --- a/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift +++ b/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift @@ -1,5 +1,5 @@ -import ArgumentParser import AppKit +import ArgumentParser import Foundation import KeyPathAppKit import KeyPathCLISupport @@ -63,7 +63,7 @@ struct SystemUninstall: AsyncParsableCommand { _ = application.terminate() } - for _ in 0..<30 where applications.contains(where: { !$0.isTerminated }) { + for _ in 0 ..< 30 where applications.contains(where: { !$0.isTerminated }) { try? await Task.sleep(for: .milliseconds(100)) } @@ -71,7 +71,7 @@ struct SystemUninstall: AsyncParsableCommand { _ = application.forceTerminate() } - for _ in 0..<20 where applications.contains(where: { !$0.isTerminated }) { + for _ in 0 ..< 20 where applications.contains(where: { !$0.isTerminated }) { try? await Task.sleep(for: .milliseconds(100)) } diff --git a/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift b/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift index 67c964452..2118ec629 100644 --- a/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift +++ b/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift @@ -914,7 +914,9 @@ public final class ServiceBootstrapper { ServiceHealthChecker.shared.invalidateHealthCache() daemonLoaded = await ServiceHealthChecker.shared.isServiceLoaded(serviceID: Self.vhidDaemonServiceID) managerLoaded = await ServiceHealthChecker.shared.isServiceLoaded(serviceID: Self.vhidManagerServiceID) - if daemonLoaded, managerLoaded { break } + if daemonLoaded, managerLoaded { + break + } _ = await WizardSleep.ms(500) } let configured = ServiceHealthChecker.shared.isVHIDDaemonConfiguredCorrectly() From 25b67a5092e258109c9ecd2098a98ae450d8b87e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 07:09:44 -0700 Subject: [PATCH 81/99] Record resource-gated Shift matrix attempt --- docs/testing/keypath-hid-fixture-physical-results.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md index 8acfa9cbf..2b74ad599 100644 --- a/docs/testing/keypath-hid-fixture-physical-results.md +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -123,9 +123,13 @@ The complete host, core, client, and QEMU suite passes. The diagnostic compiler now independently varies Shift lead and release lag around a fixed key hold. The combined runner persists the firmware's exact report trace alongside Jig focus, event, output, -release, and timing evidence. A three-cell smoke attempt was excluded before HID execution: the first -attempt could not acquire Jig focus after reopening the app, and the second was rejected by elevated -macOS memory pressure. These are fail-closed harness admissions, not KeyPath test outcomes. +release, and timing evidence. Three three-cell smoke attempts were excluded before HID execution: +the first could not acquire Jig focus after reopening the app, the second was rejected by elevated +macOS memory pressure, and the third timed out at the 0.9x-per-core competing-load ceiling while the +operator was on a video call. The third artifact is +`~/.local/state/keypath-hid-capture-jig/modifier-matrix/shift-smoke-20260728T140721Z/summary.json`; +it records zero completed cases and zero submitted reports. These are fail-closed harness admissions, +not KeyPath test outcomes. Focus orchestration now preserves an existing healthy Jig, waits for resources without activating it, and requests focus only immediately before arm. Physical runs require exclusive use of the active From 0219682eb4edadd4468376a9c4571a7bc6450e8b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 15:21:52 -0700 Subject: [PATCH 82/99] Record passing Shift timing smoke matrix --- .../keypath-hid-fixture-physical-results.md | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md index 2b74ad599..8557b4240 100644 --- a/docs/testing/keypath-hid-fixture-physical-results.md +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -114,12 +114,12 @@ Kanata. It does establish that the prior corruptions came through the working Ka delivery path. KeyPath was relaunched afterward, and a recovery baseline passed 34 / 34 characters and 68 / 68 reports with all keys and modifiers released. -## 2026-07-28 Shift-matrix readiness +## 2026-07-28 Shift-matrix readiness and smoke result -The fixture and harness are ready for the next dedicated physical window, but no new KeyPath result -is claimed yet. Firmware build `4d1cb1cb54c4` is running from a valid OTA slot on `beachFi`; -authenticated status reports mounted USB, healthy live display frames, and a completed boot splash. -The complete host, core, client, and QEMU suite passes. +Firmware build `4d1cb1cb54c4` is running from a valid OTA slot and rotated successfully from its stale +`192.168.4.21` address to `529beach` at `10.0.0.47`. Authenticated status reports mounted USB, +healthy live display frames, and a completed boot splash. The complete host, core, client, and QEMU +suite passes. The diagnostic compiler now independently varies Shift lead and release lag around a fixed key hold. The combined runner persists the firmware's exact report trace alongside Jig focus, event, output, @@ -133,6 +133,19 @@ not KeyPath test outcomes. Focus orchestration now preserves an existing healthy Jig, waits for resources without activating it, and requests focus only immediately before arm. Physical runs require exclusive use of the active -desktop because real USB keyboard input cannot target a background application. Reserve about 10 -minutes for the three-cell smoke matrix; after it is valid, reserve another 20-30 minutes for the full -5x5 Shift lead/release matrix. +desktop because real USB keyboard input cannot target a background application. + +The admitted smoke run `shift-smoke-20260728T222037Z` completed all three cells: + +| Shift lead | Release lag | Output | Firmware trace | Timing | +|---:|---:|---:|---:|---:| +| 0 ms | 0 ms | 24 / 24 exact | 48 reports | 0 late; 34 us maximum | +| 4 ms | 0 ms | 24 / 24 exact | 58 reports | 0 late; 35 us maximum | +| 8 ms | 0 ms | 24 / 24 exact | 58 reports | 0 late; 34 us maximum | + +Every cell retained Jig focus and ended with no pressed keys or active modifiers. The combined +summary is +`~/.local/state/keypath-hid-capture-jig/modifier-matrix/shift-smoke-20260728T222037Z/summary.json`. +This proves the harness can execute and correlate the timing variants, but 24 characters per cell +are too few to supersede the earlier 240-character calm failure. Reserve 20-30 minutes for the full +5x5 Shift lead/release matrix before deciding whether the defect persists or is timing-sensitive. From f5a9022ae2146ad6d8019f26e252a9a5d57e247f Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 15:42:35 -0700 Subject: [PATCH 83/99] Document full physical Shift timing matrix --- .../keypath-hid-fixture-physical-results.md | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md index 8557b4240..4ac6639b0 100644 --- a/docs/testing/keypath-hid-fixture-physical-results.md +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -147,5 +147,45 @@ Every cell retained Jig focus and ended with no pressed keys or active modifiers summary is `~/.local/state/keypath-hid-capture-jig/modifier-matrix/shift-smoke-20260728T222037Z/summary.json`. This proves the harness can execute and correlate the timing variants, but 24 characters per cell -are too few to supersede the earlier 240-character calm failure. Reserve 20-30 minutes for the full -5x5 Shift lead/release matrix before deciding whether the defect persists or is timing-sensitive. +are too few to supersede the earlier 240-character calm failure. + +### Full 5x5 Shift lead/release matrix + +The full matrix completed all 25 timing cells with a 60-character shifted corpus per cell, a fixed +8 ms key hold, and a 50 ms character interval. It covered Shift lead and release-lag values of 0, +2, 4, 8, and 12 ms. Across 1,500 received characters and 4,000 firmware trace reports, every cell +retained Jig focus, every key and modifier was released, and the fixture recorded zero late reports; +maximum firmware lateness was 175 us. + +| Shift lead | Exact cells | Wrong characters | Shift demotions | Result | +|---:|---:|---:|---:|---| +| 0 ms | 1 / 5 | 10 / 300 | 10 | Defect reproduced in 4 / 5 release-lag variants | +| 2 ms | 5 / 5 | 0 / 300 | 0 | Exact | +| 4 ms | 5 / 5 | 0 / 300 | 0 | Exact | +| 8 ms | 5 / 5 | 0 / 300 | 0 | Exact | +| 12 ms | 5 / 5 | 0 / 300 | 0 | Exact | + +Every mismatch was a Shift demotion: `_→-`, `?→/`, `Z→z`, or `!→1`. Release lag did not show a +monotonic relationship with corruption: the 0 ms-lead / 4 ms-release cell was exact, while the +other four zero-lead cells produced between one and five demotions. All 20 cells with a 2 ms or +greater Shift lead were exact (1,200 / 1,200 characters). + +This isolates the observed defect to modifier assertion timing in the working +fixture → Kanata → VirtualHID → application path. CPU load is not required, firmware deadline +misses are not implicated, and adding a single 2 ms lead before the shifted key removed every +observed corruption in this matrix. The result identifies a reliable reproduction boundary; it +does not yet prove whether the product-side fault is in Kanata's physical-input handling or the +VirtualHID delivery boundary. + +The result is combined from these fail-closed artifacts: + +- `shift-full-20260728T222602Z`: 10 valid cells (0 and 2 ms lead rows). The next cell was not + executed after the fixture's Wi-Fi control service timed out. +- `shift-full-resume-20260728T223504Z`: 14 valid cells (4 and 8 ms rows plus four 12 ms cells). The + final cell was not executed because the Jig's macOS memory-pressure admission timed out. +- `shift-full-final-20260728T224119Z`: the single missing 12 / 12 ms cell, retried only after three + consecutive healthy resource samples. + +The Wi-Fi timeout and memory-pressure rejection are harness-infrastructure events, not product test +outcomes. Neither submitted HID reports for its blocked cell, and only the completed, independently +validated cells are included in the 25-cell result. From 592c9aa4904b32b0c2a81f4f9e20c860e0343202 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 18:38:22 -0700 Subject: [PATCH 84/99] Harden physical HID demos and evidence capture --- Scripts/lab/hid-capture-jig-client | 2 + Scripts/lab/hid-capture-jig-tool | 90 ++-- .../Sources/HIDCaptureJig/main.swift | 78 +++- Scripts/lab/hid-capture-jig/build-app.sh | 19 + Scripts/lab/physical-hid-capture-run | 401 +++++++++++++++--- Scripts/lab/pico-hid-fixture-control-soak | 186 ++++++++ Scripts/lab/pico-hid-fixture-tool | 8 + Scripts/lab/pico-hid-fixture/CMakeLists.txt | 3 +- Scripts/lab/pico-hid-fixture/README.md | 42 +- .../lab/pico-hid-fixture/src/fixture_core.c | 100 ++++- .../lab/pico-hid-fixture/src/fixture_core.h | 4 + .../lab/pico-hid-fixture/src/fixture_demo.c | 67 +++ .../lab/pico-hid-fixture/src/fixture_demo.h | 14 + .../main/CMakeLists.txt | 1 + .../main/fixture_board.c | 7 +- .../main/fixture_display.c | 11 +- .../main/fixture_http.c | 78 +++- .../main/fixture_runtime.c | 63 ++- .../main/fixture_runtime.h | 2 + .../tests/fixture_core_tests.c | 43 ++ .../lab/pico-hid-fixture/tests/run-tests.sh | 1 + .../lab/tests/hid-capture-jig-client-tests.py | 2 + .../tests/physical-hid-capture-run-tests.py | 53 ++- .../pico-hid-fixture-control-soak-tests.py | 67 +++ .../keypath-hid-fixture-physical-results.md | 31 ++ docs/testing/keypath-hid-fixture-readiness.md | 9 +- 26 files changed, 1247 insertions(+), 135 deletions(-) create mode 100755 Scripts/lab/pico-hid-fixture-control-soak create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_demo.c create mode 100644 Scripts/lab/pico-hid-fixture/src/fixture_demo.h create mode 100755 Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py diff --git a/Scripts/lab/hid-capture-jig-client b/Scripts/lab/hid-capture-jig-client index 1bd1bc70c..31d36c536 100755 --- a/Scripts/lab/hid-capture-jig-client +++ b/Scripts/lab/hid-capture-jig-client @@ -66,6 +66,7 @@ def build_parser() -> argparse.ArgumentParser: arm.add_argument("--expected", required=True, type=pathlib.Path) arm.add_argument("--timeout-ms", type=int, default=10_000) arm.add_argument("--settle-ms", type=int, default=250) + arm.add_argument("--instruction") wait = commands.add_parser("wait") wait.add_argument("--timeout-seconds", type=float, default=15.0) @@ -89,6 +90,7 @@ def main() -> int: expected=arguments.expected.read_text(), timeoutMs=arguments.timeout_ms, settleMs=arguments.settle_ms, + instruction=arguments.instruction, ) elif arguments.command == "wait": deadline = time.monotonic() + arguments.timeout_seconds diff --git a/Scripts/lab/hid-capture-jig-tool b/Scripts/lab/hid-capture-jig-tool index 9dee29b53..f290cbd2d 100755 --- a/Scripts/lab/hid-capture-jig-tool +++ b/Scripts/lab/hid-capture-jig-tool @@ -13,11 +13,58 @@ usage() { "Commands:" \ " build Build and ad-hoc sign the isolated capture app" \ " test Run capture-core and control-client tests" \ - " open Build, open, focus, and verify the capture app" \ + " open Open, focus, and verify the cached capture app" \ + " demo Arm the offline device demo and wait for its exact result" \ " status Query the running capture app" \ " close Ask the capture app to quit" } +app_binary_hash() { + if [[ -f "$app_path/Contents/MacOS/HIDCaptureJig" ]]; then + shasum -a 256 "$app_path/Contents/MacOS/HIDCaptureJig" | awk '{print $1}' + fi +} + +wait_for_app_exit() { + for _ in {1..50}; do + pgrep -x HIDCaptureJig >/dev/null || return 0 + sleep 0.1 + done + printf '%s\n' "hid-capture-jig-tool: existing capture app did not quit" >&2 + return 1 +} + +open_app() { + was_running=0 + pgrep -x HIDCaptureJig >/dev/null && was_running=1 + before_hash=$(app_binary_hash) + "$package_dir/build-app.sh" >/dev/null + after_hash=$(app_binary_hash) + + if [[ "$was_running" -eq 1 && "$before_hash" == "$after_hash" ]]; then + "$client" focus >/dev/null + else + if [[ "$was_running" -eq 1 ]]; then + "$client" quit >/dev/null 2>&1 || true + wait_for_app_exit + fi + open "$app_path" + fi + + result=$(mktemp -t keypath-hid-capture-jig-status.XXXXXX) + for _ in {1..50}; do + if KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT=0.2 "$client" status >"$result" 2>/dev/null; then + cat "$result" + rm -f "$result" + return 0 + fi + sleep 0.1 + done + rm -f "$result" + printf '%s\n' "hid-capture-jig-tool: app opened but did not become controllable" >&2 + return 1 +} + case ${1:-} in build) [[ $# -eq 1 ]] || { usage >&2; exit 2; } @@ -30,30 +77,23 @@ case ${1:-} in ;; open) [[ $# -eq 1 ]] || { usage >&2; exit 2; } - if pgrep -x HIDCaptureJig >/dev/null; then - "$client" quit >/dev/null 2>&1 || true - for _ in {1..50}; do - pgrep -x HIDCaptureJig >/dev/null || break - sleep 0.1 - done - if pgrep -x HIDCaptureJig >/dev/null; then - printf '%s\n' "hid-capture-jig-tool: existing capture app did not quit" >&2 - exit 1 - fi - fi - "$package_dir/build-app.sh" - open "$app_path" - result=$(mktemp -t keypath-hid-capture-jig-status.XXXXXX) - trap 'rm -f "$result"' EXIT - for _ in {1..50}; do - if KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT=0.2 "$client" status >"$result" 2>/dev/null; then - cat "$result" - exit 0 - fi - sleep 0.1 - done - printf '%s\n' "hid-capture-jig-tool: app opened but did not become controllable" >&2 - exit 1 + open_app + ;; + demo) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + open_app >/dev/null + expected=$(mktemp -t keypath-hid-demo-expected.XXXXXX) + trap 'rm -f "$expected"' EXIT + printf 'KeyPath demo OK\n' >"$expected" + "$client" reset >/dev/null + "$client" focus >/dev/null + "$client" arm --run-id offline-demo --expected "$expected" \ + --timeout-ms 30000 --settle-ms 300 \ + --instruction "PRESS TOP POWER, THEN TAP DEVICE" >/dev/null + printf '%s\n' \ + "demo armed PRESS TOP POWER, THEN TAP THE DEVICE SCREEN" \ + "waiting exact Jig result (30 second limit)" + "$client" wait --timeout-seconds 31 --poll-ms 50 ;; status) [[ $# -eq 1 ]] || { usage >&2; exit 2; } diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift index e8fc118c9..c3500819d 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -10,6 +10,7 @@ private struct ControlCommand: Codable { let expected: String? let timeoutMs: UInt64? let settleMs: UInt64? + let instruction: String? } private struct ControlResponse: Codable { @@ -127,6 +128,7 @@ private final class CaptureCanvas: NSView { let session: CaptureSession var onFocusChange: ((Bool) -> Void)? var systemReadiness = SystemReadinessAssessment.calibrating + var operatorInstruction = "" private var fontCache: [String: NSFont] = [:] private var paragraphCache: [Int: NSParagraphStyle] = [:] private let brandAmber = NSColor( @@ -410,6 +412,70 @@ private final class CaptureCanvas: NSView { color: NSColor.tertiaryLabelColor ) } + + if !operatorInstruction.isEmpty, + snapshot.state == .armed || snapshot.state == .capturing + { + drawOperatorInstruction( + operatorInstruction, + in: inset, + mode: layout.mode, + pulse: motion.breath + ) + } + } + + private func drawOperatorInstruction( + _ instruction: String, + in bounds: NSRect, + mode: CaptureLayoutMode, + pulse: Double + ) { + let horizontalInset: CGFloat = mode == .tiny ? 6 : (mode == .compact ? 24 : 56) + let height: CGFloat = mode == .tiny ? 104 : (mode == .compact ? 132 : 164) + let frame = NSRect( + x: bounds.minX + horizontalInset, + y: bounds.midY - height / 2, + width: max(120, bounds.width - horizontalInset * 2), + height: height + ) + let path = NSBezierPath( + roundedRect: frame, + xRadius: mode == .tiny ? 14 : 20, + yRadius: mode == .tiny ? 14 : 20 + ) + let background = NSColor.windowBackgroundColor.withAlphaComponent(0.97) + background.setFill() + path.fill() + brandOrange.withAlphaComponent(0.72 + 0.2 * pulse).setStroke() + path.lineWidth = mode == .tiny ? 2 : 3 + path.stroke() + + let labelHeight: CGFloat = mode == .tiny ? 18 : 24 + drawText( + "ACTION REQUIRED", + rect: NSRect( + x: frame.minX + 16, + y: frame.maxY - labelHeight - (mode == .tiny ? 10 : 16), + width: frame.width - 32, + height: labelHeight + ), + font: font(size: mode == .tiny ? 12 : 16, weight: .bold, monospaced: true), + color: brandOrange, + alignment: .center + ) + drawWrappedText( + instruction, + rect: NSRect( + x: frame.minX + (mode == .tiny ? 12 : 24), + y: frame.minY + (mode == .tiny ? 10 : 18), + width: frame.width - (mode == .tiny ? 24 : 48), + height: frame.height - labelHeight - (mode == .tiny ? 28 : 48) + ), + font: font(size: mode == .tiny ? 14 : 22, weight: .semibold), + color: NSColor.labelColor, + alignment: .center + ) } private func drawField( @@ -632,10 +698,17 @@ private final class CaptureCanvas: NSView { ]) } - private func drawWrappedText(_ text: String, rect: NSRect, font: NSFont, color: NSColor) { + private func drawWrappedText( + _ text: String, + rect: NSRect, + font: NSFont, + color: NSColor, + alignment: NSTextAlignment = .left + ) { let paragraph = NSMutableParagraphStyle() paragraph.lineBreakMode = .byWordWrapping paragraph.lineSpacing = 2 + paragraph.alignment = alignment (text as NSString).draw( with: rect, options: [.usesLineFragmentOrigin, .usesFontLeading], @@ -951,6 +1024,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega focused: focused, nowNs: monotonicNow() ) + canvas.operatorInstruction = String((command.instruction ?? "").prefix(240)) persistedTerminalKey = "" runActive = ok lastRenderedState = nil @@ -978,12 +1052,14 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega break case "reset": session.reset() + canvas.operatorInstruction = "" persistedTerminalKey = "" runActive = false lastRenderedState = nil message = "capture session reset" case "finalize": session.finalize(nowNs: monotonicNow()) + canvas.operatorInstruction = "" message = "capture finalized" case "quit": message = "capture jig quitting" diff --git a/Scripts/lab/hid-capture-jig/build-app.sh b/Scripts/lab/hid-capture-jig/build-app.sh index 76e8ec8d7..155f05071 100755 --- a/Scripts/lab/hid-capture-jig/build-app.sh +++ b/Scripts/lab/hid-capture-jig/build-app.sh @@ -3,6 +3,24 @@ set -euo pipefail script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) app_path=${KEYPATH_CAPTURE_JIG_APP:-"$HOME/.cache/keypath-hid-capture-jig/KeyPath HID Capture Jig.app"} +source_stamp="$app_path/Contents/Resources/source.sha256" +source_hash=$( + { + find "$script_dir/Sources" "$script_dir/Resources" -type f -print + printf '%s\n' "$script_dir/Package.swift" + printf '%s\n' "$script_dir/../../../Sources/KeyPathApp/Resources/AppIcon.icns" + } | LC_ALL=C sort | while IFS= read -r source; do + shasum -a 256 "$source" + done | shasum -a 256 | awk '{print $1}' +) + +if [[ ${KEYPATH_CAPTURE_JIG_FORCE_BUILD:-0} != 1 && + -x "$app_path/Contents/MacOS/HIDCaptureJig" && + -f "$source_stamp" && + $(<"$source_stamp") == "$source_hash" ]]; then + printf '%s\n' "$app_path" + exit 0 +fi icon_work=$(mktemp -d "${TMPDIR:-/tmp}/keypath-hid-jig-icon.XXXXXX") trap 'rm -rf "$icon_work"' EXIT @@ -17,5 +35,6 @@ xcrun swift "$script_dir/Resources/generate_jig_icon.swift" "$icon_work/JigIcon. iconutil -c icns "$icon_work/JigIcon.iconset" -o "$app_path/Contents/Resources/AppIcon.icns" install -m 644 "$script_dir/../../../Sources/KeyPathApp/Resources/AppIcon.icns" \ "$app_path/Contents/Resources/KeyPathLogo.icns" +printf '%s\n' "$source_hash" >"$source_stamp" codesign --force --sign - --timestamp=none "$app_path" >/dev/null printf '%s\n' "$app_path" diff --git a/Scripts/lab/physical-hid-capture-run b/Scripts/lab/physical-hid-capture-run index c87c01e25..d6c7e4157 100755 --- a/Scripts/lab/physical-hid-capture-run +++ b/Scripts/lab/physical-hid-capture-run @@ -50,7 +50,8 @@ def run_json( result = subprocess.run(command, text=True, capture_output=True, env=environment) if result.returncode not in allowed_returncodes: detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" - raise RuntimeError(f"{' '.join(command[:3])} failed: {detail}") + concise = detail.splitlines()[-1] if detail else f"exit {result.returncode}" + raise RuntimeError(f"{' '.join(command[:3])} failed: {concise[:600]}") try: return json.loads(result.stdout) except json.JSONDecodeError as error: @@ -64,16 +65,64 @@ def run_checked(command: list[str], environment: dict[str, str]) -> None: raise RuntimeError(f"{' '.join(command[:3])} failed: {detail}") -def fixture_command(host: str, action: str, *arguments: str) -> list[str]: - return [str(FIXTURE_CLIENT), "--host", host, action, *arguments] +def fixture_command( + host: str, action: str, *arguments: str, timeout: float | None = None +) -> list[str]: + command = [str(FIXTURE_CLIENT), "--host", host] + if timeout is not None: + command.extend(["--timeout", f"{timeout:g}"]) + return [*command, action, *arguments] def capture_command(action: str, *arguments: str) -> list[str]: return [str(CAPTURE_CLIENT), action, *arguments] -def resolve_fixture_host(initial_host: str, environment: dict[str, str]) -> tuple[str, dict[str, Any]]: - status = run_json(fixture_command(initial_host, "status"), environment) +def record_control_error( + errors: list[dict[str, Any]], action: str, error: BaseException +) -> None: + errors.append({ + "timestampEpoch": time.time(), + "action": action, + "error": str(error)[:800], + }) + + +def run_fixture_json_with_retry( + host: str, + action: str, + *arguments: str, + environment: dict[str, str], + retry_seconds: float, + errors: list[dict[str, Any]], + request_timeout: float = 1.5, +) -> dict[str, Any]: + deadline = time.monotonic() + max(0, retry_seconds) + while True: + try: + return run_json( + fixture_command(host, action, *arguments, timeout=request_timeout), + environment, + ) + except (OSError, ValueError, RuntimeError, KeyError) as error: + record_control_error(errors, action, error) + if time.monotonic() >= deadline: + raise RuntimeError( + f"fixture {action} unavailable after {retry_seconds:g}s: {error}" + ) from error + time.sleep(0.25) + + +def resolve_fixture_host( + initial_host: str, + environment: dict[str, str], + errors: list[dict[str, Any]] | None = None, +) -> tuple[str, dict[str, Any]]: + control_errors = errors if errors is not None else [] + status = run_fixture_json_with_retry( + initial_host, "status", environment=environment, + retry_seconds=10, errors=control_errors, + ) address = str(status.get("address", "")) try: ipaddress.ip_address(address) @@ -87,16 +136,32 @@ def safe_run_id(value: str) -> str: def wait_for_fixture_terminal( - host: str, environment: dict[str, str], timeout_seconds: float + host: str, environment: dict[str, str], timeout_seconds: float, + errors: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + control_errors = errors if errors is not None else [] deadline = time.monotonic() + timeout_seconds + latest: dict[str, Any] | None = None while True: - status = run_json(fixture_command(host, "status"), environment) - if status.get("state") not in {"armed", "running"}: - return status + remaining = max(0, deadline - time.monotonic()) + try: + latest = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=min(2, remaining), errors=control_errors, + request_timeout=min(1, max(0.2, remaining)), + ) + except RuntimeError: + if time.monotonic() >= deadline: + break + continue + if latest.get("state") not in {"armed", "running"}: + return latest if time.monotonic() >= deadline: - return status + break time.sleep(0.05) + if latest is not None: + return latest + raise RuntimeError("fixture status remained unavailable through the terminal wait") def wait_for_capture_settle( @@ -118,13 +183,19 @@ def wait_for_capture_settle( time.sleep(0.05) -def fetch_fixture_trace(host: str, environment: dict[str, str]) -> list[dict[str, Any]]: +def fetch_fixture_trace( + host: str, environment: dict[str, str], errors: list[dict[str, Any]] | None = None +) -> list[dict[str, Any]]: """Fetch the fixture's bounded trace in chronological order after a run.""" traces: list[dict[str, Any]] = [] start = 0 available: int | None = None while available is None or start < available: - response = run_json(fixture_command(host, "trace", "--from", str(start), "--limit", "8"), environment) + control_errors = errors if errors is not None else [] + response = run_fixture_json_with_retry( + host, "trace", "--from", str(start), "--limit", "8", + environment=environment, retry_seconds=10, errors=control_errors, + ) if not isinstance(response, list) or not response or not isinstance(response[0], dict): raise RuntimeError("fixture trace returned an invalid batch") header = response[0] @@ -137,6 +208,32 @@ def fetch_fixture_trace(host: str, environment: dict[str, str]) -> list[dict[str return traces +def wait_for_capture_release( + environment: dict[str, str], timeout_seconds: float = 3 +) -> tuple[dict[str, Any] | None, bool]: + deadline = time.monotonic() + timeout_seconds + latest: dict[str, Any] | None = None + while time.monotonic() < deadline: + try: + latest = run_json(capture_command("status"), environment) + except (OSError, ValueError, RuntimeError, KeyError): + time.sleep(0.05) + continue + snapshot = latest.get("snapshot", {}) + if not snapshot.get("pressedKeyCodes") and snapshot.get("activeModifiers", 0) == 0: + return latest, True + time.sleep(0.05) + return latest, False + + +def write_artifact(path: pathlib.Path, artifact: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") + temporary.chmod(0o600) + os.replace(temporary, path) + + class ControlledCPULoad: """Bounded synthetic CPU pressure with samples suitable for test artifacts.""" @@ -246,6 +343,10 @@ def parser() -> argparse.ArgumentParser: def main() -> int: arguments = parser().parse_args() + output = arguments.output or ( + pathlib.Path.home() / ".local/state/keypath-hid-capture-jig/combined" / + f"{safe_run_id(arguments.run_id)}.json" + ) if not arguments.text.is_file(): print(f"physical-hid-capture-run: text file does not exist: {arguments.text}", file=sys.stderr) return 2 @@ -255,6 +356,26 @@ def main() -> int: capture_armed = False cpu_load: ControlledCPULoad | None = None cpu_load_result: dict[str, Any] | None = None + stage = "argument-validation" + started_at_epoch = time.time() + timeout_ms = 0 + control_errors: list[dict[str, Any]] = [] + expected = "" + expected_reports = 0 + interruption_mode = "unknown" + initial_status: dict[str, Any] | None = None + capture_preflight: dict[str, Any] | None = None + load: dict[str, Any] | None = None + fixture_arm: dict[str, Any] | None = None + presentation_testing: dict[str, Any] | None = None + capture_focus: dict[str, Any] | None = None + capture_arm: dict[str, Any] | None = None + start: dict[str, Any] | None = None + abort_response: dict[str, Any] | None = None + capture_initial_terminal: dict[str, Any] | None = None + capture_response: dict[str, Any] | None = None + fixture_status: dict[str, Any] = {} + fixture_trace: list[dict[str, Any]] = [] try: if arguments.abort_after_ms is not None and arguments.abort_after_ms < 1: raise RuntimeError("--abort-after-ms must be positive") @@ -271,15 +392,20 @@ def main() -> int: raise RuntimeError(f"--cpu-load-workers must be between 0 and {logical_cpus}") if not 100 <= arguments.cpu_sample_ms <= 5_000: raise RuntimeError("--cpu-sample-ms must be between 100 and 5000") + stage = "fixture-discovery" environment["KEYPATH_FIXTURE_TOKEN"] = load_fixture_token() - host, initial_status = resolve_fixture_host(arguments.fixture_host, environment) + host, initial_status = resolve_fixture_host( + arguments.fixture_host, environment, control_errors + ) fixture_host = host + stage = "capture-preflight" capture_preflight = run_json(capture_command("status"), environment) readiness = capture_preflight.get("systemReadiness") if not isinstance(readiness, dict): raise RuntimeError( "capture Jig does not report system readiness; rebuild and reopen it before testing" ) + stage = "host-resource-admission" if not readiness.get("canProceed"): detail = str(readiness.get("detail", "the Mac is not inside the capture resource envelope")) suggestions = readiness.get("suggestions", []) @@ -295,8 +421,23 @@ def main() -> int: # the fail-closed capture timeout freezes the evidence snapshot. timeout_margin_ms = max(10_000, estimated_ms // 2) timeout_ms = arguments.timeout_ms or max(15_000, estimated_ms + timeout_margin_ms) + interruption_mode = ( + "remote-abort" if arguments.abort_after_ms is not None else + "external-abort" if arguments.expect_external_abort_ms is not None else + "usb-cycle" if arguments.expect_usb_cycle_ms is not None else + "completion" + ) + operator_instruction = { + "external-abort": "PRESS BOOT OR TAP THE SCREEN NOW TO ABORT THE ACTIVE TEST", + "usb-cycle": "UNPLUG USB-C NOW · WAIT 2 SECONDS · RECONNECT THE SAME CABLE", + }.get(interruption_mode) + fixture_detail, fixture_next = { + "external-abort": ("PRESS BOOT OR TOUCH NOW", "VERIFY SAFE ABORT"), + "usb-cycle": ("UNPLUG USB-C NOW", "WAIT 2S + RECONNECT"), + }.get(interruption_mode, ("CAPTURE JIG ARMED", "VERIFY OUTPUT")) with tempfile.TemporaryDirectory(prefix="keypath-hid-capture-") as temporary: + stage = "script-compilation" script = pathlib.Path(temporary) / "fixture-script.txt" expected_path = pathlib.Path(temporary) / "expected.txt" expected_path.write_text(expected) @@ -312,21 +453,34 @@ def main() -> int: header = script.read_text().splitlines()[0].split() expected_reports = int(header[2]) * int(header[3]) - load = run_json(fixture_command(host, "load-script", str(script)), environment) + stage = "fixture-load" + load = run_fixture_json_with_retry( + host, "load-script", str(script), environment=environment, + retry_seconds=8, errors=control_errors, + ) fixture_mutated = True - fixture_arm = run_json(fixture_command(host, "arm", arguments.run_id), environment) - presentation_testing = run_json(fixture_command( + stage = "fixture-arm" + fixture_arm = run_fixture_json_with_retry( + host, "arm", arguments.run_id, environment=environment, + retry_seconds=8, errors=control_errors, + ) + stage = "fixture-presentation" + presentation_testing = run_fixture_json_with_retry( host, "present", "--phase", "testing", "--result", "none", "--progress", "0", "--title", arguments.run_id[:32], - "--detail", "CAPTURE JIG ARMED", "--next", "VERIFY OUTPUT", + "--detail", fixture_detail, "--next", fixture_next, "--reports-expected", str(expected_reports), "--reports-observed", "0", - ), environment) + environment=environment, retry_seconds=5, errors=control_errors, + ) # Acquire focus at the last possible moment. Keeping the Jig focused while # readiness checks or compilation run would steal the user's own typing. + stage = "capture-focus" capture_focus = run_json(capture_command("focus"), environment) + stage = "capture-arm" capture_arm = run_json(capture_command( "arm", "--run-id", arguments.run_id, "--expected", str(expected_path), "--timeout-ms", str(timeout_ms), "--settle-ms", str(arguments.settle_ms), + *(["--instruction", operator_instruction] if operator_instruction else []), ), environment) capture_armed = True if arguments.cpu_load_workers is not None: @@ -334,23 +488,20 @@ def main() -> int: workers=arguments.cpu_load_workers, sample_ms=arguments.cpu_sample_ms ) cpu_load.start() - start = run_json(fixture_command( - host, "start", arguments.run_id, "--delay-ms", str(arguments.delay_ms) - ), environment) - abort_response = None - saw_usb_unmounted = False - interruption_mode = ( - "remote-abort" if arguments.abort_after_ms is not None else - "external-abort" if arguments.expect_external_abort_ms is not None else - "usb-cycle" if arguments.expect_usb_cycle_ms is not None else - "completion" + stage = "fixture-start" + start = run_fixture_json_with_retry( + host, "start", arguments.run_id, "--delay-ms", str(arguments.delay_ms), + environment=environment, retry_seconds=5, errors=control_errors, ) + stage = "active-capture" + saw_usb_unmounted = False if interruption_mode == "completion": capture_initial_terminal = run_json(capture_command( "wait", "--timeout-seconds", f"{timeout_ms / 1000 + 2:g}", "--poll-ms", "100" ), environment, (0, 1)) fixture_status = wait_for_fixture_terminal( - host, environment, timeout_seconds=timeout_ms / 1000 + 2 + host, environment, timeout_seconds=timeout_ms / 1000 + 2, + errors=control_errors, ) capture_response = wait_for_capture_settle( environment, settle_ms=arguments.settle_ms, @@ -359,10 +510,16 @@ def main() -> int: elif interruption_mode == "remote-abort": capture_initial_terminal = None time.sleep(arguments.abort_after_ms / 1000) - abort_response = run_json(fixture_command(host, "abort"), environment) + abort_response = run_fixture_json_with_retry( + host, "abort", environment=environment, + retry_seconds=5, errors=control_errors, + ) release_deadline = time.monotonic() + 2 while True: - fixture_status = run_json(fixture_command(host, "status"), environment) + fixture_status = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=2, errors=control_errors, + ) submitted = int(fixture_status.get("reportsSubmitted", 0)) completed = int(fixture_status.get("transfersCompleted", 0)) if fixture_status.get("state") == "aborted" and completed >= submitted + 1: @@ -375,7 +532,11 @@ def main() -> int: capture_initial_terminal = None external_deadline = time.monotonic() + arguments.expect_external_abort_ms / 1000 while True: - fixture_status = run_json(fixture_command(host, "status"), environment) + fixture_status = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=min(2, max(0, external_deadline - time.monotonic())), + errors=control_errors, + ) if fixture_status.get("state") == "aborted": break if time.monotonic() >= external_deadline: @@ -391,13 +552,25 @@ def main() -> int: if time.monotonic() >= release_deadline: break time.sleep(0.025) - fixture_status = run_json(fixture_command(host, "status"), environment) + fixture_status = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=2, errors=control_errors, + ) capture_response = run_json(capture_command("finalize"), environment, (0, 1)) else: capture_initial_terminal = None usb_deadline = time.monotonic() + arguments.expect_usb_cycle_ms / 1000 while True: - fixture_status = run_json(fixture_command(host, "status"), environment) + remaining = max(0, usb_deadline - time.monotonic()) + try: + fixture_status = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=min(2, remaining), errors=control_errors, + ) + except RuntimeError: + if time.monotonic() >= usb_deadline: + break + continue if fixture_status.get("state") == "error" and not fixture_status.get("usbMounted", True): saw_usb_unmounted = True if saw_usb_unmounted and fixture_status.get("usbMounted"): @@ -413,7 +586,8 @@ def main() -> int: if cpu_load is not None: cpu_load_result = cpu_load.stop() cpu_load = None - fixture_trace = fetch_fixture_trace(host, environment) + stage = "evidence-collection" + fixture_trace = fetch_fixture_trace(host, environment, control_errors) capture = capture_response["snapshot"] late_reports = int(fixture_status.get("lateReports", 0)) maximum_lateness_us = int(fixture_status.get("maximumLatenessUs", 0)) @@ -465,17 +639,21 @@ def main() -> int: "noUnmatchedUpEvents": capture.get("unmatchedUpEvents", 0) == 0, } passed = all(checks.values()) - presentation_result = run_json(fixture_command( - host, "present", "--phase", "result", "--result", "pass" if passed else "fail", - "--progress", "1000", "--title", arguments.run_id[:32], - "--detail", f"{len(capture.get('received', ''))} / {len(expected)} CHARS", - "--next", "NEXT MATRIX CASE", "--reports-expected", str(expected_reports), - "--reports-observed", str(fixture_status.get("reportsSubmitted", 0)), - "--latency-p95-us", str(fixture_status.get("maximumLatenessUs", 0)), - "--safe-release", - ), environment) + try: + presentation_result = run_fixture_json_with_retry( + host, "present", "--phase", "result", "--result", "pass" if passed else "fail", + "--progress", "1000", "--title", arguments.run_id[:32], + "--detail", f"{len(capture.get('received', ''))} / {len(expected)} CHARS", + "--next", "NEXT MATRIX CASE", "--reports-expected", str(expected_reports), + "--reports-observed", str(fixture_status.get("reportsSubmitted", 0)), + "--latency-p95-us", str(fixture_status.get("maximumLatenessUs", 0)), + "--safe-release", environment=environment, + retry_seconds=5, errors=control_errors, + ) + except RuntimeError as presentation_error: + presentation_result = {"ok": False, "error": str(presentation_error)} artifact = { - "schemaVersion": 1, + "schemaVersion": 2, "runID": arguments.run_id, "status": "passed" if passed else "failed", "fixtureHost": host, @@ -508,14 +686,14 @@ def main() -> int: "fixtureTrace": fixture_trace, "capture": capture, "cpuLoad": cpu_load_result, + "controlPlane": { + "degraded": bool(control_errors), + "errors": control_errors, + }, + "startedAtEpoch": started_at_epoch, + "finishedAtEpoch": time.time(), } - output = arguments.output or ( - pathlib.Path.home() / ".local/state/keypath-hid-capture-jig/combined" / - f"{safe_run_id(arguments.run_id)}.json" - ) - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") - output.chmod(0o600) + write_artifact(output, artifact) summary = { "runID": arguments.run_id, "status": artifact["status"], @@ -532,20 +710,125 @@ def main() -> int: return 0 if passed else 1 except (OSError, ValueError, RuntimeError, KeyError) as error: cleanup: list[str] = [] + abort_confirmed = False + if cpu_load is not None: + cpu_load_result = cpu_load.stop() + cpu_load = None + cleanup.append("bounded CPU load stopped") if fixture_mutated and fixture_host: try: - run_json(fixture_command(fixture_host, "abort"), environment) + abort_response = run_fixture_json_with_retry( + fixture_host, "abort", environment=environment, + retry_seconds=5, errors=control_errors, + ) + abort_confirmed = True cleanup.append("fixture aborted with all-keys-released queued") except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: cleanup.append(f"fixture abort failed: {cleanup_error}") if capture_armed: + if not abort_confirmed: + cleanup.append( + "fixture abort unconfirmed; waiting for the bounded capture window" + ) + try: + capture_response = run_json(capture_command( + "wait", "--timeout-seconds", f"{max(2, timeout_ms / 1000 + 2):g}", + "--poll-ms", "100", + ), environment, (0, 1)) + cleanup.append("capture reached a terminal state before cleanup") + except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: + cleanup.append(f"capture terminal wait failed: {cleanup_error}") + release_snapshot, release_confirmed = wait_for_capture_release( + environment, timeout_seconds=5 + ) + cleanup.append( + "capture observed all keys and modifiers released" + if release_confirmed else + "capture release remained unconfirmed after 5s" + ) + capture_state = (capture_response or {}).get("snapshot", {}).get("state") + if capture_state not in {"passed", "failed"}: + try: + capture_response = run_json( + capture_command("finalize"), environment, (0, 1) + ) + cleanup.append("capture finalized") + except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: + cleanup.append(f"capture finalize failed: {cleanup_error}") + capture_response = release_snapshot + if fixture_host and fixture_mutated: try: - run_json(capture_command("finalize"), environment, (0, 1)) - cleanup.append("capture finalized") - except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: - cleanup.append(f"capture finalize failed: {cleanup_error}") - suffix = f"; cleanup: {'; '.join(cleanup)}" if cleanup else "" - print(f"physical-hid-capture-run: {error}{suffix}", file=sys.stderr) + fixture_status = run_fixture_json_with_retry( + fixture_host, "status", environment=environment, + retry_seconds=3, errors=control_errors, + ) + except (OSError, ValueError, RuntimeError, KeyError): + pass + try: + fixture_trace = fetch_fixture_trace( + fixture_host, environment, control_errors + ) + except (OSError, ValueError, RuntimeError, KeyError): + pass + capture = (capture_response or {}).get("snapshot", {}) + classification = ( + "fixture-control-plane" if control_errors or stage.startswith("fixture") else + "host-resource-admission" if stage == "host-resource-admission" else + "capture-oracle" if stage.startswith("capture") else + "harness-infrastructure" + ) + artifact = { + "schemaVersion": 2, + "runID": arguments.run_id, + "status": "inconclusive", + "fixtureHost": fixture_host or arguments.fixture_host, + "expectedReports": expected_reports, + "interruptionMode": interruption_mode, + "failure": { + "classification": classification, + "stage": stage, + "error": str(error)[:1200], + }, + "cleanup": cleanup, + "controlPlane": { + "degraded": bool(control_errors), + "errors": control_errors, + }, + "control": { + "initialFixtureStatus": initial_status, + "capturePreflight": capture_preflight, + "load": load, + "fixtureArm": fixture_arm, + "captureFocus": capture_focus, + "captureArm": capture_arm, + "captureInitialTerminal": capture_initial_terminal, + "presentationTesting": presentation_testing, + "start": start, + "abort": abort_response, + }, + "fixture": fixture_status, + "fixtureTrace": fixture_trace, + "capture": capture, + "cpuLoad": cpu_load_result, + "startedAtEpoch": started_at_epoch, + "finishedAtEpoch": time.time(), + } + write_artifact(output, artifact) + summary = { + "runID": arguments.run_id, + "status": "inconclusive", + "classification": classification, + "stage": stage, + "artifact": str(output), + "receivedCharacters": len(capture.get("received", "")), + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + } + print(json.dumps(summary, indent=2, sort_keys=True)) + print( + f"physical-hid-capture-run: {classification} at {stage}: {error}", + file=sys.stderr, + ) return 2 finally: if cpu_load is not None: diff --git a/Scripts/lab/pico-hid-fixture-control-soak b/Scripts/lab/pico-hid-fixture-control-soak new file mode 100755 index 000000000..3154fdcf3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture-control-soak @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Measure the ESP fixture control plane without sending any HID reports.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import importlib.machinery +import importlib.util +import json +import os +import pathlib +import socket +import statistics +import sys +import time +import urllib.error + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +CLIENT_PATH = SCRIPT_DIR / "pico-hid-fixture-client" + + +def load_client_module(): + loader = importlib.machinery.SourceFileLoader("keypath_fixture_client", str(CLIENT_PATH)) + spec = importlib.util.spec_from_loader(loader.name, loader) + if spec is None: + raise RuntimeError("could not load fixture client") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +def utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z") + + +def atomic_write(path: pathlib.Path, value: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + temporary.chmod(0o600) + os.replace(temporary, path) + + +def percentile(values: list[float], fraction: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + index = min(len(ordered) - 1, int((len(ordered) - 1) * fraction)) + return ordered[index] + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + root.add_argument("--host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + root.add_argument("--port", type=int, default=8080) + root.add_argument("--requests", type=int, default=120) + root.add_argument("--interval-ms", type=int, default=100) + root.add_argument("--timeout", type=float, default=1.5) + root.add_argument("--max-p95-ms", type=float, default=750.0) + root.add_argument("--max-consecutive-failures", type=int, default=3) + root.add_argument("--output", type=pathlib.Path) + return root + + +def main() -> int: + arguments = parser().parse_args() + if not 1 <= arguments.requests <= 10_000: + raise SystemExit("--requests must be between 1 and 10000") + if (arguments.interval_ms < 0 or arguments.timeout <= 0 or arguments.max_p95_ms <= 0 or + arguments.max_consecutive_failures < 1): + raise SystemExit("interval and timeout must not be negative") + token = os.environ.get("KEYPATH_FIXTURE_TOKEN", "") + if not token: + raise SystemExit("KEYPATH_FIXTURE_TOKEN is required") + + output = arguments.output + if output is None: + stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + output = (pathlib.Path.home() / ".local/state/keypath-hid-fixture/control" / + f"control-soak-{stamp}.json") + client_module = load_client_module() + started_at = utc_now() + discovery_started = time.monotonic() + resolution_error = "" + try: + resolved_host = socket.getaddrinfo( + arguments.host, arguments.port, socket.AF_INET, socket.SOCK_STREAM + )[0][4][0] + except OSError as error: + resolved_host = "" + resolution_error = str(error)[:240] + discovery_ms = (time.monotonic() - discovery_started) * 1000.0 + client = (client_module.FixtureClient(resolved_host, token, arguments.port, arguments.timeout) + if resolved_host else None) + latencies_ms: list[float] = [] + failures: list[dict[str, object]] = [] + first_status: dict[str, object] | None = None + last_status: dict[str, object] | None = None + consecutive_failures = 0 + if resolution_error: + failures.append({"request": 0, "error": f"host resolution failed: {resolution_error}"}) + for index in range(arguments.requests if client else 0): + started = time.monotonic() + try: + status = client.status() + latency_ms = (time.monotonic() - started) * 1000.0 + latencies_ms.append(latency_ms) + if first_status is None: + first_status = status + last_status = status + consecutive_failures = 0 + except (OSError, RuntimeError, ValueError, json.JSONDecodeError, + urllib.error.URLError) as error: + failures.append({"request": index + 1, "error": str(error)[:240]}) + consecutive_failures += 1 + if consecutive_failures >= arguments.max_consecutive_failures: + break + if index + 1 < arguments.requests and arguments.interval_ms: + time.sleep(arguments.interval_ms / 1000.0) + + resets_observed = bool(first_status and last_status and + last_status.get("diagnostics", {}).get("uptimeMs", 0) < + first_status.get("diagnostics", {}).get("uptimeMs", 0)) + build_changed = bool(first_status and last_status and + first_status.get("build") != last_status.get("build")) + p95_ms = percentile(latencies_ms, 0.95) if latencies_ms else 0.0 + reasons: list[str] = [] + if failures: + reasons.append("request failures") + if len(latencies_ms) != arguments.requests: + reasons.append("request count incomplete") + if p95_ms > arguments.max_p95_ms: + reasons.append("p95 latency exceeded budget") + if resets_observed: + reasons.append("fixture reset observed") + if build_changed: + reasons.append("firmware build changed") + status = "pass" if not reasons else "fail" + artifact: dict[str, object] = { + "schemaVersion": 1, + "kind": "keypath-fixture-control-soak", + "status": status, + "startedAt": started_at, + "endedAt": utc_now(), + "target": { + "requestedHost": arguments.host, + "resolvedHost": resolved_host or None, + "port": arguments.port, + "discoveryMs": round(discovery_ms, 3), + }, + "requests": { + "attempted": arguments.requests, + "succeeded": len(latencies_ms), + "failed": len(failures), + "intervalMs": arguments.interval_ms, + "timeoutSeconds": arguments.timeout, + "maxConsecutiveFailures": arguments.max_consecutive_failures, + }, + "latencyMs": { + "minimum": round(min(latencies_ms), 3) if latencies_ms else None, + "median": round(statistics.median(latencies_ms), 3) if latencies_ms else None, + "p95": round(p95_ms, 3) if latencies_ms else None, + "maximum": round(max(latencies_ms), 3) if latencies_ms else None, + "p95Budget": arguments.max_p95_ms, + }, + "failureReasons": reasons, + "continuity": {"resetObserved": resets_observed, "buildChanged": build_changed}, + "firstDiagnostics": first_status.get("diagnostics") if first_status else None, + "lastDiagnostics": last_status.get("diagnostics") if last_status else None, + "failures": failures, + } + atomic_write(output, artifact) + print(json.dumps({ + "status": status, + "requests": arguments.requests, + "failures": len(failures), + "p95Ms": artifact["latencyMs"]["p95"], + "artifact": str(output), + }, indent=2, sort_keys=True)) + return 0 if status == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/pico-hid-fixture-tool b/Scripts/lab/pico-hid-fixture-tool index 1bf595d7d..65cf0baab 100755 --- a/Scripts/lab/pico-hid-fixture-tool +++ b/Scripts/lab/pico-hid-fixture-tool @@ -27,6 +27,7 @@ usage() { " install [--port DEV] Run checks, tests, build, flash, and health check" \ " update Build, install, and verify firmware over authenticated Wi-Fi OTA" \ " status Query the running fixture over Wi-Fi" \ + " soak [OPTIONS] Measure status reliability without sending HID reports" \ "" \ "The fixture token is read from the environment or sops; never pass it on the command line." } @@ -171,6 +172,7 @@ command_test() { "$fixture_root/tests/run-tests.sh" python3 "$script_dir/tests/pico-hid-fixture-client-tests.py" python3 "$script_dir/tests/pico-hid-fixture-tool-tests.py" + python3 "$script_dir/tests/pico-hid-fixture-control-soak-tests.py" python3 "$script_dir/tests/scenario-matrix-runner-tests.py" python3 "$script_dir/tests/physical-hid-capture-run-tests.py" python3 "$script_dir/tests/physical-hid-shift-matrix-tests.py" @@ -213,6 +215,11 @@ command_status() { "$fixture_client" status } +command_soak() { + validate_credentials + "$script_dir/pico-hid-fixture-control-soak" "$@" +} + firmware_build_id() { generated_config="$build_dir/esp-idf/main/generated/fixture_config.h" [[ -f "$generated_config" ]] || fail "generated build identity is missing: $generated_config" @@ -292,6 +299,7 @@ case "$command" in install) command_install "$@" ;; update) [[ $# -eq 0 ]] || fail "update takes no options"; command_update ;; status) [[ $# -eq 0 ]] || fail "status takes no options"; command_status ;; + soak) command_soak "$@" ;; help|-h|--help) usage ;; *) usage >&2; fail "unknown command: $command" ;; esac diff --git a/Scripts/lab/pico-hid-fixture/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/CMakeLists.txt index 0c71735d8..600d5ee95 100644 --- a/Scripts/lab/pico-hid-fixture/CMakeLists.txt +++ b/Scripts/lab/pico-hid-fixture/CMakeLists.txt @@ -34,7 +34,8 @@ endif() configure_file(src/fixture_config.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/fixture_config.h @ONLY) add_executable(keypath_pico_hid_fixture - src/fixture_core.c + src/fixture_core.c + src/fixture_demo.c src/http_server.c src/main.c src/usb_descriptors.c diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 103d20a3e..438735257 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -45,8 +45,9 @@ are in The Waveshare board needs no shield, speaker, external debugger, power supply, or second microcontroller. Its display presents the run state and timing pressure; touch or the physical -button aborts an armed/running script, and its buzzer provides sparse transition cues. Pico 2 W -uses its onboard green LED instead. +button aborts an ordinary armed/running script, and its buzzer provides sparse transition cues. +The top power button also arms a bounded offline demo while the fixture is terminal; tapping the +screen is the separate confirmation that starts it. Pico 2 W uses its onboard green LED instead. On a cold boot, the display briefly presents the official Hacker Dojo torii mark before dissolving into the live KeyPath startup scene. The mark is rendered from lightweight LVGL vector primitives, @@ -71,6 +72,9 @@ so the splash needs no image decoder and does not delay USB, Wi-Fi, or the HID e Wi-Fi; the bearer token is defense in depth, not a substitute for network isolation. - The fixture schedules every report locally. Wi-Fi jitter can shift the start acknowledgement but cannot alter inter-key timing after the script starts. +- The built-in demo requires two deliberate actions—top power, then touch—and uses a fixed, + compiled-in `KeyPath demo OK` sequence. Once armed, it does not abort if Wi-Fi disconnects. It + reports `HID SENT / CHECK JIG`, never an invented pass; only the independent Jig can classify it. - The deterministic physical timing contract starts at a 4 ms character interval with a 2 ms key hold. That is already a 250 Hz key cadence. A physical 3 ms diagnostic preserved output but missed USB deadlines because its alternating 2 ms/1 ms press-release gaps leave no scheduling @@ -84,7 +88,27 @@ so the splash needs no image decoder and does not delay USB, Wi-Fi, or the HID e and removes expensive particle layers before animation can compete with HID timing. - Production exposes HID only—no USB serial console—so the VM observes the same device shape used by the test. The screen reports Wi-Fi/IP/USB state, while authenticated `/v1/status` and - `/v1/trace` provide the deeper diagnostics and exact firmware build identifier. + `/v1/trace` provide the deeper diagnostics and exact firmware build identifier. Status also + reports uptime/reset, heap, Wi-Fi reconnect, HTTP restart/request, and handler-latency counters. + +## 30-second offline demo + +The showroom path does not compile the Jig or depend on Wi-Fi after arming. From the KeyPath +worktree, run: + +```bash +Scripts/lab/hid-capture-jig-tool demo +``` + +The command reuses the signed app when its source hash is unchanged, brings the Jig forward, arms +the exact expected text, and puts `PRESS TOP POWER, THEN TAP DEVICE` in the Jig itself. Press the +board's top power button once, then tap the display. The command returns the Jig's exact pass/fail +within 30 seconds and the Jig writes its normal evidence artifact. BOOT or another screen tap while +the demo is running remains a fail-closed abort. + +This is the presentation path, not a replacement for the strict matrix runner. It demonstrates a +known physical HID sequence quickly; product conclusions still require correlated fixture trace, +Jig, resource, Kanata, and VirtualHID evidence. ## Pico LED states @@ -309,6 +333,17 @@ Scripts/lab/pico-hid-fixture-client present --phase result --result pass --progr --reports-expected 400 --reports-observed 400 --latency-p95-us 620 --safe-release ``` +Measure the control plane without emitting HID reports: + +```bash +Scripts/lab/pico-hid-fixture-tool soak --requests 120 +``` + +The soak resolves mDNS once, then measures the ESP endpoint by IP, stops after three consecutive +failures, enforces a 750 ms p95 budget, and atomically writes an artifact under +`~/.local/state/keypath-hid-fixture/control/`. Discovery time is recorded separately so slow mDNS +cannot be mistaken for a slow firmware handler. + `run-text` combines compile, load, arm, and start. Production scenarios should still perform each stage explicitly so the VM can prove USB admission, arm its independent observers, and verify the requested load threshold before `start`. @@ -341,6 +376,7 @@ campaign classification. The fixture token remains environment-only. ```bash Scripts/lab/pico-hid-fixture/tests/run-tests.sh python3 Scripts/lab/tests/pico-hid-fixture-client-tests.py +python3 Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh ``` diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.c b/Scripts/lab/pico-hid-fixture/src/fixture_core.c index e11534731..48f0bde1c 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_core.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.c @@ -40,6 +40,32 @@ static bool report_has_unique_keys(const fixture_event_t *event) { return true; } +static void invalidate_script(fixture_t *fixture) { + fixture->state = FIXTURE_IDLE; + fixture->event_count = 0u; + fixture->run_id[0] = '\0'; +} + +static void finish_loading(fixture_t *fixture, const char *run_id, uint32_t script_crc32, + uint32_t event_count, uint32_t repeat_count, uint32_t cycle_us) { + snprintf(fixture->run_id, sizeof(fixture->run_id), "%s", run_id); + fixture->script_crc32 = script_crc32; + fixture->event_count = event_count; + fixture->repeat_count = repeat_count; + fixture->cycle_us = cycle_us; + fixture->state = FIXTURE_LOADED; + fixture->next_event = 0u; + fixture->current_repeat = 0u; + fixture->reports_submitted = 0u; + fixture->transfers_completed = 0u; + fixture->late_reports = 0u; + fixture->maximum_lateness_us = 0; + fixture->submitted_crc32 = 0u; + fixture->trace_head = 0u; + fixture->trace_count = 0u; + fixture->error[0] = '\0'; +} + void fixture_init(fixture_t *fixture) { memset(fixture, 0, sizeof(*fixture)); fixture->state = FIXTURE_IDLE; @@ -86,9 +112,7 @@ bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, // Invalidate the previous script before parsing into the fixture's bounded // event storage. A malformed replacement must never leave a partly // overwritten script available to arm. - fixture->state = FIXTURE_IDLE; - fixture->event_count = 0u; - fixture->run_id[0] = '\0'; + invalidate_script(fixture); const char *newline = memchr(body, '\n', length); if (!newline) { @@ -190,22 +214,60 @@ bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, return false; } - snprintf(fixture->run_id, sizeof(fixture->run_id), "%s", run_id); - fixture->script_crc32 = declared_crc; - fixture->event_count = event_count; - fixture->repeat_count = repeat_count; - fixture->cycle_us = cycle_us; - fixture->state = FIXTURE_LOADED; - fixture->next_event = 0u; - fixture->current_repeat = 0u; - fixture->reports_submitted = 0u; - fixture->transfers_completed = 0u; - fixture->late_reports = 0u; - fixture->maximum_lateness_us = 0; - fixture->submitted_crc32 = 0u; - fixture->trace_head = 0u; - fixture->trace_count = 0u; - fixture->error[0] = '\0'; + finish_loading(fixture, run_id, declared_crc, event_count, repeat_count, cycle_us); + set_error(error, error_capacity, ""); + return true; +} + +bool fixture_load_events(fixture_t *fixture, const char *run_id, + const fixture_event_t *events, uint32_t event_count, + uint32_t repeat_count, uint32_t cycle_us, + char *error, size_t error_capacity) { + if (!fixture || !events) { + set_error(error, error_capacity, "event list is empty"); + return false; + } + if (fixture->state == FIXTURE_RUNNING || fixture->state == FIXTURE_ARMED) { + set_error(error, error_capacity, "fixture is armed or running"); + return false; + } + invalidate_script(fixture); + if (!valid_run_id(run_id)) { + set_error(error, error_capacity, "run id contains unsupported characters"); + return false; + } + if (event_count == 0u || event_count > FIXTURE_MAX_EVENTS) { + set_error(error, error_capacity, "event count is outside fixture limits"); + return false; + } + if (repeat_count == 0u || repeat_count > FIXTURE_MAX_REPEATS || + (uint64_t)event_count * repeat_count > FIXTURE_MAX_TOTAL_REPORTS) { + set_error(error, error_capacity, "repeat count is outside fixture limits"); + return false; + } + if (cycle_us == 0u) { + set_error(error, error_capacity, "cycle duration must be positive"); + return false; + } + uint32_t previous_at = 0u; + for (uint32_t index = 0u; index < event_count; ++index) { + if ((index > 0u && events[index].at_us <= previous_at) || events[index].at_us >= cycle_us) { + set_error(error, error_capacity, "event times must increase and remain inside the cycle"); + return false; + } + if (!report_has_unique_keys(&events[index])) { + set_error(error, error_capacity, "a report contains duplicate nonzero key usages"); + return false; + } + previous_at = events[index].at_us; + } + if (!report_is_empty(&events[event_count - 1u])) { + set_error(error, error_capacity, "each cycle must end with an all-keys-released report"); + return false; + } + memcpy(fixture->events, events, event_count * sizeof(*events)); + finish_loading(fixture, run_id, fixture_crc32(events, event_count * sizeof(*events)), + event_count, repeat_count, cycle_us); set_error(error, error_capacity, ""); return true; } diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.h b/Scripts/lab/pico-hid-fixture/src/fixture_core.h index ba0b4c9b3..b82bf7a2d 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_core.h +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.h @@ -71,6 +71,10 @@ uint32_t fixture_crc32(const void *bytes, size_t length); bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, char *error, size_t error_capacity); +bool fixture_load_events(fixture_t *fixture, const char *run_id, + const fixture_event_t *events, uint32_t event_count, + uint32_t repeat_count, uint32_t cycle_us, + char *error, size_t error_capacity); bool fixture_arm(fixture_t *fixture, const char *run_id, char *error, size_t error_capacity); bool fixture_start(fixture_t *fixture, const char *run_id, uint32_t delay_ms, uint64_t now_us, char *error, size_t error_capacity); diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_demo.c b/Scripts/lab/pico-hid-fixture/src/fixture_demo.c new file mode 100644 index 000000000..dc62299de --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_demo.c @@ -0,0 +1,67 @@ +#include "fixture_demo.h" + +#include + +#define DEMO_SHIFT 0x02u +#define DEMO_INTERVAL_US 60000u +#define DEMO_HOLD_US 20000u +#define DEMO_SHIFT_LEAD_US 5000u +#define DEMO_SHIFT_RELEASE_LAG_US 5000u +#define DEMO_CYCLE_GAP_US 250000u + +static bool demo_usage(char character, uint8_t *usage, bool *shifted) { + if (character >= 'a' && character <= 'z') { + *usage = (uint8_t)(0x04u + (uint8_t)(character - 'a')); + *shifted = false; + return true; + } + if (character >= 'A' && character <= 'Z') { + *usage = (uint8_t)(0x04u + (uint8_t)(tolower((unsigned char)character) - 'a')); + *shifted = true; + return true; + } + if (character == ' ') { + *usage = 0x2cu; + *shifted = false; + return true; + } + if (character == '\n') { + *usage = 0x28u; + *shifted = false; + return true; + } + return false; +} + +bool fixture_demo_load(fixture_t *fixture, char *error, size_t error_capacity) { + fixture_event_t events[sizeof(FIXTURE_DEMO_TEXT) * 4u] = {0}; + uint32_t event_count = 0u; + uint32_t character_index = 0u; + for (const char *cursor = FIXTURE_DEMO_TEXT; *cursor; ++cursor, ++character_index) { + uint8_t usage = 0u; + bool shifted = false; + if (!demo_usage(*cursor, &usage, &shifted)) return false; + uint32_t base_us = character_index * DEMO_INTERVAL_US; + if (shifted) { + events[event_count++] = (fixture_event_t){.at_us = base_us, .modifiers = DEMO_SHIFT}; + events[event_count++] = (fixture_event_t){ + .at_us = base_us + DEMO_SHIFT_LEAD_US, + .modifiers = DEMO_SHIFT, + .keys = {usage}, + }; + events[event_count++] = (fixture_event_t){ + .at_us = base_us + DEMO_SHIFT_LEAD_US + DEMO_HOLD_US, + .modifiers = DEMO_SHIFT, + }; + events[event_count++] = (fixture_event_t){ + .at_us = base_us + DEMO_SHIFT_LEAD_US + DEMO_HOLD_US + DEMO_SHIFT_RELEASE_LAG_US, + }; + } else { + events[event_count++] = (fixture_event_t){.at_us = base_us, .keys = {usage}}; + events[event_count++] = (fixture_event_t){.at_us = base_us + DEMO_HOLD_US}; + } + } + uint32_t cycle_us = character_index * DEMO_INTERVAL_US + DEMO_CYCLE_GAP_US; + return fixture_load_events(fixture, FIXTURE_DEMO_RUN_ID, events, event_count, 1u, cycle_us, + error, error_capacity); +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_demo.h b/Scripts/lab/pico-hid-fixture/src/fixture_demo.h new file mode 100644 index 000000000..336bcff8a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_demo.h @@ -0,0 +1,14 @@ +#ifndef KEYPATH_FIXTURE_DEMO_H +#define KEYPATH_FIXTURE_DEMO_H + +#include +#include + +#include "fixture_core.h" + +#define FIXTURE_DEMO_RUN_ID "offline-demo" +#define FIXTURE_DEMO_TEXT "KeyPath demo OK\n" + +bool fixture_demo_load(fixture_t *fixture, char *error, size_t error_capacity); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt index f9dda8b7c..3626ab76f 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt @@ -29,6 +29,7 @@ set(fixture_sources "${component_dir}/fixture_runtime.c" "${shared_source}/fixture_button_feedback.c" "${shared_source}/fixture_core.c" + "${shared_source}/fixture_demo.c" "${shared_source}/fixture_presentation.c" "${shared_source}/fixture_splash_model.c" "${shared_source}/fixture_ui_model.c" diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c index 79f8d0970..2186a0946 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c @@ -73,7 +73,12 @@ static void board_task(void *context) { } if (power_pressed && !previous_power_pressed) { publish_feedback(FIXTURE_BUTTON_POWER); - fixture_board_tone(520u, 55u); + char error[128]; + if (!button_enabled && fixture_runtime_prepare_demo(error, sizeof(error))) { + fixture_board_tone(760u, 70u); + } else { + fixture_board_tone(520u, 55u); + } } previous_boot_pressed = boot_pressed; previous_power_pressed = power_pressed; diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c index b50856cda..8add0a66b 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c @@ -146,7 +146,16 @@ static void touch_event(lv_event_t *event) { if (lv_event_get_code(event) != LV_EVENT_PRESSED) return; fixture_runtime_snapshot_t snapshot; fixture_runtime_snapshot(&snapshot); - if (snapshot.ui.state == FIXTURE_ARMED || snapshot.ui.state == FIXTURE_RUNNING) { + if (snapshot.ui.state == FIXTURE_ARMED) { + char error[128]; + if (fixture_runtime_start_demo(error, sizeof(error))) { + fixture_board_tone(980u, 70u); + return; + } + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x241323), 0); + fixture_runtime_abort("touch abort"); + fixture_board_tone(220u, 90u); + } else if (snapshot.ui.state == FIXTURE_RUNNING) { lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x241323), 0); fixture_runtime_abort("touch abort"); fixture_board_tone(220u, 90u); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c index 923ca5890..f883fea62 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -13,6 +13,7 @@ #include "esp_netif.h" #include "esp_ota_ops.h" #include "esp_system.h" +#include "esp_timer.h" #include "esp_wifi.h" #include "fixture_config.h" #include "fixture_display.h" @@ -35,6 +36,15 @@ static char *script_buffer; static httpd_handle_t http_server; static fixture_wifi_model_t wifi_model; static volatile bool control_plane_ready; +static uint32_t wifi_disconnect_count; +static uint32_t wifi_connect_count; +static uint32_t last_wifi_disconnect_reason; +static uint32_t http_server_start_count; +static uint32_t status_request_count; +static uint32_t status_response_count; +static uint32_t status_failure_count; +static uint32_t last_status_latency_us; +static uint32_t maximum_status_latency_us; typedef struct { const char *ssid; @@ -60,6 +70,7 @@ static esp_err_t send_json(httpd_req_t *request, const char *status, const char httpd_resp_set_status(request, status); httpd_resp_set_type(request, "application/json"); httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + httpd_resp_set_hdr(request, "Connection", "close"); return httpd_resp_sendstr(request, body); } @@ -235,7 +246,12 @@ static esp_err_t firmware_handler(httpd_req_t *request) { } static esp_err_t status_handler(httpd_req_t *request) { - if (require_auth(request) != ESP_OK) return ESP_OK; + uint64_t started_us = (uint64_t)esp_timer_get_time(); + ++status_request_count; + if (require_auth(request) != ESP_OK) { + ++status_failure_count; + return ESP_OK; + } fixture_runtime_snapshot_t snapshot; fixture_runtime_snapshot(&snapshot); fixture_display_health_t display_health; @@ -247,8 +263,8 @@ static esp_err_t status_handler(httpd_req_t *request) { bool update_ready = !snapshot.pending_release && !snapshot.firmware_update_in_progress && (snapshot.ui.state == FIXTURE_IDLE || snapshot.ui.state == FIXTURE_COMPLETE || snapshot.ui.state == FIXTURE_ABORTED || snapshot.ui.state == FIXTURE_ERROR); - char body[2048]; - snprintf(body, sizeof(body), + char body[3072]; + int body_length = snprintf(body, sizeof(body), "{\"ok\":true,\"firmware\":\"%s\",\"build\":\"%s\"," "\"platform\":\"waveshare-esp32-s3-touch-lcd-1.69\"," "\"state\":\"%s\",\"runId\":\"%s\",\"scriptCRC32\":\"%08" PRIx32 "\"," @@ -264,7 +280,14 @@ static esp_err_t status_handler(httpd_req_t *request) { "\"title\":\"%s\",\"detail\":\"%s\",\"next\":\"%s\"," "\"reportsExpected\":%" PRIu32 ",\"reportsObserved\":%" PRIu32 "," "\"dropped\":%" PRIu32 ",\"duplicated\":%" PRIu32 ",\"repeated\":%" PRIu32 "," - "\"latencyP95Us\":%" PRIu32 ",\"safeRelease\":%s}}\n", + "\"latencyP95Us\":%" PRIu32 ",\"safeRelease\":%s}," + "\"diagnostics\":{\"uptimeMs\":%" PRIu64 ",\"resetReason\":%d," + "\"freeHeapBytes\":%" PRIu32 ",\"minimumFreeHeapBytes\":%" PRIu32 "," + "\"wifiConnects\":%" PRIu32 ",\"wifiDisconnects\":%" PRIu32 "," + "\"lastWifiDisconnectReason\":%" PRIu32 ",\"httpServerStarts\":%" PRIu32 "," + "\"statusRequests\":%" PRIu32 ",\"statusResponses\":%" PRIu32 "," + "\"statusFailures\":%" PRIu32 ",\"lastStatusLatencyUs\":%" PRIu32 "," + "\"maximumStatusLatencyUs\":%" PRIu32 "}}\n", KEYPATH_FIXTURE_FIRMWARE_VERSION, KEYPATH_FIXTURE_BUILD_ID, fixture_state_name(snapshot.ui.state), snapshot.run_id, snapshot.script_crc32, snapshot.ui.event_count, snapshot.ui.repeat_count, @@ -282,8 +305,26 @@ static esp_err_t status_handler(httpd_req_t *request) { snapshot.presentation.title, snapshot.presentation.detail, snapshot.presentation.next, snapshot.presentation.reports_expected, snapshot.presentation.reports_observed, snapshot.presentation.dropped, snapshot.presentation.duplicated, snapshot.presentation.repeated, - snapshot.presentation.latency_p95_us, snapshot.presentation.safe_release ? "true" : "false"); - return send_json(request, "200 OK", body); + snapshot.presentation.latency_p95_us, snapshot.presentation.safe_release ? "true" : "false", + (uint64_t)(esp_timer_get_time() / 1000), (int)esp_reset_reason(), + (uint32_t)esp_get_free_heap_size(), (uint32_t)esp_get_minimum_free_heap_size(), + wifi_connect_count, wifi_disconnect_count, last_wifi_disconnect_reason, + http_server_start_count, status_request_count, status_response_count, + status_failure_count, last_status_latency_us, maximum_status_latency_us); + if (body_length < 0 || (size_t)body_length >= sizeof(body)) { + ++status_failure_count; + return send_json(request, "500 Internal Server Error", + "{\"ok\":false,\"error\":\"status response overflow\"}\n"); + } + esp_err_t result = send_json(request, "200 OK", body); + uint64_t elapsed_us = (uint64_t)esp_timer_get_time() - started_us; + last_status_latency_us = elapsed_us > UINT32_MAX ? UINT32_MAX : (uint32_t)elapsed_us; + if (last_status_latency_us > maximum_status_latency_us) { + maximum_status_latency_us = last_status_latency_us; + } + if (result == ESP_OK) ++status_response_count; + else ++status_failure_count; + return result; } static esp_err_t receive_body(httpd_req_t *request, size_t maximum, size_t *length) { @@ -471,8 +512,12 @@ static esp_err_t start_http_server(void) { config.max_uri_handlers = 8; config.stack_size = 8192; config.core_id = 0; - config.task_priority = 4; + config.task_priority = 8; + config.lru_purge_enable = true; + config.recv_wait_timeout = 3; + config.send_wait_timeout = 3; ESP_RETURN_ON_ERROR(httpd_start(&http_server, &config), TAG, "HTTP server start failed"); + ++http_server_start_count; const httpd_uri_t handlers[] = { {.uri = "/v1/status", .method = HTTP_GET, .handler = status_handler}, {.uri = "/v1/script", .method = HTTP_POST, .handler = script_handler}, @@ -491,6 +536,17 @@ static esp_err_t start_http_server(void) { return ESP_OK; } +static void stop_http_server(void) { + control_plane_ready = false; + if (!http_server) return; + httpd_handle_t server = http_server; + http_server = NULL; + esp_err_t result = httpd_stop(server); + if (result != ESP_OK) { + ESP_LOGW(TAG, "HTTP server stop failed: %s", esp_err_to_name(result)); + } +} + bool fixture_network_control_ready(void) { return control_plane_ready; } @@ -514,7 +570,11 @@ static void wifi_event(void *argument, esp_event_base_t base, int32_t id, void * if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { esp_wifi_connect(); } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *event = data; + ++wifi_disconnect_count; + last_wifi_disconnect_reason = event ? event->reason : 0u; fixture_runtime_set_network(false, "unassigned"); + stop_http_server(); if (fixture_wifi_model_note_disconnect(&wifi_model, sizeof(wifi_profiles) / sizeof(wifi_profiles[0]), 2u)) { @@ -522,6 +582,7 @@ static void wifi_event(void *argument, esp_event_base_t base, int32_t id, void * } esp_wifi_connect(); } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ++wifi_connect_count; ip_event_got_ip_t *event = data; char address[16]; snprintf(address, sizeof(address), IPSTR, IP2STR(&event->ip_info.ip)); @@ -557,5 +618,6 @@ esp_err_t fixture_network_start(void) { ESP_ERROR_CHECK(mdns_instance_name_set("KeyPath HID fixture")); ESP_ERROR_CHECK(mdns_service_add("KeyPath HID fixture", "_http", "_tcp", CONFIG_KEYPATH_FIXTURE_HTTP_PORT, NULL, 0)); - return esp_wifi_start(); + ESP_RETURN_ON_ERROR(esp_wifi_start(), TAG, "Wi-Fi start failed"); + return esp_wifi_set_ps(WIFI_PS_NONE); } diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c index f9df42267..ee6052a24 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c @@ -7,6 +7,7 @@ #include "esp_mac.h" #include "esp_rom_sys.h" #include "esp_timer.h" +#include "fixture_demo.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" @@ -118,8 +119,25 @@ static void executor_task(void *context) { uint32_t wait_us; runtime_lock(); uint64_t now_us = (uint64_t)esp_timer_get_time(); + fixture_state_t previous_state = fixture.state; fixture_poll(&fixture, now_us, tud_mounted(), tud_hid_ready(), send_keyboard_report, NULL); + bool offline_demo = strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) == 0; + if (offline_demo && fixture.state == FIXTURE_RUNNING && fixture.event_count > 0u) { + uint64_t total_reports = (uint64_t)fixture.event_count * fixture.repeat_count; + presentation.progress_per_mille = + (uint16_t)((fixture.reports_submitted * 1000u) / total_reports); + presentation.reports_observed = (uint32_t)fixture.reports_submitted; + } else if (offline_demo && previous_state == FIXTURE_RUNNING && + fixture.state == FIXTURE_COMPLETE) { + presentation.phase = FIXTURE_PRESENT_RESULT; + presentation.result = FIXTURE_RESULT_INCONCLUSIVE; + presentation.progress_per_mille = 1000u; + presentation.reports_observed = (uint32_t)fixture.reports_submitted; + snprintf(presentation.title, sizeof(presentation.title), "HID SENT"); + snprintf(presentation.detail, sizeof(presentation.detail), "CHECK JIG FOR RESULT"); + snprintf(presentation.next, sizeof(presentation.next), "POWER TO RUN AGAIN"); + } running = fixture.state == FIXTURE_RUNNING; wait_us = fixture_time_until_next_action_us(&fixture, now_us); runtime_unlock(); @@ -174,7 +192,8 @@ void fixture_runtime_start_executor(void) { void fixture_runtime_set_network(bool connected, const char *address) { runtime_lock(); - if (network_connected && !connected) { + bool offline_demo = strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) == 0; + if (network_connected && !connected && !offline_demo) { fixture_abort_if_active(&fixture, "Wi-Fi disconnected during active run"); } network_connected = connected; @@ -250,6 +269,48 @@ bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, s return ok; } +bool fixture_runtime_prepare_demo(char *error, size_t capacity) { + runtime_lock(); + bool terminal = fixture.state == FIXTURE_IDLE || fixture.state == FIXTURE_COMPLETE || + fixture.state == FIXTURE_ABORTED || fixture.state == FIXTURE_ERROR; + bool ok = false; + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update in progress"); + } else if (!terminal) { + snprintf(error, capacity, "fixture is busy"); + } else if (fixture_demo_load(&fixture, error, capacity) && + fixture_arm(&fixture, FIXTURE_DEMO_RUN_ID, error, capacity)) { + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_NEXT; + presentation.reports_expected = fixture.event_count; + snprintf(presentation.title, sizeof(presentation.title), "DEMO ARMED"); + snprintf(presentation.detail, sizeof(presentation.detail), "TAP SCREEN TO TYPE"); + snprintf(presentation.next, sizeof(presentation.next), "TAP SCREEN"); + ok = true; + } + runtime_unlock(); + return ok; +} + +bool fixture_runtime_start_demo(char *error, size_t capacity) { + runtime_lock(); + bool ok = false; + if (fixture.state != FIXTURE_ARMED || strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) != 0) { + snprintf(error, capacity, "offline demo is not armed"); + } else if (fixture_start(&fixture, FIXTURE_DEMO_RUN_ID, 500u, + (uint64_t)esp_timer_get_time(), error, capacity)) { + presentation.phase = FIXTURE_PRESENT_TESTING; + presentation.result = FIXTURE_RESULT_NONE; + presentation.progress_per_mille = 0u; + snprintf(presentation.title, sizeof(presentation.title), "TYPING DEMO"); + snprintf(presentation.detail, sizeof(presentation.detail), "KEYPATH HID IN MOTION"); + snprintf(presentation.next, sizeof(presentation.next), "CHECK CAPTURE JIG"); + ok = true; + } + runtime_unlock(); + return ok; +} + void fixture_runtime_abort(const char *reason) { runtime_lock(); fixture_abort(&fixture, reason); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h index 8457ee768..08d2a10dc 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h @@ -34,6 +34,8 @@ void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot); bool fixture_runtime_load(const char *body, size_t length, char *error, size_t capacity); bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity); bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity); +bool fixture_runtime_prepare_demo(char *error, size_t capacity); +bool fixture_runtime_start_demo(char *error, size_t capacity); void fixture_runtime_abort(const char *reason); void fixture_runtime_set_presentation(const fixture_presentation_t *presentation); bool fixture_runtime_begin_firmware_update(char *error, size_t capacity); diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index d2f48907a..3b30a1192 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -1,5 +1,6 @@ #include "fixture_button_feedback.h" #include "fixture_core.h" +#include "fixture_demo.h" #include "fixture_presentation.h" #include "fixture_splash_model.h" #include "fixture_ui_model.h" @@ -91,6 +92,46 @@ static void test_rejects_corrupt_and_unsafe_scripts(void) { assert(strstr(error, "duplicate")); } +static void test_loads_compiled_in_events_with_the_same_safety_checks(void) { + fixture_t fixture; + fixture_init(&fixture); + char error[128]; + const fixture_event_t events[] = { + {.at_us = 0u, .modifiers = 2u, .keys = {14u}}, + {.at_us = 2000u, .modifiers = 2u}, + {.at_us = 4000u}, + }; + assert(fixture_load_events(&fixture, "offline-demo", events, 3u, 1u, 12000u, + error, sizeof(error))); + assert(fixture.state == FIXTURE_LOADED); + assert(fixture.event_count == 3u); + assert(fixture.events[0].keys[0] == 14u); + assert(fixture.script_crc32 != 0u); + + fixture_event_t unsafe[] = {{.at_us = 0u, .keys = {4u}}}; + assert(!fixture_load_events(&fixture, "unsafe", unsafe, 1u, 1u, 12000u, + error, sizeof(error))); + assert(strstr(error, "all-keys-released")); + assert(fixture.state == FIXTURE_IDLE); +} + +static void test_offline_demo_is_precompiled_with_safe_shift_timing(void) { + fixture_t fixture; + fixture_init(&fixture); + char error[128]; + assert(fixture_demo_load(&fixture, error, sizeof(error))); + assert(strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) == 0); + assert(fixture.repeat_count == 1u); + assert(fixture.event_count > strlen(FIXTURE_DEMO_TEXT) * 2u); + assert(fixture.events[0].modifiers == 2u); + assert(fixture.events[0].keys[0] == 0u); + assert(fixture.events[1].at_us == 5000u); + assert(fixture.events[1].keys[0] == 14u); /* K */ + const fixture_event_t *release = &fixture.events[fixture.event_count - 1u]; + assert(release->modifiers == 0u); + assert(release->keys[0] == 0u); +} + static void test_failed_replacement_invalidates_previous_script(void) { fixture_t fixture; fixture_init(&fixture); @@ -421,6 +462,8 @@ static void test_button_feedback_identifies_physical_positions_and_expires(void) int main(void) { test_load_arm_run_and_repeat(); test_rejects_corrupt_and_unsafe_scripts(); + test_loads_compiled_in_events_with_the_same_safety_checks(); + test_offline_demo_is_precompiled_with_safe_shift_timing(); test_failed_replacement_invalidates_previous_script(); test_abort_and_unmount_force_release(); test_control_plane_disconnect_aborts_only_active_runs(); diff --git a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh index 14811968d..2cab9f23c 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh @@ -9,6 +9,7 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \ -I"$fixture_root/src" \ "$fixture_root/src/fixture_button_feedback.c" \ "$fixture_root/src/fixture_core.c" \ + "$fixture_root/src/fixture_demo.c" \ "$fixture_root/src/fixture_presentation.c" \ "$fixture_root/src/fixture_splash_model.c" \ "$fixture_root/src/fixture_ui_model.c" \ diff --git a/Scripts/lab/tests/hid-capture-jig-client-tests.py b/Scripts/lab/tests/hid-capture-jig-client-tests.py index d8336222b..892c77b62 100755 --- a/Scripts/lab/tests/hid-capture-jig-client-tests.py +++ b/Scripts/lab/tests/hid-capture-jig-client-tests.py @@ -76,6 +76,7 @@ def test_arm_transports_expected_text_by_file(self): result = self.run_client( directory, "arm", "--run-id", "physical-1", "--expected", str(expected), "--timeout-ms", "7000", "--settle-ms", "300", + "--instruction", "UNPLUG USB-C NOW", ) self.assertEqual(result.returncode, 0, result.stderr) response = json.loads(result.stdout) @@ -83,6 +84,7 @@ def test_arm_transports_expected_text_by_file(self): self.assertEqual(jig.commands[0]["runID"], "physical-1") self.assertEqual(jig.commands[0]["timeoutMs"], 7000) self.assertEqual(jig.commands[0]["settleMs"], 300) + self.assertEqual(jig.commands[0]["instruction"], "UNPLUG USB-C NOW") def test_focus_uses_a_distinct_control_action(self): with tempfile.TemporaryDirectory() as temporary: diff --git a/Scripts/lab/tests/physical-hid-capture-run-tests.py b/Scripts/lab/tests/physical-hid-capture-run-tests.py index 8defce743..14b271665 100644 --- a/Scripts/lab/tests/physical-hid-capture-run-tests.py +++ b/Scripts/lab/tests/physical-hid-capture-run-tests.py @@ -43,7 +43,10 @@ def load_runner(): def action(command: list[str]) -> tuple[str, str]: if "hid-capture-jig-client" in command[0]: return "capture", command[1] - return "fixture", command[3] + fixture_actions = { + "status", "load-script", "arm", "start", "abort", "trace", "present", + } + return "fixture", next(value for value in command[1:] if value in fixture_actions) class PhysicalHIDCaptureRunTests(unittest.TestCase): @@ -73,6 +76,8 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): fixture_status_calls += 1 if fixture_status_calls == 1: return {"address": "10.0.0.47", "state": "idle"} + if fixture_status_calls == 2: + raise RuntimeError("transient fixture status timeout") return { "state": "complete", "reportsSubmitted": 2, "lateReports": 1, "maximumLatenessUs": 2_000, @@ -121,11 +126,14 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): ) self.assertIsNotNone(capture_arm_command) assert capture_arm_command is not None + self.assertNotIn("--instruction", capture_arm_command) self.assertEqual(capture_arm_command[capture_arm_command.index("--timeout-ms") + 1], "15000") artifact = json.loads(output_path.read_text()) self.assertEqual(artifact["control"]["capturePreflight"]["snapshot"]["state"], "idle") self.assertTrue(artifact["checks"]["latenessWithinBudget"]) self.assertEqual(artifact["timingBudget"]["maxLateReports"], 2) + self.assertTrue(artifact["controlPlane"]["degraded"]) + self.assertIn("status timeout", artifact["controlPlane"]["errors"][0]["error"]) def test_busy_capture_preflight_stops_before_fixture_mutation(self) -> None: calls: list[tuple[str, str]] = [] @@ -150,20 +158,24 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): with tempfile.TemporaryDirectory() as directory: input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" input_path.write_text("a") arguments = [ str(RUNNER), "--run-id", "busy-run", "--text", str(input_path), - "--fixture-host", "fixture.local", + "--fixture-host", "fixture.local", "--output", str(output_path), ] error = io.StringIO() with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ mock.patch.object(self.runner.sys, "argv", arguments), \ - contextlib.redirect_stderr(error): + contextlib.redirect_stderr(error): self.assertEqual(self.runner.main(), 2) + artifact = json.loads(output_path.read_text()) + self.assertIn("CPU 93%", error.getvalue()) self.assertIn("Pause builds or VMs", error.getvalue()) + self.assertEqual(artifact["failure"]["classification"], "host-resource-admission") self.assertNotIn(("fixture", "load-script"), calls) self.assertNotIn(("fixture", "arm"), calls) @@ -253,10 +265,11 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): with tempfile.TemporaryDirectory() as directory: input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" input_path.write_text("a") arguments = [ str(RUNNER), "--run-id", "test-run", "--text", str(input_path), - "--fixture-host", "fixture.local", + "--fixture-host", "fixture.local", "--output", str(output_path), ] error = io.StringIO() with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ @@ -266,8 +279,10 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): contextlib.redirect_stderr(error): self.assertEqual(self.runner.main(), 2) + artifact = json.loads(output_path.read_text()) self.assertIn(("fixture", "abort"), calls) - self.assertIn("fixture aborted with all-keys-released queued", error.getvalue()) + self.assertIn("fixture aborted with all-keys-released queued", artifact["cleanup"]) + self.assertIn("capture-oracle", error.getvalue()) def test_abort_mode_verifies_released_prefix(self) -> None: calls: list[tuple[str, str]] = [] @@ -328,14 +343,16 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): self.assertIn(("capture", "finalize"), calls) def test_late_report_allowance_requires_a_lateness_ceiling(self) -> None: - arguments = [ - str(RUNNER), "--run-id", "invalid-budget", "--text", str(RUNNER), - "--max-late-reports", "1", - ] - error = io.StringIO() - with mock.patch.object(self.runner.sys, "argv", arguments), \ - contextlib.redirect_stderr(error): - self.assertEqual(self.runner.main(), 2) + with tempfile.TemporaryDirectory() as directory: + arguments = [ + str(RUNNER), "--run-id", "invalid-budget", "--text", str(RUNNER), + "--max-late-reports", "1", "--output", + str(pathlib.Path(directory) / "result.json"), + ] + error = io.StringIO() + with mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stderr(error), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 2) self.assertIn("--max-lateness-us is required", error.getvalue()) def test_external_abort_mode_waits_for_physical_abort(self) -> None: @@ -363,6 +380,11 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): "activeModifiers": 0, "duplicateDownEvents": 0, "repeatEvents": 0, "unmatchedUpEvents": 0, "issues": [], "events": [], }} + if (target, operation) == ("capture", "arm"): + self.assertEqual( + command[command.index("--instruction") + 1], + "PRESS BOOT OR TAP THE SCREEN NOW TO ABORT THE ACTIVE TEST", + ) return {"ok": True} self._run_interruption_case( @@ -402,6 +424,11 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): "activeModifiers": 0, "duplicateDownEvents": 0, "repeatEvents": 0, "unmatchedUpEvents": 0, "issues": [], "events": [], }} + if (target, operation) == ("capture", "arm"): + self.assertEqual( + command[command.index("--instruction") + 1], + "UNPLUG USB-C NOW · WAIT 2 SECONDS · RECONNECT THE SAME CABLE", + ) return {"ok": True} self._run_interruption_case( diff --git a/Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py b/Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py new file mode 100755 index 000000000..23971c81d --- /dev/null +++ b/Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 + +import http.server +import json +import os +import pathlib +import subprocess +import tempfile +import threading +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SOAK = ROOT / "lab/pico-hid-fixture-control-soak" + + +class StatusHandler(http.server.BaseHTTPRequestHandler): + count = 0 + + def do_GET(self): + if self.path != "/v1/status" or self.headers.get("Authorization") != "Bearer test-token": + self.send_error(403) + return + type(self).count += 1 + body = json.dumps({ + "ok": True, + "build": "abc123", + "diagnostics": {"uptimeMs": 10_000 + type(self).count, "statusRequests": type(self).count}, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + +class ControlSoakTests(unittest.TestCase): + def test_records_latency_and_continuity_without_exposing_token(self): + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), StatusHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / "soak.json" + result = subprocess.run([ + str(SOAK), "--host", "127.0.0.1", "--port", str(server.server_port), + "--requests", "5", "--interval-ms", "0", "--output", str(output), + ], text=True, capture_output=True, + env=os.environ | {"KEYPATH_FIXTURE_TOKEN": "test-token"}) + self.assertEqual(result.returncode, 0, result.stderr) + artifact = json.loads(output.read_text()) + self.assertEqual(artifact["status"], "pass") + self.assertEqual(artifact["requests"]["succeeded"], 5) + self.assertFalse(artifact["continuity"]["resetObserved"]) + self.assertEqual(artifact["target"]["resolvedHost"], "127.0.0.1") + self.assertIsNotNone(artifact["latencyMs"]["p95"]) + self.assertNotIn("test-token", result.stdout + result.stderr + output.read_text()) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md index 4ac6639b0..712bd88de 100644 --- a/docs/testing/keypath-hid-fixture-physical-results.md +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -189,3 +189,34 @@ The result is combined from these fail-closed artifacts: The Wi-Fi timeout and memory-pressure rejection are harness-infrastructure events, not product test outcomes. Neither submitted HID reports for its blocked cell, and only the completed, independently validated cells are included in the 25-cell result. + +## 2026-07-28 demo/control reliability evidence + +Two audience-demo attempts are excluded from KeyPath conclusions. The first capture expected 1,200 +characters and received 18; the second expected 120 and received 3 before the control request +failed and the old runner finalized early. Both captures ended with no pressed keys or modifiers, +but neither completed the synchronized protocol. Their artifacts are: + +- `~/.local/state/keypath-hid-capture-jig/artifacts/physical-usb-cycle-instructed-20260729T010245Z-failed.json` +- `~/.local/state/keypath-hid-capture-jig/artifacts/physical-demo-immediate-20260729T011124Z-failed.json` + +These exposed harness defects: transient ESP control loss could discard later evidence, the runner +could finalize before confirming release, the showroom path rebuilt/re-signed the Jig, and the demo +depended on a live Wi-Fi start request. The runner now writes an atomic inconclusive artifact on +every exception, retries bounded control operations, aborts best-effort, waits for the Jig to +observe release, and records control degradation separately. The showroom path now reuses a +source-hashed Jig app and starts a fixed on-device script with top-power then touch, so Wi-Fi is not +on the critical start path. + +A read-only control soak against the still-installed pre-diagnostic firmware then completed 20/20 +status requests with a 206.745 ms p95, 219.339 ms maximum, no observed reboot, and no build change. +The artifact is +`~/.local/state/keypath-hid-fixture/control/control-soak-20260729T013236Z.json`. An earlier version +of the soak measured 3.2–5.3 second calls because it repeated `.local` resolution for every request; +that result is retained but superseded. Discovery and ESP endpoint latency are now measured +separately. The next firmware install will add reset, heap, Wi-Fi, HTTP restart/request, and handler +latency counters to this evidence. + +The USB-removal gate remains unproven on this board without a battery or other independent power: +disconnecting its sole USB-C cable removes both data and power, so it cannot execute or report the +reconnect safety path. Do not count a power-cycle as a USB-unmount acceptance result. diff --git a/docs/testing/keypath-hid-fixture-readiness.md b/docs/testing/keypath-hid-fixture-readiness.md index 320492271..bbb550d7f 100644 --- a/docs/testing/keypath-hid-fixture-readiness.md +++ b/docs/testing/keypath-hid-fixture-readiness.md @@ -46,7 +46,9 @@ primitives and runs on the display task. It must never postpone HID, USB, or net | `READY` | Wi-Fi is connected and no test is armed. | Safe to load a script. | | `SCRIPT LOADED` | Script passed admission and CRC validation. | Arm only after observers are ready. | | `ARMED` | Fixture is ready to emit reports. | Start the synchronized run or abort. | +| `DEMO ARMED` | The fixed offline demo is loaded; no key has been sent. | Tap the screen only after the Jig says it is armed. | | `TYPING` or `SENDING KEYS` | Reports are being emitted from locally timed script data. | Avoid disconnecting USB unless testing removal. | +| `HID SENT / CHECK JIG` | The offline demo finished sending reports. | Read the independent Jig result; this is not itself a pass. | | `HID PRIORITY` | Timing pressure was detected and display work was reduced to 8 FPS. | The test may continue; inspect trace timing afterward. | | Pass, fail, or inconclusive result | Campaign supplied a classified outcome and metrics. | Preserve the campaign evidence. | | `ATTENTION` | Safety or runtime error. | Record the detail, query `status` and `trace`, and do not blindly retry. | @@ -55,8 +57,8 @@ primitives and runs on the display task. It must never postpone HID, USB, or net - **Core 1:** the priority-20 HID scheduler. It owns locally timed keyboard reports and uses a short polling loop only while a script is running. -- **Core 0:** priority-18 TinyUSB service, priority-6 display, priority-5 button and buzzer work, - priority-4 HTTP control, and the ESP-IDF Wi-Fi task. +- **Core 0:** priority-18 TinyUSB service, priority-8 HTTP control, priority-6 display, priority-5 + button and buzzer work, and the ESP-IDF Wi-Fi task. - Animation quality automatically steps from showcase to active to `HID PRIORITY`; visual fidelity is expendable, report timing is not. @@ -75,7 +77,8 @@ Record pass, fail, or notes for each item before calling the device finished: - `READY` shows a readable IP address and unambiguous USB status. - Color, icons, and motion distinguish loaded, armed, running, protected, complete, and error. - `HID PRIORITY` visibly calms the display without making it appear frozen. -- Touch and the function button abort only armed or running tests. +- Ordinary tests: touch and BOOT abort armed or running scripts. Offline demo: top power arms, + touch starts, and BOOT/touch during execution aborts. - Tones are audible but not distracting; current-board revision 2 uses GPIO42. - Pass, fail, and inconclusive remain understandable without reading the HTTP response. - At typical viewing distance, there is no clipping, tearing, unreadably small text, or excessive From 6c050bdf3c53d053d646bd5015531de7d2b4c312 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 18:44:35 -0700 Subject: [PATCH 85/99] Record post-update fixture control soak --- .../keypath-hid-fixture-physical-results.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md index 712bd88de..b41c53e52 100644 --- a/docs/testing/keypath-hid-fixture-physical-results.md +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -220,3 +220,19 @@ latency counters to this evidence. The USB-removal gate remains unproven on this board without a battery or other independent power: disconnecting its sole USB-C cable removes both data and power, so it cannot execute or report the reconnect safety path. Do not count a power-cycle as a USB-unmount acceptance result. + +### Post-update control soak + +Authenticated OTA installed build `f92209f3ea52` into valid slot `ota_1`; the fixture returned to +idle with mounted USB, healthy display frames, completed splash, and an update-safe release state. +The first diagnostic-bearing soak then passed 120/120 status requests with no failure, reset, build +change, or HTTP server restart. Host-observed latency was 28.153 ms median, 93.715 ms p95, and +105.845 ms maximum. Firmware handler latency peaked at 7.465 ms; the remaining time is host/network +transport rather than response construction. Free heap remained 8.33 MB and minimum free heap was +8.29 MB. The artifact is +`~/.local/state/keypath-hid-fixture/control/control-soak-20260729T014347Z.json`. + +This validates the updated control plane under a 120-request idle soak. It does not yet validate +the two-step offline demo's physical buttons/touch/output or control responsiveness concurrent with +a high-rate HID run; those require an exclusive desktop window because the next action emits real +keyboard reports. From aa2293342eda1e10f35056db0f9f942036f0d6ac Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 20:08:14 -0700 Subject: [PATCH 86/99] Add reliable one-command HID showroom proof --- Scripts/lab/hid-capture-jig-tool | 50 ++++++++++++++++++- Scripts/lab/tests/fixtures/showroom-demo.txt | 1 + .../tests/physical-hid-capture-run-tests.py | 4 ++ .../keypath-hid-fixture-physical-results.md | 19 +++++++ docs/testing/keypath-hid-fixture-readiness.md | 16 ++++++ 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 Scripts/lab/tests/fixtures/showroom-demo.txt diff --git a/Scripts/lab/hid-capture-jig-tool b/Scripts/lab/hid-capture-jig-tool index f290cbd2d..df48ecf99 100755 --- a/Scripts/lab/hid-capture-jig-tool +++ b/Scripts/lab/hid-capture-jig-tool @@ -4,6 +4,7 @@ set -euo pipefail script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) package_dir="$script_dir/hid-capture-jig" client="$script_dir/hid-capture-jig-client" +physical_runner="$script_dir/physical-hid-capture-run" app_path=${KEYPATH_CAPTURE_JIG_APP:-"$HOME/.cache/keypath-hid-capture-jig/KeyPath HID Capture Jig.app"} usage() { @@ -14,7 +15,8 @@ usage() { " build Build and ad-hoc sign the isolated capture app" \ " test Run capture-core and control-client tests" \ " open Open, focus, and verify the cached capture app" \ - " demo Arm the offline device demo and wait for its exact result" \ + " showroom Run the zero-touch ESP32-to-Jig demo and preserve evidence" \ + " demo Arm the offline top-power/touch demo and wait for its result" \ " status Query the running capture app" \ " close Ask the capture app to quit" } @@ -65,6 +67,31 @@ open_app() { return 1 } +wait_for_readiness() { + readiness=$(mktemp -t keypath-hid-capture-jig-readiness.XXXXXX) + for attempt in {1..60}; do + if "$client" status >"$readiness" 2>/dev/null && \ + /usr/bin/python3 -c \ + 'import json,sys; raise SystemExit(not json.load(open(sys.argv[1]))["systemReadiness"]["canProceed"])' \ + "$readiness"; then + rm -f "$readiness" + return 0 + fi + if (( attempt % 10 == 0 )); then + /usr/bin/python3 -c \ + 'import json,sys; r=json.load(open(sys.argv[1]))["systemReadiness"]; print("showroom waiting " + str(r["detail"]))' \ + "$readiness" 2>/dev/null || true + fi + sleep 0.5 + done + printf '%s\n' "showroom blocked host did not enter a stable capture window" >&2 + /usr/bin/python3 -c \ + 'import json,sys; r=json.load(open(sys.argv[1]))["systemReadiness"]; print("reason " + str(r["detail"]), file=sys.stderr); [print("next " + str(s), file=sys.stderr) for s in r.get("suggestions", [])]' \ + "$readiness" || true + rm -f "$readiness" + return 1 +} + case ${1:-} in build) [[ $# -eq 1 ]] || { usage >&2; exit 2; } @@ -79,6 +106,27 @@ case ${1:-} in [[ $# -eq 1 ]] || { usage >&2; exit 2; } open_app ;; + showroom) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + open_app >/dev/null + wait_for_readiness + run_id="showroom-$(date -u +%Y%m%dT%H%M%SZ)" + artifact="$HOME/.local/state/keypath-hid-capture-jig/artifacts/$run_id.json" + printf '%s\n' \ + "showroom running ESP32 -> real USB HID -> independent Jig" \ + "expected KeyPath demo OK + Return" + "$physical_runner" \ + --run-id "$run_id" \ + --text "$script_dir/tests/fixtures/showroom-demo.txt" \ + --interval-ms 60 \ + --hold-ms 16 \ + --shift-lead-ms 5 \ + --shift-release-lag-ms 5 \ + --delay-ms 800 \ + --settle-ms 300 \ + --timeout-ms 10000 \ + --output "$artifact" + ;; demo) [[ $# -eq 1 ]] || { usage >&2; exit 2; } open_app >/dev/null diff --git a/Scripts/lab/tests/fixtures/showroom-demo.txt b/Scripts/lab/tests/fixtures/showroom-demo.txt new file mode 100644 index 000000000..2c79e395b --- /dev/null +++ b/Scripts/lab/tests/fixtures/showroom-demo.txt @@ -0,0 +1 @@ +KeyPath demo OK diff --git a/Scripts/lab/tests/physical-hid-capture-run-tests.py b/Scripts/lab/tests/physical-hid-capture-run-tests.py index 14b271665..e75dcf931 100644 --- a/Scripts/lab/tests/physical-hid-capture-run-tests.py +++ b/Scripts/lab/tests/physical-hid-capture-run-tests.py @@ -16,6 +16,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[3] RUNNER = ROOT / "Scripts/lab/physical-hid-capture-run" +SHOWROOM_TEXT = ROOT / "Scripts/lab/tests/fixtures/showroom-demo.txt" READY_PREFLIGHT = { "canProceed": True, @@ -53,6 +54,9 @@ class PhysicalHIDCaptureRunTests(unittest.TestCase): def setUp(self) -> None: self.runner = load_runner() + def test_showroom_payload_is_short_fixed_and_return_terminated(self) -> None: + self.assertEqual(SHOWROOM_TEXT.read_bytes(), b"KeyPath demo OK\n") + @staticmethod def compile_script(command: list[str], environment: dict[str, str]) -> None: del environment diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md index b41c53e52..e2cafdb3f 100644 --- a/docs/testing/keypath-hid-fixture-physical-results.md +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -236,3 +236,22 @@ This validates the updated control plane under a 120-request idle soak. It does the two-step offline demo's physical buttons/touch/output or control responsiveness concurrent with a high-rate HID run; those require an exclusive desktop window because the next action emits real keyboard reports. + +### Exact showroom proof + +With exclusive desktop ownership and the Jig's three-sample resource gate green, the authenticated +showroom path passed end to end. ESP32 build `f92209f3ea52` submitted all 40 locally timed USB HID +reports for `KeyPath demo OK` plus Return. The independent Jig captured all 16 expected characters, +reported no capture issues, and observed every key and modifier released. The fixture trace also +contained all 40 reports with no lateness beyond the configured zero-tolerance budget. Evidence is +stored in `~/.local/state/keypath-hid-capture-jig/artifacts/showroom-live.json`. + +The packaged zero-touch command was then validated separately in 7.1 seconds with the same 40/40 +reports, 16/16 characters, clean release, and exact independent result. Its timestamped evidence is +`~/.local/state/keypath-hid-capture-jig/artifacts/showroom-20260729T030744Z.json`. + +The preceding offline attempt is not counted as a product or fixture failure: the Jig remained +focused and ready but received zero events, while the fixture recorded no top-power or touch event. +That proves only that the physical two-step trigger was not completed during its 30-second window. +The repeatable audience path is now `Scripts/lab/hid-capture-jig-tool showroom`; it requires no +operator timing and retains the full combined artifact. diff --git a/docs/testing/keypath-hid-fixture-readiness.md b/docs/testing/keypath-hid-fixture-readiness.md index bbb550d7f..45d0006fc 100644 --- a/docs/testing/keypath-hid-fixture-readiness.md +++ b/docs/testing/keypath-hid-fixture-readiness.md @@ -26,6 +26,22 @@ The command checks the Mac and encrypted credentials, runs all software tests, b firmware, finds an unambiguous serial device, flashes it, and verifies the authenticated status API. It does not ask the operator to copy credentials into a terminal. +### Fast showroom proof + +When the Mac is exclusively available for real keyboard input, run: + +```bash +cd /Users/malpern/local-code/keypath-pico-hid-fixture +Scripts/lab/hid-capture-jig-tool showroom +``` + +This is the zero-touch path: it opens and focuses the source-hashed Jig, waits up to 30 seconds for +three clean host-resource samples, then asks the ESP32 to type `KeyPath demo OK` plus Return over +real USB HID. The Jig is the independent oracle and must capture the exact 16 characters with all +keys and modifiers released. A timestamped combined artifact is retained under +`~/.local/state/keypath-hid-capture-jig/artifacts/` whether the run passes or becomes inconclusive. +Use `hid-capture-jig-tool demo` only to demonstrate the separate offline top-power-then-touch path. + ### Cold-boot screen sequence | Approximate time | Display | Operator meaning | From 3dc8e7f44ecf2528d50896671f00f6ee1872ab80 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:16:44 -0700 Subject: [PATCH 87/99] Bypass idle admission for HID demos only --- Scripts/lab/hid-capture-jig-client | 5 ++++ Scripts/lab/hid-capture-jig-tool | 28 ++----------------- .../Sources/HIDCaptureJig/main.swift | 10 +++++-- Scripts/lab/physical-hid-capture-run | 11 +++++++- .../lab/tests/hid-capture-jig-client-tests.py | 2 ++ .../tests/physical-hid-capture-run-tests.py | 16 +++++++++-- docs/testing/keypath-hid-fixture-readiness.md | 17 +++++++---- 7 files changed, 52 insertions(+), 37 deletions(-) diff --git a/Scripts/lab/hid-capture-jig-client b/Scripts/lab/hid-capture-jig-client index 31d36c536..6e76ffbd5 100755 --- a/Scripts/lab/hid-capture-jig-client +++ b/Scripts/lab/hid-capture-jig-client @@ -67,6 +67,10 @@ def build_parser() -> argparse.ArgumentParser: arm.add_argument("--timeout-ms", type=int, default=10_000) arm.add_argument("--settle-ms", type=int, default=250) arm.add_argument("--instruction") + arm.add_argument( + "--demo-mode", action="store_true", + help="bypass host-resource admission for a non-acceptance demo", + ) wait = commands.add_parser("wait") wait.add_argument("--timeout-seconds", type=float, default=15.0) @@ -91,6 +95,7 @@ def main() -> int: timeoutMs=arguments.timeout_ms, settleMs=arguments.settle_ms, instruction=arguments.instruction, + demoMode=arguments.demo_mode, ) elif arguments.command == "wait": deadline = time.monotonic() + arguments.timeout_seconds diff --git a/Scripts/lab/hid-capture-jig-tool b/Scripts/lab/hid-capture-jig-tool index df48ecf99..1cbdab93b 100755 --- a/Scripts/lab/hid-capture-jig-tool +++ b/Scripts/lab/hid-capture-jig-tool @@ -67,31 +67,6 @@ open_app() { return 1 } -wait_for_readiness() { - readiness=$(mktemp -t keypath-hid-capture-jig-readiness.XXXXXX) - for attempt in {1..60}; do - if "$client" status >"$readiness" 2>/dev/null && \ - /usr/bin/python3 -c \ - 'import json,sys; raise SystemExit(not json.load(open(sys.argv[1]))["systemReadiness"]["canProceed"])' \ - "$readiness"; then - rm -f "$readiness" - return 0 - fi - if (( attempt % 10 == 0 )); then - /usr/bin/python3 -c \ - 'import json,sys; r=json.load(open(sys.argv[1]))["systemReadiness"]; print("showroom waiting " + str(r["detail"]))' \ - "$readiness" 2>/dev/null || true - fi - sleep 0.5 - done - printf '%s\n' "showroom blocked host did not enter a stable capture window" >&2 - /usr/bin/python3 -c \ - 'import json,sys; r=json.load(open(sys.argv[1]))["systemReadiness"]; print("reason " + str(r["detail"]), file=sys.stderr); [print("next " + str(s), file=sys.stderr) for s in r.get("suggestions", [])]' \ - "$readiness" || true - rm -f "$readiness" - return 1 -} - case ${1:-} in build) [[ $# -eq 1 ]] || { usage >&2; exit 2; } @@ -109,7 +84,6 @@ case ${1:-} in showroom) [[ $# -eq 1 ]] || { usage >&2; exit 2; } open_app >/dev/null - wait_for_readiness run_id="showroom-$(date -u +%Y%m%dT%H%M%SZ)" artifact="$HOME/.local/state/keypath-hid-capture-jig/artifacts/$run_id.json" printf '%s\n' \ @@ -125,6 +99,7 @@ case ${1:-} in --delay-ms 800 \ --settle-ms 300 \ --timeout-ms 10000 \ + --demo-mode \ --output "$artifact" ;; demo) @@ -137,6 +112,7 @@ case ${1:-} in "$client" focus >/dev/null "$client" arm --run-id offline-demo --expected "$expected" \ --timeout-ms 30000 --settle-ms 300 \ + --demo-mode \ --instruction "PRESS TOP POWER, THEN TAP DEVICE" >/dev/null printf '%s\n' \ "demo armed PRESS TOP POWER, THEN TAP THE DEVICE SCREEN" \ diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift index c3500819d..a0e5ce014 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -11,6 +11,7 @@ private struct ControlCommand: Codable { let timeoutMs: UInt64? let settleMs: UInt64? let instruction: String? + let demoMode: Bool? } private struct ControlResponse: Codable { @@ -991,7 +992,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega private func beginArm(_ command: ControlCommand, attempt: Int) { let readiness = resourceMonitor.assessment - guard readiness.canProceed else { + let demoMode = command.demoMode == true + guard demoMode || readiness.canProceed else { runActive = false canvas.systemReadiness = readiness canvas.needsDisplay = true @@ -1032,7 +1034,11 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega write(ControlResponse( id: command.id, ok: ok, - message: ok ? "capture armed and focused" : "capture refused: focus or command bounds invalid", + message: ok + ? (demoMode + ? "demo capture armed and focused; host resource admission bypassed" + : "capture armed and focused") + : "capture refused: focus or command bounds invalid", processID: ProcessInfo.processInfo.processIdentifier, snapshot: session.snapshot(nowNs: monotonicNow()), systemReadiness: readiness diff --git a/Scripts/lab/physical-hid-capture-run b/Scripts/lab/physical-hid-capture-run index d6c7e4157..34012914c 100755 --- a/Scripts/lab/physical-hid-capture-run +++ b/Scripts/lab/physical-hid-capture-run @@ -317,6 +317,10 @@ def parser() -> argparse.ArgumentParser: root.add_argument("--delay-ms", type=int, default=1200) root.add_argument("--settle-ms", type=int, default=300) root.add_argument("--timeout-ms", type=int) + root.add_argument( + "--demo-mode", action="store_true", + help="bypass host-resource admission; results are demonstration evidence only", + ) root.add_argument("--max-late-reports", type=int, default=0) root.add_argument("--max-lateness-us", type=int, default=0) root.add_argument( @@ -406,7 +410,7 @@ def main() -> int: "capture Jig does not report system readiness; rebuild and reopen it before testing" ) stage = "host-resource-admission" - if not readiness.get("canProceed"): + if not arguments.demo_mode and not readiness.get("canProceed"): detail = str(readiness.get("detail", "the Mac is not inside the capture resource envelope")) suggestions = readiness.get("suggestions", []) help_text = " ".join(str(value) for value in suggestions if value) @@ -480,6 +484,7 @@ def main() -> int: capture_arm = run_json(capture_command( "arm", "--run-id", arguments.run_id, "--expected", str(expected_path), "--timeout-ms", str(timeout_ms), "--settle-ms", str(arguments.settle_ms), + *(["--demo-mode"] if arguments.demo_mode else []), *(["--instruction", operator_instruction] if operator_instruction else []), ), environment) capture_armed = True @@ -656,6 +661,7 @@ def main() -> int: "schemaVersion": 2, "runID": arguments.run_id, "status": "passed" if passed else "failed", + "admissionMode": "demo-bypass" if arguments.demo_mode else "strict", "fixtureHost": host, "expectedReports": expected_reports, "interruptionMode": interruption_mode, @@ -697,6 +703,7 @@ def main() -> int: summary = { "runID": arguments.run_id, "status": artifact["status"], + "admissionMode": artifact["admissionMode"], "artifact": str(output), "fixtureHost": host, "expectedReports": expected_reports, @@ -781,6 +788,7 @@ def main() -> int: "schemaVersion": 2, "runID": arguments.run_id, "status": "inconclusive", + "admissionMode": "demo-bypass" if arguments.demo_mode else "strict", "fixtureHost": fixture_host or arguments.fixture_host, "expectedReports": expected_reports, "interruptionMode": interruption_mode, @@ -817,6 +825,7 @@ def main() -> int: summary = { "runID": arguments.run_id, "status": "inconclusive", + "admissionMode": artifact["admissionMode"], "classification": classification, "stage": stage, "artifact": str(output), diff --git a/Scripts/lab/tests/hid-capture-jig-client-tests.py b/Scripts/lab/tests/hid-capture-jig-client-tests.py index 892c77b62..0325c8d49 100755 --- a/Scripts/lab/tests/hid-capture-jig-client-tests.py +++ b/Scripts/lab/tests/hid-capture-jig-client-tests.py @@ -76,6 +76,7 @@ def test_arm_transports_expected_text_by_file(self): result = self.run_client( directory, "arm", "--run-id", "physical-1", "--expected", str(expected), "--timeout-ms", "7000", "--settle-ms", "300", + "--demo-mode", "--instruction", "UNPLUG USB-C NOW", ) self.assertEqual(result.returncode, 0, result.stderr) @@ -85,6 +86,7 @@ def test_arm_transports_expected_text_by_file(self): self.assertEqual(jig.commands[0]["timeoutMs"], 7000) self.assertEqual(jig.commands[0]["settleMs"], 300) self.assertEqual(jig.commands[0]["instruction"], "UNPLUG USB-C NOW") + self.assertTrue(jig.commands[0]["demoMode"]) def test_focus_uses_a_distinct_control_action(self): with tempfile.TemporaryDirectory() as temporary: diff --git a/Scripts/lab/tests/physical-hid-capture-run-tests.py b/Scripts/lab/tests/physical-hid-capture-run-tests.py index e75dcf931..f7c1544d3 100644 --- a/Scripts/lab/tests/physical-hid-capture-run-tests.py +++ b/Scripts/lab/tests/physical-hid-capture-run-tests.py @@ -25,6 +25,13 @@ "suggestions": [], } +BUSY_PREFLIGHT = { + "canProceed": False, + "state": "waiting", + "detail": "CPU 93% (limit 80%)", + "suggestions": ["Pause builds or VMs."], +} + TRACE_BATCH = [ {"runId": "test-run", "from": 0, "available": 2}, {"sequence": 1, "modifiers": 0, "keys": [4, 0, 0, 0, 0, 0]}, @@ -63,7 +70,7 @@ def compile_script(command: list[str], environment: dict[str, str]) -> None: output = pathlib.Path(command[command.index("--output") + 1]) output.write_text("KPHID1 test-run 2 1 100000 00000000\n0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n") - def test_capture_preflight_happens_before_fixture_mutation(self) -> None: + def test_demo_mode_records_busy_preflight_then_runs_with_explicit_bypass(self) -> None: calls: list[tuple[str, str]] = [] fixture_status_calls = 0 capture_status_calls = 0 @@ -95,7 +102,7 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): }} return { "ok": True, "snapshot": {"state": "idle"}, - "systemReadiness": READY_PREFLIGHT, + "systemReadiness": BUSY_PREFLIGHT, } if (target, operation) == ("capture", "arm"): capture_arm_command = command @@ -114,6 +121,7 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): arguments = [ str(RUNNER), "--run-id", "test-run", "--text", str(input_path), "--fixture-host", "fixture.local", "--output", str(output_path), + "--demo-mode", "--max-late-reports", "2", "--max-lateness-us", "3000", ] with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ @@ -130,9 +138,12 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): ) self.assertIsNotNone(capture_arm_command) assert capture_arm_command is not None + self.assertIn("--demo-mode", capture_arm_command) self.assertNotIn("--instruction", capture_arm_command) self.assertEqual(capture_arm_command[capture_arm_command.index("--timeout-ms") + 1], "15000") artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["admissionMode"], "demo-bypass") + self.assertFalse(artifact["control"]["capturePreflight"]["systemReadiness"]["canProceed"]) self.assertEqual(artifact["control"]["capturePreflight"]["snapshot"]["state"], "idle") self.assertTrue(artifact["checks"]["latenessWithinBudget"]) self.assertEqual(artifact["timingBudget"]["maxLateReports"], 2) @@ -179,6 +190,7 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): self.assertIn("CPU 93%", error.getvalue()) self.assertIn("Pause builds or VMs", error.getvalue()) + self.assertEqual(artifact["admissionMode"], "strict") self.assertEqual(artifact["failure"]["classification"], "host-resource-admission") self.assertNotIn(("fixture", "load-script"), calls) self.assertNotIn(("fixture", "arm"), calls) diff --git a/docs/testing/keypath-hid-fixture-readiness.md b/docs/testing/keypath-hid-fixture-readiness.md index 45d0006fc..6e02ed046 100644 --- a/docs/testing/keypath-hid-fixture-readiness.md +++ b/docs/testing/keypath-hid-fixture-readiness.md @@ -35,12 +35,17 @@ cd /Users/malpern/local-code/keypath-pico-hid-fixture Scripts/lab/hid-capture-jig-tool showroom ``` -This is the zero-touch path: it opens and focuses the source-hashed Jig, waits up to 30 seconds for -three clean host-resource samples, then asks the ESP32 to type `KeyPath demo OK` plus Return over -real USB HID. The Jig is the independent oracle and must capture the exact 16 characters with all -keys and modifiers released. A timestamped combined artifact is retained under -`~/.local/state/keypath-hid-capture-jig/artifacts/` whether the run passes or becomes inconclusive. -Use `hid-capture-jig-tool demo` only to demonstrate the separate offline top-power-then-touch path. +This is the zero-touch path: it opens and focuses the source-hashed Jig, then immediately asks the +ESP32 to type `KeyPath demo OK` plus Return over real USB HID. Demo mode deliberately bypasses the +Mac CPU, load, memory-pressure, and thermal admission gate so an audience is never left waiting for +the machine to become idle. It still requires exclusive keyboard focus, bounded execution, exact +independent Jig capture of all 16 characters, and verified release of every key and modifier. + +The bypass makes this demonstration evidence only: it must never be used to accept or reject a +KeyPath build. `physical-hid-capture-run` remains strict by default, and matrix/acceptance callers do +not pass `--demo-mode`; they still require three clean host-resource samples. A timestamped combined +artifact records `admissionMode: demo-bypass` or `strict`. Use `hid-capture-jig-tool demo` only for +the separate offline top-power-then-touch presentation; it uses the same demo-only bypass. ### Cold-boot screen sequence From 3c8675d7aacba019fe5758b52622cee5b8a26eeb Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:29:39 -0700 Subject: [PATCH 88/99] Extend HID demos to twenty seconds --- Scripts/lab/hid-capture-jig-tool | 13 +++++++++---- Scripts/lab/pico-hid-fixture/src/fixture_demo.c | 6 ++++-- .../lab/pico-hid-fixture/tests/fixture_core_tests.c | 4 +++- docs/testing/keypath-hid-fixture-readiness.md | 13 ++++++++----- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/Scripts/lab/hid-capture-jig-tool b/Scripts/lab/hid-capture-jig-tool index 1cbdab93b..1d116b992 100755 --- a/Scripts/lab/hid-capture-jig-tool +++ b/Scripts/lab/hid-capture-jig-tool @@ -88,7 +88,8 @@ case ${1:-} in artifact="$HOME/.local/state/keypath-hid-capture-jig/artifacts/$run_id.json" printf '%s\n' \ "showroom running ESP32 -> real USB HID -> independent Jig" \ - "expected KeyPath demo OK + Return" + "duration 20 seconds of animated typing" \ + "expected 14 exact KeyPath demo OK + Return cycles" "$physical_runner" \ --run-id "$run_id" \ --text "$script_dir/tests/fixtures/showroom-demo.txt" \ @@ -96,9 +97,11 @@ case ${1:-} in --hold-ms 16 \ --shift-lead-ms 5 \ --shift-release-lag-ms 5 \ + --repeat 14 \ + --cycle-gap-ms 469 \ --delay-ms 800 \ --settle-ms 300 \ - --timeout-ms 10000 \ + --timeout-ms 35000 \ --demo-mode \ --output "$artifact" ;; @@ -107,7 +110,9 @@ case ${1:-} in open_app >/dev/null expected=$(mktemp -t keypath-hid-demo-expected.XXXXXX) trap 'rm -f "$expected"' EXIT - printf 'KeyPath demo OK\n' >"$expected" + for _ in {1..14}; do + printf 'KeyPath demo OK\n' >>"$expected" + done "$client" reset >/dev/null "$client" focus >/dev/null "$client" arm --run-id offline-demo --expected "$expected" \ @@ -116,7 +121,7 @@ case ${1:-} in --instruction "PRESS TOP POWER, THEN TAP DEVICE" >/dev/null printf '%s\n' \ "demo armed PRESS TOP POWER, THEN TAP THE DEVICE SCREEN" \ - "waiting exact Jig result (30 second limit)" + "waiting exact 20 second Jig result (30 second limit)" "$client" wait --timeout-seconds 31 --poll-ms 50 ;; status) diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_demo.c b/Scripts/lab/pico-hid-fixture/src/fixture_demo.c index dc62299de..8e023e6cc 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_demo.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_demo.c @@ -7,7 +7,8 @@ #define DEMO_HOLD_US 20000u #define DEMO_SHIFT_LEAD_US 5000u #define DEMO_SHIFT_RELEASE_LAG_US 5000u -#define DEMO_CYCLE_GAP_US 250000u +#define DEMO_REPEAT_COUNT 14u +#define DEMO_CYCLE_GAP_US 469000u static bool demo_usage(char character, uint8_t *usage, bool *shifted) { if (character >= 'a' && character <= 'z') { @@ -62,6 +63,7 @@ bool fixture_demo_load(fixture_t *fixture, char *error, size_t error_capacity) { } } uint32_t cycle_us = character_index * DEMO_INTERVAL_US + DEMO_CYCLE_GAP_US; - return fixture_load_events(fixture, FIXTURE_DEMO_RUN_ID, events, event_count, 1u, cycle_us, + return fixture_load_events(fixture, FIXTURE_DEMO_RUN_ID, events, event_count, + DEMO_REPEAT_COUNT, cycle_us, error, error_capacity); } diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index 3b30a1192..faaed260f 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -121,7 +121,9 @@ static void test_offline_demo_is_precompiled_with_safe_shift_timing(void) { char error[128]; assert(fixture_demo_load(&fixture, error, sizeof(error))); assert(strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) == 0); - assert(fixture.repeat_count == 1u); + assert(fixture.repeat_count == 14u); + assert(fixture.cycle_us == 1429000u); + assert((uint64_t)fixture.repeat_count * fixture.cycle_us == 20006000u); assert(fixture.event_count > strlen(FIXTURE_DEMO_TEXT) * 2u); assert(fixture.events[0].modifiers == 2u); assert(fixture.events[0].keys[0] == 0u); diff --git a/docs/testing/keypath-hid-fixture-readiness.md b/docs/testing/keypath-hid-fixture-readiness.md index 6e02ed046..ab7d74cef 100644 --- a/docs/testing/keypath-hid-fixture-readiness.md +++ b/docs/testing/keypath-hid-fixture-readiness.md @@ -36,16 +36,19 @@ Scripts/lab/hid-capture-jig-tool showroom ``` This is the zero-touch path: it opens and focuses the source-hashed Jig, then immediately asks the -ESP32 to type `KeyPath demo OK` plus Return over real USB HID. Demo mode deliberately bypasses the -Mac CPU, load, memory-pressure, and thermal admission gate so an audience is never left waiting for -the machine to become idle. It still requires exclusive keyboard focus, bounded execution, exact -independent Jig capture of all 16 characters, and verified release of every key and modifier. +ESP32 to run 14 lively `KeyPath demo OK` plus Return cycles over real USB HID. At the 60 ms key +cadence and 469 ms cycle gap, the active sequence lasts 20.006 seconds and produces 224 expected +characters from 560 reports. Demo mode deliberately bypasses the Mac CPU, load, memory-pressure, +and thermal admission gate so an audience is never left waiting for the machine to become idle. It +still requires exclusive keyboard focus, bounded execution, exact independent Jig capture of every +character, and verified release of every key and modifier. The bypass makes this demonstration evidence only: it must never be used to accept or reject a KeyPath build. `physical-hid-capture-run` remains strict by default, and matrix/acceptance callers do not pass `--demo-mode`; they still require three clean host-resource samples. A timestamped combined artifact records `admissionMode: demo-bypass` or `strict`. Use `hid-capture-jig-tool demo` only for -the separate offline top-power-then-touch presentation; it uses the same demo-only bypass. +the separate offline top-power-then-touch presentation; it uses the same demo-only bypass and the +same 14-cycle, 20.006-second active sequence. ### Cold-boot screen sequence From f04606ed1420bd93138f6e1cb65a1bb5ea9d85a5 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:37:36 -0700 Subject: [PATCH 89/99] Restart Jig before HID demos --- Scripts/lab/hid-capture-jig-tool | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Scripts/lab/hid-capture-jig-tool b/Scripts/lab/hid-capture-jig-tool index 1d116b992..4cf5203de 100755 --- a/Scripts/lab/hid-capture-jig-tool +++ b/Scripts/lab/hid-capture-jig-tool @@ -37,13 +37,14 @@ wait_for_app_exit() { } open_app() { + force_restart=${1:-0} was_running=0 pgrep -x HIDCaptureJig >/dev/null && was_running=1 before_hash=$(app_binary_hash) "$package_dir/build-app.sh" >/dev/null after_hash=$(app_binary_hash) - if [[ "$was_running" -eq 1 && "$before_hash" == "$after_hash" ]]; then + if [[ "$was_running" -eq 1 && "$before_hash" == "$after_hash" && "$force_restart" -eq 0 ]]; then "$client" focus >/dev/null else if [[ "$was_running" -eq 1 ]]; then @@ -83,7 +84,10 @@ case ${1:-} in ;; showroom) [[ $# -eq 1 ]] || { usage >&2; exit 2; } - open_app >/dev/null + # A separately rebuilt app may still have an older executable resident + # in memory. Always relaunch before real HID so the client and Jig use + # the exact same protocol and admission policy. + open_app 1 >/dev/null run_id="showroom-$(date -u +%Y%m%dT%H%M%SZ)" artifact="$HOME/.local/state/keypath-hid-capture-jig/artifacts/$run_id.json" printf '%s\n' \ @@ -107,7 +111,7 @@ case ${1:-} in ;; demo) [[ $# -eq 1 ]] || { usage >&2; exit 2; } - open_app >/dev/null + open_app 1 >/dev/null expected=$(mktemp -t keypath-hid-demo-expected.XXXXXX) trap 'rm -f "$expected"' EXIT for _ in {1..14}; do From 144ebc77911372c0478e94f955a7e65b26277400 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:39:28 -0700 Subject: [PATCH 90/99] Record twenty-second HID showroom proof --- .../testing/keypath-hid-fixture-physical-results.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md index e2cafdb3f..a30c0f038 100644 --- a/docs/testing/keypath-hid-fixture-physical-results.md +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -250,6 +250,19 @@ The packaged zero-touch command was then validated separately in 7.1 seconds wit reports, 16/16 characters, clean release, and exact independent result. Its timestamped evidence is `~/.local/state/keypath-hid-capture-jig/artifacts/showroom-20260729T030744Z.json`. +The saved showroom command now runs 14 cycles for a 20.006-second active presentation. Firmware +build `612941a38ed8` submitted all 560 expected reports; the Jig captured all 224 expected +characters exactly, observed no capture issues, and verified every key and modifier released. The +run deliberately used `admissionMode: demo-bypass`, so it is presentation evidence rather than a +strict KeyPath acceptance result. Its artifact is +`~/.local/state/keypath-hid-capture-jig/artifacts/showroom-20260729T053642Z.json`. + +The immediately preceding attempt was rejected before HID emission because an older Jig executable +was still resident after the app bundle had been rebuilt. Showroom and offline demo launches now +restart the source-hashed Jig before arming, preventing an old in-memory protocol or admission policy +from being mistaken for the current build. The rejected attempt retained an inconclusive artifact +and confirmed all keys and modifiers released. + The preceding offline attempt is not counted as a product or fixture failure: the Jig remained focused and ready but received zero events, while the fixture recorded no top-power or touch event. That proves only that the physical two-step trigger was not completed during its 30-second window. From ed2ed02fec04e2bc7cdd647b697809f321d402f7 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 28 Jul 2026 22:58:52 -0700 Subject: [PATCH 91/99] Add strict repeated-key HID research matrix --- Scripts/lab/hid-capture-jig-tool | 8 +- Scripts/lab/physical-hid-capture-run | 95 +++++ Scripts/lab/physical-hid-repeat-matrix | 395 ++++++++++++++++++ Scripts/lab/pico-hid-fixture-tool | 2 + Scripts/lab/pico-hid-fixture/README.md | 22 + .../tests/fixtures/repeat-alternating.txt | 1 + .../tests/fixtures/repeat-roll.txt | 1 + .../tests/fixtures/repeat-single.txt | 1 + .../lab/tests/hid-capture-jig-tool-tests.py | 107 +++++ .../tests/physical-hid-capture-run-tests.py | 72 ++++ .../tests/physical-hid-repeat-matrix-tests.py | 137 ++++++ .../keypath-hid-fixture-physical-results.md | 14 + 12 files changed, 852 insertions(+), 3 deletions(-) create mode 100755 Scripts/lab/physical-hid-repeat-matrix create mode 100644 Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-alternating.txt create mode 100644 Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-roll.txt create mode 100644 Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-single.txt create mode 100644 Scripts/lab/tests/hid-capture-jig-tool-tests.py create mode 100644 Scripts/lab/tests/physical-hid-repeat-matrix-tests.py diff --git a/Scripts/lab/hid-capture-jig-tool b/Scripts/lab/hid-capture-jig-tool index 4cf5203de..d79c63ab1 100755 --- a/Scripts/lab/hid-capture-jig-tool +++ b/Scripts/lab/hid-capture-jig-tool @@ -3,8 +3,9 @@ set -euo pipefail script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) package_dir="$script_dir/hid-capture-jig" -client="$script_dir/hid-capture-jig-client" -physical_runner="$script_dir/physical-hid-capture-run" +client=${KEYPATH_CAPTURE_JIG_CLIENT:-"$script_dir/hid-capture-jig-client"} +physical_runner=${KEYPATH_PHYSICAL_HID_RUNNER:-"$script_dir/physical-hid-capture-run"} +build_app=${KEYPATH_CAPTURE_JIG_BUILD_APP:-"$package_dir/build-app.sh"} app_path=${KEYPATH_CAPTURE_JIG_APP:-"$HOME/.cache/keypath-hid-capture-jig/KeyPath HID Capture Jig.app"} usage() { @@ -41,7 +42,7 @@ open_app() { was_running=0 pgrep -x HIDCaptureJig >/dev/null && was_running=1 before_hash=$(app_binary_hash) - "$package_dir/build-app.sh" >/dev/null + "$build_app" >/dev/null after_hash=$(app_binary_hash) if [[ "$was_running" -eq 1 && "$before_hash" == "$after_hash" && "$force_restart" -eq 0 ]]; then @@ -77,6 +78,7 @@ case ${1:-} in [[ $# -eq 1 ]] || { usage >&2; exit 2; } swift test --package-path "$package_dir" --jobs "${KEYPATH_CAPTURE_JIG_BUILD_JOBS:-4}" python3 "$script_dir/tests/hid-capture-jig-client-tests.py" + python3 "$script_dir/tests/hid-capture-jig-tool-tests.py" ;; open) [[ $# -eq 1 ]] || { usage >&2; exit 2; } diff --git a/Scripts/lab/physical-hid-capture-run b/Scripts/lab/physical-hid-capture-run index 34012914c..36125914f 100755 --- a/Scripts/lab/physical-hid-capture-run +++ b/Scripts/lab/physical-hid-capture-run @@ -8,6 +8,7 @@ import ipaddress import json import os import pathlib +import signal import subprocess import sys import tempfile @@ -303,6 +304,80 @@ class ControlledCPULoad: self.stop_event.wait(self.sample_seconds) +class ControlledSwiftCompileLoad: + """Bounded, generated Swift type-check pressure that never mutates the worktree.""" + + def __init__(self): + self.temporary: tempfile.TemporaryDirectory[str] | None = None + self.process: subprocess.Popen | None = None + self.started_at = 0.0 + self.source_bytes = 0 + self.compiler = "" + + def start(self) -> None: + try: + result = subprocess.run( + ["/usr/bin/xcrun", "--find", "swiftc"], text=True, + capture_output=True, check=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError) as error: + raise RuntimeError("could not locate swiftc for the bounded compile load") from error + self.compiler = result.stdout.strip() + if not self.compiler: + raise RuntimeError("xcrun returned an empty swiftc path") + self.temporary = tempfile.TemporaryDirectory(prefix="keypath-swift-load-") + source = pathlib.Path(self.temporary.name) / "GeneratedLoad.swift" + lines = ["import Foundation", ""] + for index in range(700): + lines.extend([ + f"@inline(never) func workload{index}(_ values: S) -> Int ", + "where S.Element == Int {", + f" values.enumerated().reduce({index}) {{ partial, pair in", + f" partial &+ ((pair.offset &+ pair.element &+ {index}) % 97)", + " }", + "}", + "", + ]) + source.write_text("\n".join(lines)) + self.source_bytes = source.stat().st_size + self.started_at = time.time() + self.process = subprocess.Popen( + [ + "/bin/bash", "-c", + 'while :; do "$1" -typecheck "$2" >/dev/null 2>&1 || exit $?; done', + "keypath-swift-compile-load", self.compiler, str(source), + ], + start_new_session=True, + ) + + def stop(self) -> dict[str, Any]: + return_code: int | None = None + if self.process is not None: + try: + os.killpg(self.process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + return_code = self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + return_code = self.process.wait(timeout=2) + if self.temporary is not None: + self.temporary.cleanup() + self.temporary = None + return { + "enabled": True, + "compiler": self.compiler, + "sourceBytes": self.source_bytes, + "startedAtEpoch": self.started_at, + "durationMs": round(max(0, time.time() - self.started_at) * 1000), + "returnCode": return_code, + } + + def parser() -> argparse.ArgumentParser: root = argparse.ArgumentParser(description=__doc__) root.add_argument("--run-id", required=True) @@ -328,6 +403,10 @@ def parser() -> argparse.ArgumentParser: help="start this many bounded CPU workers after capture preflight and record CPU samples", ) root.add_argument("--cpu-sample-ms", type=int, default=250) + root.add_argument( + "--swift-compile-load", action="store_true", + help="run a bounded generated Swift type-check loop after strict admission", + ) interruption = root.add_mutually_exclusive_group() interruption.add_argument( "--abort-after-ms", type=int, @@ -360,6 +439,8 @@ def main() -> int: capture_armed = False cpu_load: ControlledCPULoad | None = None cpu_load_result: dict[str, Any] | None = None + swift_compile_load: ControlledSwiftCompileLoad | None = None + swift_compile_load_result: dict[str, Any] | None = None stage = "argument-validation" started_at_epoch = time.time() timeout_ms = 0 @@ -493,6 +574,9 @@ def main() -> int: workers=arguments.cpu_load_workers, sample_ms=arguments.cpu_sample_ms ) cpu_load.start() + if arguments.swift_compile_load: + swift_compile_load = ControlledSwiftCompileLoad() + swift_compile_load.start() stage = "fixture-start" start = run_fixture_json_with_retry( host, "start", arguments.run_id, "--delay-ms", str(arguments.delay_ms), @@ -591,6 +675,9 @@ def main() -> int: if cpu_load is not None: cpu_load_result = cpu_load.stop() cpu_load = None + if swift_compile_load is not None: + swift_compile_load_result = swift_compile_load.stop() + swift_compile_load = None stage = "evidence-collection" fixture_trace = fetch_fixture_trace(host, environment, control_errors) capture = capture_response["snapshot"] @@ -692,6 +779,7 @@ def main() -> int: "fixtureTrace": fixture_trace, "capture": capture, "cpuLoad": cpu_load_result, + "swiftCompileLoad": swift_compile_load_result, "controlPlane": { "degraded": bool(control_errors), "errors": control_errors, @@ -722,6 +810,10 @@ def main() -> int: cpu_load_result = cpu_load.stop() cpu_load = None cleanup.append("bounded CPU load stopped") + if swift_compile_load is not None: + swift_compile_load_result = swift_compile_load.stop() + swift_compile_load = None + cleanup.append("bounded Swift compile load stopped") if fixture_mutated and fixture_host: try: abort_response = run_fixture_json_with_retry( @@ -818,6 +910,7 @@ def main() -> int: "fixtureTrace": fixture_trace, "capture": capture, "cpuLoad": cpu_load_result, + "swiftCompileLoad": swift_compile_load_result, "startedAtEpoch": started_at_epoch, "finishedAtEpoch": time.time(), } @@ -842,6 +935,8 @@ def main() -> int: finally: if cpu_load is not None: cpu_load.stop() + if swift_compile_load is not None: + swift_compile_load.stop() if __name__ == "__main__": diff --git a/Scripts/lab/physical-hid-repeat-matrix b/Scripts/lab/physical-hid-repeat-matrix new file mode 100755 index 000000000..a02242893 --- /dev/null +++ b/Scripts/lab/physical-hid-repeat-matrix @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Run strict physical-HID repeat and duplicate-key cases across pace and CPU load.""" + +from __future__ import annotations + +import argparse +import datetime +import difflib +import json +import os +import pathlib +import subprocess +import sys +import time +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RUNNER = pathlib.Path(os.environ.get( + "KEYPATH_PHYSICAL_HID_RUNNER", ROOT / "Scripts/lab/physical-hid-capture-run" +)) +JIG_TOOL = pathlib.Path(os.environ.get( + "KEYPATH_CAPTURE_JIG_TOOL", ROOT / "Scripts/lab/hid-capture-jig-tool" +)) +FIXTURES = ROOT / "Scripts/lab/pico-hid-fixture/tests/fixtures" +DEFAULT_CORPORA = [ + ("single", FIXTURES / "repeat-single.txt"), + ("alternating", FIXTURES / "repeat-alternating.txt"), + ("roll", FIXTURES / "repeat-roll.txt"), + ("shifted", FIXTURES / "burst.txt"), +] + + +def integer_list(value: str) -> list[int]: + try: + values = [int(part) for part in value.split(",")] + except ValueError as error: + raise argparse.ArgumentTypeError("values must be comma-separated integers") from error + if not values or any(item < 0 for item in values) or len(set(values)) != len(values): + raise argparse.ArgumentTypeError("values must be unique non-negative integers") + return values + + +def safe_name(value: str) -> str: + return "".join(character if character.isalnum() or character in "-_." else "_" for character in value) + + +def case_run_id(prefix: str, corpus: str, interval_ms: int, workers: int, swift_compile: bool) -> str: + suffix = f"-{safe_name(corpus)[:4]}-i{interval_ms}-w{workers}-s{int(swift_compile)}" + prefix_budget = 48 - len(suffix) + return f"{safe_name(prefix)[:prefix_budget]}{suffix}" + + +def edit_counts(expected: str, received: str) -> dict[str, int]: + counts = {"addedCharacters": 0, "deletedCharacters": 0, "substitutedCharacters": 0} + matcher = difflib.SequenceMatcher(a=expected, b=received, autojunk=False) + for tag, first_start, first_end, second_start, second_end in matcher.get_opcodes(): + first_length = first_end - first_start + second_length = second_end - second_start + if tag == "insert": + counts["addedCharacters"] += second_length + elif tag == "delete": + counts["deletedCharacters"] += first_length + elif tag == "replace": + common = min(first_length, second_length) + counts["substitutedCharacters"] += common + counts["deletedCharacters"] += first_length - common + counts["addedCharacters"] += second_length - common + return counts + + +def analyze_artifact(artifact: dict[str, Any]) -> dict[str, Any]: + capture = artifact.get("capture", {}) + fixture = artifact.get("fixture", {}) + expected = str(capture.get("expected", "")) + received = str(capture.get("received", "")) + edits = edit_counts(expected, received) + wrong = sum(edits.values()) + focus_valid = bool(capture.get("focused")) + all_keys_released = not capture.get("pressedKeyCodes") + all_modifiers_released = capture.get("activeModifiers", 0) == 0 + fixture_complete = fixture.get("state") == "complete" + return { + "runID": artifact.get("runID"), + "runnerStatus": artifact.get("status"), + "admissionMode": artifact.get("admissionMode"), + "expectedCharacters": len(expected), + "receivedCharacters": len(received), + "wrongCharacters": wrong, + **edits, + "duplicateDownEvents": int(capture.get("duplicateDownEvents", 0)), + "repeatEvents": int(capture.get("repeatEvents", 0)), + "unmatchedUpEvents": int(capture.get("unmatchedUpEvents", 0)), + "focusValid": focus_valid, + "allKeysReleased": all_keys_released, + "allModifiersReleased": all_modifiers_released, + "fixtureComplete": fixture_complete, + "lateReports": fixture.get("lateReports"), + "maximumLatenessUs": fixture.get("maximumLatenessUs"), + "validEvidence": ( + artifact.get("admissionMode") == "strict" and focus_valid and + all_keys_released and all_modifiers_released and fixture_complete + ), + } + + +def classify(cases: list[dict[str, Any]]) -> str: + if not cases or any(not case["validEvidence"] for case in cases): + return "harness-invalid" + if any( + case["addedCharacters"] or case["duplicateDownEvents"] or case["repeatEvents"] + for case in cases + ): + return "repeated-input-observed" + if any(case["wrongCharacters"] for case in cases): + return "output-corruption-observed" + return "exact-output-observed" + + +def ensure_jig_running() -> tuple[bool, str]: + status = subprocess.run([str(JIG_TOOL), "status"], text=True, capture_output=True) + if status.returncode == 0: + return True, status.stderr.strip() or status.stdout.strip() or "capture Jig is responsive" + opened = subprocess.run([str(JIG_TOOL), "open"], text=True, capture_output=True) + detail = opened.stderr.strip() or opened.stdout.strip() or f"exit {opened.returncode}" + return opened.returncode == 0, detail + + +def wait_for_jig_readiness(timeout_seconds: float) -> tuple[bool, str]: + deadline = time.monotonic() + timeout_seconds + detail = "capture Jig readiness is unavailable" + while True: + result = subprocess.run([str(JIG_TOOL), "status"], text=True, capture_output=True) + if result.returncode == 0: + try: + readiness = json.loads(result.stdout).get("systemReadiness", {}) + detail = str(readiness.get("detail", detail)) + if readiness.get("canProceed"): + return True, detail + except (json.JSONDecodeError, AttributeError): + detail = "capture Jig returned invalid readiness JSON" + else: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + if time.monotonic() >= deadline: + return False, detail + time.sleep(1) + + +def write_json(path: pathlib.Path, value: dict[str, Any]) -> None: + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + temporary.chmod(0o600) + os.replace(temporary, path) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + result.add_argument("--run-id-prefix", default=f"repeat-matrix-{stamp}") + result.add_argument("--interval-values", type=integer_list, default=integer_list("50,10,5")) + result.add_argument("--cpu-worker-values", type=integer_list, default=integer_list("0,2,6")) + result.add_argument( + "--swift-compile-worker-values", type=integer_list, default=integer_list("0,2") + ) + result.add_argument("--no-swift-compile", action="store_true") + result.add_argument("--hold-ms", type=int, default=2) + result.add_argument("--repeat", type=int, default=4) + result.add_argument("--cycle-gap-ms", type=int, default=100) + result.add_argument("--delay-ms", type=int, default=800) + result.add_argument("--readiness-timeout-seconds", type=float, default=60) + result.add_argument("--stress-max-late-reports", type=int, default=5) + result.add_argument("--stress-max-lateness-us", type=int, default=5000) + result.add_argument("--fixture-host", default=os.environ.get( + "KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local" + )) + result.add_argument("--output-directory", type=pathlib.Path) + result.add_argument("--resume", action="store_true") + result.add_argument("--exclusive-desktop-confirmed", action="store_true") + result.add_argument("--continue-after-infrastructure-error", action="store_true") + return result + + +def main() -> int: + arguments = parser().parse_args() + if not arguments.exclusive_desktop_confirmed: + print( + "physical-hid-repeat-matrix: --exclusive-desktop-confirmed is required; " + "real USB keyboard input owns the active desktop", + file=sys.stderr, + ) + return 2 + if arguments.hold_ms < 1 or arguments.repeat < 1 or arguments.cycle_gap_ms < 0: + print("physical-hid-repeat-matrix: hold/repeat must be positive and gap non-negative", file=sys.stderr) + return 2 + if any(interval < 4 or arguments.hold_ms >= interval for interval in arguments.interval_values): + print("physical-hid-repeat-matrix: intervals must be >=4 ms and exceed hold-ms", file=sys.stderr) + return 2 + if arguments.readiness_timeout_seconds < 0: + print("physical-hid-repeat-matrix: readiness timeout cannot be negative", file=sys.stderr) + return 2 + logical_cpus = max(1, os.cpu_count() or 1) + all_worker_values = [ + *arguments.cpu_worker_values, + *([] if arguments.no_swift_compile else arguments.swift_compile_worker_values), + ] + if any(workers > logical_cpus for workers in all_worker_values): + print(f"physical-hid-repeat-matrix: CPU workers cannot exceed {logical_cpus}", file=sys.stderr) + return 2 + if arguments.stress_max_late_reports < 0 or arguments.stress_max_lateness_us < 0: + print("physical-hid-repeat-matrix: timing budgets cannot be negative", file=sys.stderr) + return 2 + missing = [str(path) for _, path in DEFAULT_CORPORA if not path.is_file()] + if missing: + print(f"physical-hid-repeat-matrix: missing corpus: {', '.join(missing)}", file=sys.stderr) + return 2 + + output_directory = arguments.output_directory or ( + pathlib.Path.home() / ".local/state/keypath-hid-capture-jig/repeat-matrix" / + safe_name(arguments.run_id_prefix) + ) + output_directory.mkdir(parents=True, exist_ok=True) + output_directory.chmod(0o700) + load_profiles = [ + {"name": f"cpu{workers}", "cpuWorkers": workers, "swiftCompile": False} + for workers in arguments.cpu_worker_values + ] + if not arguments.no_swift_compile: + load_profiles.extend([ + {"name": f"swift-cpu{workers}", "cpuWorkers": workers, "swiftCompile": True} + for workers in arguments.swift_compile_worker_values + ]) + plan = { + "schemaVersion": 1, + "runIDPrefix": arguments.run_id_prefix, + "corpora": [{"name": name, "path": str(path)} for name, path in DEFAULT_CORPORA], + "intervalValuesMs": arguments.interval_values, + "loadProfiles": load_profiles, + "holdMs": arguments.hold_ms, + "repeat": arguments.repeat, + "cycleGapMs": arguments.cycle_gap_ms, + "caseCount": len(DEFAULT_CORPORA) * len(arguments.interval_values) * len(load_profiles), + "admissionMode": "strict", + "exclusiveDesktopConfirmed": True, + } + plan_path = output_directory / "plan.json" + if plan_path.is_file(): + if not arguments.resume: + print( + "physical-hid-repeat-matrix: output already contains a plan; use --resume or a new prefix", + file=sys.stderr, + ) + return 2 + try: + existing_plan = json.loads(plan_path.read_text()) + except json.JSONDecodeError: + print("physical-hid-repeat-matrix: existing plan is invalid JSON", file=sys.stderr) + return 2 + if existing_plan != plan: + print("physical-hid-repeat-matrix: existing plan differs; use a new prefix", file=sys.stderr) + return 2 + else: + write_json(plan_path, plan) + + jig_ready, detail = ensure_jig_running() + if not jig_ready: + print(f"physical-hid-repeat-matrix: could not open capture Jig: {detail}", file=sys.stderr) + return 2 + + cases: list[dict[str, Any]] = [] + infrastructure_errors: list[dict[str, Any]] = [] + stop = False + for corpus_name, corpus_path in DEFAULT_CORPORA: + for interval_ms in arguments.interval_values: + for profile in load_profiles: + workers = int(profile["cpuWorkers"]) + swift_compile = bool(profile["swiftCompile"]) + base_run_id = case_run_id( + arguments.run_id_prefix, corpus_name, interval_ms, workers, swift_compile + ) + run_id = base_run_id + artifact_path = output_directory / f"{run_id}.json" + if arguments.resume and artifact_path.is_file(): + case = analyze_artifact(json.loads(artifact_path.read_text())) + if case["validEvidence"]: + case.update({ + "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, + "artifact": str(artifact_path), "resumed": True, + }) + cases.append(case) + continue + retry = 2 + while True: + run_id = f"{base_run_id[:40]}-retry{retry}" + artifact_path = output_directory / f"{run_id}.json" + if not artifact_path.exists(): + break + retry += 1 + jig_ready, detail = ensure_jig_running() + if not jig_ready: + infrastructure_errors.append({ + "runID": run_id, "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, "returnCode": 2, + "detail": f"capture Jig unavailable: {detail}", + }) + if not arguments.continue_after_infrastructure_error: + stop = True + break + continue + resource_ready, detail = wait_for_jig_readiness(arguments.readiness_timeout_seconds) + if not resource_ready: + infrastructure_errors.append({ + "runID": run_id, "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, "returnCode": 2, + "detail": f"capture Jig resource gate timed out: {detail}", + }) + if not arguments.continue_after_infrastructure_error: + stop = True + break + continue + command = [ + str(RUNNER), "--run-id", run_id, "--text", str(corpus_path), + "--fixture-host", arguments.fixture_host, + "--interval-ms", str(interval_ms), "--hold-ms", str(arguments.hold_ms), + "--repeat", str(arguments.repeat), "--cycle-gap-ms", str(arguments.cycle_gap_ms), + "--delay-ms", str(arguments.delay_ms), "--cpu-load-workers", str(workers), + "--output", str(artifact_path), + ] + if swift_compile: + command.append("--swift-compile-load") + if workers > 0 or swift_compile: + command.extend([ + "--max-late-reports", str(arguments.stress_max_late_reports), + "--max-lateness-us", str(arguments.stress_max_lateness_us), + ]) + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode == 2 or not artifact_path.is_file(): + infrastructure_errors.append({ + "runID": run_id, "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, "returnCode": result.returncode, + "detail": result.stderr.strip() or result.stdout.strip() or "artifact missing", + }) + if not arguments.continue_after_infrastructure_error: + stop = True + break + continue + case = analyze_artifact(json.loads(artifact_path.read_text())) + case.update({ + "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, + "artifact": str(artifact_path), "resumed": False, + }) + cases.append(case) + print(json.dumps({key: case[key] for key in ( + "runID", "corpus", "intervalMs", "loadProfile", "cpuWorkers", + "swiftCompile", "wrongCharacters", + "addedCharacters", "repeatEvents", "focusValid", "lateReports", + )}, sort_keys=True), flush=True) + if stop: + break + if stop: + break + + classification = classify(cases) + status = "blocked" if infrastructure_errors or classification == "harness-invalid" else "completed" + summary = { + "schemaVersion": 1, + "status": status, + "classification": classification, + "plan": plan, + "completedCases": len(cases), + "infrastructureErrors": infrastructure_errors, + "cases": cases, + } + summary_path = output_directory / "summary.json" + write_json(summary_path, summary) + print(json.dumps({ + "status": status, + "classification": classification, + "completedCases": len(cases), + "plannedCases": plan["caseCount"], + "summary": str(summary_path), + }, indent=2, sort_keys=True)) + if status == "blocked": + return 2 + return 0 if classification == "exact-output-observed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/pico-hid-fixture-tool b/Scripts/lab/pico-hid-fixture-tool index 65cf0baab..2cac4de42 100755 --- a/Scripts/lab/pico-hid-fixture-tool +++ b/Scripts/lab/pico-hid-fixture-tool @@ -176,6 +176,8 @@ command_test() { python3 "$script_dir/tests/scenario-matrix-runner-tests.py" python3 "$script_dir/tests/physical-hid-capture-run-tests.py" python3 "$script_dir/tests/physical-hid-shift-matrix-tests.py" + python3 "$script_dir/tests/physical-hid-repeat-matrix-tests.py" + python3 "$script_dir/tests/hid-capture-jig-tool-tests.py" "$fixture_root/tests/run-esp32-qemu-smoke.sh" } diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 438735257..68d59b387 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -252,6 +252,28 @@ logical CPUs, average/maximum CPU, and timestamped CPU/load samples. If capture runner still waits for the fixture to finish and for the macOS event queue to settle, preserving the complete event ledger instead of a first-mismatch prefix. +For the repeated-key regression, use the strict resumable campaign rather than an ad hoc typing +script: + +```bash +Scripts/lab/physical-hid-repeat-matrix --exclusive-desktop-confirmed +``` + +The default 60-case plan crosses four input shapes (one repeated key, alternating keys, a rolling +home-row sequence, and shifted symbols), 50/10/5 ms pacing, and five load profiles: calm, two and +six bounded CPU workers, generated Swift compilation, and generated Swift compilation plus two CPU +workers. Every case still passes the normal calm admission gate before its bounded load begins; the +campaign never uses demo mode. The generated compiler workload lives in a temporary directory and +does not clean, touch, or build the KeyPath worktree. + +Each combined artifact distinguishes additions from deletions and substitutions and retains the +Jig's duplicate-down, host-repeat, unmatched-up, focus, release, and event evidence alongside the +ESP32 trace and timing. The summary classifies any inserted character or unexpected repeat event as +`repeated-input-observed`, while focus loss or incomplete release fails closed as `harness-invalid`. +Use the same `--run-id-prefix` and `--resume` after an infrastructure interruption to reuse completed +case artifacts without repeating valid HID runs. The explicit desktop confirmation is mandatory +because a real USB keyboard cannot safely target a background window while the operator is typing. + ## Pico 2 W build Install CMake, an Arm embedded compiler, and the Raspberry Pi Pico SDK. Keep Wi-Fi credentials and diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-alternating.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-alternating.txt new file mode 100644 index 000000000..4106b96e1 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-alternating.txt @@ -0,0 +1 @@ +abababababababababababababababababababababababababababababababab diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-roll.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-roll.txt new file mode 100644 index 000000000..430d0429b --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-roll.txt @@ -0,0 +1 @@ +asdfjkl;asdfjkl;asdfjkl;asdfjkl;asdfjkl;asdfjkl;asdfjkl;asdfjkl; diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-single.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-single.txt new file mode 100644 index 000000000..93f308c87 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-single.txt @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/Scripts/lab/tests/hid-capture-jig-tool-tests.py b/Scripts/lab/tests/hid-capture-jig-tool-tests.py new file mode 100644 index 000000000..bac4c7c2a --- /dev/null +++ b/Scripts/lab/tests/hid-capture-jig-tool-tests.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Contract tests for the HID Capture Jig launcher.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +TOOL = ROOT / "Scripts/lab/hid-capture-jig-tool" + + +class HIDCaptureJigToolTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = pathlib.Path(self.temporary.name) + self.fake_bin = self.directory / "bin" + self.fake_bin.mkdir() + self.state = self.directory / "running" + self.log = self.directory / "calls.log" + self.app = self.directory / "KeyPath HID Capture Jig.app" + binary = self.app / "Contents/MacOS/HIDCaptureJig" + binary.parent.mkdir(parents=True) + binary.write_text("current jig binary\n") + binary.chmod(0o755) + self.state.write_text("old process\n") + + self.build_app = self.directory / "build-app" + self._write_executable(self.build_app, "#!/bin/bash\nexit 0\n") + self.client = self.directory / "client" + self._write_executable(self.client, textwrap.dedent(f"""\ + #!/bin/bash + set -eu + printf 'client %s\\n' "$*" >> {str(self.log)!r} + case "$1" in + quit) rm -f {str(self.state)!r}; printf '{{"ok":true}}\\n' ;; + status) + [[ -f {str(self.state)!r} ]] || exit 2 + printf '{{"ok":true,"snapshot":{{"state":"idle"}}}}\\n' + ;; + focus) printf '{{"ok":true}}\\n' ;; + *) printf '{{"ok":true}}\\n' ;; + esac + """)) + self.runner = self.directory / "runner" + self._write_executable(self.runner, textwrap.dedent(f"""\ + #!/bin/bash + printf 'runner %s\\n' "$*" >> {str(self.log)!r} + printf '{{"status":"passed"}}\\n' + """)) + self._write_executable(self.fake_bin / "pgrep", textwrap.dedent(f"""\ + #!/bin/bash + [[ -f {str(self.state)!r} ]] + """)) + self._write_executable(self.fake_bin / "open", textwrap.dedent(f"""\ + #!/bin/bash + printf 'open %s\\n' "$*" >> {str(self.log)!r} + printf 'new process\\n' > {str(self.state)!r} + """)) + + self.environment = os.environ.copy() + self.environment.update({ + "HOME": str(self.directory), + "PATH": f"{self.fake_bin}:{self.environment['PATH']}", + "KEYPATH_CAPTURE_JIG_APP": str(self.app), + "KEYPATH_CAPTURE_JIG_BUILD_APP": str(self.build_app), + "KEYPATH_CAPTURE_JIG_CLIENT": str(self.client), + "KEYPATH_PHYSICAL_HID_RUNNER": str(self.runner), + }) + + def tearDown(self) -> None: + self.temporary.cleanup() + + @staticmethod + def _write_executable(path: pathlib.Path, contents: str) -> None: + path.write_text(contents) + path.chmod(0o755) + + def run_tool(self, command: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(TOOL), command], text=True, capture_output=True, env=self.environment + ) + + def test_showroom_restarts_a_healthy_but_potentially_stale_process(self) -> None: + result = self.run_tool("showroom") + + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text().splitlines() + self.assertIn("client quit", calls) + self.assertTrue(any(call.startswith("open ") for call in calls)) + self.assertFalse(any(call == "client focus" for call in calls)) + runner_call = next(call for call in calls if call.startswith("runner ")) + self.assertIn("--demo-mode", runner_call) + self.assertIn("--repeat 14", runner_call) + self.assertIn("--cycle-gap-ms 469", runner_call) + self.assertLess(calls.index("client quit"), next( + index for index, call in enumerate(calls) if call.startswith("open ") + )) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/physical-hid-capture-run-tests.py b/Scripts/lab/tests/physical-hid-capture-run-tests.py index f7c1544d3..9b44219fe 100644 --- a/Scripts/lab/tests/physical-hid-capture-run-tests.py +++ b/Scripts/lab/tests/physical-hid-capture-run-tests.py @@ -195,6 +195,78 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): self.assertNotIn(("fixture", "load-script"), calls) self.assertNotIn(("fixture", "arm"), calls) + def test_swift_compile_load_starts_after_capture_arm_and_is_recorded(self) -> None: + timeline: list[tuple[str, str]] = [] + fixture_status_calls = 0 + capture_status_calls = 0 + + def snapshot(): + return { + "state": "passed", "received": "a", "focused": True, + "pressedKeyCodes": [], "activeModifiers": 0, "issues": [], + "events": [{}, {}], + } + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls, capture_status_calls + target, operation = action(command) + timeline.append((target, operation)) + if (target, operation) == ("fixture", "trace"): + return TRACE_BATCH + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + return { + "state": "complete", "reportsSubmitted": 2, + "transfersCompleted": 2, "lateReports": 0, + "maximumLatenessUs": 0, + } + if (target, operation) == ("capture", "status"): + capture_status_calls += 1 + if capture_status_calls == 1: + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + return {"ok": True, "snapshot": snapshot()} + if (target, operation) == ("capture", "wait"): + return {"snapshot": snapshot()} + return {"ok": True} + + class FakeSwiftCompileLoad: + def start(self): + timeline.append(("load", "swift-start")) + + def stop(self): + timeline.append(("load", "swift-stop")) + return {"enabled": True, "sourceBytes": 12345, "durationMs": 500} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("a") + arguments = [ + str(RUNNER), "--run-id", "swift-load-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--swift-compile-load", + "--output", str(output_path), + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner, "ControlledSwiftCompileLoad", FakeSwiftCompileLoad), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 0) + + artifact = json.loads(output_path.read_text()) + + self.assertLess(timeline.index(("capture", "arm")), timeline.index(("load", "swift-start"))) + self.assertLess(timeline.index(("load", "swift-start")), timeline.index(("fixture", "start"))) + self.assertEqual(artifact["swiftCompileLoad"]["sourceBytes"], 12345) + self.assertEqual(timeline.count(("load", "swift-stop")), 1) + def test_failed_capture_waits_for_fixture_and_persists_complete_evidence(self) -> None: fixture_status_calls = 0 capture_status_calls = 0 diff --git a/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py b/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py new file mode 100644 index 000000000..77abba44f --- /dev/null +++ b/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Contract tests for the strict repeat/duplicate physical-HID matrix.""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import unittest +from unittest import mock + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "Scripts/lab/physical-hid-repeat-matrix" +LOADER = importlib.machinery.SourceFileLoader("physical_hid_repeat_matrix", str(SCRIPT)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def artifact(expected: str, received: str, *, focused: bool = True) -> dict: + return { + "runID": "case", + "status": "passed" if expected == received else "failed", + "admissionMode": "strict", + "fixture": {"state": "complete", "lateReports": 0, "maximumLatenessUs": 0}, + "capture": { + "expected": expected, + "received": received, + "focused": focused, + "pressedKeyCodes": [], + "activeModifiers": 0, + "duplicateDownEvents": 0, + "repeatEvents": 0, + "unmatchedUpEvents": 0, + }, + } + + +class PhysicalHIDRepeatMatrixTests(unittest.TestCase): + def test_case_run_ids_fit_firmware_limit_and_keep_profile_identity(self) -> None: + prefix = "repeat-matrix-with-an-extremely-long-human-readable-campaign-name" + identifiers = { + MODULE.case_run_id(prefix, "alternating", 5, workers, swift) + for workers, swift in ((0, False), (2, False), (0, True), (2, True)) + } + self.assertEqual(len(identifiers), 4) + self.assertTrue(all(len(identifier) <= 48 for identifier in identifiers)) + + def test_campaign_requires_explicit_exclusive_desktop_confirmation(self) -> None: + with mock.patch.object(MODULE.sys, "argv", [str(SCRIPT)]): + self.assertEqual(MODULE.main(), 2) + + def test_analysis_distinguishes_additions_deletions_and_substitutions(self) -> None: + added = MODULE.analyze_artifact(artifact("abcd", "abbcd")) + deleted = MODULE.analyze_artifact(artifact("abcd", "acd")) + substituted = MODULE.analyze_artifact(artifact("abcd", "abXd")) + + self.assertEqual(added["addedCharacters"], 1) + self.assertEqual(deleted["deletedCharacters"], 1) + self.assertEqual(substituted["substitutedCharacters"], 1) + + def test_classification_calls_added_output_a_repeated_input_observation(self) -> None: + case = MODULE.analyze_artifact(artifact("abcd", "abbcd")) + self.assertEqual(MODULE.classify([case]), "repeated-input-observed") + + def test_classification_fails_closed_when_focus_is_lost(self) -> None: + case = MODULE.analyze_artifact(artifact("abcd", "abcd", focused=False)) + self.assertEqual(MODULE.classify([case]), "harness-invalid") + + def test_campaign_uses_strict_runner_and_load_budget_only_for_loaded_case(self) -> None: + commands: list[list[str]] = [] + + def fake_run(command, text=True, capture_output=True): + del text, capture_output + commands.append(command) + if command[-1] == "status": + return subprocess.CompletedProcess( + command, 0, + stdout=json.dumps({ + "systemReadiness": {"canProceed": True, "detail": "stable"} + }), + stderr="", + ) + output = pathlib.Path(command[command.index("--output") + 1]) + corpus = pathlib.Path(command[command.index("--text") + 1]).read_text() + repeat = int(command[command.index("--repeat") + 1]) + output.write_text(json.dumps(artifact(corpus * repeat, corpus * repeat))) + return subprocess.CompletedProcess(command, 0, stdout="{}", stderr="") + + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + corpus = root / "corpus.txt" + corpus.write_text("aaaa") + output = root / "output" + arguments = [ + str(SCRIPT), "--run-id-prefix", "test", "--interval-values", "10", + "--cpu-worker-values", "0,2", "--hold-ms", "2", "--repeat", "1", + "--readiness-timeout-seconds", "0", "--output-directory", str(output), + "--exclusive-desktop-confirmed", + ] + with mock.patch.object(MODULE, "DEFAULT_CORPORA", [("single", corpus)]), \ + mock.patch.object(MODULE, "ensure_jig_running", return_value=(True, "ready")), \ + mock.patch.object(MODULE.subprocess, "run", side_effect=fake_run), \ + mock.patch.object(MODULE.sys, "argv", arguments): + self.assertEqual(MODULE.main(), 0) + + runner_commands = [command for command in commands if "--output" in command] + self.assertEqual(len(runner_commands), 4) + calm = next(command for command in runner_commands if ( + command[command.index("--cpu-load-workers") + 1] == "0" and + "--swift-compile-load" not in command + )) + loaded = next(command for command in runner_commands if ( + command[command.index("--cpu-load-workers") + 1] == "2" and + "--swift-compile-load" not in command + )) + swift = next(command for command in runner_commands if ( + command[command.index("--cpu-load-workers") + 1] == "0" and + "--swift-compile-load" in command + )) + self.assertNotIn("--demo-mode", calm) + self.assertNotIn("--max-late-reports", calm) + self.assertIn("--max-late-reports", loaded) + self.assertEqual(loaded[loaded.index("--max-late-reports") + 1], "5") + self.assertIn("--max-late-reports", swift) + summary = json.loads((output / "summary.json").read_text()) + self.assertEqual(summary["classification"], "exact-output-observed") + self.assertEqual(summary["completedCases"], 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md index a30c0f038..70041994c 100644 --- a/docs/testing/keypath-hid-fixture-physical-results.md +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -70,6 +70,20 @@ The `physical-jig-button-abort-post-flash-1` attempt is excluded: its 30-second window expired without a physical button event, after which the fixture completed normally. It is not evidence of either a passing or failing physical-button abort. +## Open repeated-key research campaign + +The original report of repeated keypresses while compiling a large Swift program remains distinct +from the Shift-demotion defect below. `Scripts/lab/physical-hid-repeat-matrix` now defines the strict, +resumable campaign needed to investigate it. The plan covers repeated, alternating, rolling, and +shifted corpora at 50, 10, and 5 ms pacing under calm CPU, bounded CPU saturation, generated Swift +type-check pressure, and combined compiler/CPU pressure. + +The campaign starts every load only after the ordinary three-sample host admission and capture arm. +It records exact output, inserted/deleted/substituted characters, AppKit duplicate-down/repeat/up +counters, focus and release state, firmware timing, and the full bounded report trace. It contains +no demo-mode bypass. This campaign is implemented and host-tested but has not yet emitted physical +HID; do not claim a KeyPath result until its strict case artifacts exist. + ## 2026-07-27 shifted-key CPU matrix Firmware build `ccd910cb18d9` ran the same 20-cycle shifted corpus From 7292a5e773ea22ac09b299474b30092d56f253cc Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 29 Jul 2026 09:25:28 -0700 Subject: [PATCH 92/99] Give HID Jig its own visual identity --- .../Sources/HIDCaptureJig/main.swift | 18 +++++++++--------- Scripts/lab/hid-capture-jig/build-app.sh | 5 ++--- .../lab/tests/hid-capture-jig-tool-tests.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift index a0e5ce014..a7ad219f6 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -141,8 +141,8 @@ private final class CaptureCanvas: NSView { private let brandLight = NSColor( calibratedRed: 1.0, green: 0.96, blue: 0.83, alpha: 1 ) - private lazy var keyPathLogo: NSImage? = { - guard let url = Bundle.main.url(forResource: "KeyPathLogo", withExtension: "icns") else { + private lazy var jigLogo: NSImage? = { + guard let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns") else { return nil } return NSImage(contentsOf: url) @@ -265,12 +265,12 @@ private final class CaptureCanvas: NSView { width: markSize, height: markSize ) - drawKeyPathLogo(in: markFrame) + drawJigLogo(in: markFrame) let focusWidth: CGFloat = layout.mode == .tiny ? 72 : 130 let titleX = markFrame.maxX + (layout.mode == .tiny ? 6 : 10) drawText( - layout.mode == .tiny ? "KEYPATH HID JIG" : "KEYPATH / HID CAPTURE JIG", + layout.mode == .tiny ? "HID JIG" : "HID CAPTURE JIG", rect: NSRect( x: titleX, y: header.midY - 10, @@ -745,12 +745,12 @@ private final class CaptureCanvas: NSView { } } - private func drawKeyPathLogo(in frame: NSRect) { - guard let keyPathLogo else { + private func drawJigLogo(in frame: NSRect) { + guard let jigLogo else { drawText( - "KP", + "JIG", rect: frame, - font: font(size: frame.height * 0.42, weight: .bold, monospaced: true), + font: font(size: frame.height * 0.30, weight: .bold, monospaced: true), color: brandAmber, alignment: .center ) @@ -758,7 +758,7 @@ private final class CaptureCanvas: NSView { } NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current?.imageInterpolation = .high - keyPathLogo.draw( + jigLogo.draw( in: frame, from: .zero, operation: .sourceOver, diff --git a/Scripts/lab/hid-capture-jig/build-app.sh b/Scripts/lab/hid-capture-jig/build-app.sh index 155f05071..3b311fddd 100755 --- a/Scripts/lab/hid-capture-jig/build-app.sh +++ b/Scripts/lab/hid-capture-jig/build-app.sh @@ -8,7 +8,6 @@ source_hash=$( { find "$script_dir/Sources" "$script_dir/Resources" -type f -print printf '%s\n' "$script_dir/Package.swift" - printf '%s\n' "$script_dir/../../../Sources/KeyPathApp/Resources/AppIcon.icns" } | LC_ALL=C sort | while IFS= read -r source; do shasum -a 256 "$source" done | shasum -a 256 | awk '{print $1}' @@ -29,12 +28,12 @@ bin_path=$(swift build --package-path "$script_dir" --show-bin-path) mkdir -p "$app_path/Contents/MacOS" mkdir -p "$app_path/Contents/Resources" +# Keep incremental app builds from retaining resources removed from the bundle. +rm -f "$app_path/Contents/Resources/KeyPathLogo.icns" install -m 755 "$bin_path/HIDCaptureJig" "$app_path/Contents/MacOS/HIDCaptureJig" install -m 644 "$script_dir/Resources/Info.plist" "$app_path/Contents/Info.plist" xcrun swift "$script_dir/Resources/generate_jig_icon.swift" "$icon_work/JigIcon.iconset" iconutil -c icns "$icon_work/JigIcon.iconset" -o "$app_path/Contents/Resources/AppIcon.icns" -install -m 644 "$script_dir/../../../Sources/KeyPathApp/Resources/AppIcon.icns" \ - "$app_path/Contents/Resources/KeyPathLogo.icns" printf '%s\n' "$source_hash" >"$source_stamp" codesign --force --sign - --timestamp=none "$app_path" >/dev/null printf '%s\n' "$app_path" diff --git a/Scripts/lab/tests/hid-capture-jig-tool-tests.py b/Scripts/lab/tests/hid-capture-jig-tool-tests.py index bac4c7c2a..40f55094f 100644 --- a/Scripts/lab/tests/hid-capture-jig-tool-tests.py +++ b/Scripts/lab/tests/hid-capture-jig-tool-tests.py @@ -102,6 +102,24 @@ def test_showroom_restarts_a_healthy_but_potentially_stale_process(self) -> None index for index, call in enumerate(calls) if call.startswith("open ") )) + def test_jig_uses_its_own_icon_and_does_not_bundle_the_keypath_logo(self) -> None: + source = ( + ROOT / "Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift" + ).read_text() + build_script = ( + ROOT / "Scripts/lab/hid-capture-jig/build-app.sh" + ).read_text() + + self.assertIn('forResource: "AppIcon"', source) + self.assertIn("drawJigLogo", source) + self.assertNotIn("KeyPathLogo", source) + self.assertIn( + 'rm -f "$app_path/Contents/Resources/KeyPathLogo.icns"', + build_script, + ) + self.assertEqual(build_script.count("KeyPathLogo"), 1) + self.assertNotIn("Sources/KeyPathApp/Resources/AppIcon.icns", build_script) + if __name__ == "__main__": unittest.main() From d3e9f8944369087b760d60331afc2cd60b2f255f Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Wed, 29 Jul 2026 09:30:19 -0700 Subject: [PATCH 93/99] Set the Jig app icon at launch --- .../lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift | 9 +++++++++ Scripts/lab/tests/hid-capture-jig-tool-tests.py | 1 + 2 files changed, 10 insertions(+) diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift index a7ad219f6..58f631e58 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -1110,6 +1110,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega private enum HIDCaptureJigMain { static func main() { let application = NSApplication.shared + installApplicationIcon(on: application) installMainMenu(on: application) let delegate = AppDelegate() application.delegate = delegate @@ -1117,6 +1118,14 @@ private enum HIDCaptureJigMain { withExtendedLifetime(delegate) {} } + private static func installApplicationIcon(on application: NSApplication) { + guard let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), + let image = NSImage(contentsOf: url) + else { return } + image.isTemplate = false + application.applicationIconImage = image + } + private static func installMainMenu(on application: NSApplication) { let applicationName = "KeyPath HID Capture Jig" let mainMenu = NSMenu() diff --git a/Scripts/lab/tests/hid-capture-jig-tool-tests.py b/Scripts/lab/tests/hid-capture-jig-tool-tests.py index 40f55094f..539d52434 100644 --- a/Scripts/lab/tests/hid-capture-jig-tool-tests.py +++ b/Scripts/lab/tests/hid-capture-jig-tool-tests.py @@ -112,6 +112,7 @@ def test_jig_uses_its_own_icon_and_does_not_bundle_the_keypath_logo(self) -> Non self.assertIn('forResource: "AppIcon"', source) self.assertIn("drawJigLogo", source) + self.assertIn("application.applicationIconImage = image", source) self.assertNotIn("KeyPathLogo", source) self.assertIn( 'rm -f "$app_path/Contents/Resources/KeyPathLogo.icns"', From 5e812fcddcf9fe0ded6b579bfbf6ea985ce449e6 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 31 Jul 2026 07:35:03 -0700 Subject: [PATCH 94/99] Add Typover Bear test monitor mode --- Scripts/lab/hid-capture-jig-client | 67 +++ .../HIDCaptureCore/TypoverBearMonitor.swift | 211 +++++++++ .../Sources/HIDCaptureJig/main.swift | 437 +++++++++++++++++- .../TypoverBearMonitorTests.swift | 92 ++++ Scripts/lab/pico-hid-fixture/README.md | 21 + .../lab/tests/hid-capture-jig-client-tests.py | 27 ++ 6 files changed, 834 insertions(+), 21 deletions(-) create mode 100644 Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/TypoverBearMonitor.swift create mode 100644 Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/TypoverBearMonitorTests.swift diff --git a/Scripts/lab/hid-capture-jig-client b/Scripts/lab/hid-capture-jig-client index 6e76ffbd5..2f40d7f03 100755 --- a/Scripts/lab/hid-capture-jig-client +++ b/Scripts/lab/hid-capture-jig-client @@ -60,6 +60,39 @@ def build_parser() -> argparse.ArgumentParser: commands.add_parser("reset") commands.add_parser("finalize") commands.add_parser("quit") + commands.add_parser("bear-reset") + + bear_prepare = commands.add_parser( + "bear-prepare", help="show the Typover Bear focus gate without scheduling keys" + ) + bear_prepare.add_argument("--run-id", required=True) + bear_prepare.add_argument("--case-count", required=True, type=int) + bear_prepare.add_argument("--message", required=True) + + bear_begin = commands.add_parser( + "bear-begin", help="show the nonactivating Typover Bear monitor" + ) + bear_begin.add_argument("--run-id", required=True) + bear_begin.add_argument("--case-index", required=True, type=int) + bear_begin.add_argument("--case-count", required=True, type=int) + bear_begin.add_argument("--interval-ms", required=True, type=int) + bear_begin.add_argument("--word-count", required=True, type=int) + bear_begin.add_argument("--scheduled-text", required=True, type=pathlib.Path) + bear_begin.add_argument("--start-delay-ms", required=True, type=int) + bear_begin.add_argument("--bear-focused", action="store_true") + + bear_update = commands.add_parser( + "bear-update", help="update Typover Bear progress or verified results" + ) + bear_update.add_argument( + "--phase", required=True, + choices=("preparing", "waitingForBear", "countdown", "typing", "analyzing", + "passed", "safeMisses", "failed"), + ) + bear_update.add_argument("--corrected-words", type=int) + bear_update.add_argument("--missed-words", type=int) + bear_update.add_argument("--message") + bear_update.add_argument("--bear-focused", action=argparse.BooleanOptionalAction) arm = commands.add_parser("arm") arm.add_argument("--run-id", required=True) @@ -97,6 +130,40 @@ def main() -> int: instruction=arguments.instruction, demoMode=arguments.demo_mode, ) + elif arguments.command == "bear-prepare": + response = send( + "bear-monitor-prepare", + runID=arguments.run_id, + caseCount=arguments.case_count, + message=arguments.message, + ) + elif arguments.command == "bear-begin": + if not arguments.scheduled_text.is_file(): + raise RuntimeError( + f"scheduled-text file does not exist: {arguments.scheduled_text}" + ) + response = send( + "bear-monitor-begin", + runID=arguments.run_id, + caseIndex=arguments.case_index, + caseCount=arguments.case_count, + intervalMs=arguments.interval_ms, + wordCount=arguments.word_count, + scheduledText=arguments.scheduled_text.read_text(), + startDelayMs=arguments.start_delay_ms, + bearFocused=arguments.bear_focused, + ) + elif arguments.command == "bear-update": + response = send( + "bear-monitor-update", + phase=arguments.phase, + correctedWords=arguments.corrected_words, + missedWords=arguments.missed_words, + message=arguments.message, + bearFocused=arguments.bear_focused, + ) + elif arguments.command == "bear-reset": + response = send("bear-monitor-reset") elif arguments.command == "wait": deadline = time.monotonic() + arguments.timeout_seconds response = send("status") diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/TypoverBearMonitor.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/TypoverBearMonitor.swift new file mode 100644 index 000000000..2e114ef03 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/TypoverBearMonitor.swift @@ -0,0 +1,211 @@ +import Foundation + +public enum TypoverBearMonitorPhase: String, Codable, Equatable, Sendable { + case inactive + case preparing + case waitingForBear + case countdown + case typing + case analyzing + case passed + case safeMisses + case failed +} + +public struct TypoverBearMonitorSnapshot: Codable, Equatable, Sendable { + public let phase: TypoverBearMonitorPhase + public let runID: String + public let caseIndex: Int + public let caseCount: Int + public let intervalMs: Int + public let wordCount: Int + public let scheduledText: String + public let scheduledStartAtNs: UInt64? + public let scheduledEvents: [CapturedKeyEvent] + public let correctedWords: Int + public let missedWords: Int + public let message: String + public let bearFocused: Bool + + public var isActive: Bool { + phase != .inactive + } + + public var totalKeyPresses: Int { + scheduledText.count + } + + public var presentedKeyPresses: Int { + scheduledEvents.count + } + + public var completion: Double { + guard totalKeyPresses > 0 else { return 0 } + return min(1, Double(presentedKeyPresses) / Double(totalKeyPresses)) + } + + public var keysPerSecond: Double { + intervalMs > 0 ? 1000 / Double(intervalMs) : 0 + } +} + +public final class TypoverBearMonitorSession { + private var phase = TypoverBearMonitorPhase.inactive + private var runID = "" + private var caseIndex = 0 + private var caseCount = 0 + private var intervalMs = 0 + private var wordCount = 0 + private var scheduledText = "" + private var scheduledStartAtNs: UInt64? + private var correctedWords = 0 + private var missedWords = 0 + private var message = "" + private var bearFocused = false + + public init() {} + + @discardableResult + public func prepare( + runID: String, + caseCount: Int, + message: String, + nowNs _: UInt64 + ) -> Bool { + guard !runID.isEmpty, runID.count <= 80, + (1 ... 100).contains(caseCount) + else { return false } + reset() + self.runID = runID + self.caseCount = caseCount + self.message = String(message.prefix(240)) + phase = .waitingForBear + return true + } + + @discardableResult + public func begin( + runID: String, + caseIndex: Int, + caseCount: Int, + intervalMs: Int, + wordCount: Int, + scheduledText: String, + startDelayMs: Int, + bearFocused: Bool, + nowNs: UInt64 + ) -> Bool { + guard !runID.isEmpty, runID.count <= 80, + caseIndex >= 1, caseCount >= caseIndex, caseCount <= 100, + (4 ... 2000).contains(intervalMs), + (1 ... 100).contains(wordCount), + !scheduledText.isEmpty, scheduledText.count <= 512, + scheduledText.unicodeScalars.allSatisfy(\.isASCII), + (100 ... 60000).contains(startDelayMs) + else { return false } + + self.runID = runID + self.caseIndex = caseIndex + self.caseCount = caseCount + self.intervalMs = intervalMs + self.wordCount = wordCount + self.scheduledText = scheduledText + scheduledStartAtNs = nowNs &+ UInt64(startDelayMs) &* 1_000_000 + correctedWords = 0 + missedWords = 0 + message = "Bear owns keyboard focus" + self.bearFocused = bearFocused + phase = .countdown + return true + } + + @discardableResult + public func update( + phase: TypoverBearMonitorPhase, + correctedWords: Int? = nil, + missedWords: Int? = nil, + message: String? = nil, + bearFocused: Bool? = nil + ) -> Bool { + guard self.phase != .inactive, phase != .inactive else { return false } + let nextCorrected = correctedWords ?? self.correctedWords + let nextMissed = missedWords ?? self.missedWords + guard nextCorrected >= 0, nextMissed >= 0, + nextCorrected + nextMissed <= wordCount + else { return false } + self.phase = phase + self.correctedWords = nextCorrected + self.missedWords = nextMissed + if let message { + self.message = String(message.prefix(240)) + } + if let bearFocused { + self.bearFocused = bearFocused + } + return true + } + + public func reset() { + phase = .inactive + runID = "" + caseIndex = 0 + caseCount = 0 + intervalMs = 0 + wordCount = 0 + scheduledText = "" + scheduledStartAtNs = nil + correctedWords = 0 + missedWords = 0 + message = "" + bearFocused = false + } + + public func snapshot(nowNs: UInt64) -> TypoverBearMonitorSnapshot { + TypoverBearMonitorSnapshot( + phase: phase, + runID: runID, + caseIndex: caseIndex, + caseCount: caseCount, + intervalMs: intervalMs, + wordCount: wordCount, + scheduledText: scheduledText, + scheduledStartAtNs: scheduledStartAtNs, + scheduledEvents: scheduledEvents(nowNs: nowNs), + correctedWords: correctedWords, + missedWords: missedWords, + message: message, + bearFocused: bearFocused + ) + } + + private func scheduledEvents(nowNs: UInt64) -> [CapturedKeyEvent] { + guard phase != .inactive, let scheduledStartAtNs, + nowNs >= scheduledStartAtNs + else { return [] } + let intervalNs = UInt64(intervalMs) * 1_000_000 + let elapsed = nowNs - scheduledStartAtNs + let count = min(scheduledText.count, Int(elapsed / intervalNs) + 1) + return Array(scheduledText.prefix(count)).enumerated().map { index, character in + CapturedKeyEvent( + sequence: index + 1, + phase: .down, + keyCode: Self.keyCode(for: character), + characters: String(character), + modifiers: 0, + isRepeat: false, + timestampNs: scheduledStartAtNs &+ UInt64(index) &* intervalNs + ) + } + } + + private static func keyCode(for character: Character) -> UInt16 { + switch character { + case "t": 17 + case "e": 14 + case "h": 4 + case " ": 49 + case "\n", "\r": 36 + default: 0 + } + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift index 58f631e58..91c597b75 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -12,6 +12,17 @@ private struct ControlCommand: Codable { let settleMs: UInt64? let instruction: String? let demoMode: Bool? + let phase: String? + let caseIndex: Int? + let caseCount: Int? + let intervalMs: Int? + let wordCount: Int? + let scheduledText: String? + let startDelayMs: Int? + let correctedWords: Int? + let missedWords: Int? + let message: String? + let bearFocused: Bool? } private struct ControlResponse: Codable { @@ -21,6 +32,7 @@ private struct ControlResponse: Codable { let processID: Int32 let snapshot: CaptureSnapshot let systemReadiness: SystemReadinessAssessment + let bearMonitor: TypoverBearMonitorSnapshot } private func monotonicNow() -> UInt64 { @@ -127,6 +139,7 @@ private final class SystemResourceMonitor { private final class CaptureCanvas: NSView { let session: CaptureSession + let bearMonitor: TypoverBearMonitorSession var onFocusChange: ((Bool) -> Void)? var systemReadiness = SystemReadinessAssessment.calibrating var operatorInstruction = "" @@ -148,8 +161,9 @@ private final class CaptureCanvas: NSView { return NSImage(contentsOf: url) }() - init(session: CaptureSession) { + init(session: CaptureSession, bearMonitor: TypoverBearMonitorSession) { self.session = session + self.bearMonitor = bearMonitor super.init(frame: .zero) wantsLayer = true } @@ -240,6 +254,17 @@ private final class CaptureCanvas: NSView { opaqueBackground.setFill() bounds.fill() + let bearSnapshot = bearMonitor.snapshot(nowNs: nowNs) + if bearSnapshot.isActive { + drawBearMonitor( + bearSnapshot, + nowNs: nowNs, + layout: layout, + inset: inset + ) + return + } + let accent = systemReadiness.state == .waiting && snapshot.state == .idle ? brandOrange : color(for: snapshot.state) let headerHeight = CGFloat(layout.headerHeight) @@ -426,6 +451,254 @@ private final class CaptureCanvas: NSView { } } + private func drawBearMonitor( + _ snapshot: TypoverBearMonitorSnapshot, + nowNs: UInt64, + layout: CaptureLayoutMetrics, + inset: NSRect + ) { + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + let output = KeycapBurstModel.resolve( + events: snapshot.scheduledEvents, + pressedKeyCodes: [], + nowNs: nowNs, + reduceMotion: reduceMotion + ) + let accent: NSColor = switch snapshot.phase { + case .passed: .systemGreen + case .safeMisses: .systemOrange + case .failed: .systemRed + default: brandAmber + } + let cycleNs: UInt64 = snapshot.phase == .typing ? 1_900_000_000 : 3_800_000_000 + let cycle = Double(nowNs % cycleNs) / Double(cycleNs) + let breath = reduceMotion ? 0.5 : (sin(cycle * .pi * 2 - .pi / 2) + 1) / 2 + let headerHeight = CGFloat(layout.headerHeight) + let header = NSRect( + x: inset.minX, y: inset.maxY - headerHeight, + width: inset.width, height: headerHeight + ) + let headerPath = NSBezierPath( + roundedRect: header, + xRadius: layout.mode == .tiny ? 10 : 14, + yRadius: layout.mode == .tiny ? 10 : 14 + ) + accent.withAlphaComponent(0.08 + breath * 0.025).setFill() + headerPath.fill() + accent.withAlphaComponent(0.24).setStroke() + headerPath.lineWidth = 1 + headerPath.stroke() + + let markSize: CGFloat = layout.mode == .tiny ? 22 : 34 + let markFrame = NSRect( + x: header.minX + (layout.mode == .tiny ? 8 : 14), + y: header.midY - markSize / 2, + width: markSize, + height: markSize + ) + drawJigLogo(in: markFrame) + let focusWidth: CGFloat = layout.mode == .tiny ? 88 : 160 + let titleX = markFrame.maxX + (layout.mode == .tiny ? 6 : 10) + drawText( + layout.mode == .tiny ? "TYPOVER · BEAR" : "TYPOVER · BEAR PHYSICAL HID", + rect: NSRect( + x: titleX, y: header.midY - 10, + width: max(40, header.maxX - focusWidth - titleX - 14), height: 22 + ), + font: font( + size: layout.mode == .tiny ? 10 : 14, + weight: .semibold, + monospaced: true + ), + color: accent + ) + let focusLabel = if snapshot.bearFocused { + "BEAR FOCUSED" + } else if snapshot.phase == .waitingForBear || snapshot.phase == .preparing { + "FOCUS BEAR" + } else { + "FOCUS LOST" + } + drawText( + focusLabel, + rect: NSRect( + x: header.maxX - focusWidth - (layout.mode == .tiny ? 10 : 18), + y: header.midY - 10, width: focusWidth, height: 22 + ), + font: font( + size: layout.mode == .tiny ? 9 : 12, + weight: .bold, + monospaced: true + ), + color: snapshot.bearFocused ? .systemGreen : .systemRed, + alignment: .right + ) + drawActivityRail( + in: header, + completion: snapshot.completion, + breath: breath, + eventPulse: output.recentPresses > 0 ? min(1, output.intensity + 0.25) : 0, + glintPosition: cycle, + stateAccent: accent + ) + + var cursor = header.minY - (layout.mode == .tiny ? 5 : 12) + let stateHeight: CGFloat = layout.mode == .tiny ? 28 : 48 + drawText( + bearPhaseTitle(snapshot, nowNs: nowNs), + rect: NSRect( + x: inset.minX, y: cursor - stateHeight, + width: inset.width, height: stateHeight + ), + font: font(size: CGFloat(layout.stateFontSize), weight: .bold), + color: accent + ) + cursor -= stateHeight + let runHeight: CGFloat = layout.mode == .tiny ? 14 : 22 + let caseSummary = if snapshot.caseIndex == 0 { + "\(snapshot.caseCount) CASE MATRIX · WAITING FOR BEAR" + } else { + String( + format: "CASE %d OF %d · %d MS / KEY · %.1f KEYS / SEC", + snapshot.caseIndex, + snapshot.caseCount, + snapshot.intervalMs, + snapshot.keysPerSecond + ) + } + drawText( + caseSummary, + rect: NSRect( + x: inset.minX, y: cursor - runHeight, + width: inset.width, height: runHeight + ), + font: font( + size: layout.mode == .tiny ? 9 : 14, + weight: .regular, + monospaced: true + ), + color: .secondaryLabelColor + ) + cursor -= runHeight + (layout.mode == .tiny ? 4 : 10) + + let labelHeight: CGFloat = layout.mode == .tiny ? 11 : 16 + let fieldHeight = CGFloat(layout.fieldHeight) + let fieldBlockHeight = labelHeight + fieldHeight + (layout.mode == .tiny ? 4 : 8) + let inputSummary = snapshot.wordCount == 0 + ? "Continuous \"teh\" bursts · \(snapshot.caseCount) repeatable timing cases" + : "\"teh\" × \(snapshot.wordCount) · \(snapshot.totalKeyPresses) scheduled key presses" + drawField( + label: "PHYSICAL TEST INPUT", + value: inputSummary, + frame: NSRect( + x: inset.minX, y: cursor - labelHeight - fieldHeight, + width: inset.width, height: fieldHeight + ), + labelHeight: labelHeight, + compact: layout.mode != .regular + ) + cursor -= fieldBlockHeight + let resultText = switch snapshot.phase { + case .passed: + "\(snapshot.correctedWords) corrected · no safe misses" + case .safeMisses: + "\(snapshot.correctedWords) corrected · \(snapshot.missedWords) safe misses" + case .failed: + "Evidence invalid · no result credited" + default: + "Waiting for exact Bear text + Typover log evidence" + } + let resultColor: NSColor = switch snapshot.phase { + case .passed: .systemGreen + case .safeMisses: .systemOrange + case .failed: .systemRed + default: .labelColor + } + drawField( + label: "VERIFIED TYPOVER RESULT", + value: resultText, + frame: NSRect( + x: inset.minX, y: cursor - labelHeight - fieldHeight, + width: inset.width, height: fieldHeight + ), + labelHeight: labelHeight, + compact: layout.mode != .regular, + valueColor: resultColor + ) + cursor -= fieldBlockHeight + + let issueHeight: CGFloat = layout.mode == .tiny ? 18 : 28 + let detail = snapshot.message.isEmpty + ? "The monitor is presentation-only; Bear owns the physical keyboard." + : snapshot.message + drawText( + detail, + rect: NSRect( + x: inset.minX, y: cursor - issueHeight, + width: inset.width, height: issueHeight + ), + font: font( + size: layout.mode == .tiny ? 9 : 13, + weight: .medium + ), + color: snapshot.bearFocused ? .secondaryLabelColor : .systemRed + ) + cursor -= issueHeight + (layout.mode == .regular ? 12 : 6) + + let footerClearance: CGFloat = layout.showsFooter ? 28 : 0 + let burstFrame = NSRect( + x: inset.minX, + y: inset.minY + footerClearance, + width: inset.width, + height: max(54, cursor - inset.minY - footerClearance) + ) + let keyEvidence: String = if output.totalPresses == 0 { + snapshot.phase == .countdown + ? "WAITING FOR ESP32 START" : "NO SCHEDULED KEYS YET" + } else if output.presentedPresses < snapshot.totalKeyPresses { + "SCHEDULED \(output.presentedPresses)/\(snapshot.totalKeyPresses) · BURST \(output.recentPresses)" + } else { + "\(snapshot.totalKeyPresses) PHYSICAL PRESSES SCHEDULED" + } + drawKeycapBurst( + output: output, + title: layout.mode == .tiny ? "ESP32 KEYS" : "SCHEDULED ESP32 KEYCAP STACK", + evidence: keyEvidence, + frame: burstFrame, + mode: layout.mode + ) + + if layout.showsFooter { + drawText( + "Presentation only · Bear remains frontmost · results require Bear text and Typover logs", + rect: NSRect(x: inset.minX, y: inset.minY, width: inset.width, height: 20), + font: font(size: layout.mode == .regular ? 12 : 10, weight: .regular), + color: .tertiaryLabelColor + ) + } + } + + private func bearPhaseTitle( + _ snapshot: TypoverBearMonitorSnapshot, + nowNs: UInt64 + ) -> String { + switch snapshot.phase { + case .inactive: return "READY" + case .preparing: return "PREPARING FIXTURE" + case .waitingForBear: return "FOCUS THE BEAR NOTE" + case .countdown: + guard let start = snapshot.scheduledStartAtNs, start > nowNs else { + return "RAPID TYPING" + } + return String(format: "STARTING IN %.1F S", Double(start - nowNs) / 1_000_000_000) + case .typing: return "RAPID TYPING" + case .analyzing: return "VERIFYING IN BEAR" + case .passed: return "ALL CORRECTIONS OBSERVED" + case .safeMisses: return "SAFE MISSES OBSERVED" + case .failed: return "EVIDENCE INVALID" + } + } + private func drawOperatorInstruction( _ instruction: String, in bounds: NSRect, @@ -520,6 +793,31 @@ private final class CaptureCanvas: NSView { nowNs: nowNs, reduceMotion: reduceMotion ) + let evidence = if output.totalPresses == 0 { + "WAITING FOR KEY-DOWN" + } else if output.presentedPresses < output.totalPresses { + "PLAYING \(output.presentedPresses)/\(output.totalPresses) · BURST \(output.recentPresses)" + } else if output.recentPresses > 0 { + "\(output.recentPresses) NOW · \(output.totalPresses) TOTAL" + } else { + "\(output.totalPresses) PRESSES CAPTURED" + } + drawKeycapBurst( + output: output, + title: mode == .tiny ? "LIVE KEYS" : "LIVE KEYCAP STACK", + evidence: evidence, + frame: frame, + mode: mode + ) + } + + private func drawKeycapBurst( + output: KeycapBurstOutput, + title: String, + evidence: String, + frame: NSRect, + mode: CaptureLayoutMode + ) { let panel = NSBezierPath( roundedRect: frame, xRadius: mode == .tiny ? 10 : 16, @@ -534,7 +832,7 @@ private final class CaptureCanvas: NSView { let titleHeight: CGFloat = mode == .tiny ? 14 : 20 let titleInset: CGFloat = mode == .tiny ? 9 : 14 drawText( - mode == .tiny ? "LIVE KEYS" : "LIVE KEYCAP STACK", + title, rect: NSRect( x: frame.minX + titleInset, y: frame.maxY - titleHeight - (mode == .tiny ? 5 : 9), @@ -544,15 +842,6 @@ private final class CaptureCanvas: NSView { font: font(size: mode == .tiny ? 8 : 11, weight: .semibold, monospaced: true), color: NSColor.tertiaryLabelColor ) - let evidence = if output.totalPresses == 0 { - "WAITING FOR KEY-DOWN" - } else if output.presentedPresses < output.totalPresses { - "PLAYING \(output.presentedPresses)/\(output.totalPresses) · BURST \(output.recentPresses)" - } else if output.recentPresses > 0 { - "\(output.recentPresses) NOW · \(output.totalPresses) TOTAL" - } else { - "\(output.totalPresses) PRESSES CAPTURED" - } drawText( evidence, rect: NSRect( @@ -773,25 +1062,43 @@ private final class CaptureCanvas: NSView { in header: NSRect, motion: CaptureBrandMotion, stateAccent: NSColor + ) { + drawActivityRail( + in: header, + completion: motion.completion, + breath: motion.breath, + eventPulse: motion.eventPulse, + glintPosition: motion.glintPosition, + stateAccent: stateAccent + ) + } + + private func drawActivityRail( + in header: NSRect, + completion: Double, + breath _: Double, + eventPulse: Double, + glintPosition: Double, + stateAccent: NSColor ) { let rail = NSRect(x: header.minX + 12, y: header.minY + 3, width: header.width - 24, height: 2) NSColor.labelColor.withAlphaComponent(0.07).setFill() NSBezierPath(roundedRect: rail, xRadius: 1, yRadius: 1).fill() - if motion.completion > 0 { + if completion > 0 { let fill = NSRect( x: rail.minX, y: rail.minY, - width: max(2, rail.width * CGFloat(motion.completion)), height: rail.height + width: max(2, rail.width * CGFloat(completion)), height: rail.height ) stateAccent.withAlphaComponent(0.72).setFill() NSBezierPath(roundedRect: fill, xRadius: 1, yRadius: 1).fill() } - let travel = rail.minX + rail.width * CGFloat(motion.glintPosition) + let travel = rail.minX + rail.width * CGFloat(glintPosition) drawGlint( center: NSPoint(x: travel, y: rail.midY), - size: 5 + CGFloat(motion.eventPulse * 4), - color: brandLight.withAlphaComponent(0.55 + motion.eventPulse * 0.4) + size: 5 + CGFloat(eventPulse * 4), + color: brandLight.withAlphaComponent(0.55 + eventPulse * 0.4) ) } @@ -815,6 +1122,7 @@ private final class CaptureCanvas: NSView { private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { private let session = CaptureSession() + private let bearMonitor = TypoverBearMonitorSession() private let resourceMonitor = SystemResourceMonitor() private var window: NSWindow! private var canvas: CaptureCanvas! @@ -870,7 +1178,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega } private func buildWindow() { - canvas = CaptureCanvas(session: session) + canvas = CaptureCanvas(session: session, bearMonitor: bearMonitor) canvas.onFocusChange = { [weak self] focused in self?.session.noteFocus(focused, nowNs: monotonicNow()) } @@ -917,6 +1225,11 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega canvas.needsDisplay = true } let snapshot = session.snapshot(nowNs: nowNs) + let bearSnapshot = bearMonitor.snapshot(nowNs: nowNs) + if bearSnapshot.isActive { + canvas.needsDisplay = true + return + } let burstAnimating = KeycapBurstModel.resolve( events: snapshot.events, pressedKeyCodes: snapshot.pressedKeyCodes, @@ -970,6 +1283,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega } private func beginFocus(_ command: ControlCommand, attempt: Int) { + leaveBearMonitorMode() window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) window.makeFirstResponder(canvas) @@ -986,11 +1300,13 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega message: focused ? "capture jig focused" : "capture jig could not acquire keyboard focus", processID: ProcessInfo.processInfo.processIdentifier, snapshot: session.snapshot(nowNs: monotonicNow()), - systemReadiness: resourceMonitor.assessment + systemReadiness: resourceMonitor.assessment, + bearMonitor: bearMonitor.snapshot(nowNs: monotonicNow()) )) } private func beginArm(_ command: ControlCommand, attempt: Int) { + leaveBearMonitorMode() let readiness = resourceMonitor.assessment let demoMode = command.demoMode == true guard demoMode || readiness.canProceed else { @@ -1004,7 +1320,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega message: "capture paused: \(readiness.detail)\(guidance)", processID: ProcessInfo.processInfo.processIdentifier, snapshot: session.snapshot(nowNs: monotonicNow()), - systemReadiness: readiness + systemReadiness: readiness, + bearMonitor: bearMonitor.snapshot(nowNs: monotonicNow()) )) return } @@ -1041,7 +1358,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega : "capture refused: focus or command bounds invalid", processID: ProcessInfo.processInfo.processIdentifier, snapshot: session.snapshot(nowNs: monotonicNow()), - systemReadiness: readiness + systemReadiness: readiness, + bearMonitor: bearMonitor.snapshot(nowNs: monotonicNow()) )) } @@ -1056,8 +1374,65 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega switch command.action { case "status": break + case "bear-monitor-prepare": + session.reset() + ok = bearMonitor.prepare( + runID: command.runID ?? "", + caseCount: command.caseCount ?? 0, + message: command.message ?? "Focus the disposable Bear note", + nowNs: monotonicNow() + ) + if ok { + enterBearMonitorMode() + message = "Typover Bear focus gate visible without taking keyboard focus" + } else { + bearMonitor.reset() + message = "Typover Bear focus gate refused invalid matrix bounds" + } + case "bear-monitor-begin": + session.reset() + ok = bearMonitor.begin( + runID: command.runID ?? "", + caseIndex: command.caseIndex ?? 0, + caseCount: command.caseCount ?? 0, + intervalMs: command.intervalMs ?? 0, + wordCount: command.wordCount ?? 0, + scheduledText: command.scheduledText ?? "", + startDelayMs: command.startDelayMs ?? 0, + bearFocused: command.bearFocused == true, + nowNs: monotonicNow() + ) + if ok { + enterBearMonitorMode() + message = "Typover Bear monitor visible without taking keyboard focus" + } else { + bearMonitor.reset() + message = "Typover Bear monitor refused invalid case bounds" + } + case "bear-monitor-update": + guard let rawPhase = command.phase, + let phase = TypoverBearMonitorPhase(rawValue: rawPhase) + else { + ok = false + message = "Typover Bear monitor update needs a valid phase" + break + } + ok = bearMonitor.update( + phase: phase, + correctedWords: command.correctedWords, + missedWords: command.missedWords, + message: command.message, + bearFocused: command.bearFocused + ) + message = ok + ? "Typover Bear monitor updated" + : "Typover Bear monitor refused inconsistent evidence" + case "bear-monitor-reset": + leaveBearMonitorMode() + message = "Typover Bear monitor reset" case "reset": session.reset() + leaveBearMonitorMode() canvas.operatorInstruction = "" persistedTerminalKey = "" runActive = false @@ -1082,10 +1457,30 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelega message: message, processID: ProcessInfo.processInfo.processIdentifier, snapshot: snapshot, - systemReadiness: resourceMonitor.assessment + systemReadiness: resourceMonitor.assessment, + bearMonitor: bearMonitor.snapshot(nowNs: monotonicNow()) ) } + private func enterBearMonitorMode() { + runActive = false + canvas.operatorInstruction = "" + window.title = "Typover · Bear Physical HID Monitor" + window.level = .floating + window.hidesOnDeactivate = false + window.ignoresMouseEvents = true + window.orderFrontRegardless() + canvas.needsDisplay = true + } + + private func leaveBearMonitorMode() { + bearMonitor.reset() + window?.ignoresMouseEvents = false + window?.level = .normal + window?.title = "KeyPath HID Capture Jig" + canvas?.needsDisplay = true + } + private func persistTerminalResultIfNeeded(_ snapshot: CaptureSnapshot) { guard snapshot.state == .passed || snapshot.state == .failed, !snapshot.runID.isEmpty else { return } let key = "\(snapshot.runID):\(snapshot.state.rawValue):\(snapshot.events.count)" diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/TypoverBearMonitorTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/TypoverBearMonitorTests.swift new file mode 100644 index 000000000..970d10fe5 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/TypoverBearMonitorTests.swift @@ -0,0 +1,92 @@ +import HIDCaptureCore +import XCTest + +final class TypoverBearMonitorTests: XCTestCase { + private let start: UInt64 = 1_000_000_000 + + func testBeginCreatesCountdownWithoutInventingObservedKeys() { + let session = TypoverBearMonitorSession() + XCTAssertTrue(session.begin( + runID: "bear-1", caseIndex: 1, caseCount: 4, + intervalMs: 40, wordCount: 2, scheduledText: "teh teh \n", + startDelayMs: 1500, bearFocused: true, nowNs: start + )) + + let snapshot = session.snapshot(nowNs: start + 1_000_000_000) + XCTAssertEqual(snapshot.phase, .countdown) + XCTAssertTrue(snapshot.scheduledEvents.isEmpty) + XCTAssertEqual(snapshot.totalKeyPresses, 9) + XCTAssertEqual(snapshot.keysPerSecond, 25, accuracy: 0.001) + } + + func testPrepareShowsBearFocusGateWithoutStartingAKeySchedule() { + let session = TypoverBearMonitorSession() + XCTAssertTrue(session.prepare( + runID: "bear-matrix", caseCount: 4, + message: "Focus the disposable Bear note", nowNs: start + )) + + let snapshot = session.snapshot(nowNs: start + 90_000_000_000) + XCTAssertEqual(snapshot.phase, .waitingForBear) + XCTAssertEqual(snapshot.caseIndex, 0) + XCTAssertEqual(snapshot.caseCount, 4) + XCTAssertTrue(snapshot.scheduledEvents.isEmpty) + XCTAssertNil(snapshot.scheduledStartAtNs) + } + + func testScheduledKeycapsFollowThePhysicalCadence() { + let session = TypoverBearMonitorSession() + XCTAssertTrue(session.begin( + runID: "bear-2", caseIndex: 2, caseCount: 4, + intervalMs: 100, wordCount: 1, scheduledText: "teh \n", + startDelayMs: 500, bearFocused: true, nowNs: start + )) + XCTAssertTrue(session.update(phase: .typing)) + + let first = session.snapshot(nowNs: start + 500_000_000) + let third = session.snapshot(nowNs: start + 700_000_000) + XCTAssertEqual(first.scheduledEvents.map(\.characters), ["t"]) + XCTAssertEqual(third.scheduledEvents.map(\.characters), ["t", "e", "h"]) + XCTAssertEqual(third.scheduledEvents.map(\.timestampNs), [ + start + 500_000_000, + start + 600_000_000, + start + 700_000_000, + ]) + } + + func testAnalysisCountsAreBoundedByTheCaseWordCount() { + let session = TypoverBearMonitorSession() + XCTAssertTrue(session.begin( + runID: "bear-3", caseIndex: 3, caseCount: 4, + intervalMs: 80, wordCount: 20, scheduledText: "teh ", + startDelayMs: 500, bearFocused: true, nowNs: start + )) + + XCTAssertTrue(session.update( + phase: .safeMisses, + correctedWords: 14, + missedWords: 6, + message: "14 applied · 6 safe misses", + bearFocused: true + )) + XCTAssertFalse(session.update( + phase: .passed, + correctedWords: 21, + missedWords: 0 + )) + let snapshot = session.snapshot(nowNs: start) + XCTAssertEqual(snapshot.correctedWords, 14) + XCTAssertEqual(snapshot.missedWords, 6) + XCTAssertEqual(snapshot.phase, .safeMisses) + } + + func testInvalidOrNonASCIIPlansFailClosed() { + let session = TypoverBearMonitorSession() + XCTAssertFalse(session.begin( + runID: "", caseIndex: 0, caseCount: 0, + intervalMs: 3, wordCount: 0, scheduledText: "é", + startDelayMs: 0, bearFocused: false, nowNs: start + )) + XCTAssertEqual(session.snapshot(nowNs: start).phase, .inactive) + } +} diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 68d59b387..17703ff70 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -233,6 +233,27 @@ uses capture timestamps, caps the visible stack at ten keycaps, and gives faster ages out in 900 ms, while the complete, unmodified event ledger remains in the JSON artifact. Reduce Motion removes travel, compression, and paced playback while preserving live labels and evidence. +The same AppKit app also has a nonactivating **Typover · Bear Physical HID** monitor mode. In this +mode Bear—not the Jig—must remain frontmost, so the Jig cannot observe keyboard events directly. It +instead animates the ESP32 schedule supplied by the Typover harness and labels that animation +`SCHEDULED ESP32 KEYCAP STACK`. Corrections and misses appear only after the harness verifies the exact +Bear range against Typover's unified-log evidence. This keeps the useful rapid-keypress visualization +without presenting scheduled input as captured input. + +The local file-RPC client exposes the monitor independently of a physical run: + +```bash +Scripts/lab/hid-capture-jig-tool open +Scripts/lab/hid-capture-jig-client bear-prepare \ + --run-id preview --case-count 4 --message "Focus the disposable Bear note" +``` + +`bear-begin` starts a scheduled-key presentation, `bear-update` supplies verified progress or results, +and `bear-reset` returns to the normal capture UI. Monitor commands order the floating window without +activating it, ignore mouse events while monitoring, and therefore do not take keyboard focus from +Bear. Normal `focus` or `arm` commands leave monitor mode and restore the Jig's interactive capture +behavior. + This is the baseline reliability policy. Deliberate load-matrix cases use the runner's bounded CPU envelope rather than bypassing the gate with an unstructured "ignore busy" switch. The runner performs the normal calm preflight first, then starts the requested number of tracked CPU workers diff --git a/Scripts/lab/tests/hid-capture-jig-client-tests.py b/Scripts/lab/tests/hid-capture-jig-client-tests.py index 0325c8d49..bb3cf72fd 100755 --- a/Scripts/lab/tests/hid-capture-jig-client-tests.py +++ b/Scripts/lab/tests/hid-capture-jig-client-tests.py @@ -96,6 +96,33 @@ def test_focus_uses_a_distinct_control_action(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(jig.commands[0]["action"], "focus") + def test_bear_monitor_transports_schedule_without_claiming_capture(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + scheduled = directory / "scheduled.txt" + scheduled.write_text("teh teh \n") + with FakeJig(directory) as jig: + begin = self.run_client( + directory, "bear-begin", "--run-id", "typover-1", + "--case-index", "2", "--case-count", "4", + "--interval-ms", "60", "--word-count", "2", + "--scheduled-text", str(scheduled), "--start-delay-ms", "1500", + "--bear-focused", + ) + update = self.run_client( + directory, "bear-update", "--phase", "safeMisses", + "--corrected-words", "1", "--missed-words", "1", + "--message", "one safe miss", "--bear-focused", + ) + self.assertEqual(begin.returncode, 0, begin.stderr) + self.assertEqual(update.returncode, 0, update.stderr) + self.assertEqual(jig.commands[0]["action"], "bear-monitor-begin") + self.assertEqual(jig.commands[0]["scheduledText"], "teh teh \n") + self.assertEqual(jig.commands[0]["intervalMs"], 60) + self.assertEqual(jig.commands[1]["action"], "bear-monitor-update") + self.assertEqual(jig.commands[1]["phase"], "safeMisses") + self.assertEqual(jig.commands[1]["correctedWords"], 1) + def test_missing_app_fails_with_actionable_message(self): with tempfile.TemporaryDirectory() as temporary: result = self.run_client(pathlib.Path(temporary), "status", timeout="0.1") From dc7c8249ef80ffa4e57f43e631d8c1b1305a0159 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 31 Jul 2026 07:46:27 -0700 Subject: [PATCH 95/99] Add Bear branding to HID test displays --- .../Sources/HIDCaptureJig/main.swift | 93 +++++++++++++++---- Scripts/lab/pico-hid-fixture-client | 4 +- Scripts/lab/pico-hid-fixture/README.md | 8 ++ .../src/fixture_presentation.c | 20 ++++ .../src/fixture_presentation.h | 9 ++ .../src/fixture_visual_model.c | 15 ++- .../src/fixture_visual_model.h | 1 + .../main/fixture_display.c | 72 +++++++++++++- .../main/fixture_http.c | 11 ++- .../main/fixture_runtime.c | 1 + .../tests/fixture_core_tests.c | 14 +++ .../tests/pico-hid-fixture-client-tests.py | 11 ++- 12 files changed, 229 insertions(+), 30 deletions(-) diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift index 91c597b75..64174b382 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -154,6 +154,9 @@ private final class CaptureCanvas: NSView { private let brandLight = NSColor( calibratedRed: 1.0, green: 0.96, blue: 0.83, alpha: 1 ) + private let bearCoral = NSColor( + calibratedRed: 0.88, green: 0.25, blue: 0.18, alpha: 1 + ) private lazy var jigLogo: NSImage? = { guard let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns") else { return nil @@ -161,6 +164,15 @@ private final class CaptureCanvas: NSView { return NSImage(contentsOf: url) }() + private lazy var bearLogo: NSImage? = { + guard let url = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "net.shinyfrog.bear" + ) else { + return nil + } + return NSWorkspace.shared.icon(forFile: url.path) + }() + init(session: CaptureSession, bearMonitor: TypoverBearMonitorSession) { self.session = session self.bearMonitor = bearMonitor @@ -483,35 +495,44 @@ private final class CaptureCanvas: NSView { xRadius: layout.mode == .tiny ? 10 : 14, yRadius: layout.mode == .tiny ? 10 : 14 ) - accent.withAlphaComponent(0.08 + breath * 0.025).setFill() + bearCoral.withAlphaComponent(0.09 + breath * 0.025).setFill() headerPath.fill() - accent.withAlphaComponent(0.24).setStroke() + bearCoral.withAlphaComponent(0.30).setStroke() headerPath.lineWidth = 1 headerPath.stroke() - let markSize: CGFloat = layout.mode == .tiny ? 22 : 34 + let markSize: CGFloat = layout.mode == .tiny ? 24 : 36 let markFrame = NSRect( x: header.minX + (layout.mode == .tiny ? 8 : 14), y: header.midY - markSize / 2, width: markSize, height: markSize ) - drawJigLogo(in: markFrame) - let focusWidth: CGFloat = layout.mode == .tiny ? 88 : 160 + drawBearLogo(in: markFrame) + let focusWidth: CGFloat = layout.mode == .tiny ? 88 : 142 let titleX = markFrame.maxX + (layout.mode == .tiny ? 6 : 10) - drawText( - layout.mode == .tiny ? "TYPOVER · BEAR" : "TYPOVER · BEAR PHYSICAL HID", - rect: NSRect( - x: titleX, y: header.midY - 10, - width: max(40, header.maxX - focusWidth - titleX - 14), height: 22 - ), - font: font( - size: layout.mode == .tiny ? 10 : 14, - weight: .semibold, - monospaced: true - ), - color: accent - ) + let titleWidth = max(40, header.maxX - focusWidth - titleX - 14) + if layout.mode == .tiny { + drawText( + "BEAR · TYPOVER", + rect: NSRect(x: titleX, y: header.midY - 9, width: titleWidth, height: 20), + font: font(size: 10, weight: .bold), + color: bearCoral + ) + } else { + drawText( + "BEAR", + rect: NSRect(x: titleX, y: header.midY - 2, width: titleWidth, height: 22), + font: font(size: 17, weight: .bold), + color: bearCoral + ) + drawText( + "TYPOVER · PHYSICAL HID TEST", + rect: NSRect(x: titleX, y: header.midY - 17, width: titleWidth, height: 15), + font: font(size: 9, weight: .semibold, monospaced: true), + color: NSColor.secondaryLabelColor + ) + } let focusLabel = if snapshot.bearFocused { "BEAR FOCUSED" } else if snapshot.phase == .waitingForBear || snapshot.phase == .preparing { @@ -1058,6 +1079,42 @@ private final class CaptureCanvas: NSView { NSGraphicsContext.restoreGraphicsState() } + private func drawBearLogo(in frame: NSRect) { + guard let bearLogo else { + let badge = NSBezierPath( + roundedRect: frame, + xRadius: frame.width * 0.24, + yRadius: frame.height * 0.24 + ) + bearCoral.setFill() + badge.fill() + drawText( + "B", + rect: NSRect( + x: frame.minX, + y: frame.midY - frame.height * 0.27, + width: frame.width, + height: frame.height * 0.58 + ), + font: font(size: frame.height * 0.48, weight: .heavy), + color: .white, + alignment: .center + ) + return + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current?.imageInterpolation = .high + bearLogo.draw( + in: frame, + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high] + ) + NSGraphicsContext.restoreGraphicsState() + } + private func drawActivityRail( in header: NSRect, motion: CaptureBrandMotion, diff --git a/Scripts/lab/pico-hid-fixture-client b/Scripts/lab/pico-hid-fixture-client index 7961c1fcd..e15907966 100755 --- a/Scripts/lab/pico-hid-fixture-client +++ b/Scripts/lab/pico-hid-fixture-client @@ -222,6 +222,7 @@ def parser() -> argparse.ArgumentParser: choices=("auto", "preparing", "countdown", "testing", "observing", "resolving", "result", "next")) present_command.add_argument("--result", choices=("none", "pass", "fail", "inconclusive"), default="none") + present_command.add_argument("--brand", choices=("none", "keypath", "bear"), default="none") present_command.add_argument("--progress", type=int, choices=range(0, 1001), default=0, metavar="0..1000") present_command.add_argument("--title", default="") present_command.add_argument("--detail", default="") @@ -275,7 +276,8 @@ def main() -> int: elif arguments.command == "trace": print_json(client.trace(arguments.start, arguments.limit)) elif arguments.command == "present": print_json(client.present({ - "phase": arguments.phase, "result": arguments.result, "progress": arguments.progress, + "phase": arguments.phase, "result": arguments.result, "brand": arguments.brand, + "progress": arguments.progress, "title": arguments.title, "detail": arguments.detail, "next": arguments.next, "reportsExpected": arguments.reports_expected, "reportsObserved": arguments.reports_observed, "dropped": arguments.dropped, "duplicated": arguments.duplicated, diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 17703ff70..f81da3e2c 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -240,6 +240,14 @@ instead animates the ESP32 schedule supplied by the Typover harness and labels t Bear range against Typover's unified-log evidence. This keeps the useful rapid-keypress visualization without presenting scheduled input as captured input. +Bear tests use explicit identity on both displays. The AppKit monitor loads Bear's installed macOS app +icon and gives the Bear wordmark primary placement above `TYPOVER · PHYSICAL HID TEST`. The fixture +presentation protocol carries `brand: bear`; the ESP32 then switches from its generic HID-oracle scene +to a coral Bear face, `BEAR / TYPOVER TEST` header, and Bear-specific preparation, typing, verification, +and result titles. The brand value is also returned in the fixture status JSON so artifacts can prove +which presentation mode was active. A firmware build verifies the LVGL scene compiles, but the physical +LCD color, alignment, and legibility must still be checked on the connected board. + The local file-RPC client exposes the monitor independently of a physical run: ```bash diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c index 05ec1cf69..1059abef9 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c @@ -32,6 +32,15 @@ const char *fixture_result_name(fixture_result_t result) { return "none"; } +const char *fixture_presentation_brand_name(fixture_presentation_brand_t brand) { + switch (brand) { + case FIXTURE_BRAND_NONE: return "none"; + case FIXTURE_BRAND_KEYPATH: return "keypath"; + case FIXTURE_BRAND_BEAR: return "bear"; + } + return "none"; +} + bool fixture_presentation_parse_phase(const char *value, fixture_presentation_phase_t *phase) { if (!value || !phase) return false; for (int candidate = FIXTURE_PRESENT_AUTO; candidate <= FIXTURE_PRESENT_NEXT; ++candidate) { @@ -54,6 +63,17 @@ bool fixture_presentation_parse_result(const char *value, fixture_result_t *resu return false; } +bool fixture_presentation_parse_brand(const char *value, fixture_presentation_brand_t *brand) { + if (!value || !brand) return false; + for (int candidate = FIXTURE_BRAND_NONE; candidate <= FIXTURE_BRAND_BEAR; ++candidate) { + if (strcmp(value, fixture_presentation_brand_name((fixture_presentation_brand_t)candidate)) == 0) { + *brand = (fixture_presentation_brand_t)candidate; + return true; + } + } + return false; +} + bool fixture_presentation_text_valid(const char *value, size_t maximum_length) { if (!value || strlen(value) > maximum_length) return false; for (const unsigned char *cursor = (const unsigned char *)value; *cursor; ++cursor) { diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h index ed0c1e46f..b01f5e8a3 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h +++ b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h @@ -23,9 +23,16 @@ typedef enum { FIXTURE_RESULT_INCONCLUSIVE, } fixture_result_t; +typedef enum { + FIXTURE_BRAND_NONE = 0, + FIXTURE_BRAND_KEYPATH, + FIXTURE_BRAND_BEAR, +} fixture_presentation_brand_t; + typedef struct { fixture_presentation_phase_t phase; fixture_result_t result; + fixture_presentation_brand_t brand; bool branded_firmware_update; uint16_t progress_per_mille; uint32_t reports_expected; @@ -43,8 +50,10 @@ typedef struct { void fixture_presentation_init(fixture_presentation_t *presentation); const char *fixture_presentation_phase_name(fixture_presentation_phase_t phase); const char *fixture_result_name(fixture_result_t result); +const char *fixture_presentation_brand_name(fixture_presentation_brand_t brand); bool fixture_presentation_parse_phase(const char *value, fixture_presentation_phase_t *phase); bool fixture_presentation_parse_result(const char *value, fixture_result_t *result); +bool fixture_presentation_parse_brand(const char *value, fixture_presentation_brand_t *brand); bool fixture_presentation_text_valid(const char *value, size_t maximum_length); #endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c index 92b5025c6..057c1f831 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c @@ -116,9 +116,14 @@ void fixture_visual_resolve(const fixture_ui_output_t *ui, fixture_visual_output_t *visual) { const char *title = scene_title(ui->scene); visual->icon = scene_icon(ui->scene); - visual->variant = presentation->branded_firmware_update - ? FIXTURE_VISUAL_KEYPATH_UPDATE - : FIXTURE_VISUAL_STANDARD; + if (presentation->branded_firmware_update || + presentation->brand == FIXTURE_BRAND_KEYPATH) { + visual->variant = FIXTURE_VISUAL_KEYPATH_UPDATE; + } else if (presentation->brand == FIXTURE_BRAND_BEAR) { + visual->variant = FIXTURE_VISUAL_BEAR_TEST; + } else { + visual->variant = FIXTURE_VISUAL_STANDARD; + } visual->accent_rgb = scene_accent(ui->scene); visual->progress_per_mille = ui->progress_per_mille; visual->angular_speed_milliradians = ui->scene == FIXTURE_UI_RUNNING ? 4200u : 1550u; @@ -133,6 +138,10 @@ void fixture_visual_resolve(const fixture_ui_output_t *ui, visual->accent_rgb = 0xf3a128u; visual->angular_speed_milliradians = 2500u; } + if (presentation->brand == FIXTURE_BRAND_BEAR && + presentation->result == FIXTURE_RESULT_NONE) { + visual->accent_rgb = 0xe34c42u; + } apply_result(presentation->result, visual, &title); if (ui->quality == FIXTURE_UI_PROTECTED) visual->angular_speed_milliradians = 480u; snprintf(visual->title, sizeof(visual->title), "%s", title); diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h index d355f59e8..f14623e9f 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h @@ -26,6 +26,7 @@ typedef enum { typedef enum { FIXTURE_VISUAL_STANDARD = 0, FIXTURE_VISUAL_KEYPATH_UPDATE, + FIXTURE_VISUAL_BEAR_TEST, } fixture_visual_variant_t; typedef struct { diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c index 8add0a66b..46f4f4935 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c @@ -36,6 +36,13 @@ typedef struct { lv_obj_t *brand_glint_vertical; lv_obj_t *icon_front; lv_obj_t *icon_back; + lv_obj_t *bear_ear_left; + lv_obj_t *bear_ear_right; + lv_obj_t *bear_head; + lv_obj_t *bear_muzzle; + lv_obj_t *bear_eye_left; + lv_obj_t *bear_eye_right; + lv_obj_t *bear_nose; lv_obj_t *keys[KEY_COUNT]; lv_obj_t *particles[PARTICLE_COUNT]; lv_obj_t *eyebrow; @@ -222,6 +229,41 @@ static void build_ui(void) { lv_obj_set_style_text_font(ui.icon_front, &lv_font_montserrat_20, 0); lv_obj_align(ui.icon_front, LV_ALIGN_CENTER, 0, -2); + /* Display-native Bear identity for Typover's app-specific physical tests. */ + ui.bear_ear_left = make_circle(ui.screen, 27, lv_color_hex(0xe34c42), LV_OPA_COVER); + lv_obj_align(ui.bear_ear_left, LV_ALIGN_CENTER, -27, -28); + ui.bear_ear_right = make_circle(ui.screen, 27, lv_color_hex(0xe34c42), LV_OPA_COVER); + lv_obj_align(ui.bear_ear_right, LV_ALIGN_CENTER, 27, -28); + ui.bear_head = make_circle(ui.screen, 78, lv_color_hex(0xe34c42), LV_OPA_COVER); + lv_obj_set_style_shadow_color(ui.bear_head, lv_color_hex(0xf18c49), 0); + lv_obj_set_style_shadow_width(ui.bear_head, 18, 0); + lv_obj_set_style_shadow_opa(ui.bear_head, LV_OPA_20, 0); + lv_obj_align(ui.bear_head, LV_ALIGN_CENTER, 0, -2); + + ui.bear_muzzle = lv_obj_create(ui.screen); + lv_obj_remove_style_all(ui.bear_muzzle); + lv_obj_set_size(ui.bear_muzzle, 45, 29); + lv_obj_set_style_radius(ui.bear_muzzle, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(ui.bear_muzzle, lv_color_hex(0xffeee0), 0); + lv_obj_set_style_bg_opa(ui.bear_muzzle, LV_OPA_COVER, 0); + lv_obj_align(ui.bear_muzzle, LV_ALIGN_CENTER, 0, 11); + lv_obj_clear_flag(ui.bear_muzzle, LV_OBJ_FLAG_CLICKABLE); + + ui.bear_eye_left = make_circle(ui.screen, 6, lv_color_hex(0x421f20), LV_OPA_COVER); + lv_obj_align(ui.bear_eye_left, LV_ALIGN_CENTER, -14, -10); + ui.bear_eye_right = make_circle(ui.screen, 6, lv_color_hex(0x421f20), LV_OPA_COVER); + lv_obj_align(ui.bear_eye_right, LV_ALIGN_CENTER, 14, -10); + ui.bear_nose = make_circle(ui.screen, 10, lv_color_hex(0x421f20), LV_OPA_COVER); + lv_obj_align(ui.bear_nose, LV_ALIGN_CENTER, 0, 6); + + set_hidden(ui.bear_ear_left, true); + set_hidden(ui.bear_ear_right, true); + set_hidden(ui.bear_head, true); + set_hidden(ui.bear_muzzle, true); + set_hidden(ui.bear_eye_left, true); + set_hidden(ui.bear_eye_right, true); + set_hidden(ui.bear_nose, true); + for (size_t index = 0; index < KEY_COUNT; ++index) { ui.keys[index] = lv_obj_create(ui.core); lv_obj_remove_style_all(ui.keys[index]); @@ -537,16 +579,38 @@ static void render(const fixture_ui_output_t *output, lv_arc_set_value(ui.progress, visual.progress_per_mille); bool branded_update = visual.variant == FIXTURE_VISUAL_KEYPATH_UPDATE; + bool bear_test = visual.variant == FIXTURE_VISUAL_BEAR_TEST; lv_label_set_text_static( - ui.eyebrow, branded_update ? "KEYPATH / SECURE UPDATE" : "KEYPATH / HID ORACLE"); - set_hidden(ui.core, branded_update); - set_hidden(ui.icon_front, branded_update); - set_hidden(ui.icon_back, branded_update); + ui.eyebrow, branded_update ? "KEYPATH / SECURE UPDATE" : + bear_test ? "BEAR / TYPOVER TEST" : "KEYPATH / HID ORACLE"); + lv_obj_set_style_text_color(ui.eyebrow, + bear_test ? lv_color_hex(0xe34c42) : lv_color_hex(0x71909d), 0); + set_hidden(ui.core, branded_update || bear_test); + set_hidden(ui.icon_front, branded_update || bear_test); + set_hidden(ui.icon_back, branded_update || bear_test); set_hidden(ui.brand_keycap, !branded_update); set_hidden(ui.brand_light, !branded_update); set_hidden(ui.brand_lid, !branded_update); set_hidden(ui.brand_glint_horizontal, !branded_update); set_hidden(ui.brand_glint_vertical, !branded_update); + set_hidden(ui.bear_ear_left, !bear_test); + set_hidden(ui.bear_ear_right, !bear_test); + set_hidden(ui.bear_head, !bear_test); + set_hidden(ui.bear_muzzle, !bear_test); + set_hidden(ui.bear_eye_left, !bear_test); + set_hidden(ui.bear_eye_right, !bear_test); + set_hidden(ui.bear_nose, !bear_test); + + if (bear_test) { + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x160f12), 0); + int bear_pulse = (int)((sinf(ui.phase * 1.6f) + 1.0f) * 6.0f); +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + bear_pulse = 4; +#endif + lv_obj_set_style_shadow_opa(ui.bear_head, (lv_opa_t)(24 + bear_pulse), 0); + lv_obj_set_style_transform_scale(ui.bear_head, 256 + bear_pulse, 0); + lv_obj_set_style_transform_scale(ui.bear_muzzle, 256 + bear_pulse, 0); + } if (branded_update) { int fill_height = 2 + (int)(visual.progress_per_mille * 34u / 1000u); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c index f883fea62..a7d631025 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -276,7 +276,7 @@ static esp_err_t status_handler(httpd_req_t *request) { "\"displayLastFrameMs\":%" PRIu64 ",\"splashEnabled\":%s,\"splashComplete\":%s," "\"updateReady\":%s,\"updateInProgress\":%s,\"otaSlot\":\"%s\",\"otaState\":\"%s\"," "\"wifiConnected\":%s,\"address\":\"%s\",\"network\":\"%s\",\"error\":\"%s\"," - "\"presentation\":{\"phase\":\"%s\",\"result\":\"%s\",\"progress\":%u," + "\"presentation\":{\"phase\":\"%s\",\"result\":\"%s\",\"brand\":\"%s\",\"progress\":%u," "\"title\":\"%s\",\"detail\":\"%s\",\"next\":\"%s\"," "\"reportsExpected\":%" PRIu32 ",\"reportsObserved\":%" PRIu32 "," "\"dropped\":%" PRIu32 ",\"duplicated\":%" PRIu32 ",\"repeated\":%" PRIu32 "," @@ -301,7 +301,9 @@ static esp_err_t status_handler(httpd_req_t *request) { ota_state_value, snapshot.ui.wifi_connected ? "true" : "false", snapshot.network_address, snapshot.network_name, snapshot.error, fixture_presentation_phase_name(snapshot.presentation.phase), - fixture_result_name(snapshot.presentation.result), snapshot.presentation.progress_per_mille, + fixture_result_name(snapshot.presentation.result), + fixture_presentation_brand_name(snapshot.presentation.brand), + snapshot.presentation.progress_per_mille, snapshot.presentation.title, snapshot.presentation.detail, snapshot.presentation.next, snapshot.presentation.reports_expected, snapshot.presentation.reports_observed, snapshot.presentation.dropped, snapshot.presentation.duplicated, snapshot.presentation.repeated, @@ -439,12 +441,15 @@ static esp_err_t presentation_handler(httpd_req_t *request) { fixture_presentation_init(&presentation); cJSON *phase = root ? cJSON_GetObjectItemCaseSensitive(root, "phase") : NULL; cJSON *result = root ? cJSON_GetObjectItemCaseSensitive(root, "result") : NULL; + cJSON *brand = root ? cJSON_GetObjectItemCaseSensitive(root, "brand") : NULL; uint32_t progress = 0u; cJSON *safe_release = root ? cJSON_GetObjectItemCaseSensitive(root, "safeRelease") : NULL; bool valid = root && cJSON_IsObject(root) && cJSON_IsString(phase) && fixture_presentation_parse_phase(phase->valuestring, &presentation.phase) && (!result || (cJSON_IsString(result) && fixture_presentation_parse_result(result->valuestring, &presentation.result))) && + (!brand || (cJSON_IsString(brand) && + fixture_presentation_parse_brand(brand->valuestring, &presentation.brand))) && json_u32(root, "progress", 1000u, &progress) && json_u32(root, "reportsExpected", UINT32_MAX, &presentation.reports_expected) && json_u32(root, "reportsObserved", UINT32_MAX, &presentation.reports_observed) && @@ -464,7 +469,7 @@ static esp_err_t presentation_handler(httpd_req_t *request) { cJSON_Delete(root); if (!valid) { return send_json(request, "400 Bad Request", - "{\"ok\":false,\"message\":\"invalid phase, result, metric, or display text\"}\n"); + "{\"ok\":false,\"message\":\"invalid phase, result, brand, metric, or display text\"}\n"); } return send_json(request, "200 OK", "{\"ok\":true,\"message\":\"presentation updated\"}\n"); } diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c index ee6052a24..610748a6a 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c @@ -339,6 +339,7 @@ bool fixture_runtime_begin_firmware_update(char *error, size_t capacity) { } else { firmware_update_in_progress = true; fixture_presentation_init(&presentation); + presentation.brand = FIXTURE_BRAND_KEYPATH; presentation.branded_firmware_update = true; presentation.phase = FIXTURE_PRESENT_PREPARING; snprintf(presentation.title, sizeof(presentation.title), "FIRMWARE UPDATE"); diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index faaed260f..6dd466834 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -287,9 +287,13 @@ static void test_presentation_contract(void) { assert(presentation.phase == FIXTURE_PRESENT_OBSERVING); assert(fixture_presentation_parse_result("inconclusive", &presentation.result)); assert(presentation.result == FIXTURE_RESULT_INCONCLUSIVE); + assert(fixture_presentation_parse_brand("bear", &presentation.brand)); + assert(presentation.brand == FIXTURE_BRAND_BEAR); + assert(strcmp(fixture_presentation_brand_name(presentation.brand), "bear") == 0); assert(fixture_presentation_text_valid("Swift stress / pass 2", 32u)); assert(!fixture_presentation_text_valid("unsafe \"label\"", 32u)); assert(!fixture_presentation_parse_phase("dancing", &presentation.phase)); + assert(!fixture_presentation_parse_brand("unknown", &presentation.brand)); } static void test_visual_model_resolves_automatic_and_campaign_states(void) { @@ -331,6 +335,16 @@ static void test_visual_model_resolves_automatic_and_campaign_states(void) { assert(visual.angular_speed_milliradians == 2500u); assert(strcmp(visual.title, "FIRMWARE UPDATE") == 0); + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_TESTING; + presentation.brand = FIXTURE_BRAND_BEAR; + snprintf(presentation.title, sizeof(presentation.title), "TYPING IN BEAR"); + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.variant == FIXTURE_VISUAL_BEAR_TEST); + assert(visual.icon == FIXTURE_ICON_KEYBOARD); + assert(visual.accent_rgb == 0xe34c42u); + assert(strcmp(visual.title, "TYPING IN BEAR") == 0); + presentation.result = FIXTURE_RESULT_FAIL; fixture_visual_resolve(&ui, &presentation, &visual); assert(visual.icon == FIXTURE_ICON_CLOSE); diff --git a/Scripts/lab/tests/pico-hid-fixture-client-tests.py b/Scripts/lab/tests/pico-hid-fixture-client-tests.py index 2030d27dc..c054f3d37 100755 --- a/Scripts/lab/tests/pico-hid-fixture-client-tests.py +++ b/Scripts/lab/tests/pico-hid-fixture-client-tests.py @@ -138,15 +138,24 @@ def test_trace_decodes_ndjson(self): self.assertEqual(trace[1]["sequence"], 1) def test_presentation_uses_bounded_json_channel(self): - self.client.present({"phase": "result", "result": "pass", "progress": 1000, + self.client.present({"phase": "result", "result": "pass", "brand": "bear", "progress": 1000, "title": "Swift stress", "reportsExpected": 40, "reportsObserved": 40, "safeRelease": True}) method, path, auth, body = RecordingHandler.requests[-1] self.assertEqual((method, path, auth), ("POST", "/v1/presentation", "Bearer test-token")) payload = json.loads(body) self.assertEqual(payload["result"], "pass") + self.assertEqual(payload["brand"], "bear") self.assertEqual(payload["reportsObserved"], 40) + def test_presentation_parser_accepts_explicit_bear_brand(self): + arguments = CLIENT.parser().parse_args([ + "present", "--phase", "testing", "--brand", "bear", + "--title", "TYPING IN BEAR", + ]) + self.assertEqual(arguments.brand, "bear") + self.assertEqual(arguments.title, "TYPING IN BEAR") + def test_firmware_update_authenticates_image_and_verifies_reconnected_build(self): firmware = b"esp32-application-image" result = self.client.update_firmware(firmware, "a1b2c3d4", wait_seconds=2) From a2f194d27d523e7d27c42ae2159409f6d6aac730 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 31 Jul 2026 07:56:20 -0700 Subject: [PATCH 96/99] Use Bear-specific test status colors --- .../Sources/HIDCaptureJig/main.swift | 105 ++++++++++++++---- Scripts/lab/pico-hid-fixture/README.md | 5 + .../src/fixture_visual_model.c | 25 ++++- .../tests/fixture_core_tests.c | 12 +- 4 files changed, 119 insertions(+), 28 deletions(-) diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift index 64174b382..825a11ddc 100644 --- a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -157,6 +157,25 @@ private final class CaptureCanvas: NSView { private let bearCoral = NSColor( calibratedRed: 0.88, green: 0.25, blue: 0.18, alpha: 1 ) + private let bearMint = NSColor( + calibratedRed: 0.40, green: 0.79, blue: 0.64, alpha: 1 + ) + private let bearAmber = NSColor( + calibratedRed: 0.95, green: 0.72, blue: 0.29, alpha: 1 + ) + private let bearViolet = NSColor( + calibratedRed: 0.61, green: 0.54, blue: 0.98, alpha: 1 + ) + private let bearCream = NSColor( + calibratedRed: 1.0, green: 0.94, blue: 0.88, alpha: 1 + ) + private let bearCanvasBackground = NSColor(name: nil) { appearance in + let match = appearance.bestMatch(from: [.darkAqua, .aqua]) + return match == .darkAqua + ? NSColor(calibratedRed: 0.086, green: 0.059, blue: 0.071, alpha: 1) + : NSColor(calibratedRed: 1.0, green: 0.976, blue: 0.961, alpha: 1) + } + private lazy var jigLogo: NSImage? = { guard let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns") else { return nil @@ -477,14 +496,16 @@ private final class CaptureCanvas: NSView { reduceMotion: reduceMotion ) let accent: NSColor = switch snapshot.phase { - case .passed: .systemGreen - case .safeMisses: .systemOrange - case .failed: .systemRed - default: brandAmber + case .passed: bearMint + case .safeMisses: bearAmber + case .failed: bearViolet + default: bearCoral } let cycleNs: UInt64 = snapshot.phase == .typing ? 1_900_000_000 : 3_800_000_000 let cycle = Double(nowNs % cycleNs) / Double(cycleNs) let breath = reduceMotion ? 0.5 : (sin(cycle * .pi * 2 - .pi / 2) + 1) / 2 + bearCanvasBackground.setFill() + bounds.fill() let headerHeight = CGFloat(layout.headerHeight) let header = NSRect( x: inset.minX, y: inset.maxY - headerHeight, @@ -540,6 +561,13 @@ private final class CaptureCanvas: NSView { } else { "FOCUS LOST" } + let focusColor = if snapshot.bearFocused { + bearCoral + } else if snapshot.phase == .waitingForBear || snapshot.phase == .preparing { + bearAmber + } else { + bearViolet + } drawText( focusLabel, rect: NSRect( @@ -551,7 +579,7 @@ private final class CaptureCanvas: NSView { weight: .bold, monospaced: true ), - color: snapshot.bearFocused ? .systemGreen : .systemRed, + color: focusColor, alignment: .right ) drawActivityRail( @@ -630,9 +658,9 @@ private final class CaptureCanvas: NSView { "Waiting for exact Bear text + Typover log evidence" } let resultColor: NSColor = switch snapshot.phase { - case .passed: .systemGreen - case .safeMisses: .systemOrange - case .failed: .systemRed + case .passed: bearMint + case .safeMisses: bearAmber + case .failed: bearViolet default: .labelColor } drawField( @@ -652,6 +680,14 @@ private final class CaptureCanvas: NSView { let detail = snapshot.message.isEmpty ? "The monitor is presentation-only; Bear owns the physical keyboard." : snapshot.message + let detailColor = if snapshot.bearFocused + || snapshot.phase == .waitingForBear + || snapshot.phase == .preparing + { + NSColor.secondaryLabelColor + } else { + bearViolet + } drawText( detail, rect: NSRect( @@ -662,7 +698,7 @@ private final class CaptureCanvas: NSView { size: layout.mode == .tiny ? 9 : 13, weight: .medium ), - color: snapshot.bearFocused ? .secondaryLabelColor : .systemRed + color: detailColor ) cursor -= issueHeight + (layout.mode == .regular ? 12 : 6) @@ -686,7 +722,8 @@ private final class CaptureCanvas: NSView { title: layout.mode == .tiny ? "ESP32 KEYS" : "SCHEDULED ESP32 KEYCAP STACK", evidence: keyEvidence, frame: burstFrame, - mode: layout.mode + mode: layout.mode, + bearMode: true ) if layout.showsFooter { @@ -837,8 +874,11 @@ private final class CaptureCanvas: NSView { title: String, evidence: String, frame: NSRect, - mode: CaptureLayoutMode + mode: CaptureLayoutMode, + bearMode: Bool = false ) { + let primary = bearMode ? bearCoral : brandAmber + let secondary = bearMode ? bearAmber : brandOrange let panel = NSBezierPath( roundedRect: frame, xRadius: mode == .tiny ? 10 : 16, @@ -846,7 +886,7 @@ private final class CaptureCanvas: NSView { ) NSColor.controlBackgroundColor.withAlphaComponent(0.62).setFill() panel.fill() - brandAmber.withAlphaComponent(0.12 + 0.13 * output.intensity).setStroke() + primary.withAlphaComponent(0.12 + 0.13 * output.intensity).setStroke() panel.lineWidth = 1 panel.stroke() @@ -872,7 +912,7 @@ private final class CaptureCanvas: NSView { height: titleHeight ), font: font(size: mode == .tiny ? 8 : 10, weight: .medium, monospaced: true), - color: output.recentPresses > 0 ? brandOrange : NSColor.tertiaryLabelColor, + color: output.recentPresses > 0 ? secondary : NSColor.tertiaryLabelColor, alignment: .right ) @@ -885,7 +925,7 @@ private final class CaptureCanvas: NSView { ) guard !output.items.isEmpty else { - drawEmptyKeycapStack(in: stage, mode: mode) + drawEmptyKeycapStack(in: stage, mode: mode, bearMode: bearMode) return } @@ -897,11 +937,21 @@ private final class CaptureCanvas: NSView { ) let center = NSPoint(x: stage.midX, y: stage.midY - capHeight * 0.08) for item in output.items { - drawKeycap(item, center: center, size: NSSize(width: capWidth, height: capHeight)) + drawKeycap( + item, + center: center, + size: NSSize(width: capWidth, height: capHeight), + bearMode: bearMode + ) } } - private func drawEmptyKeycapStack(in frame: NSRect, mode: CaptureLayoutMode) { + private func drawEmptyKeycapStack( + in frame: NSRect, + mode: CaptureLayoutMode, + bearMode: Bool + ) { + let primary = bearMode ? bearCoral : brandAmber let capWidth = min(mode == .tiny ? 54 : 82, frame.width * 0.28) let capHeight = capWidth * 0.64 for index in 0 ..< 3 { @@ -912,9 +962,9 @@ private final class CaptureCanvas: NSView { height: capHeight ) let path = NSBezierPath(roundedRect: cap, xRadius: 11, yRadius: 11) - brandAmber.withAlphaComponent(0.08 + CGFloat(index) * 0.035).setFill() + primary.withAlphaComponent(0.08 + CGFloat(index) * 0.035).setFill() path.fill() - brandAmber.withAlphaComponent(0.18).setStroke() + primary.withAlphaComponent(0.18).setStroke() path.lineWidth = 1 path.stroke() } @@ -934,7 +984,16 @@ private final class CaptureCanvas: NSView { } } - private func drawKeycap(_ item: KeycapBurstItem, center: NSPoint, size: NSSize) { + private func drawKeycap( + _ item: KeycapBurstItem, + center: NSPoint, + size: NSSize, + bearMode: Bool + ) { + let primary = bearMode ? bearCoral : brandAmber + let secondary = bearMode ? bearAmber : brandOrange + let light = bearMode ? bearCream : brandLight + let repeatColor = bearMode ? bearViolet : NSColor.systemRed let width = size.width * CGFloat(item.scale) let height = size.height * CGFloat(item.scale) let x = center.x - width / 2 + CGFloat(item.xOffset) @@ -945,7 +1004,7 @@ private final class CaptureCanvas: NSView { let base = NSRect(x: x, y: y, width: width, height: height) let basePath = NSBezierPath(roundedRect: base, xRadius: radius, yRadius: radius) - (item.isRepeat ? NSColor.systemRed : brandOrange).withAlphaComponent(opacity * 0.88).setFill() + (item.isRepeat ? repeatColor : secondary).withAlphaComponent(opacity * 0.88).setFill() basePath.fill() let compression = CGFloat(item.pressDepth) * baseDepth * 0.76 @@ -962,11 +1021,11 @@ private final class CaptureCanvas: NSView { ) let pressedGlow = item.isPressed || item.pressDepth > 0.15 let faceColor = pressedGlow - ? brandLight.blended(withFraction: 0.16, of: brandAmber) ?? brandLight - : brandLight + ? light.blended(withFraction: 0.16, of: primary) ?? light + : light faceColor.withAlphaComponent(opacity).setFill() facePath.fill() - brandAmber.withAlphaComponent(opacity * (pressedGlow ? 0.95 : 0.60)).setStroke() + primary.withAlphaComponent(opacity * (pressedGlow ? 0.95 : 0.60)).setStroke() facePath.lineWidth = pressedGlow ? 2 : 1 facePath.stroke() diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index f81da3e2c..8f7e215a8 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -248,6 +248,11 @@ and result titles. The brand value is also returned in the fixture status JSON s which presentation mode was active. A firmware build verifies the LVGL scene compiles, but the physical LCD color, alignment, and legibility must still be checked on the connected board. +Bear mode uses its own semantic palette on both displays: coral means Bear identity, focus, and active +testing; mint means verified pass; amber means safe misses or review; and violet means failure or lost +focus. Red is deliberately reserved for Bear's identity rather than reused as an error signal. Every +non-active state also has an explicit text label and result icon so color is never the only evidence. + The local file-RPC client exposes the monitor independently of a physical run: ```bash diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c index 057c1f831..324d213d5 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c @@ -111,6 +111,24 @@ static void apply_result(fixture_result_t result, fixture_visual_output_t *visua } } +static void apply_bear_palette(fixture_result_t result, + fixture_visual_output_t *visual) { + switch (result) { + case FIXTURE_RESULT_PASS: + visual->accent_rgb = 0x66c9a3u; + break; + case FIXTURE_RESULT_FAIL: + visual->accent_rgb = 0x9b8afbu; + break; + case FIXTURE_RESULT_INCONCLUSIVE: + visual->accent_rgb = 0xf2b84bu; + break; + case FIXTURE_RESULT_NONE: + visual->accent_rgb = 0xe34c42u; + break; + } +} + void fixture_visual_resolve(const fixture_ui_output_t *ui, const fixture_presentation_t *presentation, fixture_visual_output_t *visual) { @@ -138,11 +156,10 @@ void fixture_visual_resolve(const fixture_ui_output_t *ui, visual->accent_rgb = 0xf3a128u; visual->angular_speed_milliradians = 2500u; } - if (presentation->brand == FIXTURE_BRAND_BEAR && - presentation->result == FIXTURE_RESULT_NONE) { - visual->accent_rgb = 0xe34c42u; - } apply_result(presentation->result, visual, &title); + if (presentation->brand == FIXTURE_BRAND_BEAR) { + apply_bear_palette(presentation->result, visual); + } if (ui->quality == FIXTURE_UI_PROTECTED) visual->angular_speed_milliradians = 480u; snprintf(visual->title, sizeof(visual->title), "%s", title); } diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index 6dd466834..f01dcf4b3 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -348,9 +348,19 @@ static void test_visual_model_resolves_automatic_and_campaign_states(void) { presentation.result = FIXTURE_RESULT_FAIL; fixture_visual_resolve(&ui, &presentation, &visual); assert(visual.icon == FIXTURE_ICON_CLOSE); - assert(visual.accent_rgb == 0xff5c72u); + assert(visual.accent_rgb == 0x9b8afbu); assert(strcmp(visual.title, "TEST FAILED") == 0); + presentation.result = FIXTURE_RESULT_INCONCLUSIVE; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_WARNING); + assert(visual.accent_rgb == 0xf2b84bu); + + presentation.result = FIXTURE_RESULT_PASS; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_OK); + assert(visual.accent_rgb == 0x66c9a3u); + ui.quality = FIXTURE_UI_PROTECTED; fixture_visual_resolve(&ui, &presentation, &visual); assert(visual.angular_speed_milliradians == 480u); From 56a0eb19f4a3c270b68071ac1efdb28c47db620e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 31 Jul 2026 17:06:10 -0700 Subject: [PATCH 97/99] Add prioritized fixture Wi-Fi profiles --- Scripts/lab/pico-hid-fixture-tool | 4 +- Scripts/lab/pico-hid-fixture/README.md | 16 +++-- .../main/CMakeLists.txt | 3 +- .../main/fixture_config.h.in | 5 +- .../main/fixture_http.c | 60 ++++++++++++++++--- .../tests/run-esp32-qemu-smoke.sh | 4 +- .../lab/tests/pico-hid-fixture-tool-tests.py | 6 +- 7 files changed, 77 insertions(+), 21 deletions(-) diff --git a/Scripts/lab/pico-hid-fixture-tool b/Scripts/lab/pico-hid-fixture-tool index 2cac4de42..60d441202 100755 --- a/Scripts/lab/pico-hid-fixture-tool +++ b/Scripts/lab/pico-hid-fixture-tool @@ -12,7 +12,7 @@ device_dir=${KEYPATH_FIXTURE_DEVICE_DIR:-/dev} add_secret_app=${KEYPATH_FIXTURE_ADD_SECRET_APP:-"/Applications/Add Secret.app"} fixture_client=${KEYPATH_FIXTURE_CLIENT:-"$script_dir/pico-hid-fixture-client"} -required_secrets="KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 KEYPATH_WIFI_SSID_2 KEYPATH_WIFI_PASSWORD_2 KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 KEYPATH_WIFI_SSID_4 KEYPATH_WIFI_PASSWORD_4 KEYPATH_FIXTURE_TOKEN" +required_secrets="KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 KEYPATH_WIFI_SSID_4 KEYPATH_WIFI_PASSWORD_4 KEYPATH_HACKER_DOJO_USERNAME KEYPATH_HACKER_DOJO_PASSWORD KEYPATH_FIXTURE_TOKEN" usage() { printf '%s\n' \ @@ -58,7 +58,7 @@ load_credentials() { [[ -f "$secrets_file" ]] || fail "encrypted secrets file is missing: $secrets_file" while IFS='=' read -r secret_name secret_value; do case "$secret_name" in - KEYPATH_WIFI_SSID_1|KEYPATH_WIFI_PASSWORD_1|KEYPATH_WIFI_SSID_2|KEYPATH_WIFI_PASSWORD_2|KEYPATH_WIFI_SSID_3|KEYPATH_WIFI_PASSWORD_3|KEYPATH_WIFI_SSID_4|KEYPATH_WIFI_PASSWORD_4|KEYPATH_FIXTURE_TOKEN) + KEYPATH_WIFI_SSID_1|KEYPATH_WIFI_PASSWORD_1|KEYPATH_WIFI_SSID_3|KEYPATH_WIFI_PASSWORD_3|KEYPATH_WIFI_SSID_4|KEYPATH_WIFI_PASSWORD_4|KEYPATH_HACKER_DOJO_USERNAME|KEYPATH_HACKER_DOJO_PASSWORD|KEYPATH_FIXTURE_TOKEN) printf -v "$secret_name" '%s' "$secret_value" export "$secret_name" ;; diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index 8f7e215a8..a5cb3cea4 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -130,18 +130,24 @@ Build credentials are supplied only through the environment: ```bash source ~/.cache/keypath-esp32/esp-idf/export.sh -export KEYPATH_WIFI_SSID_1='primary-network' +export KEYPATH_WIFI_SSID_1='Alpern-Home-5G' export KEYPATH_WIFI_PASSWORD_1='...' -export KEYPATH_WIFI_SSID_2='fallback-network-one' -export KEYPATH_WIFI_PASSWORD_2='...' -export KEYPATH_WIFI_SSID_3='fallback-network-two' +export KEYPATH_WIFI_SSID_3='iPhone' export KEYPATH_WIFI_PASSWORD_3='...' -export KEYPATH_WIFI_SSID_4='current-location-network' +export KEYPATH_WIFI_SSID_4='beachFi' export KEYPATH_WIFI_PASSWORD_4='...' +export KEYPATH_HACKER_DOJO_USERNAME='...' +export KEYPATH_HACKER_DOJO_PASSWORD='...' export KEYPATH_FIXTURE_TOKEN='at-least-16-random-characters' idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 build ``` +The fixture tries `Alpern-Home-5G` first, `Hacker Dojo` second, `beachFi` third, and `iPhone` fourth. +`Hacker Dojo` is a WPA2-Enterprise PEAP profile; the other three use WPA2-Personal. The enterprise +username and password must be stored through Add Secret rather than checked into the repository. +This personal-fixture prototype does not yet pin the enterprise authentication server's CA +certificate; add certificate validation before distributing the fixture or its credentials. + The board revision defaults to 2, whose buzzer is on GPIO42. Revision 1 used GPIO33; change `KeyPath fixture → Waveshare board revision` with `idf.py menuconfig` if the delivered board is an older revision. Flashing waits for the physical board: diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt index 3626ab76f..63650e195 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt @@ -1,8 +1,8 @@ foreach(secret_name KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 - KEYPATH_WIFI_SSID_2 KEYPATH_WIFI_PASSWORD_2 KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 KEYPATH_WIFI_SSID_4 KEYPATH_WIFI_PASSWORD_4 + KEYPATH_HACKER_DOJO_USERNAME KEYPATH_HACKER_DOJO_PASSWORD KEYPATH_FIXTURE_TOKEN) if(NOT DEFINED ENV{${secret_name}} OR "$ENV{${secret_name}}" STREQUAL "") message(FATAL_ERROR "${secret_name} must be supplied in the build environment") @@ -92,6 +92,7 @@ idf_component_register( mbedtls mdns nvs_flash + wpa_supplicant ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in index 111915055..d7d33047a 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in @@ -3,12 +3,13 @@ #define KEYPATH_WIFI_SSID_1 "@KEYPATH_WIFI_SSID_1_ESCAPED@" #define KEYPATH_WIFI_PASSWORD_1 "@KEYPATH_WIFI_PASSWORD_1_ESCAPED@" -#define KEYPATH_WIFI_SSID_2 "@KEYPATH_WIFI_SSID_2_ESCAPED@" -#define KEYPATH_WIFI_PASSWORD_2 "@KEYPATH_WIFI_PASSWORD_2_ESCAPED@" #define KEYPATH_WIFI_SSID_3 "@KEYPATH_WIFI_SSID_3_ESCAPED@" #define KEYPATH_WIFI_PASSWORD_3 "@KEYPATH_WIFI_PASSWORD_3_ESCAPED@" #define KEYPATH_WIFI_SSID_4 "@KEYPATH_WIFI_SSID_4_ESCAPED@" #define KEYPATH_WIFI_PASSWORD_4 "@KEYPATH_WIFI_PASSWORD_4_ESCAPED@" +#define KEYPATH_HACKER_DOJO_SSID "Hacker Dojo" +#define KEYPATH_HACKER_DOJO_USERNAME "@KEYPATH_HACKER_DOJO_USERNAME_ESCAPED@" +#define KEYPATH_HACKER_DOJO_PASSWORD "@KEYPATH_HACKER_DOJO_PASSWORD_ESCAPED@" #define KEYPATH_FIXTURE_TOKEN "@KEYPATH_FIXTURE_TOKEN_ESCAPED@" #define KEYPATH_FIXTURE_FIRMWARE_VERSION "0.3.0-esp32s3" #define KEYPATH_FIXTURE_BUILD_ID "@KEYPATH_FIXTURE_BUILD_ID@" diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c index a7d631025..4da4a9a13 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -7,6 +7,7 @@ #include "cJSON.h" #include "esp_event.h" #include "esp_check.h" +#include "esp_eap_client.h" #include "esp_heap_caps.h" #include "esp_http_server.h" #include "esp_log.h" @@ -45,17 +46,27 @@ static uint32_t status_response_count; static uint32_t status_failure_count; static uint32_t last_status_latency_us; static uint32_t maximum_status_latency_us; +static bool enterprise_wifi_enabled; + +typedef enum { + WIFI_PROFILE_PERSONAL, + WIFI_PROFILE_ENTERPRISE, +} wifi_profile_authentication_t; typedef struct { const char *ssid; + const char *username; const char *password; + wifi_profile_authentication_t authentication; } wifi_profile_t; static const wifi_profile_t wifi_profiles[] = { - {KEYPATH_WIFI_SSID_4, KEYPATH_WIFI_PASSWORD_4}, - {KEYPATH_WIFI_SSID_1, KEYPATH_WIFI_PASSWORD_1}, - {KEYPATH_WIFI_SSID_3, KEYPATH_WIFI_PASSWORD_3}, - {KEYPATH_WIFI_SSID_2, KEYPATH_WIFI_PASSWORD_2}, + /* Expected location order: home, Hacker Dojo, beach, phone hotspot. */ + {KEYPATH_WIFI_SSID_1, NULL, KEYPATH_WIFI_PASSWORD_1, WIFI_PROFILE_PERSONAL}, + {KEYPATH_HACKER_DOJO_SSID, KEYPATH_HACKER_DOJO_USERNAME, + KEYPATH_HACKER_DOJO_PASSWORD, WIFI_PROFILE_ENTERPRISE}, + {KEYPATH_WIFI_SSID_4, NULL, KEYPATH_WIFI_PASSWORD_4, WIFI_PROFILE_PERSONAL}, + {KEYPATH_WIFI_SSID_3, NULL, KEYPATH_WIFI_PASSWORD_3, WIFI_PROFILE_PERSONAL}, }; static bool authorized(httpd_req_t *request) { @@ -559,15 +570,50 @@ bool fixture_network_control_ready(void) { static esp_err_t select_wifi_profile(size_t index) { wifi_model.profile_index = index % (sizeof(wifi_profiles) / sizeof(wifi_profiles[0])); const wifi_profile_t *profile = &wifi_profiles[wifi_model.profile_index]; + + if (enterprise_wifi_enabled) { + ESP_RETURN_ON_ERROR(esp_wifi_sta_enterprise_disable(), TAG, + "could not disable the previous enterprise profile"); + enterprise_wifi_enabled = false; + } + wifi_config_t wifi = {0}; snprintf((char *)wifi.sta.ssid, sizeof(wifi.sta.ssid), "%s", profile->ssid); - snprintf((char *)wifi.sta.password, sizeof(wifi.sta.password), "%s", profile->password); - wifi.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; wifi.sta.pmf_cfg.capable = true; wifi.sta.pmf_cfg.required = false; + if (profile->authentication == WIFI_PROFILE_ENTERPRISE) { + wifi.sta.threshold.authmode = WIFI_AUTH_WPA2_ENTERPRISE; + } else { + snprintf((char *)wifi.sta.password, sizeof(wifi.sta.password), "%s", profile->password); + wifi.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + } fixture_runtime_set_network_name(profile->ssid); ESP_LOGI(TAG, "selecting Wi-Fi profile %u", (unsigned int)(wifi_model.profile_index + 1u)); - return esp_wifi_set_config(WIFI_IF_STA, &wifi); + ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &wifi), TAG, + "could not configure the selected Wi-Fi profile"); + + if (profile->authentication == WIFI_PROFILE_ENTERPRISE) { + size_t username_length = strlen(profile->username); + size_t password_length = strlen(profile->password); + ESP_RETURN_ON_ERROR( + esp_eap_client_set_identity((const unsigned char *)profile->username, + (int)username_length), + TAG, "could not configure enterprise identity"); + ESP_RETURN_ON_ERROR( + esp_eap_client_set_username((const unsigned char *)profile->username, + (int)username_length), + TAG, "could not configure enterprise username"); + ESP_RETURN_ON_ERROR( + esp_eap_client_set_password((const unsigned char *)profile->password, + (int)password_length), + TAG, "could not configure enterprise password"); + ESP_RETURN_ON_ERROR(esp_eap_client_set_eap_methods(ESP_EAP_TYPE_PEAP), TAG, + "could not select PEAP authentication"); + ESP_RETURN_ON_ERROR(esp_wifi_sta_enterprise_enable(), TAG, + "could not enable enterprise authentication"); + enterprise_wifi_enabled = true; + } + return ESP_OK; } static void wifi_event(void *argument, esp_event_base_t base, int32_t id, void *data) { diff --git a/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh index 7292bb582..a096ab181 100755 --- a/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh +++ b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh @@ -27,12 +27,12 @@ fi source "$idf_path/export.sh" >/dev/null export KEYPATH_WIFI_SSID_1=fixture-qemu-primary export KEYPATH_WIFI_PASSWORD_1=fixture-qemu-placeholder -export KEYPATH_WIFI_SSID_2=fixture-qemu-fallback-one -export KEYPATH_WIFI_PASSWORD_2=fixture-qemu-placeholder export KEYPATH_WIFI_SSID_3=fixture-qemu-fallback-two export KEYPATH_WIFI_PASSWORD_3=fixture-qemu-placeholder export KEYPATH_WIFI_SSID_4=fixture-qemu-current-location export KEYPATH_WIFI_PASSWORD_4=fixture-qemu-placeholder +export KEYPATH_HACKER_DOJO_USERNAME=fixture-qemu-enterprise-user +export KEYPATH_HACKER_DOJO_PASSWORD=fixture-qemu-enterprise-password export KEYPATH_FIXTURE_TOKEN=fixture-qemu-token-placeholder export KEYPATH_QEMU_SMOKE=1 diff --git a/Scripts/lab/tests/pico-hid-fixture-tool-tests.py b/Scripts/lab/tests/pico-hid-fixture-tool-tests.py index 57b4449b5..8ba297dd3 100644 --- a/Scripts/lab/tests/pico-hid-fixture-tool-tests.py +++ b/Scripts/lab/tests/pico-hid-fixture-tool-tests.py @@ -36,12 +36,12 @@ def setUp(self) -> None: "KEYPATH_FIXTURE_SECRETS_FILE": str(self.directory / "missing-secrets.env"), "KEYPATH_WIFI_SSID_1": "fixture-primary", "KEYPATH_WIFI_PASSWORD_1": "fixture-password-one", - "KEYPATH_WIFI_SSID_2": "fixture-fallback-one", - "KEYPATH_WIFI_PASSWORD_2": "fixture-password-two", "KEYPATH_WIFI_SSID_3": "fixture-fallback-two", "KEYPATH_WIFI_PASSWORD_3": "fixture-password-three", "KEYPATH_WIFI_SSID_4": "fixture-current-location", "KEYPATH_WIFI_PASSWORD_4": "fixture-password-four", + "KEYPATH_HACKER_DOJO_USERNAME": "fixture-enterprise-user", + "KEYPATH_HACKER_DOJO_PASSWORD": "fixture-enterprise-password", "KEYPATH_FIXTURE_TOKEN": "fixture-test-token-value", }) @@ -64,6 +64,8 @@ def test_doctor_accepts_environment_credentials_without_printing_values(self) -> self.assertIn("production credentials (values hidden)", result.stdout) self.assertIn("board not connected", result.stdout) self.assertNotIn(self.environment["KEYPATH_WIFI_PASSWORD_1"], result.stdout + result.stderr) + self.assertNotIn(self.environment["KEYPATH_HACKER_DOJO_PASSWORD"], + result.stdout + result.stderr) self.assertNotIn(self.environment["KEYPATH_FIXTURE_TOKEN"], result.stdout + result.stderr) def test_doctor_rejects_missing_or_placeholder_credentials(self) -> None: From fc54e8baacec0533a3e73952fafd7cca8ec5a482 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 31 Jul 2026 18:32:30 -0700 Subject: [PATCH 98/99] Brand physical HID tests by target app --- Scripts/lab/physical-hid-capture-run | 8 ++++++++ Scripts/lab/pico-hid-fixture/README.md | 5 +++++ Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c | 9 +++++++++ Scripts/lab/tests/physical-hid-capture-run-tests.py | 8 ++++++++ 4 files changed, 30 insertions(+) diff --git a/Scripts/lab/physical-hid-capture-run b/Scripts/lab/physical-hid-capture-run index 36125914f..754082001 100755 --- a/Scripts/lab/physical-hid-capture-run +++ b/Scripts/lab/physical-hid-capture-run @@ -383,6 +383,10 @@ def parser() -> argparse.ArgumentParser: root.add_argument("--run-id", required=True) root.add_argument("--text", required=True, type=pathlib.Path) root.add_argument("--fixture-host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + root.add_argument( + "--brand", choices=("keypath", "bear"), default="keypath", + help="select the centered app logo and presentation palette on the ESP32", + ) root.add_argument("--interval-ms", type=int, default=50) root.add_argument("--hold-ms", type=int, default=12) root.add_argument("--shift-lead-ms", type=int, default=0) @@ -552,6 +556,7 @@ def main() -> int: stage = "fixture-presentation" presentation_testing = run_fixture_json_with_retry( host, "present", "--phase", "testing", "--result", "none", + "--brand", arguments.brand, "--progress", "0", "--title", arguments.run_id[:32], "--detail", fixture_detail, "--next", fixture_next, "--reports-expected", str(expected_reports), "--reports-observed", "0", @@ -734,6 +739,7 @@ def main() -> int: try: presentation_result = run_fixture_json_with_retry( host, "present", "--phase", "result", "--result", "pass" if passed else "fail", + "--brand", arguments.brand, "--progress", "1000", "--title", arguments.run_id[:32], "--detail", f"{len(capture.get('received', ''))} / {len(expected)} CHARS", "--next", "NEXT MATRIX CASE", "--reports-expected", str(expected_reports), @@ -750,6 +756,7 @@ def main() -> int: "status": "passed" if passed else "failed", "admissionMode": "demo-bypass" if arguments.demo_mode else "strict", "fixtureHost": host, + "presentationBrand": arguments.brand, "expectedReports": expected_reports, "interruptionMode": interruption_mode, "timingBudget": { @@ -882,6 +889,7 @@ def main() -> int: "status": "inconclusive", "admissionMode": "demo-bypass" if arguments.demo_mode else "strict", "fixtureHost": fixture_host or arguments.fixture_host, + "presentationBrand": arguments.brand, "expectedReports": expected_reports, "interruptionMode": interruption_mode, "failure": { diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index a5cb3cea4..6eb2c7a39 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -254,6 +254,11 @@ and result titles. The brand value is also returned in the fixture status JSON s which presentation mode was active. A firmware build verifies the LVGL scene compiles, but the physical LCD color, alignment, and legibility must still be checked on the connected board. +KeyPath physical HID runs likewise send `brand: keypath` by default. The ESP32 keeps the rotating +orbit and progress arcs visible while placing KeyPath's opened, illuminated orange keycap mark in +their center. The shared runner accepts `--brand keypath|bear` for explicit target selection and +records the chosen value as `presentationBrand` in each evidence artifact. + Bear mode uses its own semantic palette on both displays: coral means Bear identity, focus, and active testing; mint means verified pass; amber means safe misses or review; and violet means failure or lost focus. Red is deliberately reserved for Bear's identity rather than reused as an error signal. Every diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index f01dcf4b3..2a64d702c 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -335,6 +335,15 @@ static void test_visual_model_resolves_automatic_and_campaign_states(void) { assert(visual.angular_speed_milliradians == 2500u); assert(strcmp(visual.title, "FIRMWARE UPDATE") == 0); + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_TESTING; + presentation.brand = FIXTURE_BRAND_KEYPATH; + snprintf(presentation.title, sizeof(presentation.title), "KEYPATH HID TEST"); + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.variant == FIXTURE_VISUAL_KEYPATH_UPDATE); + assert(visual.icon == FIXTURE_ICON_KEYBOARD); + assert(strcmp(visual.title, "KEYPATH HID TEST") == 0); + fixture_presentation_init(&presentation); presentation.phase = FIXTURE_PRESENT_TESTING; presentation.brand = FIXTURE_BRAND_BEAR; diff --git a/Scripts/lab/tests/physical-hid-capture-run-tests.py b/Scripts/lab/tests/physical-hid-capture-run-tests.py index 9b44219fe..4904974d8 100644 --- a/Scripts/lab/tests/physical-hid-capture-run-tests.py +++ b/Scripts/lab/tests/physical-hid-capture-run-tests.py @@ -64,6 +64,14 @@ def setUp(self) -> None: def test_showroom_payload_is_short_fixed_and_return_terminated(self) -> None: self.assertEqual(SHOWROOM_TEXT.read_bytes(), b"KeyPath demo OK\n") + def test_presentation_brand_defaults_to_keypath_and_accepts_bear(self) -> None: + required = ["--run-id", "brand-test", "--text", str(RUNNER)] + self.assertEqual(self.runner.parser().parse_args(required).brand, "keypath") + self.assertEqual( + self.runner.parser().parse_args([*required, "--brand", "bear"]).brand, + "bear", + ) + @staticmethod def compile_script(command: list[str], environment: dict[str, str]) -> None: del environment From 9246c915d47b57afb90111431a204ee99c0d2331 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 31 Jul 2026 18:33:59 -0700 Subject: [PATCH 99/99] Harden physical HID capture under system load --- External/kanata | 2 +- Scripts/lab/hid-capture-jig-client | 41 +-- Scripts/lab/physical-hid-capture-run | 249 +++++++++++++----- Scripts/lab/physical-hid-repeat-matrix | 48 +++- Scripts/lab/pico-hid-fixture-client | 70 +++++ .../lab/tests/hid-capture-jig-client-tests.py | 23 +- .../tests/physical-hid-capture-run-tests.py | 192 ++++++++++++-- .../tests/physical-hid-repeat-matrix-tests.py | 31 +++ .../tests/pico-hid-fixture-client-tests.py | 5 + 9 files changed, 557 insertions(+), 104 deletions(-) diff --git a/External/kanata b/External/kanata index c50826f15..51a01ed23 160000 --- a/External/kanata +++ b/External/kanata @@ -1 +1 @@ -Subproject commit c50826f15329dc9a1eddd59af425ea2d9ba1f392 +Subproject commit 51a01ed2388ab36a2b40bdd3c54b61613d737663 diff --git a/Scripts/lab/hid-capture-jig-client b/Scripts/lab/hid-capture-jig-client index 2f40d7f03..7a538f209 100755 --- a/Scripts/lab/hid-capture-jig-client +++ b/Scripts/lab/hid-capture-jig-client @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import fcntl import json import os import pathlib @@ -29,23 +30,31 @@ def atomic_json_write(path: pathlib.Path, value: dict[str, object]) -> None: def send(action: str, **fields: object) -> dict[str, object]: directory = state_directory() - request_id = str(uuid.uuid4()) - command = {"id": request_id, "action": action} - command.update({key: value for key, value in fields.items() if value is not None}) - atomic_json_write(directory / "command.json", command) - - timeout = float(os.environ.get("KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT", "5")) - deadline = time.monotonic() + timeout - response_path = directory / "response.json" - while time.monotonic() < deadline: - try: - response = json.loads(response_path.read_text()) - except (FileNotFoundError, json.JSONDecodeError): + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + lock_path = directory / "command.lock" + with lock_path.open("a+") as lock: + lock_path.chmod(0o600) + # The Jig intentionally exposes one command/response slot. Serialize every + # client for the entire request so a readiness probe cannot overwrite Focus, + # Arm, Finalize, or another status request before its response arrives. + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + request_id = str(uuid.uuid4()) + command = {"id": request_id, "action": action} + command.update({key: value for key, value in fields.items() if value is not None}) + atomic_json_write(directory / "command.json", command) + + timeout = float(os.environ.get("KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT", "5")) + deadline = time.monotonic() + timeout + response_path = directory / "response.json" + while time.monotonic() < deadline: + try: + response = json.loads(response_path.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + time.sleep(0.025) + continue + if response.get("id") == request_id: + return response time.sleep(0.025) - continue - if response.get("id") == request_id: - return response - time.sleep(0.025) raise RuntimeError( f"HID Capture Jig did not answer within {timeout:g}s; launch it with " "Scripts/lab/hid-capture-jig-tool open" diff --git a/Scripts/lab/physical-hid-capture-run b/Scripts/lab/physical-hid-capture-run index 754082001..038e225c0 100755 --- a/Scripts/lab/physical-hid-capture-run +++ b/Scripts/lab/physical-hid-capture-run @@ -114,6 +114,60 @@ def run_fixture_json_with_retry( time.sleep(0.25) +def run_fixture_state_change_with_confirmation( + host: str, + action: str, + run_id: str, + *arguments: str, + include_run_id_argument: bool = True, + expected_states: set[str], + environment: dict[str, str], + retry_seconds: float, + errors: list[dict[str, Any]], + action_request_timeout: float = 10, + status_request_timeout: float = 10, + require_run_id_match: bool = True, +) -> dict[str, Any]: + """Retry an idempotent intent, confirming a lost response from fixture status.""" + deadline = time.monotonic() + max(0, retry_seconds) + latest_error: BaseException | None = None + while True: + try: + command_arguments = ( + (run_id, *arguments) if include_run_id_argument else arguments + ) + return run_json( + fixture_command( + host, action, *command_arguments, timeout=action_request_timeout + ), + environment, + ) + except (OSError, ValueError, RuntimeError, KeyError) as error: + latest_error = error + record_control_error(errors, action, error) + try: + status = run_json( + fixture_command(host, "status", timeout=status_request_timeout), environment + ) + observed_run_id = status.get("runId", status.get("runID")) + run_id_matches = observed_run_id == run_id or not require_run_id_match + if run_id_matches and status.get("state") in expected_states: + return { + "ok": True, + "message": f"{action} confirmed from fixture status after response loss", + "confirmedByStatus": True, + "status": status, + } + except (OSError, ValueError, RuntimeError, KeyError) as error: + latest_error = error + record_control_error(errors, "status", error) + if time.monotonic() >= deadline: + raise RuntimeError( + f"fixture {action} unavailable after {retry_seconds:g}s: {latest_error}" + ) from latest_error + time.sleep(0.25) + + def resolve_fixture_host( initial_host: str, environment: dict[str, str], @@ -122,7 +176,9 @@ def resolve_fixture_host( control_errors = errors if errors is not None else [] status = run_fixture_json_with_retry( initial_host, "status", environment=environment, - retry_seconds=10, errors=control_errors, + # The firmware intentionally owns a bounded 60-second Wi-Fi reconnect + # window. Wait through it before declaring a hands-off campaign blocked. + retry_seconds=60, errors=control_errors, ) address = str(status.get("address", "")) try: @@ -188,25 +244,20 @@ def fetch_fixture_trace( host: str, environment: dict[str, str], errors: list[dict[str, Any]] | None = None ) -> list[dict[str, Any]]: """Fetch the fixture's bounded trace in chronological order after a run.""" - traces: list[dict[str, Any]] = [] - start = 0 - available: int | None = None - while available is None or start < available: - control_errors = errors if errors is not None else [] - response = run_fixture_json_with_retry( - host, "trace", "--from", str(start), "--limit", "8", - environment=environment, retry_seconds=10, errors=control_errors, + control_errors = errors if errors is not None else [] + try: + response = run_json( + fixture_command( + host, "trace-all", "--limit", "8", "--retry-seconds", "60", timeout=10 + ), + environment, ) - if not isinstance(response, list) or not response or not isinstance(response[0], dict): - raise RuntimeError("fixture trace returned an invalid batch") - header = response[0] - available = int(header.get("available", 0)) - batch = [entry for entry in response[1:] if isinstance(entry, dict)] - traces.extend(batch) - if not batch: - break - start += len(batch) - return traces + except (OSError, ValueError, RuntimeError, KeyError) as error: + record_control_error(control_errors, "trace-all", error) + raise + if not isinstance(response, list) or any(not isinstance(entry, dict) for entry in response): + raise RuntimeError("fixture trace-all returned invalid JSON") + return response def wait_for_capture_release( @@ -313,6 +364,7 @@ class ControlledSwiftCompileLoad: self.started_at = 0.0 self.source_bytes = 0 self.compiler = "" + self.sdk = "" def start(self) -> None: try: @@ -325,6 +377,16 @@ class ControlledSwiftCompileLoad: self.compiler = result.stdout.strip() if not self.compiler: raise RuntimeError("xcrun returned an empty swiftc path") + try: + result = subprocess.run( + ["/usr/bin/xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True, + capture_output=True, check=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError) as error: + raise RuntimeError("could not locate the macOS SDK for the bounded compile load") from error + self.sdk = result.stdout.strip() + if not self.sdk: + raise RuntimeError("xcrun returned an empty macOS SDK path") self.temporary = tempfile.TemporaryDirectory(prefix="keypath-swift-load-") source = pathlib.Path(self.temporary.name) / "GeneratedLoad.swift" lines = ["import Foundation", ""] @@ -344,33 +406,59 @@ class ControlledSwiftCompileLoad: self.process = subprocess.Popen( [ "/bin/bash", "-c", - 'while :; do "$1" -typecheck "$2" >/dev/null 2>&1 || exit $?; done', - "keypath-swift-compile-load", self.compiler, str(source), + 'while :; do "$1" -sdk "$2" -typecheck "$3" >/dev/null 2>&1 || exit $?; done', + "keypath-swift-compile-load", self.compiler, self.sdk, str(source), ], start_new_session=True, ) + time.sleep(0.2) + early_return_code = self.process.poll() + if early_return_code is not None: + self.process = None + self.temporary.cleanup() + self.temporary = None + raise RuntimeError( + f"bounded Swift compile load exited during startup ({early_return_code})" + ) def stop(self) -> dict[str, Any]: return_code: int | None = None - if self.process is not None: - try: - os.killpg(self.process.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - return_code = self.process.wait(timeout=2) - except subprocess.TimeoutExpired: - try: - os.killpg(self.process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - return_code = self.process.wait(timeout=2) - if self.temporary is not None: - self.temporary.cleanup() - self.temporary = None + process = self.process + self.process = None + try: + if process is not None: + return_code = process.poll() + if return_code is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + # The short-lived group leader may exit between poll() and + # killpg(). Never signal a recycled process-group ID. + if process.poll() is None: + try: + process.terminate() + except (ProcessLookupError, PermissionError): + pass + try: + return_code = process.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + if process.poll() is None: + try: + process.kill() + except (ProcessLookupError, PermissionError): + pass + return_code = process.wait(timeout=2) + finally: + if self.temporary is not None: + self.temporary.cleanup() + self.temporary = None return { "enabled": True, "compiler": self.compiler, + "sdk": self.sdk, "sourceBytes": self.source_bytes, "startedAtEpoch": self.started_at, "durationMs": round(max(0, time.time() - self.started_at) * 1000), @@ -453,6 +541,7 @@ def main() -> int: expected_reports = 0 interruption_mode = "unknown" initial_status: dict[str, Any] | None = None + stale_abort_response: dict[str, Any] | None = None capture_preflight: dict[str, Any] | None = None load: dict[str, Any] | None = None fixture_arm: dict[str, Any] | None = None @@ -487,6 +576,14 @@ def main() -> int: arguments.fixture_host, environment, control_errors ) fixture_host = host + if initial_status.get("state") in {"loaded", "armed", "running"}: + stage = "fixture-stale-run-abort" + stale_abort_response = run_fixture_state_change_with_confirmation( + host, "abort", arguments.run_id, + include_run_id_argument=False, expected_states={"aborted"}, + environment=environment, retry_seconds=60, errors=control_errors, + require_run_id_match=False, + ) stage = "capture-preflight" capture_preflight = run_json(capture_command("status"), environment) readiness = capture_preflight.get("systemReadiness") @@ -502,14 +599,22 @@ def main() -> int: raise RuntimeError(f"capture Jig paused the run: {detail}. {help_text}".strip()) text = arguments.text.read_text() expected = text * arguments.repeat - estimated_ms = arguments.delay_ms + arguments.repeat * ( + load_requested = ( + arguments.cpu_load_workers is not None or arguments.swift_compile_load + ) + effective_delay_ms = max(arguments.delay_ms, 10_000) if load_requested else arguments.delay_ms + estimated_ms = effective_delay_ms + arguments.repeat * ( len(text) * arguments.interval_ms + arguments.cycle_gap_ms ) # WindowServer can queue valid HID events while the capture app is starved by a # concurrent compile. Preserve enough wall-clock margin to drain that queue before # the fail-closed capture timeout freezes the evidence snapshot. timeout_margin_ms = max(10_000, estimated_ms // 2) - timeout_ms = arguments.timeout_ms or max(15_000, estimated_ms + timeout_margin_ms) + # Capture is armed before the authenticated Start request. The ESP32 can + # accept Start while its Wi-Fi reply is delayed, so the oracle window must + # cover the same bounded 60-second control-plane recovery period or valid + # HID can arrive after a false 15-second timeout. + timeout_ms = arguments.timeout_ms or max(90_000, estimated_ms + timeout_margin_ms) interruption_mode = ( "remote-abort" if arguments.abort_after_ms is not None else "external-abort" if arguments.expect_external_abort_ms is not None else @@ -543,25 +648,34 @@ def main() -> int: expected_reports = int(header[2]) * int(header[3]) stage = "fixture-load" - load = run_fixture_json_with_retry( - host, "load-script", str(script), environment=environment, - retry_seconds=8, errors=control_errors, + load = run_fixture_state_change_with_confirmation( + host, "load-script", arguments.run_id, str(script), + include_run_id_argument=False, + expected_states={"loaded", "armed", "running", "complete"}, + environment=environment, retry_seconds=60, errors=control_errors, ) fixture_mutated = True stage = "fixture-arm" - fixture_arm = run_fixture_json_with_retry( - host, "arm", arguments.run_id, environment=environment, - retry_seconds=8, errors=control_errors, + fixture_arm = run_fixture_state_change_with_confirmation( + host, "arm", arguments.run_id, expected_states={"armed", "running", "complete"}, + environment=environment, retry_seconds=60, errors=control_errors, ) stage = "fixture-presentation" - presentation_testing = run_fixture_json_with_retry( - host, "present", "--phase", "testing", "--result", "none", - "--brand", arguments.brand, - "--progress", "0", "--title", arguments.run_id[:32], - "--detail", fixture_detail, "--next", fixture_next, - "--reports-expected", str(expected_reports), "--reports-observed", "0", - environment=environment, retry_seconds=5, errors=control_errors, - ) + try: + presentation_testing = run_fixture_json_with_retry( + host, "present", "--phase", "testing", "--result", "none", + "--brand", arguments.brand, + "--progress", "0", "--title", arguments.run_id[:32], + "--detail", fixture_detail, "--next", fixture_next, + "--reports-expected", str(expected_reports), "--reports-observed", "0", + environment=environment, retry_seconds=20, errors=control_errors, + request_timeout=10, + ) + except RuntimeError as presentation_error: + # Presentation is operator feedback, not part of HID correctness. + # Preserve the degraded-control-plane evidence and continue only + # because arm, focus, capture and release all have independent gates. + presentation_testing = {"ok": False, "error": str(presentation_error)} # Acquire focus at the last possible moment. Keeping the Jig focused while # readiness checks or compilation run would steal the user's own typing. stage = "capture-focus" @@ -574,6 +688,16 @@ def main() -> int: *(["--instruction", operator_instruction] if operator_instruction else []), ), environment) capture_armed = True + stage = "fixture-start" + start = run_fixture_state_change_with_confirmation( + host, "start", arguments.run_id, "--delay-ms", str(effective_delay_ms), + expected_states={"running", "complete"}, + environment=environment, retry_seconds=60, errors=control_errors, + ) + # Schedule and confirm Start before applying host pressure. This keeps + # Swift/CPU load from starving the Wi-Fi request that begins the run, + # while the 10-second lead ensures the load is active before USB HID. + stage = "load-start" if arguments.cpu_load_workers is not None: cpu_load = ControlledCPULoad( workers=arguments.cpu_load_workers, sample_ms=arguments.cpu_sample_ms @@ -582,11 +706,6 @@ def main() -> int: if arguments.swift_compile_load: swift_compile_load = ControlledSwiftCompileLoad() swift_compile_load.start() - stage = "fixture-start" - start = run_fixture_json_with_retry( - host, "start", arguments.run_id, "--delay-ms", str(arguments.delay_ms), - environment=environment, retry_seconds=5, errors=control_errors, - ) stage = "active-capture" saw_usb_unmounted = False if interruption_mode == "completion": @@ -768,9 +887,15 @@ def main() -> int: "keyHoldMs": arguments.hold_ms, "shiftReleaseLagMs": arguments.shift_release_lag_ms, }, + "scheduling": { + "requestedDelayMs": arguments.delay_ms, + "effectiveDelayMs": effective_delay_ms, + "loadStartedAfterStartConfirmation": load_requested, + }, "checks": checks, "control": { "initialFixtureStatus": initial_status, + "staleRunAbort": stale_abort_response, "capturePreflight": capture_preflight, "load": load, "fixtureArm": fixture_arm, @@ -823,9 +948,11 @@ def main() -> int: cleanup.append("bounded Swift compile load stopped") if fixture_mutated and fixture_host: try: - abort_response = run_fixture_json_with_retry( - fixture_host, "abort", environment=environment, - retry_seconds=5, errors=control_errors, + abort_response = run_fixture_state_change_with_confirmation( + fixture_host, "abort", arguments.run_id, + include_run_id_argument=False, expected_states={"aborted"}, + environment=environment, retry_seconds=60, errors=control_errors, + require_run_id_match=False, ) abort_confirmed = True cleanup.append("fixture aborted with all-keys-released queued") diff --git a/Scripts/lab/physical-hid-repeat-matrix b/Scripts/lab/physical-hid-repeat-matrix index a02242893..bbaac1c2b 100755 --- a/Scripts/lab/physical-hid-repeat-matrix +++ b/Scripts/lab/physical-hid-repeat-matrix @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import datetime import difflib +import hashlib import json import os import pathlib @@ -51,6 +52,37 @@ def case_run_id(prefix: str, corpus: str, interval_ms: int, workers: int, swift_ return f"{safe_name(prefix)[:prefix_budget]}{suffix}" +def retry_run_id(base_run_id: str, retry: int) -> str: + """Keep retries unique even when long base IDs share a truncated prefix.""" + suffix = f"-{hashlib.sha256(base_run_id.encode()).hexdigest()[:8]}-retry{retry}" + return f"{base_run_id[:48 - len(suffix)]}{suffix}" + + +def valid_resume_artifact( + output_directory: pathlib.Path, base_run_id: str +) -> tuple[pathlib.Path | None, int]: + """Return the newest valid base/retry artifact and the next retry number.""" + base_path = output_directory / f"{base_run_id}.json" + if not base_path.is_file(): + return None, 2 + candidates = [base_path] + retry = 2 + while True: + candidate = output_directory / f"{retry_run_id(base_run_id, retry)}.json" + if not candidate.is_file(): + break + candidates.append(candidate) + retry += 1 + for candidate in reversed(candidates): + try: + case = analyze_artifact(json.loads(candidate.read_text())) + except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError): + continue + if case["validEvidence"]: + return candidate, retry + return None, retry + + def edit_counts(expected: str, received: str) -> dict[str, int]: counts = {"addedCharacters": 0, "deletedCharacters": 0, "substitutedCharacters": 0} matcher = difflib.SequenceMatcher(a=expected, b=received, autojunk=False) @@ -98,6 +130,7 @@ def analyze_artifact(artifact: dict[str, Any]) -> dict[str, Any]: "lateReports": fixture.get("lateReports"), "maximumLatenessUs": fixture.get("maximumLatenessUs"), "validEvidence": ( + artifact.get("status") in {"passed", "failed"} and artifact.get("admissionMode") == "strict" and focus_valid and all_keys_released and all_modifiers_released and fixture_complete ), @@ -280,8 +313,10 @@ def main() -> int: run_id = base_run_id artifact_path = output_directory / f"{run_id}.json" if arguments.resume and artifact_path.is_file(): - case = analyze_artifact(json.loads(artifact_path.read_text())) - if case["validEvidence"]: + valid_artifact, retry = valid_resume_artifact(output_directory, base_run_id) + if valid_artifact is not None: + artifact_path = valid_artifact + case = analyze_artifact(json.loads(artifact_path.read_text())) case.update({ "corpus": corpus_name, "intervalMs": interval_ms, "loadProfile": profile["name"], "cpuWorkers": workers, @@ -290,13 +325,8 @@ def main() -> int: }) cases.append(case) continue - retry = 2 - while True: - run_id = f"{base_run_id[:40]}-retry{retry}" - artifact_path = output_directory / f"{run_id}.json" - if not artifact_path.exists(): - break - retry += 1 + run_id = retry_run_id(base_run_id, retry) + artifact_path = output_directory / f"{run_id}.json" jig_ready, detail = ensure_jig_running() if not jig_ready: infrastructure_errors.append({ diff --git a/Scripts/lab/pico-hid-fixture-client b/Scripts/lab/pico-hid-fixture-client index e15907966..daed23c79 100755 --- a/Scripts/lab/pico-hid-fixture-client +++ b/Scripts/lab/pico-hid-fixture-client @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import hashlib import hmac +import http.client import json import os import pathlib @@ -85,6 +86,8 @@ class FixtureClient: if timeout <= 0: raise ValueError("fixture timeout must be positive") self.base_url = f"http://{host}:{port}" + self.host = host + self.port = port self.token = token self.timeout = timeout @@ -132,6 +135,68 @@ class FixtureClient: lines = self.request("GET", f"/v1/trace?from={start}&limit={limit}").splitlines() return [json.loads(line) for line in lines] + def trace_all(self, limit: int = 8, retry_seconds: float = 60.0) -> list[dict[str, object]]: + """Fetch the bounded trace over one persistent authenticated connection.""" + if not 1 <= limit <= 8: + raise ValueError("trace page limit must be between 1 and 8") + if retry_seconds <= 0: + raise ValueError("trace retry window must be positive") + connection: http.client.HTTPConnection | None = None + traces: list[dict[str, object]] = [] + start = 0 + available: int | None = None + run_id: str | None = None + try: + while available is None or start < available: + deadline = time.monotonic() + retry_seconds + while True: + try: + if connection is None: + connection = http.client.HTTPConnection( + self.host, self.port, timeout=self.timeout + ) + connection.request( + "GET", f"/v1/trace?from={start}&limit={limit}", + headers={"Authorization": f"Bearer {self.token}", + "Connection": "keep-alive"}, + ) + response = connection.getresponse() + body = response.read().decode("utf-8") + if response.status != 200: + raise RuntimeError( + f"fixture returned HTTP {response.status}: {body.strip()}" + ) + lines = [json.loads(line) for line in body.splitlines()] + break + except (OSError, TimeoutError, http.client.HTTPException, + json.JSONDecodeError) as error: + if connection is not None: + connection.close() + connection = None + if time.monotonic() >= deadline: + raise RuntimeError( + f"trace page {start} unavailable after {retry_seconds:g}s: {error}" + ) from error + time.sleep(0.25) + if not lines or not isinstance(lines[0], dict): + raise RuntimeError("fixture trace returned an invalid page") + header = lines[0] + page_run_id = str(header.get("runId", "")) + if run_id is None: + run_id = page_run_id + elif page_run_id != run_id: + raise RuntimeError("fixture run id changed while fetching trace") + available = int(header.get("available", 0)) + batch = [entry for entry in lines[1:] if isinstance(entry, dict)] + if not batch and start < available: + raise RuntimeError("fixture trace page did not advance") + traces.extend(batch) + start += len(batch) + return traces + finally: + if connection is not None: + connection.close() + def update_firmware(self, firmware: bytes, expected_build: str, wait_seconds: float = 90.0) -> dict[str, object]: if not firmware: @@ -217,6 +282,9 @@ def parser() -> argparse.ArgumentParser: trace_command = commands.add_parser("trace") trace_command.add_argument("--from", dest="start", type=int, default=0) trace_command.add_argument("--limit", type=int, default=8) + trace_all_command = commands.add_parser("trace-all") + trace_all_command.add_argument("--limit", type=int, default=8) + trace_all_command.add_argument("--retry-seconds", type=float, default=60.0) present_command = commands.add_parser("present", help="drive the fixture's animated test narrative") present_command.add_argument("--phase", required=True, choices=("auto", "preparing", "countdown", "testing", "observing", @@ -274,6 +342,8 @@ def main() -> int: print_json(client.update_firmware(arguments.firmware.read_bytes(), arguments.expected_build, arguments.wait_seconds)) elif arguments.command == "trace": print_json(client.trace(arguments.start, arguments.limit)) + elif arguments.command == "trace-all": + print_json(client.trace_all(arguments.limit, arguments.retry_seconds)) elif arguments.command == "present": print_json(client.present({ "phase": arguments.phase, "result": arguments.result, "brand": arguments.brand, diff --git a/Scripts/lab/tests/hid-capture-jig-client-tests.py b/Scripts/lab/tests/hid-capture-jig-client-tests.py index bb3cf72fd..364eff279 100755 --- a/Scripts/lab/tests/hid-capture-jig-client-tests.py +++ b/Scripts/lab/tests/hid-capture-jig-client-tests.py @@ -16,8 +16,9 @@ class FakeJig: - def __init__(self, directory: pathlib.Path): + def __init__(self, directory: pathlib.Path, response_delay: float = 0): self.directory = directory + self.response_delay = response_delay self.commands: list[dict[str, object]] = [] self.stop = False self.thread = threading.Thread(target=self.run, daemon=True) @@ -43,6 +44,7 @@ def run(self): continue last_id = command["id"] self.commands.append(command) + time.sleep(self.response_delay) snapshot = { "state": "armed" if command["action"] == "arm" else "idle", "expected": command.get("expected", ""), @@ -123,6 +125,25 @@ def test_bear_monitor_transports_schedule_without_claiming_capture(self): self.assertEqual(jig.commands[1]["phase"], "safeMisses") self.assertEqual(jig.commands[1]["correctedWords"], 1) + def test_concurrent_clients_are_serialized_across_the_single_command_slot(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + environment = os.environ.copy() + environment["KEYPATH_CAPTURE_JIG_STATE_DIR"] = str(directory) + environment["KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT"] = "1" + with FakeJig(directory, response_delay=0.1) as jig: + clients = [ + subprocess.Popen( + [str(CLIENT), command], text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=environment, + ) + for command in ("status", "focus") + ] + results = [client.communicate(timeout=3) for client in clients] + + self.assertEqual([client.returncode for client in clients], [0, 0], results) + self.assertEqual(sorted(command["action"] for command in jig.commands), ["focus", "status"]) + def test_missing_app_fails_with_actionable_message(self): with tempfile.TemporaryDirectory() as temporary: result = self.run_client(pathlib.Path(temporary), "status", timeout="0.1") diff --git a/Scripts/lab/tests/physical-hid-capture-run-tests.py b/Scripts/lab/tests/physical-hid-capture-run-tests.py index 4904974d8..6d3a00f6b 100644 --- a/Scripts/lab/tests/physical-hid-capture-run-tests.py +++ b/Scripts/lab/tests/physical-hid-capture-run-tests.py @@ -52,7 +52,7 @@ def action(command: list[str]) -> tuple[str, str]: if "hid-capture-jig-client" in command[0]: return "capture", command[1] fixture_actions = { - "status", "load-script", "arm", "start", "abort", "trace", "present", + "status", "load-script", "arm", "start", "abort", "trace", "trace-all", "present", } return "fixture", next(value for value in command[1:] if value in fixture_actions) @@ -83,14 +83,17 @@ def test_demo_mode_records_busy_preflight_then_runs_with_explicit_bypass(self) - fixture_status_calls = 0 capture_status_calls = 0 capture_arm_command: list[str] | None = None + presentation_commands: list[list[str]] = [] def fake_run_json(command, environment, allowed_returncodes=(0,)): del environment, allowed_returncodes nonlocal fixture_status_calls, capture_status_calls, capture_arm_command target, operation = action(command) calls.append((target, operation)) - if (target, operation) == ("fixture", "trace"): - return TRACE_BATCH + if (target, operation) == ("fixture", "present"): + presentation_commands.append(command) + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] if (target, operation) == ("fixture", "status"): fixture_status_calls += 1 if fixture_status_calls == 1: @@ -148,9 +151,15 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): assert capture_arm_command is not None self.assertIn("--demo-mode", capture_arm_command) self.assertNotIn("--instruction", capture_arm_command) - self.assertEqual(capture_arm_command[capture_arm_command.index("--timeout-ms") + 1], "15000") + self.assertEqual(capture_arm_command[capture_arm_command.index("--timeout-ms") + 1], "90000") + self.assertEqual(len(presentation_commands), 2) + self.assertTrue(all( + command[command.index("--brand") + 1] == "keypath" + for command in presentation_commands + )) artifact = json.loads(output_path.read_text()) self.assertEqual(artifact["admissionMode"], "demo-bypass") + self.assertEqual(artifact["presentationBrand"], "keypath") self.assertFalse(artifact["control"]["capturePreflight"]["systemReadiness"]["canProceed"]) self.assertEqual(artifact["control"]["capturePreflight"]["snapshot"]["state"], "idle") self.assertTrue(artifact["checks"]["latenessWithinBudget"]) @@ -203,7 +212,7 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): self.assertNotIn(("fixture", "load-script"), calls) self.assertNotIn(("fixture", "arm"), calls) - def test_swift_compile_load_starts_after_capture_arm_and_is_recorded(self) -> None: + def test_swift_compile_load_starts_after_confirmed_fixture_schedule_and_is_recorded(self) -> None: timeline: list[tuple[str, str]] = [] fixture_status_calls = 0 capture_status_calls = 0 @@ -220,8 +229,8 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): nonlocal fixture_status_calls, capture_status_calls target, operation = action(command) timeline.append((target, operation)) - if (target, operation) == ("fixture", "trace"): - return TRACE_BATCH + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] if (target, operation) == ("fixture", "status"): fixture_status_calls += 1 if fixture_status_calls == 1: @@ -271,10 +280,161 @@ def stop(self): artifact = json.loads(output_path.read_text()) self.assertLess(timeline.index(("capture", "arm")), timeline.index(("load", "swift-start"))) - self.assertLess(timeline.index(("load", "swift-start")), timeline.index(("fixture", "start"))) + self.assertLess(timeline.index(("fixture", "start")), timeline.index(("load", "swift-start"))) self.assertEqual(artifact["swiftCompileLoad"]["sourceBytes"], 12345) + self.assertEqual(artifact["scheduling"]["effectiveDelayMs"], 10000) + self.assertTrue(artifact["scheduling"]["loadStartedAfterStartConfirmation"]) self.assertEqual(timeline.count(("load", "swift-stop")), 1) + def test_swift_compile_cleanup_does_not_signal_an_exited_process_group(self) -> None: + load = self.runner.ControlledSwiftCompileLoad() + process = mock.Mock(pid=4242) + process.poll.return_value = 1 + load.process = process + + with mock.patch.object(self.runner.os, "killpg") as kill_group: + result = load.stop() + + kill_group.assert_not_called() + process.terminate.assert_not_called() + process.kill.assert_not_called() + self.assertEqual(result["returnCode"], 1) + self.assertIsNone(load.process) + + def test_swift_compile_load_uses_the_selected_macos_sdk(self) -> None: + load = self.runner.ControlledSwiftCompileLoad() + compiler = "/Toolchains/Test/usr/bin/swiftc" + sdk = "/Platforms/Test/MacOSX.sdk" + process = mock.Mock(pid=4242) + process.poll.return_value = None + lookups = [ + mock.Mock(stdout=f"{compiler}\n"), + mock.Mock(stdout=f"{sdk}\n"), + ] + + with mock.patch.object(self.runner.subprocess, "run", side_effect=lookups), \ + mock.patch.object(self.runner.subprocess, "Popen", return_value=process) as popen, \ + mock.patch.object(self.runner.time, "sleep"): + load.start() + + command = popen.call_args.args[0] + self.assertEqual(command[0:2], ["/bin/bash", "-c"]) + self.assertEqual(command[4:6], [compiler, sdk]) + self.assertIn('-sdk "$2"', command[2]) + self.assertEqual(load.sdk, sdk) + load.process = None + load.temporary.cleanup() + load.temporary = None + + def test_trace_collection_allows_the_firmware_wifi_recovery_window(self) -> None: + errors: list[dict] = [] + with mock.patch.object(self.runner, "run_json", return_value=TRACE_BATCH[1:]) as request: + trace = self.runner.fetch_fixture_trace("10.0.0.47", {}, errors) + + self.assertEqual(trace, TRACE_BATCH[1:]) + command = request.call_args.args[0] + self.assertIn("trace-all", command) + self.assertIn("60", command) + + def test_fixture_discovery_allows_the_firmware_wifi_recovery_window(self) -> None: + errors: list[dict] = [] + response = {"address": "10.0.0.47", "state": "idle"} + with mock.patch.object( + self.runner, "run_fixture_json_with_retry", return_value=response + ) as request: + host, status = self.runner.resolve_fixture_host("fixture.local", {}, errors) + + self.assertEqual(host, "10.0.0.47") + self.assertIs(status, response) + self.assertEqual(request.call_args.kwargs["retry_seconds"], 60) + self.assertIs(request.call_args.kwargs["errors"], errors) + + def test_lost_arm_response_is_confirmed_from_fixture_status(self) -> None: + errors: list[dict] = [] + responses = [ + RuntimeError("arm response timed out"), + {"state": "armed", "runId": "case"}, + ] + with mock.patch.object(self.runner, "run_json", side_effect=responses): + result = self.runner.run_fixture_state_change_with_confirmation( + "10.0.0.47", "arm", "case", expected_states={"armed"}, + environment={}, retry_seconds=1, errors=errors, + ) + + self.assertTrue(result["confirmedByStatus"]) + self.assertEqual(result["status"]["state"], "armed") + self.assertEqual(errors[0]["action"], "arm") + + def test_lost_load_response_is_confirmed_without_passing_run_id_to_command(self) -> None: + errors: list[dict] = [] + responses = [ + RuntimeError("load response timed out"), + {"state": "loaded", "runId": "case"}, + ] + with mock.patch.object(self.runner, "run_json", side_effect=responses) as request: + result = self.runner.run_fixture_state_change_with_confirmation( + "10.0.0.47", "load-script", "case", "/tmp/script.txt", + include_run_id_argument=False, expected_states={"loaded"}, + environment={}, retry_seconds=1, errors=errors, + ) + + self.assertTrue(result["confirmedByStatus"]) + load_command = request.call_args_list[0].args[0] + self.assertIn("load-script", load_command) + self.assertIn("/tmp/script.txt", load_command) + self.assertNotIn("case", load_command) + + def test_lost_abort_response_confirms_global_safe_state(self) -> None: + errors: list[dict] = [] + responses = [ + RuntimeError("abort response timed out"), + {"state": "aborted", "runId": "stale-case"}, + ] + with mock.patch.object(self.runner, "run_json", side_effect=responses) as request: + result = self.runner.run_fixture_state_change_with_confirmation( + "10.0.0.47", "abort", "new-case", + include_run_id_argument=False, expected_states={"aborted"}, + environment={}, retry_seconds=1, errors=errors, + require_run_id_match=False, + ) + + self.assertTrue(result["confirmedByStatus"]) + abort_command = request.call_args_list[0].args[0] + self.assertIn("abort", abort_command) + self.assertNotIn("new-case", abort_command) + + def test_state_confirmation_rejects_a_different_run_id(self) -> None: + errors: list[dict] = [] + responses = [ + RuntimeError("start response timed out"), + {"state": "running", "runId": "other-case"}, + ] + with mock.patch.object(self.runner, "run_json", side_effect=responses), \ + mock.patch.object(self.runner.time, "monotonic", side_effect=[0.0, 2.0]): + with self.assertRaisesRegex(RuntimeError, "fixture start unavailable"): + self.runner.run_fixture_state_change_with_confirmation( + "10.0.0.47", "start", "case", "--delay-ms", "800", + expected_states={"running", "complete"}, environment={}, + retry_seconds=1, errors=errors, + ) + + def test_swift_compile_cleanup_falls_back_when_group_signal_is_denied(self) -> None: + load = self.runner.ControlledSwiftCompileLoad() + process = mock.Mock(pid=4242) + process.poll.side_effect = [None, None] + process.wait.return_value = -15 + load.process = process + + with mock.patch.object( + self.runner.os, "killpg", side_effect=PermissionError + ) as kill_group: + result = load.stop() + + kill_group.assert_called_once_with(4242, self.runner.signal.SIGTERM) + process.terminate.assert_called_once_with() + self.assertEqual(result["returnCode"], -15) + self.assertIsNone(load.process) + def test_failed_capture_waits_for_fixture_and_persists_complete_evidence(self) -> None: fixture_status_calls = 0 capture_status_calls = 0 @@ -283,8 +443,8 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): del environment, allowed_returncodes nonlocal fixture_status_calls, capture_status_calls target, operation = action(command) - if (target, operation) == ("fixture", "trace"): - return TRACE_BATCH + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] if (target, operation) == ("fixture", "status"): fixture_status_calls += 1 if fixture_status_calls == 1: @@ -389,8 +549,8 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): nonlocal fixture_status_calls target, operation = action(command) calls.append((target, operation)) - if (target, operation) == ("fixture", "trace"): - return TRACE_BATCH + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] if (target, operation) == ("fixture", "status"): fixture_status_calls += 1 if fixture_status_calls == 1: @@ -458,8 +618,8 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): del environment, allowed_returncodes nonlocal fixture_status_calls target, operation = action(command) - if (target, operation) == ("fixture", "trace"): - return TRACE_BATCH + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] if (target, operation) == ("fixture", "status"): fixture_status_calls += 1 if fixture_status_calls == 1: @@ -494,8 +654,8 @@ def fake_run_json(command, environment, allowed_returncodes=(0,)): del environment, allowed_returncodes nonlocal fixture_status_calls target, operation = action(command) - if (target, operation) == ("fixture", "trace"): - return TRACE_BATCH + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] if (target, operation) == ("fixture", "status"): fixture_status_calls += 1 if fixture_status_calls == 1: diff --git a/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py b/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py index 77abba44f..9c1f22df0 100644 --- a/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py +++ b/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py @@ -51,6 +51,29 @@ def test_case_run_ids_fit_firmware_limit_and_keep_profile_identity(self) -> None self.assertEqual(len(identifiers), 4) self.assertTrue(all(len(identifier) <= 48 for identifier in identifiers)) + def test_retry_ids_fit_firmware_limit_and_do_not_collide_after_truncation(self) -> None: + first = MODULE.case_run_id("campaign-with-a-very-long-prefix", "single", 5, 0, True) + second = MODULE.case_run_id("campaign-with-a-very-long-prefix", "single", 5, 2, True) + retries = {MODULE.retry_run_id(first, 2), MODULE.retry_run_id(second, 2)} + + self.assertEqual(len(retries), 2) + self.assertTrue(all(len(identifier) <= 48 for identifier in retries)) + + def test_resume_reuses_a_valid_retry_instead_of_repeating_hid(self) -> None: + base = MODULE.case_run_id("campaign", "single", 5, 0, True) + with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) + inconclusive = artifact("abcd", "abcd") + inconclusive["status"] = "inconclusive" + (output / f"{base}.json").write_text(json.dumps(inconclusive)) + retry_path = output / f"{MODULE.retry_run_id(base, 2)}.json" + retry_path.write_text(json.dumps(artifact("abcd", "abcd"))) + + resumed, next_retry = MODULE.valid_resume_artifact(output, base) + + self.assertEqual(resumed, retry_path) + self.assertEqual(next_retry, 3) + def test_campaign_requires_explicit_exclusive_desktop_confirmation(self) -> None: with mock.patch.object(MODULE.sys, "argv", [str(SCRIPT)]): self.assertEqual(MODULE.main(), 2) @@ -72,6 +95,14 @@ def test_classification_fails_closed_when_focus_is_lost(self) -> None: case = MODULE.analyze_artifact(artifact("abcd", "abcd", focused=False)) self.assertEqual(MODULE.classify([case]), "harness-invalid") + def test_inconclusive_runner_artifact_is_not_valid_resume_evidence(self) -> None: + result = artifact("abcd", "abcd") + result["status"] = "inconclusive" + case = MODULE.analyze_artifact(result) + + self.assertFalse(case["validEvidence"]) + self.assertEqual(MODULE.classify([case]), "harness-invalid") + def test_campaign_uses_strict_runner_and_load_budget_only_for_loaded_case(self) -> None: commands: list[list[str]] = [] diff --git a/Scripts/lab/tests/pico-hid-fixture-client-tests.py b/Scripts/lab/tests/pico-hid-fixture-client-tests.py index c054f3d37..4440f5568 100755 --- a/Scripts/lab/tests/pico-hid-fixture-client-tests.py +++ b/Scripts/lab/tests/pico-hid-fixture-client-tests.py @@ -137,6 +137,11 @@ def test_trace_decodes_ndjson(self): self.assertEqual(trace[0]["available"], 1) self.assertEqual(trace[1]["sequence"], 1) + def test_trace_all_returns_complete_trace(self): + trace = self.client.trace_all() + self.assertEqual(trace, [{"sequence": 1}]) + self.assertEqual(RecordingHandler.requests[-1][1], "/v1/trace?from=0&limit=8") + def test_presentation_uses_bounded_json_channel(self): self.client.present({"phase": "result", "result": "pass", "brand": "bear", "progress": 1000, "title": "Swift stress", "reportsExpected": 40,