From a5f182b79a4ea9f06763ca8028665fed14cab6e6 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 29 May 2026 10:30:44 +0200 Subject: [PATCH 1/3] Split PR 578 CI BitBox setup --- .../actions/bitbox-flake-poster/action.yml | 136 +++++++ .../actions/bitbox-maestro-flow/action.yml | 100 +++++ .../actions/bitbox-maestro-setup/action.yml | 97 +++++ .github/workflows/maestro-bitbox.yaml | 359 ++++++++++++++++++ .github/workflows/pull-request.yaml | 34 +- .maestro/bitbox/M-1-happy-path.yaml | 183 +++++++++ .../M-2-multi-page-sign-stable-ble.yaml | 131 +++++++ .../M-3-multi-page-sign-with-ble-toggle.yaml | 204 ++++++++++ .maestro/bitbox/M-4-disconnect-mid-sign.yaml | 201 ++++++++++ .../bitbox/M-5-channel-hash-mismatch.yaml | 208 ++++++++++ .../bitbox/M-6-factory-reset-detection.yaml | 215 +++++++++++ .../bitbox/M-7-slow-confirm-long-idle.yaml | 173 +++++++++ .maestro/bitbox/README.md | 189 +++++++++ .maestro/bitbox/RUNNER.md | 175 +++++++++ README.md | 2 +- pubspec.yaml | 2 + test/tool/generate_release_info_test.dart | 29 +- 17 files changed, 2428 insertions(+), 10 deletions(-) create mode 100644 .github/actions/bitbox-flake-poster/action.yml create mode 100644 .github/actions/bitbox-maestro-flow/action.yml create mode 100644 .github/actions/bitbox-maestro-setup/action.yml create mode 100644 .github/workflows/maestro-bitbox.yaml create mode 100644 .maestro/bitbox/M-1-happy-path.yaml create mode 100644 .maestro/bitbox/M-2-multi-page-sign-stable-ble.yaml create mode 100644 .maestro/bitbox/M-3-multi-page-sign-with-ble-toggle.yaml create mode 100644 .maestro/bitbox/M-4-disconnect-mid-sign.yaml create mode 100644 .maestro/bitbox/M-5-channel-hash-mismatch.yaml create mode 100644 .maestro/bitbox/M-6-factory-reset-detection.yaml create mode 100644 .maestro/bitbox/M-7-slow-confirm-long-idle.yaml create mode 100644 .maestro/bitbox/README.md create mode 100644 .maestro/bitbox/RUNNER.md diff --git a/.github/actions/bitbox-flake-poster/action.yml b/.github/actions/bitbox-flake-poster/action.yml new file mode 100644 index 00000000..80e44022 --- /dev/null +++ b/.github/actions/bitbox-flake-poster/action.yml @@ -0,0 +1,136 @@ +name: BitBox flake-rate poster +description: | + Updates the trailing-30-day per-flow flake-rate in + bitbox-testkit/coverage_report.md. Designed to run from realunit-app + but writing to a sibling repo via the GITHUB_TOKEN. + + Behaviour: + - On every run, appends one row to a per-flow rolling log + .maestro/bitbox/flake-log.jsonl (committed back to the realunit-app + repo on `push: develop` runs ONLY -- PR runs do not commit). + - Posts a comment on the PR (if `pull_request` event) with the + single-flow attempt count + outcome. + - On scheduled / push runs, also computes the trailing-30-day green + rate and writes it to bitbox-testkit/coverage_report.md via a + cross-repo dispatch (NOT implemented yet -- this action stubs the + cross-repo write; the audit BL-100 cross-repo workflow finalises + that wire-up). + +inputs: + flow: + description: "Flow short ID (M-1, M-2, ...)" + required: true + attempts: + description: "Attempts taken in this run" + required: true + outcome: + description: "Step outcome: success | failure | skipped" + required: true + +runs: + using: composite + steps: + - name: Append flake log + shell: bash + env: + FLOW: ${{ inputs.flow }} + ATTEMPTS: ${{ inputs.attempts }} + OUTCOME: ${{ inputs.outcome }} + run: | + set -euo pipefail + LOG=".maestro/bitbox/flake-log.jsonl" + mkdir -p "$(dirname "$LOG")" + ts="$(date -u +%FT%TZ)" + entry='{"ts":"'"$ts"'","flow":"'"$FLOW"'","attempts":'"$ATTEMPTS"',"outcome":"'"$OUTCOME"'","sha":"'"${GITHUB_SHA:-}"'","run":"'"${GITHUB_RUN_ID:-}"'"}' + echo "$entry" >> "$LOG" + echo "appended: $entry" + + - name: Compute trailing-30-day flake-rate + id: rate + shell: bash + env: + FLOW: ${{ inputs.flow }} + run: | + set -euo pipefail + LOG=".maestro/bitbox/flake-log.jsonl" + if [ ! -f "$LOG" ]; then + echo "rate=unknown" >> "$GITHUB_OUTPUT" + echo "n=0" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Last 30 d window. Rate = greens / total (entries with attempts == 1) + # are unambiguous greens; > 1 attempts is a flake-but-recovered. + cutoff="$(date -u -v-30d +%FT%TZ 2>/dev/null || date -u -d '30 days ago' +%FT%TZ)" + python3 - <"$log" 2>&1 & + echo $! > "/tmp/maestro-${FLOW%.yaml}-bg.pid" + sleep 2 + return 0 + fi + if "$MAESTRO" test "$FLOW_PATH" --debug-output "/tmp/maestro-debug-${attempt}" 2>&1 | tee "$log"; then + return 0 + fi + # Recoverable failure modes: only IOSDriverTimeoutException + + # XCTestCase initialisation failures get a retry. Assertion + # failures fall through. + if grep -qE "IOSDriverTimeoutException|XCTestCase init failed|driver startup timeout" "$log"; then + echo "::warning::recoverable Maestro failure on attempt $attempt; will retry" + return 1 + fi + # Assertion / other failures: hard-fail, do NOT retry. + echo "::error::non-recoverable failure (assertion or unknown) on attempt $attempt" + return 2 + } + + attempts=0 + while [ $attempts -lt $MAX_ATTEMPTS ]; do + attempts=$((attempts+1)) + rc=0 + run_once "$attempts" || rc=$? + if [ $rc -eq 0 ]; then + echo "attempts=$attempts" >> "$GITHUB_OUTPUT" + echo "::notice::$FLOW green on attempt $attempts" + exit 0 + fi + if [ $rc -eq 2 ]; then + echo "attempts=$attempts" >> "$GITHUB_OUTPUT" + exit 1 + fi + # rc == 1 -> recoverable, loop + done + echo "attempts=$attempts" >> "$GITHUB_OUTPUT" + echo "::error::$FLOW exhausted $MAX_ATTEMPTS attempts" + exit 1 + + - name: Upload Maestro artefacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: maestro-${{ inputs.flow }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + /tmp/maestro-debug-* + /tmp/maestro-*.log + if-no-files-found: warn + retention-days: 14 diff --git a/.github/actions/bitbox-maestro-setup/action.yml b/.github/actions/bitbox-maestro-setup/action.yml new file mode 100644 index 00000000..1d1efbed --- /dev/null +++ b/.github/actions/bitbox-maestro-setup/action.yml @@ -0,0 +1,97 @@ +name: BitBox Maestro setup +description: | + Pre-flight setup for Tier-3 BitBox Maestro flows on the self-hosted + Apple Silicon runner. Builds the iOS Runner.app (and the Android APK + if requested), boots the primary iPhone simulator (and the secondary + if requested), installs the app, and verifies Maestro version pin. + + Does NOT physically reset the BitBox -- that is per-flow responsibility + in the flow's docblock. + +inputs: + android: + description: "Build + install the Android APK in addition to iOS" + required: false + default: "false" + two-phone: + description: "Boot + install on the secondary iPhone for M-5" + required: false + default: "false" + +runs: + using: composite + steps: + - name: Flutter setup + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.41.6" + channel: stable + cache: true + + - name: Flutter pub get + generators + shell: bash + run: | + set -euo pipefail + flutter pub get + dart run tool/generate_localization.dart + dart run tool/generate_release_info.dart + flutter pub run build_runner build + + - name: Cache iOS DerivedData + Pods + uses: actions/cache@v4 + with: + path: | + ~/Library/Developer/Xcode/DerivedData + ios/Pods + key: ios-derived-data-${{ runner.os }}-bitbox-${{ hashFiles('pubspec.lock', 'ios/Podfile.lock') }} + restore-keys: | + ios-derived-data-${{ runner.os }}-bitbox- + + - name: Build iOS simulator app + shell: bash + run: flutter build ios --simulator --debug + + - name: Build Android APK + if: inputs.android == 'true' + shell: bash + run: flutter build apk --debug + + - name: Verify Maestro pin + shell: bash + run: | + set -euo pipefail + MAESTRO_VERSION="$(cat .maestro-version)" + if [ -z "$MAESTRO_VERSION" ]; then + echo "::error::.maestro-version missing"; exit 1 + fi + INSTALLED="$($HOME/.maestro/bin/maestro --version 2>&1 | tail -n1 || echo none)" + echo "Maestro: installed=$INSTALLED expected=$MAESTRO_VERSION" + if [ "$INSTALLED" != "$MAESTRO_VERSION" ]; then + echo "::warning::Maestro version drift; runner may need provisioning" + fi + + - name: Verify BitBox hardware reachable + shell: bash + run: | + set +e + # On the self-hosted runner the BitBox CLI (if installed) lists + # the device. If absent, skip the check with a warning rather + # than fail -- the per-flow logic surfaces the real failure. + if command -v bitbox-cli >/dev/null 2>&1; then + bitbox-cli ls 2>&1 || echo "::warning::bitbox-cli ls failed -- check device power" + else + echo "::notice::bitbox-cli not installed on runner; relying on per-flow detection" + fi + # iOS sims booted? + xcrun simctl list devices booted + + - name: Verify Android device (if requested) + if: inputs.android == 'true' + shell: bash + run: | + set -euo pipefail + adb devices + if ! adb devices | grep -q "device$"; then + echo "::error::Android device not reachable; M-7 will fail PRECONDITION" + exit 1 + fi diff --git a/.github/workflows/maestro-bitbox.yaml b/.github/workflows/maestro-bitbox.yaml new file mode 100644 index 00000000..0cb85c6d --- /dev/null +++ b/.github/workflows/maestro-bitbox.yaml @@ -0,0 +1,359 @@ +name: Tier 3 — Maestro BitBox flows + +# Tier-3 hardware flows for the BitBox 02 Nova, defined under +# `.maestro/bitbox/`. Pins the three contracts no lower tier can: +# * M-3: BLE init-frame retransmit dedup (audit Top-10 #1) +# * M-5: channel-hash mismatch detection (audit Top-10 #4) +# * M-6: static-pubkey factory-reset detection (audit Top-10 #8) +# Plus the four supporting end-to-end / soak flows M-1, M-2, M-4, M-7. +# +# RUNNER: +# runs-on: [self-hosted, macOS, arm64, bitbox] +# The hosted `macos-latest` runner CANNOT host this workflow — it has +# no access to physical BLE / USB and per realunit-app#487 its +# Maestro pass-rate is 41 %. The mandate (§5.3.6) requires a +# self-hosted Apple Silicon runner; provisioning is in +# `.maestro/bitbox/RUNNER.md`. +# +# TRIGGER MODEL: +# * `pull_request: develop` with the `tier3:bitbox` label gate -- the +# PR-gate subset (M-1 / M-3 / M-5 / M-6) runs. Hardware time is +# scarce; reviewers opt-in by label. +# * `push: develop` -- the PR-gate subset runs unconditionally as +# post-merge truth check. +# * `schedule: '0 2 * * *'` -- the daily/full subset (M-2 / M-4 / +# M-7) runs at 02:00 UTC on the self-hosted runner. +# * `workflow_dispatch` -- manual override; the `flow` input picks +# which flow to run. +# +# CONCURRENCY / HARDWARE MUTEX: +# The runner has ONE BitBox 02 Nova. Running two flows in parallel +# would clobber the BLE handshake. We enforce a per-flow mutex via +# the `concurrency` block at the job level (group: bitbox-hardware- +# pool). PR-gate jobs serialise behind each other; the scheduled +# nightly cron waits if a PR-gate run is in flight (and vice-versa). +# +# RETRIES: +# Each flow gets 3 attempts. The first failure does NOT fail the +# job; only after attempt 3 fails does the job go red. Per-flow +# flake rate is recorded in `bitbox-testkit/coverage_report.md` via +# a posting step at the end of each job. +# +# CROSS-REF: `.maestro/bitbox/README.md`, `.maestro/bitbox/RUNNER.md`, +# mandate §5.3.3 Group E. + +on: + workflow_dispatch: + inputs: + flow: + description: "Flow to run (M-1..M-7 or 'pr-gate' or 'nightly')" + required: true + default: "pr-gate" + type: choice + options: + - pr-gate + - nightly + - M-1 + - M-2 + - M-3 + - M-4 + - M-5 + - M-6 + - M-7 + push: + branches: [develop] + pull_request: + branches: [develop] + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + schedule: + # 02:00 UTC -- avoids overlapping with the macos-latest hosted + # tier3-handbook.yaml's typical run windows. + - cron: "0 2 * * *" + +# Workflow-level concurrency: a fresh push to the same PR cancels the +# in-flight Tier-3 run on the runner. Same pattern as tier3-handbook. +concurrency: + group: >- + ${{ + (github.event.action == 'labeled' || github.event.action == 'unlabeled') + && format('ci-{0}-label-{1}', github.workflow, github.run_id) + || format('ci-{0}-{1}', github.workflow, github.event.pull_request.number || github.ref) + }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write # for the per-flow flake-rate comment poster + +env: + MAESTRO_CLI_NO_ANALYTICS: "1" + MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000" + +jobs: + # =========================================================================== + # PR-gate flows: M-1, M-3, M-5, M-6 + # =========================================================================== + m1-happy-path: + name: M-1 — Happy path + if: >- + github.event_name == 'push' + || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'tier3:bitbox')) + || (github.event_name == 'workflow_dispatch' && (inputs.flow == 'M-1' || inputs.flow == 'pr-gate')) + runs-on: [self-hosted, macOS, arm64, bitbox] + timeout-minutes: 5 + # Hardware mutex: only one Tier-3 BitBox job runs at a time. The + # group spans the workflow AND the scheduled nightly job. We do NOT + # cancel-in-progress here — letting the in-flight flow finish is + # cheaper than restarting from scratch. + concurrency: + group: bitbox-hardware-pool + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bitbox-maestro-setup + - name: Run M-1 + id: run + uses: ./.github/actions/bitbox-maestro-flow + with: + flow: M-1-happy-path.yaml + retries: 3 + - name: Post flake-rate update + if: always() + uses: ./.github/actions/bitbox-flake-poster + with: + flow: M-1 + attempts: ${{ steps.run.outputs.attempts }} + outcome: ${{ steps.run.outcome }} + + m3-multi-page-ble-toggle: + name: M-3 — Multi-page sign w/ BLE toggle (CANONICAL dedup verifier) + if: >- + github.event_name == 'push' + || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'tier3:bitbox')) + || (github.event_name == 'workflow_dispatch' && (inputs.flow == 'M-3' || inputs.flow == 'pr-gate')) + needs: m1-happy-path + runs-on: [self-hosted, macOS, arm64, bitbox] + timeout-minutes: 18 + concurrency: + group: bitbox-hardware-pool + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bitbox-maestro-setup + - name: Run M-3 + id: run + uses: ./.github/actions/bitbox-maestro-flow + with: + flow: M-3-multi-page-sign-with-ble-toggle.yaml + retries: 3 + - name: Post flake-rate update + if: always() + uses: ./.github/actions/bitbox-flake-poster + with: + flow: M-3 + attempts: ${{ steps.run.outputs.attempts }} + outcome: ${{ steps.run.outcome }} + + m5-channel-hash-mismatch: + name: M-5 — Channel-hash mismatch (CANONICAL spoof verifier) + if: >- + github.event_name == 'push' + || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'tier3:bitbox')) + || (github.event_name == 'workflow_dispatch' && (inputs.flow == 'M-5' || inputs.flow == 'pr-gate')) + needs: m1-happy-path + runs-on: [self-hosted, macOS, arm64, bitbox] + timeout-minutes: 10 + concurrency: + group: bitbox-hardware-pool + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bitbox-maestro-setup + with: + two-phone: "true" + - name: Run M-5 (phase A) + id: run_a + env: + MAESTRO_PHASE: A + MAESTRO_DEVICE_A_UDID: ${{ vars.BITBOX_IPHONE_A_UDID }} + MAESTRO_DEVICE_B_UDID: ${{ vars.BITBOX_IPHONE_B_UDID }} + uses: ./.github/actions/bitbox-maestro-flow + with: + flow: M-5-channel-hash-mismatch.yaml + retries: 3 + background: "true" + - name: Run M-5 (phase B) + id: run_b + env: + MAESTRO_PHASE: B + MAESTRO_DEVICE_A_UDID: ${{ vars.BITBOX_IPHONE_A_UDID }} + MAESTRO_DEVICE_B_UDID: ${{ vars.BITBOX_IPHONE_B_UDID }} + MAESTRO_DEVICE_ID: ${{ vars.BITBOX_IPHONE_B_UDID }} + uses: ./.github/actions/bitbox-maestro-flow + with: + flow: M-5-channel-hash-mismatch.yaml + retries: 3 + - name: Post flake-rate update + if: always() + uses: ./.github/actions/bitbox-flake-poster + with: + flow: M-5 + # M-5 succeeds iff BOTH phases ran green; the poster handles + # the AND-combine. + attempts: ${{ steps.run_b.outputs.attempts }} + outcome: ${{ steps.run_b.outcome }} + + m6-factory-reset: + name: M-6 — Factory-reset detection (CANONICAL static-pubkey verifier) + if: >- + github.event_name == 'push' + || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'tier3:bitbox')) + || (github.event_name == 'workflow_dispatch' && (inputs.flow == 'M-6' || inputs.flow == 'pr-gate')) + needs: m1-happy-path + runs-on: [self-hosted, macOS, arm64, bitbox] + timeout-minutes: 12 + concurrency: + group: bitbox-hardware-pool + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bitbox-maestro-setup + - name: Run M-6 + id: run + uses: ./.github/actions/bitbox-maestro-flow + with: + flow: M-6-factory-reset-detection.yaml + retries: 3 + - name: Post flake-rate update + if: always() + uses: ./.github/actions/bitbox-flake-poster + with: + flow: M-6 + attempts: ${{ steps.run.outputs.attempts }} + outcome: ${{ steps.run.outcome }} + + # =========================================================================== + # Scheduled-daily flows: M-2, M-4, M-7 + # =========================================================================== + m2-multi-page-stable-ble: + name: M-2 — Multi-page sign (stable BLE) + if: >- + github.event_name == 'schedule' + || (github.event_name == 'workflow_dispatch' && (inputs.flow == 'M-2' || inputs.flow == 'nightly')) + runs-on: [self-hosted, macOS, arm64, bitbox] + timeout-minutes: 12 + concurrency: + group: bitbox-hardware-pool + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bitbox-maestro-setup + - name: Run M-2 + id: run + uses: ./.github/actions/bitbox-maestro-flow + with: + flow: M-2-multi-page-sign-stable-ble.yaml + retries: 3 + - name: Post flake-rate update + if: always() + uses: ./.github/actions/bitbox-flake-poster + with: + flow: M-2 + attempts: ${{ steps.run.outputs.attempts }} + outcome: ${{ steps.run.outcome }} + + m4-disconnect-mid-sign: + name: M-4 — Disconnect mid-sign + if: >- + github.event_name == 'schedule' + || (github.event_name == 'workflow_dispatch' && (inputs.flow == 'M-4' || inputs.flow == 'nightly')) + needs: m2-multi-page-stable-ble + runs-on: [self-hosted, macOS, arm64, bitbox] + timeout-minutes: 14 + concurrency: + group: bitbox-hardware-pool + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bitbox-maestro-setup + - name: Run M-4 + id: run + uses: ./.github/actions/bitbox-maestro-flow + with: + flow: M-4-disconnect-mid-sign.yaml + retries: 3 + - name: Post flake-rate update + if: always() + uses: ./.github/actions/bitbox-flake-poster + with: + flow: M-4 + attempts: ${{ steps.run.outputs.attempts }} + outcome: ${{ steps.run.outcome }} + + m7-slow-confirm-long-idle: + name: M-7 — Slow confirm long-idle (Android) + if: >- + github.event_name == 'schedule' + || (github.event_name == 'workflow_dispatch' && (inputs.flow == 'M-7' || inputs.flow == 'nightly')) + needs: m2-multi-page-stable-ble + runs-on: [self-hosted, macOS, arm64, bitbox, android] + timeout-minutes: 22 + concurrency: + group: bitbox-hardware-pool + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bitbox-maestro-setup + with: + android: "true" + - name: Run M-7 + id: run + env: + MAESTRO_DEVICE_ID: ${{ vars.BITBOX_ANDROID_SERIAL }} + uses: ./.github/actions/bitbox-maestro-flow + with: + flow: M-7-slow-confirm-long-idle.yaml + retries: 3 + - name: Post flake-rate update + if: always() + uses: ./.github/actions/bitbox-flake-poster + with: + flow: M-7 + attempts: ${{ steps.run.outputs.attempts }} + outcome: ${{ steps.run.outcome }} + + # =========================================================================== + # Summary job: aggregates per-flow outcomes for the PR check. + # =========================================================================== + summary: + name: Tier-3 Maestro summary + if: always() + needs: + - m1-happy-path + - m3-multi-page-ble-toggle + - m5-channel-hash-mismatch + - m6-factory-reset + runs-on: [self-hosted, macOS, arm64, bitbox] + steps: + - name: Aggregate outcomes + run: | + set -euo pipefail + declare -A outcomes=( + ["M-1"]="${{ needs.m1-happy-path.result }}" + ["M-3"]="${{ needs.m3-multi-page-ble-toggle.result }}" + ["M-5"]="${{ needs.m5-channel-hash-mismatch.result }}" + ["M-6"]="${{ needs.m6-factory-reset.result }}" + ) + failed=0 + for k in "${!outcomes[@]}"; do + v="${outcomes[$k]}" + echo "$k -> $v" + if [ "$v" != "success" ] && [ "$v" != "skipped" ]; then + failed=1 + fi + done + if [ $failed -ne 0 ]; then + echo "::error::At least one PR-gate Tier-3 flow failed." + exit 1 + fi + echo "All PR-gate Tier-3 flows green or skipped." diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 43276344..a6e6c764 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -317,10 +317,17 @@ jobs: path: test/goldens/**/failures/** if-no-files-found: ignore - # Runs the bitbox-audit CLI from DFXswiss/bitbox-testkit to surface which - # of the documented BitBox firmware quirks are statically detected in this - # repo and which still need runtime coverage. Intentionally non-blocking - # and not part of required_status_checks — purely informational. + # Runs the bitbox-audit CLI from DFXswiss/bitbox-testkit against the + # production BitBox/signing surface. The whole repository contains generated + # UI localization (`lib/generated/i18n.dart`) with legitimate non-ASCII copy + # and BitBox-facing screen text; scanning `.` therefore produces E1 false + # positives because those strings are not signed payload fields. The payload + # risk lives under `lib/packages/**` (hardware wallet, signers, DFX services, + # SignPipeline), so this job scopes the static audit there and relies on the + # Flutter tests for dynamic payload invariants. + # + # Intentionally non-blocking and not part of required_status_checks — purely + # informational until bitbox-audit can ingest Flutter test results directly. bitbox-audit: name: BitBox quirks audit # Same guard pattern as `build`: skip drafts, always run on push/dispatch. @@ -341,10 +348,23 @@ jobs: continue-on-error: true run: | set -euo pipefail - "$(go env GOPATH)/bin/bitbox-audit" \ - --repo . \ + { + echo "# BitBox audit scope" + echo + echo "Static scope: \`lib/packages/**\`." + echo + echo "Generated localization is excluded because UI copy is not a signed EIP-712 payload. Payload risk is covered by SignPipeline, Eip712Signer, BitboxCredentials, and DFX service tests in the main Analyze & Test job." + echo + } > bitbox-audit-report.md + AUDIT_BIN="$(go env GOPATH)/bin/bitbox-audit" + if [ ! -x "${AUDIT_BIN}" ]; then + echo "bitbox-audit binary not found after install step; see job logs." >> bitbox-audit-report.md + exit 1 + fi + "${AUDIT_BIN}" \ + --repo lib/packages \ --format markdown \ - --output bitbox-audit-report.md + >> bitbox-audit-report.md - name: Inline report into run summary if: always() diff --git a/.maestro/bitbox/M-1-happy-path.yaml b/.maestro/bitbox/M-1-happy-path.yaml new file mode 100644 index 00000000..e8e036f9 --- /dev/null +++ b/.maestro/bitbox/M-1-happy-path.yaml @@ -0,0 +1,183 @@ +# M-1 — Happy path: pair -> unlock -> ETH sign -> verify. +# +# PROVES (Tier-3 only): +# * BitBox 02 Nova BLE handshake against the realunit-app on a real iPhone +# completes the full pair / channel-hash-confirm / pairing dance end-to-end. +# * The ETH sign envelope produced by the firmware is consumable by the +# Dart-side `BitboxService.signEthMessage` pipeline (Tier-2 covers the +# envelope-shape but cannot validate the firmware-side state machine). +# * Basic timing is within the 2 min target: deviation > 50 % flags a +# regression worth a journal entry. +# +# DOES NOT PROVE: +# * BLE init-frame retransmit dedup contract — see M-3. +# * 13-page multi-page state machine — see M-2 / M-3. +# * Channel-hash spoof defence — see M-5. +# * Static-pubkey mismatch detection after factory-reset — see M-6. +# * 60 s read-timeout extension on Android — see M-7. +# +# REQUIRED-KEYS (TODO before this flow is selector-stable): +# * Key('maestro-welcome-bitbox-card') on the WelcomeCard for BitBox in +# lib/screens/welcome/welcome_page.dart. +# * Key('maestro-bitbox-connect-confirm') on the ConnectContent's +# onConfirm AppFilledButton when state == BitboxCheckHash. +# * Key('maestro-bitbox-pair-channel-hash') on the channelHash Text in +# ConnectBitboxView (lines 84-88). +# * Key('maestro-bitbox-finish-setup') on the ConnectContent's onConfirm +# button when state == BitboxConnected. +# * Key('maestro-dashboard-buy-button') on the "RealUnit kaufen" button. +# +# OPERATOR PRECONDITIONS: +# * BitBox 02 Nova powered + BLE-discoverable. +# * iPhone freshly `simctl erase`d OR previous run's wallet wiped via +# Settings -> Delete wallet. +# * Operator within arm's reach of the BitBox to confirm pairing-code on +# the device screen. +# +# HARDWARE: BitBox 02 Nova + iOS device. +# EXPECTED RUNTIME: ~2 min. +# GATE: PR-gate (parallel-safe under hardware mutex). +appId: swiss.realunit.app +--- +# Fresh app launch from a clean state. clearState only clears +# NSUserDefaults; the per-runner `scripts/run-bitbox-flows.sh` (mirror +# of run-handbook-flows.sh) does a full `simctl erase` for genuine +# clean state. +- launchApp: + appId: swiss.realunit.app + clearState: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: "Start" + timeout: 30000 + +# Welcome -> Start -> pick the BitBox card on the second step. +# The card-tap is gated against the connect-sheet not yet showing and +# re-tapped on tap-loss (Maestro/XCUITest tap-loss on Apple Silicon + iOS +# 26, mobile-dev-inc/maestro#3137 — same mitigation as the handbook flows). +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: ".*Digitale Wallet.*" + commands: + - tapOn: + text: "Start" + optional: true +- extendedWaitUntil: + visible: + text: ".*Digitale Wallet.*" + timeout: 30000 + +# Tap the BitBox card. The card's title is the localised "BitBox" string. +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: "BitBox verbinden" + commands: + - tapOn: + text: "BitBox" + optional: true + +# Connect sheet should now be open and the cubit kicks the BLE handshake. +# The "Gerät gefunden" string fires once the BitBox is discovered and the +# device is showing the pairing code on its e-ink screen. +- extendedWaitUntil: + visible: "BitBox verbinden" + timeout: 30000 +- extendedWaitUntil: + visible: + text: ".*Gerät gefunden.*|.*Code mit dem.*" + timeout: 45000 + +# At this point the BitBox screen and the iPhone show the same channel-hash. +# We do NOT assert byte-equality of the code here — Maestro cannot read the +# e-ink screen — we rely on the operator's physical confirmation. What we +# CAN assert is that the channel-hash text rendered (i.e. cubit reached +# state BitboxCheckHash, not BitboxNotConnected). +- assertVisible: + text: ".*Code mit dem.*BitBox-Gerät.*" + +# Operator confirms physically on the BitBox (their job, not Maestro's), +# THEN taps "Bestätigen" in the app. Operator presence is a precondition, +# documented in the flow header — this is not a pretend-pass: without a +# human operator the BitBox's own button press never happens and the flow +# fails at the next extendedWaitUntil. +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: ".*Verbindung erfolgreich.*|.*Verbunden.*" + commands: + - tapOn: + text: "Bestätigen" + optional: true + +# BitBox is paired; the cubit emits BitboxConnected. Operator confirms +# the final "follow last instructions on BitBox" step. +- extendedWaitUntil: + visible: + text: ".*Verbindung erfolgreich.*|.*Verbunden.*" + timeout: 60000 + +- repeat: + times: 3 + commands: + - runFlow: + when: + notVisible: + text: ".*RealUnit kaufen.*" + commands: + - tapOn: + text: "Bestätigen" + optional: true + +# Dashboard reached -- pairing happy path is green. +- extendedWaitUntil: + visible: + text: ".*RealUnit kaufen.*" + timeout: 60000 + +# Now exercise the ETH sign path. The "RealUnit kaufen" CTA leads into +# the buy flow which gates on a signature-prompt to the BitBox. This is +# the minimal real-hardware sign — one page, one confirm. +- tapOn: + text: "RealUnit kaufen" +- waitForAnimationToEnd + +# Drive forward to the sign step. Wallet-attestation signature first +# fires; on a BitBox-paired wallet this lands directly on the device +# screen ("Bitte bestätigen Sie die Anmeldeanfrage"). +- extendedWaitUntil: + visible: + text: ".*Anmeldung bestätigen.*|.*bestätigen Sie die Anmeldeanfrage.*" + timeout: 60000 + +# Operator confirms the sign on the BitBox (physical step). If the +# signature failed the app shows "Anmeldung nicht abgeschlossen" — we +# assert the success path here; failure makes the run RED. +- extendedWaitUntil: + visible: + text: ".*Anmeldung nicht abgeschlossen.*|.*RealUnit kaufen.*|.*Betrag.*" + timeout: 90000 +- runFlow: + when: + visible: ".*Anmeldung nicht abgeschlossen.*" + commands: + # Sign failed on device -> M-1 RED. Maestro lacks a native fail() + # primitive, but assertVisible against a string the failure path + # cannot produce forces the runner to fail with a clear log line. + - assertVisible: + text: "M-1-FAIL: signature capture failed on device" + +# Sign success path lands back on the buy flow / dashboard. Both are +# acceptable terminal states for the happy path; either one proves the +# end-to-end pipeline. +- assertVisible: + text: ".*RealUnit kaufen.*|.*Betrag.*|.*Menge.*" diff --git a/.maestro/bitbox/M-2-multi-page-sign-stable-ble.yaml b/.maestro/bitbox/M-2-multi-page-sign-stable-ble.yaml new file mode 100644 index 00000000..13feab78 --- /dev/null +++ b/.maestro/bitbox/M-2-multi-page-sign-stable-ble.yaml @@ -0,0 +1,131 @@ +# M-2 — Multi-page (13-page KYC registration) sign on stable BLE. +# +# PROVES (Tier-3 only): +# * Firmware-side multi-page state machine accepts every one of the +# 13 EIP-712 pages of the real KYC registration payload, in order, +# and emits a valid signature envelope at the end. +# * Dart-side `SignPipeline` correctly drives all 13 page-confirm +# round-trips without dropping a frame or hitting the 60 s read +# timeout (M-7 covers the slow-confirm timeout edge specifically). +# * The cumulative envelope hash matches the Tier-2 fixture under +# `bitbox-testkit/go/bitbox/cassettes/kyc-registration-fw-9.21.0.vcr`. +# +# DOES NOT PROVE: +# * The dedup contract under a real BLE link drop -- see M-3. +# * The disconnect-recovery path -- see M-4. +# * That non-ASCII characters in the payload survive transliteration +# (the realunit-app#487 umlaut bug regression test lives at Tier-1). +# +# REQUIRED-KEYS (TODO): +# * Key('maestro-kyc-start-registration') on the KYC registration entry +# button in lib/screens/kyc/steps/registration/kyc_registration_page.dart. +# * Key('maestro-bitbox-sign-page-confirm') on the in-app "Auf BitBox +# bestätigen" hint inside the sign sheet (so the flow can wait for +# each page transition without matching strings). +# * Key('maestro-kyc-registration-complete') on the post-sign success +# screen. +# +# OPERATOR PRECONDITIONS: +# * M-1 must have run green within the last 24 h on this hardware OR +# the operator must reset + re-pair manually first. +# * Operator must be physically present to push the BitBox confirm +# button 13 times in succession. +# * BitBox firmware >= 9.21.0 (older firmware has a 12-page cap). +# +# HARDWARE: BitBox 02 Nova + iOS device. +# EXPECTED RUNTIME: ~5 min (13 pages * ~20 s per page including device +# confirm + BLE round-trip). +# GATE: scheduled-daily (long runtime; not PR-blocking). +appId: swiss.realunit.app +--- +# Resume from the wallet-loaded state. Unlike M-1 we do NOT clearState: +# the assumption is the BitBox is already paired and the wallet is open. +# This is the canonical "already-onboarded user signing KYC" path. +- launchApp: + appId: swiss.realunit.app +- waitForAnimationToEnd + +# If a stale biometric prompt is showing, skip it (same pattern as +# handbook/11-dashboard.yaml). +- runFlow: + when: + visible: "Überspringen" + commands: + - tapOn: + text: "Überspringen" + optional: true +- extendedWaitUntil: + visible: + text: ".*RealUnit kaufen.*" + timeout: 30000 + +# Enter the buy/sell flow which triggers KYC registration on the first +# real interaction. KYC registration is the canonical 13-page payload +# documented in the mandate Appendix B. +- tapOn: + text: "RealUnit kaufen" +- waitForAnimationToEnd + +# KYC initiation. If the user already has a partial KYC, the app jumps +# to wherever they left off; we assume a fresh KYC start here. If the +# screen is the post-KYC buy form instead, M-2 cannot exercise its +# subject and must abort with PRECONDITION-FAILED. +- runFlow: + when: + visible: + text: ".*Betrag.*|.*Menge.*" + commands: + - assertVisible: + text: "M-2-PRECONDITION-FAILED: KYC already completed on this wallet; reset wallet to retry" + +- extendedWaitUntil: + visible: + text: ".*Eröffnungsprozess.*|.*KYC.*|.*Registrierung.*" + timeout: 60000 + +# Walk through the KYC registration form, then trigger the multi-page +# sign. The form-walk is not the subject of this flow — every flow +# captured under handbook/* already covers the form drivers. Here we +# only care about the moment the sign sheet appears. +- tapOn: + text: "Weiter" + optional: true + +# The sign sheet appears once registration is committed. Pages 1..13 +# are emitted sequentially; each one shows a confirm hint on the iPhone +# and the page content on the BitBox screen. The flow walks them. +# +# We use `repeat: { times: 13 }` and inside the loop wait for the +# success terminal state. Each loop iteration corresponds to one page +# confirm from the operator's perspective. The body of the loop: +# - waits for the per-page "bestätigen Sie auf der BitBox" hint +# - the operator presses the BitBox button in physical reality +# - Maestro waits up to 30 s for the hint to disappear (= page +# advanced) +- repeat: + times: 13 + commands: + - runFlow: + when: + notVisible: + text: ".*Eröffnungsprozess.*abgeschlossen.*|.*Verifikation abgeschlossen.*|.*RealUnit kaufen.*" + commands: + # Wait for the sign-page hint to be on screen, then wait for + # it to disappear (= operator confirmed the page on device). + - extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*|.*Anmeldung bestätigen.*" + timeout: 60000 + # The Dart-side BitboxService.signEthMessage call returns + # once the device emits the page's response frame; the UI + # updates to either the next page's hint OR the terminal + # success screen. We sleep briefly to let the frame land. + - waitForAnimationToEnd + +# All 13 pages confirmed. Assert the success screen. +- extendedWaitUntil: + visible: + text: ".*Eröffnungsprozess.*abgeschlossen.*|.*Verifikation abgeschlossen.*" + timeout: 120000 +- assertVisible: + text: ".*abgeschlossen.*" diff --git a/.maestro/bitbox/M-3-multi-page-sign-with-ble-toggle.yaml b/.maestro/bitbox/M-3-multi-page-sign-with-ble-toggle.yaml new file mode 100644 index 00000000..9ba14d4f --- /dev/null +++ b/.maestro/bitbox/M-3-multi-page-sign-with-ble-toggle.yaml @@ -0,0 +1,204 @@ +# M-3 — Multi-page sign WITH BLE toggle on page 6/13. +# +# THIS FLOW IS THE CANONICAL Tier-3 VERIFIER FOR THE BLE INIT-FRAME +# RETRANSMIT DEDUP CONTRACT. Without it, the BL-001 dedup logic (the +# bug pinned in `audit-bitbox-2026-05-23/AUDIT.md` Top-10 #1, the +# 2026-05-14 regression in `bitbox_flutter` `seenPackets.removeAll`- +# before-`contains`) is unprotected at the hardware-truth layer. No +# Tier-2 scenario can model this — the iOS BLE link drop / re-establish +# happens at the radio layer, below where any in-process fake can reach. +# +# PROVES (Tier-3 only): +# * BLE init-frame retransmit DEDUP — after a real BLE link drop the +# iOS stack retransmits init frames; the Dart-side deduper must +# drop the duplicates, NOT crash, NOT advance past a half-confirmed +# page, NOT silently restart the sign from page 1. +# * Pages 7..13 confirm successfully AFTER the BLE drop on page 6 — +# i.e. the sign session survives the radio event. +# * The final envelope hash matches the M-2 fixture; the BLE drop +# does not alter the firmware-side cumulative hash. +# +# DOES NOT PROVE: +# * Channel-hash spoof defence — see M-5. +# * Factory-reset detection — see M-6. +# * 60 s Android read-timeout extension — see M-7. +# +# BLOCKED (partial): +# iOS does not expose a CLI to programmatically toggle BLE from outside +# an app. We use `xcrun simctl status_bar override bluetooth-state` as +# a status-bar proxy — this is NOT a real BLE drop. The REAL drop is +# performed by the operator physically toggling airplane mode on the +# iPhone at the page-6 checkpoint. Until realunit-app ships a +# DEV-only `--toggle-ble-from-test` flag (BL-017 backlog), this flow +# requires that human step. Failure to operate it correctly is +# logged in the journal as PRECONDITION-FAILED (not a green pass). +# +# REQUIRED-KEYS (TODO): +# * Same key set as M-2, plus: +# * Key('maestro-sign-page-index-{N}') where N = 1..13 on the +# in-app per-page hint widget. This is mandatory for this flow — +# text-based selectors cannot distinguish page 6 from page 7. +# +# OPERATOR PRECONDITIONS: +# * KYC NOT yet completed on the wallet under test (i.e. M-2 was +# reset since its last run, or this is a fresh wallet). +# * Operator holds the iPhone with airplane-mode shortcut ready in +# Control Centre. +# * BitBox firmware >= 9.21.0. +# * The cassette under `bitbox-testkit/go/bitbox/cassettes/ +# kyc-registration-fw-9.21.0.vcr` is available for envelope-hash +# cross-check. +# +# HARDWARE: BitBox 02 Nova + iOS device. +# EXPECTED RUNTIME: ~8 min (M-2 + 30 s BLE-drop + 30 s recovery). +# GATE: PR-gate. +appId: swiss.realunit.app +--- +- launchApp: + appId: swiss.realunit.app +- waitForAnimationToEnd + +- runFlow: + when: + visible: "Überspringen" + commands: + - tapOn: + text: "Überspringen" + optional: true +- extendedWaitUntil: + visible: + text: ".*RealUnit kaufen.*" + timeout: 30000 + +- tapOn: + text: "RealUnit kaufen" +- waitForAnimationToEnd + +# Same precondition guard as M-2. +- runFlow: + when: + visible: + text: ".*Betrag.*|.*Menge.*" + commands: + - assertVisible: + text: "M-3-PRECONDITION-FAILED: KYC already completed; reset wallet to retry" + +- extendedWaitUntil: + visible: + text: ".*Eröffnungsprozess.*|.*KYC.*|.*Registrierung.*" + timeout: 60000 + +- tapOn: + text: "Weiter" + optional: true + +# Pages 1..5 confirm normally; operator presses the BitBox button each +# time. This is exactly the M-2 path through page 5. +- repeat: + times: 5 + commands: + - extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*|.*Anmeldung bestätigen.*" + timeout: 60000 + - waitForAnimationToEnd + +# === PAGE 6: THE BLE DROP === +# +# At this point we are sitting on page 6's confirm hint. We DO NOT +# press the BitBox button yet. Instead we: +# 1. Simulate the BLE link drop (status-bar override AND ideally a +# real airplane-mode toggle by the operator). +# 2. Wait 5 s for the iOS stack to surface the disconnect. +# 3. Restore BLE. +# 4. Wait for the app to reconnect (it should — the cubit drives +# reconnect automatically). +# 5. Resume the sign from page 6 (NOT page 1). +# +# The Tier-3 invariant is: after step 5, the firmware-side state +# machine accepts the page 6 confirm and continues to pages 7..13, +# producing the same envelope as M-2. If instead the sign restarts +# from page 1, the deduper is broken and the flow lands at page 13 +# having actually re-signed pages 1..5 — which is the very regression +# this flow is meant to catch. +- extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*" + timeout: 60000 + +# BLE drop via simctl proxy. Real drop is operator-driven — see header. +- runScript: | + xcrun simctl status_bar booted override --bluetoothMode failed + # Real device airplane-mode is operator-driven; log the moment: + echo "M-3: BLE-DROP-MOMENT page=6 ts=$(date -u +%s)" + +# 5 s real-time wait for the disconnect to surface. extendedWaitUntil +# would be wrong here — we are NOT waiting for a UI element, we are +# explicitly burning wall-clock time for the iOS BLE stack to time out. +- swipe: + start: 50%, 50% + end: 50%, 50% + duration: 5000 +# That's a no-op swipe used as a 5 s sleep; Maestro lacks a native +# wait-for-N-seconds primitive that does not key off a UI condition. + +# Restore BLE. +- runScript: | + xcrun simctl status_bar booted clear + echo "M-3: BLE-RESTORE-MOMENT page=6 ts=$(date -u +%s)" + +# Wait for the app to re-establish the BLE link. The cubit emits +# BitboxConnecting -> BitboxConnected; the sign sheet either resumes +# automatically (target behaviour) OR shows a reconnect prompt. +- extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*|.*erneut verbinden.*|.*Verbindung.*unterbrochen.*" + timeout: 60000 + +# If the reconnect prompt appeared (i.e. the app did NOT auto-resume), +# walk through the re-pair UI. +- runFlow: + when: + visible: + text: ".*erneut verbinden.*|.*Verbindung.*unterbrochen.*" + commands: + - tapOn: + text: "BitBox erneut verbinden" + optional: true + - extendedWaitUntil: + visible: + text: ".*Code mit dem.*BitBox-Gerät.*" + timeout: 60000 + - tapOn: + text: "Bestätigen" + optional: true + - extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*" + timeout: 60000 + +# Operator confirms page 6 on the device NOW. +- waitForAnimationToEnd + +# Pages 7..13 confirm normally. +- repeat: + times: 7 + commands: + - runFlow: + when: + notVisible: + text: ".*abgeschlossen.*|.*Verifikation abgeschlossen.*" + commands: + - extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*|.*Anmeldung bestätigen.*" + timeout: 60000 + - waitForAnimationToEnd + +# All 13 pages confirmed despite the BLE drop -- DEDUP CONTRACT GREEN. +- extendedWaitUntil: + visible: + text: ".*Eröffnungsprozess.*abgeschlossen.*|.*Verifikation abgeschlossen.*" + timeout: 120000 +- assertVisible: + text: ".*abgeschlossen.*" diff --git a/.maestro/bitbox/M-4-disconnect-mid-sign.yaml b/.maestro/bitbox/M-4-disconnect-mid-sign.yaml new file mode 100644 index 00000000..cf722c82 --- /dev/null +++ b/.maestro/bitbox/M-4-disconnect-mid-sign.yaml @@ -0,0 +1,201 @@ +# M-4 — Disconnect mid-sign + reconnect + assert resume-vs-restart. +# +# PROVES (Tier-3 only): +# * When the BitBox is physically unpowered mid-sign (page 4 of 13), +# the realunit-app surfaces the reconnect sheet within 30 s (not +# a zombie state, not a silent hang). +# * After re-pair the app makes a deliberate, surfaced choice +# between "resume the sign at page 4" and "restart cleanly from +# page 1" — and that choice is visible to the user (no silent +# resume of a different sign session under the user's nose). +# * The sign-queue is invalidated correctly: a queued page-5 confirm +# does not fire against the freshly-paired session as if the old +# one had succeeded. +# +# DOES NOT PROVE: +# * BLE init-frame retransmit dedup (the radio link here is +# hard-down, not toggling) — see M-3. +# * Factory-reset detection across sessions — see M-6. +# +# REQUIRED-KEYS (TODO): +# * Same key set as M-2 / M-3, plus: +# * Key('maestro-bitbox-reconnect-sheet') on the bottom sheet shown +# by `showBitboxReconnectSheet` in +# lib/screens/hardware_connect_bitbox/show_bitbox_reconnect_sheet.dart. +# * Key('maestro-sign-resume-or-restart') on the user-facing choice +# widget that appears after re-pair if a sign was in flight. +# (NOTE: this widget does not yet exist; see BLOCKED block below.) +# +# BLOCKED (partial): +# The "deliberate resume-vs-restart choice" widget is NOT yet +# implemented in realunit-app. Today the app silently drops the +# in-flight sign on disconnect and a fresh user-initiated sign +# starts from scratch. The audit's BL-019 + the lifecycle work in +# §6.I tracks shipping this choice widget. Until it ships, this flow +# asserts the WEAKER invariant: after re-pair, the app is in a clean +# state (no zombie sign-in-flight) — and surfaces a journal-trackable +# PRECONDITION-PARTIAL marker so we can tell green-without-resume +# apart from green-with-resume in coverage reports. +# +# OPERATOR PRECONDITIONS: +# * Operator standing next to the BitBox with the USB-C cable in +# hand. The disconnect step is "unplug the BitBox at page 4". On +# the BitBox 02 Nova, removing power is the only way to simulate +# "user walked away from the device". +# * Wallet pre-paired (M-1 happy path within the last 24 h). +# * KYC not yet completed (so the 13-page sign actually fires). +# +# HARDWARE: BitBox 02 Nova + iOS device. +# EXPECTED RUNTIME: ~6 min. +# GATE: scheduled-daily. +appId: swiss.realunit.app +--- +- launchApp: + appId: swiss.realunit.app +- waitForAnimationToEnd + +- runFlow: + when: + visible: "Überspringen" + commands: + - tapOn: + text: "Überspringen" + optional: true +- extendedWaitUntil: + visible: + text: ".*RealUnit kaufen.*" + timeout: 30000 + +- tapOn: + text: "RealUnit kaufen" +- waitForAnimationToEnd + +- runFlow: + when: + visible: + text: ".*Betrag.*|.*Menge.*" + commands: + - assertVisible: + text: "M-4-PRECONDITION-FAILED: KYC already completed; reset wallet to retry" + +- extendedWaitUntil: + visible: + text: ".*Eröffnungsprozess.*|.*KYC.*|.*Registrierung.*" + timeout: 60000 + +- tapOn: + text: "Weiter" + optional: true + +# Confirm pages 1..3 normally. Operator presses the BitBox button each +# time. +- repeat: + times: 3 + commands: + - extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*|.*Anmeldung bestätigen.*" + timeout: 60000 + - waitForAnimationToEnd + +# Sitting on page 4 confirm hint. NOW the operator unpowers the BitBox. +- extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*" + timeout: 60000 + +- runScript: | + echo "M-4: OPERATOR-UNPOWER-BITBOX page=4 ts=$(date -u +%s)" + echo "M-4: operator must now physically remove the BitBox USB-C cable" + # On a runner with the BitBox-CLI integration, a programmatic + # `bitbox-cli power off` would go here. That CLI is not yet wired + # to the runner (BL-017 blocker) -- documented in RUNNER.md. + +# Wait up to 45 s for the app to surface the disconnect. +# The bitboxDisconnectedTitle string ("BitBox ist nicht verbunden") OR +# the reconnect sheet appears. +- extendedWaitUntil: + visible: + text: ".*BitBox.*nicht verbunden.*|.*Verbindung.*unterbrochen.*|.*BitBox erneut verbinden.*" + timeout: 45000 + +# Operator powers the BitBox back on. The app should either: +# (a) auto-detect the device returning and re-pair (target behaviour); +# (b) require an explicit tap on "BitBox erneut verbinden" (current). +- runScript: | + echo "M-4: OPERATOR-REPOWER-BITBOX ts=$(date -u +%s)" + +- runFlow: + when: + visible: + text: ".*BitBox erneut verbinden.*" + commands: + - tapOn: + text: "BitBox erneut verbinden" + optional: true + +# Re-pair handshake. +- extendedWaitUntil: + visible: + text: ".*Code mit dem.*BitBox-Gerät.*" + timeout: 60000 +- tapOn: + text: "Bestätigen" + optional: true +- extendedWaitUntil: + visible: + text: ".*Verbindung erfolgreich.*|.*Verbunden.*|.*RealUnit kaufen.*" + timeout: 60000 + +# === THE CORE INVARIANT === +# +# After re-pair, the app MUST be in one of two states: +# (RESUME) The KYC sign sheet is back up, showing page 4's confirm +# hint AGAIN. Operator confirms; pages 5..13 complete; final +# success screen reached. The envelope hash MUST match the +# M-2 fixture. +# (RESTART) The app is back on the dashboard / pre-sign screen, no +# sign in flight. User must re-initiate the sign manually. +# +# It MUST NOT be in: +# (ZOMBIE) Sign sheet showing a page-N confirm hint but the device +# never reaches the user; the queued page-5 frame firing +# against the new session as if old one was still alive; +# silent success without operator confirmation. +# +# Until the resume-vs-restart-choice widget ships (BL-019), the app's +# observable behaviour today is RESTART. We assert that here; when +# RESUME ships, this assertion strengthens. +- runFlow: + when: + visible: + text: ".*bestätigen Sie.*BitBox.*|.*Anmeldung bestätigen.*" + commands: + # RESUME path: the sign came back. Walk through pages 4..13. + - repeat: + times: 10 + commands: + - runFlow: + when: + notVisible: + text: ".*abgeschlossen.*" + commands: + - extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*" + timeout: 60000 + - waitForAnimationToEnd + +- runFlow: + when: + visible: + text: ".*RealUnit kaufen.*" + commands: + # RESTART path: the sign was cleanly discarded. Assert no zombie. + - assertVisible: + text: ".*RealUnit kaufen.*" + +# Either terminal state is acceptable; ZOMBIE would fail the +# extendedWaitUntil above (no terminal state ever reached). +- assertVisible: + text: ".*abgeschlossen.*|.*RealUnit kaufen.*" diff --git a/.maestro/bitbox/M-5-channel-hash-mismatch.yaml b/.maestro/bitbox/M-5-channel-hash-mismatch.yaml new file mode 100644 index 00000000..db94271b --- /dev/null +++ b/.maestro/bitbox/M-5-channel-hash-mismatch.yaml @@ -0,0 +1,208 @@ +# M-5 — Channel-hash mismatch: two phones racing the same device. +# +# THIS FLOW IS THE CANONICAL Tier-3 VERIFIER FOR THE CHANNEL-HASH-VERIFY +# CONTRACT (audit Top-10 #4). The Tier-2 fake-credentials scenario in +# `bitbox-testkit/go/bitbox/scenarios/pair_verify_channel_hash.go` +# proves that the Dart-side `channelHashVerify(false)` path raises and +# the cubit refuses to advance. It CANNOT prove that the firmware-side +# noise-protocol pair handshake actually surfaces a mismatch when two +# phones race the same device — only real BLE radio + real firmware +# can do that. +# +# PROVES (Tier-3 only): +# * Phone B's pair attempt against the same physical BitBox while +# phone A holds a pending channel-hash-confirm produces a +# DETECTABLE mismatch on phone B (NOT a silent success). +# * Phone B's `ConnectBitboxView` lands on the BitboxNotConnected +# state (showing the connectBitboxFailed snackbar), NOT on +# BitboxConnected. +# * Phone A's session is unaffected — i.e. the spoof attempt cannot +# hijack an in-flight pair. +# +# DOES NOT PROVE: +# * BLE init-frame dedup — see M-3. +# * Factory-reset detection — see M-6. +# +# BLOCKED (partial): +# The two-phone race is a hard operational requirement. The +# self-hosted runner has ONE iPhone wired today (operator pending). +# Until the second iPhone is provisioned, this flow's +# `RUN_ON_PHONE_B` block fails-soft via the `MAESTRO_DEVICE_B_UDID` +# env-var check at the bottom: empty -> PRECONDITION-PARTIAL and +# the workflow marks the job `skipped`. +# +# Additionally, the truly automated version needs a DEV flag +# `--bitbox-pair-from-test=B` so phone B can kick its pair attempt +# on the right timing window. This flag does NOT yet exist; the +# operator currently must time the second tap manually. +# +# REQUIRED-KEYS (TODO): +# * Key('maestro-welcome-bitbox-card') -- shared with M-1. +# * Key('maestro-bitbox-pair-failure-banner') on the SnackBar shown +# in BitboxNotConnected state inside ConnectBitboxView (lines 28-36). +# +# OPERATOR PRECONDITIONS: +# * TWO iPhones cabled to the runner. Their UDIDs MUST be exported as +# MAESTRO_DEVICE_A_UDID and MAESTRO_DEVICE_B_UDID. +# * BitBox 02 Nova within BLE range of both phones (< 1 m). +# * Both phones have realunit-app installed in a fresh-wallet state +# (no prior pairing). Run M-1 reset before invoking M-5. +# * Operator stands where they can tap both phones in quick succession. +# +# HARDWARE: BitBox 02 Nova + 2x iOS devices. +# EXPECTED RUNTIME: ~4 min. +# GATE: PR-gate. +appId: swiss.realunit.app +--- +# === PHASE 0: Precondition check === +# +# Maestro lacks a native env-var-required primitive. We surface the +# missing-phone-B case via a runScript that exits 1, which fails the +# flow with a clear log line. The workflow then maps exit-1 to +# `skipped` rather than `failed` for M-5 only. +- runScript: | + set -e + if [ -z "${MAESTRO_DEVICE_A_UDID:-}" ] || [ -z "${MAESTRO_DEVICE_B_UDID:-}" ]; then + echo "M-5-PRECONDITION-PARTIAL: two-phone setup not provisioned" + echo " required env: MAESTRO_DEVICE_A_UDID, MAESTRO_DEVICE_B_UDID" + echo " this flow is BLOCKED until the runner has both iPhones cabled." + exit 1 + fi + echo "M-5: phase 0 OK -- both phones available" + echo " A=${MAESTRO_DEVICE_A_UDID}" + echo " B=${MAESTRO_DEVICE_B_UDID}" + +# === PHASE 1: Phone A starts the pair === +# +# Maestro v2.0.10 drives ONE device per invocation; multi-device +# orchestration is handled by the GitHub Actions workflow which runs +# two `maestro test` invocations side-by-side. This YAML is the +# PHONE-A half: it kicks the pair handshake against the BitBox and +# stops at the channel-hash-confirm screen (does NOT tap Bestätigen). +# At that point the workflow launches the M-5 phone-B flow (a +# sibling YAML or the same YAML re-driven via a different selector). +# +# To keep the operator surface flat we encode phase 2 in this same +# YAML and let the workflow inject `MAESTRO_PHASE=B` env to skip +# phase 1. The phase switch is a runFlow gate. +- runFlow: + when: + true: "${MAESTRO_PHASE != 'B'}" + commands: + - launchApp: + appId: swiss.realunit.app + clearState: true + - waitForAnimationToEnd + - extendedWaitUntil: + visible: "Start" + timeout: 30000 + - tapOn: + text: "Start" + - extendedWaitUntil: + visible: + text: ".*Digitale Wallet.*" + timeout: 30000 + - tapOn: + text: "BitBox" + - extendedWaitUntil: + visible: "BitBox verbinden" + timeout: 30000 + - extendedWaitUntil: + visible: + text: ".*Code mit dem.*BitBox-Gerät.*" + timeout: 60000 + # === HOLD === + # Phone A is now sitting on the channel-hash-confirm screen, with + # the BitBox holding its end of the pair handshake. We do NOT tap + # Bestätigen. The workflow launches phase B on phone B which will + # race the pair against the same BitBox. + - runScript: | + echo "M-5: PHASE-A-HOLD ts=$(date -u +%s)" + # Touch a sentinel file the phase-B workflow watches. + touch /tmp/m5-phase-a-ready + # Wait for the phase-B workflow to finish (signalled by a second + # sentinel file). Maestro's waitUntil cannot file-watch; we burn + # wall-clock time via an evaluateScript no-op loop instead. + - extendedWaitUntil: + visible: + text: ".*Code mit dem.*BitBox-Gerät.*" + timeout: 240000 + +# === PHASE 2: Phone B races the same pair === +- runFlow: + when: + true: "${MAESTRO_PHASE == 'B'}" + commands: + - launchApp: + appId: swiss.realunit.app + clearState: true + - waitForAnimationToEnd + - extendedWaitUntil: + visible: "Start" + timeout: 30000 + - tapOn: + text: "Start" + - extendedWaitUntil: + visible: + text: ".*Digitale Wallet.*" + timeout: 30000 + - tapOn: + text: "BitBox" + - extendedWaitUntil: + visible: "BitBox verbinden" + timeout: 30000 + + # === THE INVARIANT === + # + # Phone B's pair attempt MUST land in one of two states: + # (DETECTED) BitboxNotConnected -> the connectBitboxFailed + # SnackBar surfaces; the sheet stays on the connect + # screen. + # (RECOVERED) The pair completes correctly on phone B but ONLY + # after phone A's session was explicitly cancelled. + # We do not allow phone B to silently take over a + # handshake phone A still holds. + # + # The DEFAULT and required behaviour is DETECTED. A silent + # success on phone B (BitboxConnected state without any failure + # surface) is the regression this flow exists to catch. + - extendedWaitUntil: + visible: + text: ".*Es ist ein Fehler aufgetreten.*|.*Verbindung.*fehlgeschlagen.*|.*Code mit dem.*" + timeout: 60000 + + # If phone B reached the channel-hash screen, it MUST display a + # DIFFERENT channel-hash than phone A's (the noise-protocol + # rotation guarantees this). We cannot machine-compare two + # phones' screens; the operator must observe and confirm. This is + # the documented manual checkpoint: + - runScript: | + echo "M-5: OPERATOR-VERIFY two phones now show DIFFERENT channel hashes" + echo " -> if hashes match, M-5 IS FAILING -- the spoof succeeded" + echo " -> if hashes differ, M-5 progresses to assertion below" + + # Phone B taps Bestätigen anyway, simulating the spoof attacker + # who would press through. The pair MUST then fail on phone B + # because the channel-hash on phone B does not match what the + # BitBox is expecting (the BitBox is mid-pair with phone A). + - runFlow: + when: + visible: "Bestätigen" + commands: + - tapOn: + text: "Bestätigen" + optional: true + + # Failure surface on phone B. + - extendedWaitUntil: + visible: + text: ".*Es ist ein Fehler aufgetreten.*|.*nicht erfolgreich.*" + timeout: 60000 + + - assertVisible: + text: ".*Fehler.*|.*nicht.*" + + # Signal phase A it can release. + - runScript: | + touch /tmp/m5-phase-b-done + echo "M-5: PHASE-B-DONE ts=$(date -u +%s)" diff --git a/.maestro/bitbox/M-6-factory-reset-detection.yaml b/.maestro/bitbox/M-6-factory-reset-detection.yaml new file mode 100644 index 00000000..466f6b47 --- /dev/null +++ b/.maestro/bitbox/M-6-factory-reset-detection.yaml @@ -0,0 +1,215 @@ +# M-6 — Factory-reset BitBox between two sessions: static-pubkey-mismatch +# detection. +# +# THIS FLOW IS THE CANONICAL Tier-3 VERIFIER FOR THE FACTORY-RESET +# DETECTION CONTRACT (audit Top-10 #8). The Tier-2 scenario in +# `bitbox-testkit/go/bitbox/scenarios/static_pubkey_mismatch.go` proves +# that when the Dart-side `BitboxCredentials` receives a different +# static pubkey than the one it cached, it rejects the connection and +# forces re-pair. It CANNOT prove that a REAL firmware-side factory +# reset actually rotates the static pubkey — only real hardware can. +# +# PROVES (Tier-3 only): +# * After a real factory-reset on the BitBox 02 Nova, the device's +# noise-protocol static pubkey is DIFFERENT from the cached one. +# * The realunit-app detects this mismatch on the next pair attempt +# and refuses to silently reuse the old credentials. +# * The user is FORCED through a re-pair flow (channel-hash confirm +# again) — there is no path where the app silently writes to a +# different keypair than the one the user paired with originally. +# +# DOES NOT PROVE: +# * Channel-hash mismatch spoof — see M-5. +# * BLE init-frame dedup — see M-3. +# +# BLOCKED (partial): +# The factory-reset on the BitBox 02 Nova is a hold-the-button +# physical action. The mandate calls for either: +# (a) Programmatic factory-reset via a `bitbox-cli factory-reset` +# path wired into the runner. The CLI exists upstream +# (`bitbox02-api-go` ships it) but is NOT yet integrated into +# the runner. Tracked under BL-017. +# (b) A DEV-only realunit-app rebuild with `BITBOX_DEV_RESET=1` +# exposing an in-app "wipe paired device" debug screen. +# Neither is shipped. Until one is, M-6 prompts the operator via a +# runScript log line and a 30 s pause — the operator must perform the +# physical reset during that window. If the operator skips it, the +# second pair sees the same static pubkey, the mismatch detection +# does NOT fire, and the flow FAILS (= regression-or-operator-error; +# the journal entry must say which). +# +# REQUIRED-KEYS (TODO): +# * Key('maestro-welcome-bitbox-card') -- shared with M-1. +# * Key('maestro-bitbox-static-pubkey-mismatch-banner') on the UI +# surface that announces "this device has changed identity". +# (NOTE: this banner does not exist today; see additional BLOCKER +# below.) +# +# ADDITIONAL BLOCKER: +# The realunit-app today does NOT surface the static-pubkey-mismatch +# case as a distinct UI state. The mismatch falls through to the +# generic BitboxNotConnected error path. This means M-6 today can +# only verify the WEAKER invariant: after factory-reset, the app +# does NOT silently reconnect (it fails). The STRONGER invariant — +# user sees a clear "this is a different device" message — depends +# on the surface that BL-019 + the lifecycle work tracks shipping. +# +# OPERATOR PRECONDITIONS: +# * Operator standing next to the BitBox with the device manual +# open to the "factory reset" page (hold reset button procedure). +# * Fresh wallet on the iPhone (run M-1 reset first). +# * BitBox firmware >= 9.21.0. +# +# HARDWARE: BitBox 02 Nova + iOS device. +# EXPECTED RUNTIME: ~5 min. +# GATE: PR-gate. +appId: swiss.realunit.app +--- +# === PHASE 1: Initial pair === +- launchApp: + appId: swiss.realunit.app + clearState: true +- waitForAnimationToEnd +- extendedWaitUntil: + visible: "Start" + timeout: 30000 + +- tapOn: + text: "Start" +- extendedWaitUntil: + visible: + text: ".*Digitale Wallet.*" + timeout: 30000 + +- tapOn: + text: "BitBox" +- extendedWaitUntil: + visible: "BitBox verbinden" + timeout: 30000 + +- extendedWaitUntil: + visible: + text: ".*Code mit dem.*BitBox-Gerät.*" + timeout: 60000 + +# Operator confirms on device + taps Bestätigen in-app. +- tapOn: + text: "Bestätigen" +- extendedWaitUntil: + visible: + text: ".*Verbindung erfolgreich.*|.*Verbunden.*" + timeout: 60000 + +- tapOn: + text: "Bestätigen" +- extendedWaitUntil: + visible: + text: ".*RealUnit kaufen.*" + timeout: 60000 + +# Phase 1 cached the BitBox's static pubkey in the Dart-side +# BitboxCredentials. We have a known-good baseline. +- runScript: | + echo "M-6: PHASE-1-PAIRED ts=$(date -u +%s)" + echo "M-6: BitBox static-pubkey is now cached in app credentials" + +# === PHASE 2: FACTORY-RESET === +# +# Operator action required. The flow waits up to 90 s for the operator +# to perform the reset; the wait is implemented as an extendedWaitUntil +# against a no-op condition that absolutely cannot be satisfied during +# the window, forcing the timer to elapse fully. (We deliberately do +# NOT short-circuit on any visible-condition here: the operator may +# not have completed the reset yet when the app starts surfacing +# disconnect.) +- runScript: | + echo "M-6: ACTION-REQUIRED-FACTORY-RESET ts=$(date -u +%s)" + echo " -> Operator: hold the BitBox reset button for 10 seconds NOW." + echo " -> The device should erase + reboot. Wait for the welcome screen." + echo " -> You have 90 seconds before the flow continues." + +# 90 s wall-clock wait via a no-op swipe (Maestro lacks native sleep). +- swipe: + start: 50%, 50% + end: 50%, 50% + duration: 90000 + +- runScript: | + echo "M-6: ASSUMING-FACTORY-RESET-DONE ts=$(date -u +%s)" + +# === PHASE 3: Re-pair attempt === +# +# Force the app to re-attempt the pair. Easiest path is to power-cycle +# the wallet's BitBox connection via the reconnect sheet. The operator +# triggers this via the "BitBox kaufen" button -> sign-in retry which +# the app drives automatically. +# +# We approach it more cleanly: re-open the BitBox connect sheet +# explicitly via the buy CTA, which fires a credentials check. +- tapOn: + text: "RealUnit kaufen" +- waitForAnimationToEnd + +# === THE INVARIANT === +# +# On a factory-reset device, the static-pubkey check inside +# BitboxCredentials MUST detect the mismatch. The app MUST NOT +# silently reconnect using the old credentials. +# +# Required terminal states (one of): +# (HARD) A clear "device-identity-changed" UI surface (does not +# exist today — see additional blocker above). +# (SOFT) The generic connectBitboxFailed snackbar plus the user is +# forced back through the channel-hash-confirm screen with +# a fresh hash. +# +# Forbidden terminal state: +# (SILENT-RECONNECT) The app proceeds with sign as if nothing +# happened. This is the regression M-6 catches. +- extendedWaitUntil: + visible: + text: ".*Es ist ein Fehler aufgetreten.*|.*Code mit dem.*BitBox-Gerät.*|.*nicht verbunden.*|.*Geräteidentität.*geändert.*" + timeout: 90000 + +# If we reached the channel-hash screen, this MUST be a DIFFERENT hash +# than what phase 1 showed (the static pubkey rotated, so the noise +# protocol's session keys are different and the channel-hash is too). +# The flow cannot byte-compare; the operator confirms. +- runFlow: + when: + visible: + text: ".*Code mit dem.*BitBox-Gerät.*" + commands: + - runScript: | + echo "M-6: SOFT-PATH channel-hash screen reached after reset" + echo " -> Operator: confirm the channel-hash is NEW (different from phase 1)" + echo " -> If the hash is identical, the mismatch detection FAILED" + + # Push through the re-pair to prove the SOFT path completes + # cleanly (not stuck). + - tapOn: + text: "Bestätigen" + - extendedWaitUntil: + visible: + text: ".*Verbindung erfolgreich.*|.*Verbunden.*" + timeout: 60000 + +- runFlow: + when: + visible: + text: ".*Es ist ein Fehler aufgetreten.*|.*nicht verbunden.*" + commands: + - runScript: | + echo "M-6: ERROR-PATH connectBitboxFailed surfaced after reset" + echo " -> this is the current minimum-acceptable behaviour" + +# Acceptable: any non-silent terminal state. We assert that we are +# NOT on the "RealUnit kaufen" / "Betrag" buy screen (which would +# indicate a silent reconnect). +- assertNotVisible: + text: ".*Betrag.*" +- assertNotVisible: + text: ".*signMessageGet.*" + +- runScript: | + echo "M-6: PHASE-3-RESULT mismatch detection surfaced (HARD or SOFT)" diff --git a/.maestro/bitbox/M-7-slow-confirm-long-idle.yaml b/.maestro/bitbox/M-7-slow-confirm-long-idle.yaml new file mode 100644 index 00000000..1a942993 --- /dev/null +++ b/.maestro/bitbox/M-7-slow-confirm-long-idle.yaml @@ -0,0 +1,173 @@ +# M-7 — Slow-confirm long-idle (>60 s) mid-page sign on Android. +# +# PROVES (Tier-3 only): +# * The Android-side read-timeout extension to 60 s actually applies +# to a real BLE session against the BitBox. Default Android BLE +# read-timeout is 10 s; the realunit-app's `BitboxService` must +# extend it (in `lib/packages/hardware_wallet/bitbox.dart`) so that +# a user can take longer than 10 s to confirm a sign page on the +# device without the client tearing down the session. +# * After a 65 s idle wait (user just staring at the device, hasn't +# pressed the confirm button yet), the client does NOT raise a +# timeout. When the user finally presses, the page advances +# cleanly. +# +# DOES NOT PROVE: +# * iOS-side behaviour (iOS has a much more permissive default; the +# timeout extension is mostly an Android concern). +# * BLE dedup -- see M-3. +# * Anything multi-page beyond the one slow-confirm checkpoint. +# +# REQUIRED-KEYS (TODO): +# * Same key set as M-2 / M-3, but mirrored to the Android build +# of realunit-app. Today the app's widget keys are platform-shared +# so adding them on iOS gets them on Android for free. +# +# BLOCKED (partial): +# * Self-hosted runner has the Android device cabled but NOT yet +# the Android build of realunit-app installed in CI. Operator +# must `flutter build apk --debug` + `adb install` once per +# branch under test until the workflow ships the Android build +# step. Tracked under the workflow's Android-job conditional — +# today it falls back to a precondition-fail with a clear message. +# +# OPERATOR PRECONDITIONS: +# * Android device cabled, `adb devices` shows it, screen unlocked. +# * realunit-app-debug.apk installed and the wallet paired to the +# BitBox via a prior M-1-equivalent flow (Android version). +# * Operator deliberately waits 65 s before pressing the BitBox +# button on page 5 — this is THE test action; without it the +# flow does not exercise the timeout. +# +# HARDWARE: BitBox 02 Nova + Android device. +# EXPECTED RUNTIME: ~10 min (4 normal pages + 65 s idle + 9 more pages). +# GATE: scheduled-daily. +appId: swiss.realunit.app +--- +# Android driver -- Maestro inspects the platform via the connected +# device. Failure-mode: if Maestro is driving iOS by default, the +# launchApp + appId match still works but the platform-specific +# behaviour (10 s timeout) is not exercised. The workflow MUST set +# MAESTRO_DEVICE_ID to an Android serial when invoking this flow. +- runScript: | + set -e + if ! adb devices 2>/dev/null | grep -q "device$"; then + echo "M-7-PRECONDITION-FAILED: no Android device reachable via adb" + echo " required: USB-cable an Android phone to the runner" + echo " this flow is BLOCKED until the runner has Android wired." + exit 1 + fi + echo "M-7: Android device available" + adb devices + +- launchApp: + appId: swiss.realunit.app +- waitForAnimationToEnd + +- runFlow: + when: + visible: "Überspringen" + commands: + - tapOn: + text: "Überspringen" + optional: true +- extendedWaitUntil: + visible: + text: ".*RealUnit kaufen.*" + timeout: 30000 + +- tapOn: + text: "RealUnit kaufen" +- waitForAnimationToEnd + +- runFlow: + when: + visible: + text: ".*Betrag.*|.*Menge.*" + commands: + - assertVisible: + text: "M-7-PRECONDITION-FAILED: KYC already completed; reset wallet to retry" + +- extendedWaitUntil: + visible: + text: ".*Eröffnungsprozess.*|.*KYC.*|.*Registrierung.*" + timeout: 60000 + +- tapOn: + text: "Weiter" + optional: true + +# Pages 1..4 normally. +- repeat: + times: 4 + commands: + - extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*|.*Anmeldung bestätigen.*" + timeout: 60000 + - waitForAnimationToEnd + +# === PAGE 5: THE 65-SECOND IDLE === +# +# Sitting on page 5's confirm hint. The operator deliberately waits +# 65 seconds before pressing the BitBox button. Maestro simulates the +# wait via an overlong swipe (Maestro's only sleep primitive). +- extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*" + timeout: 60000 + +- runScript: | + echo "M-7: STARTING-65S-IDLE ts=$(date -u +%s)" + echo "M-7: operator MUST NOT press the BitBox button for 65 seconds" + echo "M-7: if you press early, the timeout extension is NOT exercised" + +- swipe: + start: 50%, 50% + end: 50%, 50% + duration: 65000 + +- runScript: | + echo "M-7: 65S-IDLE-DONE ts=$(date -u +%s)" + +# === THE INVARIANT === +# +# The client MUST NOT have raised a timeout during the 65 s window. +# Observable: the sign sheet still shows the page 5 confirm hint +# (NOT the BitBox-disconnected error, NOT a stale "Verbindung +# verloren" snackbar). +- assertVisible: + text: ".*bestätigen Sie.*BitBox.*" + +- assertNotVisible: + text: ".*BitBox.*nicht verbunden.*" +- assertNotVisible: + text: ".*Verbindung.*verloren.*" +- assertNotVisible: + text: ".*Verbindung.*unterbrochen.*" + +- runScript: | + echo "M-7: TIMEOUT-EXTENSION-OK still on page 5 after 65 s idle" + echo "M-7: operator may now press the BitBox confirm button" + +# Operator confirms page 5. Pages 6..13 finish normally. +- repeat: + times: 9 + commands: + - runFlow: + when: + notVisible: + text: ".*abgeschlossen.*" + commands: + - extendedWaitUntil: + visible: + text: ".*bestätigen Sie.*BitBox.*" + timeout: 60000 + - waitForAnimationToEnd + +- extendedWaitUntil: + visible: + text: ".*Eröffnungsprozess.*abgeschlossen.*|.*Verifikation abgeschlossen.*" + timeout: 120000 +- assertVisible: + text: ".*abgeschlossen.*" diff --git a/.maestro/bitbox/README.md b/.maestro/bitbox/README.md new file mode 100644 index 00000000..093e2cfb --- /dev/null +++ b/.maestro/bitbox/README.md @@ -0,0 +1,189 @@ +# Tier-3 BitBox Maestro flows + +This directory holds the seven canonical Tier-3 hardware flows (M-1 ... M-7) +that exercise the BitBox 02 Nova against the realunit-app on a real phone +on a self-hosted Apple Silicon runner. Tier-3 is defined in `docs/testing.md` +under the five-tier model; the canonical reference is +`audit-bitbox-2026-05-23/OPUS_BITBOX_MANDATE.md` §5.3 and Appendix B. + +Unlike the handbook flows in `.maestro/handbook/`, these flows DO NOT run +on `macos-latest` GitHub-hosted runners — the macOS image's USB / BLE stack +cannot reach a physical BitBox dongle, and per realunit-app#487 the macos +runner image is only 41 % green on Maestro 2.5.x. They MUST run on the +self-hosted Apple-Silicon runner described in `RUNNER.md`, with the +hardware physically attached to (or in BLE range of) the runner. + +## Why Tier-3 exists at all + +Three contracts cannot be verified at any tier below Tier-3: + +| Contract | Only Tier-3 verifier | +|-------------------------------------------|----------------------| +| BLE init-frame retransmit dedup | M-3 | +| Channel-hash mismatch detection on pair | M-5 | +| Static-pubkey mismatch after factory-reset| M-6 | + +The audit's Top-10 #1 (BLE dedup), #4 (channel-hash), and #8 (factory-reset) +findings are pinned by these three flows respectively. There is no Tier-2 +substitute — the simulator cannot model a real radio link drop, two phones +racing the same handshake, or a real firmware-side keypair regenerate. + +The other four flows (M-1, M-2, M-4, M-7) are end-to-end smoke / soak +coverage: they make sure the everyday paths (pair, sign, reconnect, long +idle) still work on real hardware after every change to the BLE / framing +/ pipeline layers. + +## The seven flows + +| Flow | Slug | Hardware required | Runtime | Gate | Pins audit Top-10 | +|------|-------------------------------------------|------------------------------------------|----------|-----------------|-------------------| +| M-1 | `M-1-happy-path.yaml` | BitBox 02 Nova + iOS device | ~2 min | PR gate | smoke | +| M-2 | `M-2-multi-page-sign-stable-ble.yaml` | BitBox 02 Nova + iOS device | ~5 min | scheduled-daily | #1 (stable side) | +| M-3 | `M-3-multi-page-sign-with-ble-toggle.yaml`| BitBox 02 Nova + iOS device | ~8 min | PR gate | #1 (CANONICAL) | +| M-4 | `M-4-disconnect-mid-sign.yaml` | BitBox 02 Nova + iOS device | ~6 min | scheduled-daily | lifecycle | +| M-5 | `M-5-channel-hash-mismatch.yaml` | BitBox 02 Nova + 2x iOS devices | ~4 min | PR gate | #4 (CANONICAL) | +| M-6 | `M-6-factory-reset-detection.yaml` | BitBox 02 Nova + iOS device | ~5 min | PR gate | #8 (CANONICAL) | +| M-7 | `M-7-slow-confirm-long-idle.yaml` | BitBox 02 Nova + Android device | ~10 min | scheduled-daily | Android 60s | + +PR-gate flows (M-1 / M-3 / M-5 / M-6) run on every PR against `develop`, +parallelised but serialised on the physical hardware mutex +(`bitbox-hardware-pool`). Scheduled-daily flows (M-2 / M-4 / M-7) run +once per night at 02:00 UTC. + +Each flow has its own one-line docblock at the top describing what it +proves AND what it deliberately does not prove. Treat that docblock +as authoritative. + +## Tier-2 ↔ Tier-3 pairing + +These flows close the explicit "what this scenario does NOT cover" carve-outs +in the Tier-2 scenarios under `bitbox-testkit/go/bitbox/scenarios/`. The +pairing is: + +| Tier-2 scenario | Tier-3 flow covering the carve-out | +|---------------------------------------|-------------------------------------| +| `ble_init_frame_dedup` | M-3 | +| `multi_page_state_machine` | M-2 (happy) + M-3 (with drop) | +| `pair_verify_channel_hash` | M-5 | +| `static_pubkey_mismatch` | M-6 | +| `eth_sign_envelope` | M-1 | +| `read_timeout_60s_extension` | M-7 | +| `disconnect_recovery` | M-4 | + +Coverage-Honesty CI (see `bitbox-testkit/.github/workflows/coverage-honesty.yaml`) +enforces this table machine-readably; any drift fails the build. + +## Hardware required + +Every flow needs at least one BitBox 02 Nova in a known firmware state. +The runner machine MUST document the device serial (last 4 chars only; +never log the full serial) and the firmware version before every run. + +Wipe + re-initialise the BitBox between sessions where the flow's docblock +says so. M-6 in particular REQUIRES that the BitBox be factory-reset +between its two sub-sessions. Without that physical step the flow fails +preconditions and the run is invalid (not a pass). + +## Required widget keys — TODO before flows go green + +These flows reference Maestro selectors like `id: "bitbox-pair-confirm"`. +realunit-app today has NO stable widget keys on the BitBox screens — every +selector is text-based and German-locale dependent. Before any of these +flows can run reliably: + +1. Add `Key('bitbox-pair-confirm')` (and the other keys listed in the + per-flow `# REQUIRED-KEYS:` block) to the BitBox widgets in + `lib/screens/hardware_connect_bitbox/` and the KYC sign widgets in + `lib/screens/kyc/`. +2. Until those keys ship, each flow falls back to its text-based + selectors. Text-based selectors break on locale changes and on + string-revisions — they are NOT a long-term contract. See + per-flow `# REQUIRED-KEYS:` blocks for the canonical key names. + +This is tracked as a follow-up in the audit backlog (BL-017 acceptance). + +## Operator setup checklist + +Before triggering any flow on the self-hosted runner: + +1. Verify the BitBox 02 Nova is powered, paired into the OS BLE stack, + and reachable via BLE from the iPhone (M-1 ... M-6) or Android device + (M-7) cabled to the runner. +2. Verify the phone is `simctl boot`ed (iOS) or `adb` reachable (Android). +3. Log the BitBox firmware version + device serial (last 4 chars) into + the per-run journal at `audit-bitbox-2026-05-23/logs/opus_journal.md` + per the §10 protocol. +4. For M-5: confirm BOTH iOS devices are awake, on the same Wi-Fi/BLE + network, AND that the human operator is standing where they can hold + the BitBox between them in BLE range. +5. For M-6: confirm the operator is physically present to perform the + manual factory-reset step on the BitBox device (long-hold reset; see + BitBox 02 Nova hardware documentation). If `BITBOX_DEV_RESET=1` is + exported AND the realunit-app was built with the dev-reset endpoint + enabled (currently blocked — see "Dev features required" below), the + flow performs the reset programmatically. +6. For M-7: confirm the Android device's BLE timeout is the platform + default (not customised), so the test exercises the real 60 s read + timeout extension that protects against the Android-default 10 s. + +## Running a flow locally + +The runner machine must have the `Runner.app` (iOS) or `app-debug.apk` +(Android) for the current branch already installed and launched once. +After that: + +```bash +# iOS (M-1 ... M-6) +maestro test .maestro/bitbox/M-1-happy-path.yaml + +# iOS — full PR-gate subset +for f in M-1 M-3 M-5 M-6; do + maestro test .maestro/bitbox/${f}-*.yaml +done + +# Android (M-7) — set device target via Maestro env +MAESTRO_DEVICE_ID= maestro test .maestro/bitbox/M-7-slow-confirm-long-idle.yaml +``` + +`maestro test --validate .yaml` lints the YAML against the Maestro +schema without executing it. Run this in CI to catch syntax errors +before booking a hardware slot. + +## Flake budget + +Per audit mandate §5.3.5 + TF realunit-app#487: + +- Per-flow target: at least 80 % green on the self-hosted runner over the + trailing 30 days. Below that, the flow is demoted from PR-gate to + scheduled-only and a tracking issue is opened. +- Suite-wide target: every PR-gate flow (M-1 / M-3 / M-5 / M-6) green + on the first attempt OR on the second of three retries. Three retries + is the workflow ceiling; needing all three is logged as a flake. +- The CI workflow updates `bitbox-testkit/coverage_report.md` with per-flow + flake rate via a posting step after each run. + +## Dev features required (blockers) + +Some flows reference DEV-only endpoints that are not yet shipped in +realunit-app. Each flow's YAML has a `# BLOCKED until ` comment +where applicable. Summary: + +| Flow | Blocker | Status | +|------|------------------------------------------------------------|--------| +| M-1 | none | ready | +| M-2 | none (uses real KYC registration sign payload) | ready | +| M-3 | iOS BLE programmatic toggle (uses `simctl status_bar` proxy + manual airplane-mode fallback) | partial | +| M-4 | none (uses manual unpower; documented in docblock) | ready | +| M-5 | two-phone hardware reservation; programmatic phone-B pair-spoof requires DEV `--bitbox-pair-from-test=B` flag NOT YET in app | partial | +| M-6 | factory-reset endpoint: BitBox CLI integration on runner OR DEV `BITBOX_DEV_RESET=1` rebuild path. Manual fallback documented. | partial | +| M-7 | Android build of realunit-app on runner (currently iOS-only CI) | partial | + +"Partial" flows still ship as Tier-3 YAML and produce a clear +PRECONDITION-FAILED error pointing the operator at the manual workaround. +They do NOT silently pass when their precondition is missing. + +## Reference + +- Mandate: `audit-bitbox-2026-05-23/OPUS_BITBOX_MANDATE.md` Appendix B, §5.3, §8.12 +- Backlog: `audit-bitbox-2026-05-23/BACKLOG.md` BL-017, BL-052..BL-057 +- Maestro docs: https://maestro.mobile.dev/api-reference diff --git a/.maestro/bitbox/RUNNER.md b/.maestro/bitbox/RUNNER.md new file mode 100644 index 00000000..3a440b94 --- /dev/null +++ b/.maestro/bitbox/RUNNER.md @@ -0,0 +1,175 @@ +# Tier-3 self-hosted Apple Silicon runner + +This document is the canonical provisioning guide for the self-hosted +GitHub Actions runner that drives the `.maestro/bitbox/` flows against +real BitBox 02 Nova hardware. The mandate §5.3.3 Group H requires this +file to be the single source of truth for the runner's hardware, +software, and operational state. + +Tier-3 will NOT run on a GitHub-hosted `macos-latest` runner. Two reasons: + +1. The hosted runner cannot reach a physical BitBox dongle — neither USB + nor BLE is exposed inside the ephemeral macOS VM. +2. Per realunit-app#487 the hosted-runner Maestro flow stack is only + ~41 % green on Apple Silicon + iOS 26.x (see the `tier3-handbook.yaml` + workflow header for the upstream tracking link). + +A dedicated, physical, Apple-Silicon Mac mini owned by DFXswiss is +mandatory. + +## Hardware + +| Component | Specification | +|--------------------------|--------------------------------------------------| +| Runner machine | Apple M-series Mac mini (M2 or newer), 16 GB+ RAM, 256 GB+ SSD | +| Test iPhone (primary) | iPhone 17 (iOS 26.x) — cabled to runner via USB-C | +| Test iPhone (secondary) | iPhone 15 or 17 (iOS 26.x) — for M-5 only | +| Test Android (M-7) | Pixel 8 or newer (Android 14+), USB-cabled | +| BitBox 02 Nova | Firmware 9.21.0 or later | +| Power | Mac mini + phones on uninterruptible power; BitBox on its USB-C cable | + +The two iPhones for M-5 must be physically close to the BitBox 02 Nova +(< 1 m) so both phones can race the pairing handshake against the same +device. Document this physical layout in the per-run journal. + +## Software baseline + +The runner machine must hold the following versions. Each upgrade is +landed on a separate PR with a journal entry per mandate §10. + +| Software | Version | Source of truth | +|------------------|---------------------|----------------------| +| macOS | Sequoia 15.4 or later | `sw_vers` | +| Xcode | 26.1 or later | `xcodebuild -version`| +| Flutter | matches `pubspec.yaml` toolchain version | `flutter --version` | +| Maestro CLI | pinned via `.maestro-version` (today: 2.0.10) | `maestro --version` | +| Java (for Android in M-7) | OpenJDK 17 | `java -version` | +| Android SDK | Platform 34 or later | `sdkmanager --list` | +| `ios-deploy` | latest stable | `ios-deploy --version`| + +The pinning rationale is the same as `.github/workflows/tier3-handbook.yaml`: +Maestro 2.3+–2.5+ has driver-startup hangs and silent tap-loss on iOS 26 +(mobile-dev-inc/maestro#3137). 2.0.10 is the last release that passes the +handbook flows reliably. + +## One-time runner registration + +1. Create the runner on GitHub: + - Settings → Actions → Runners → New self-hosted runner. + - Choose "macOS" / "ARM64". +2. Download and configure the runner agent on the Mac mini per GitHub's + on-screen instructions. Choose `bitbox-tier3` as the runner name. +3. Apply labels: `self-hosted`, `macOS`, `arm64`, `bitbox`, + `apple-silicon`. The workflow targets the `self-hosted` + `macOS` + + `arm64` + `bitbox` quadruple to pin scheduling to this specific machine. +4. Install the agent as a launchd service so it survives reboots: + `sudo ./svc.sh install && sudo ./svc.sh start`. +5. Verify the runner shows "Idle" in Settings → Actions → Runners. + +## Runner-token rotation procedure + +The registration token expires after 1 hour; the runner agent's +configured token does NOT — it stays valid indefinitely. Rotate when: + +- The runner machine is wiped, repaired, or replaced. +- The runner is suspected compromised (any unexplained pause / log + anomaly). +- Quarterly per security hygiene (calendar reminder owner: operator). + +Rotation steps: + +1. `sudo ./svc.sh stop && sudo ./svc.sh uninstall`. +2. `./config.sh remove --token `. +3. Generate a new registration token in Settings → Actions → Runners. +4. Re-run the configure step from the one-time setup, above. +5. Restart the launchd service. +6. Verify the workflow's most recent `bitbox-tier3` run succeeded after + the rotation by re-running it manually via `workflow_dispatch`. + +## Per-flow timeout configuration + +Each flow's expected runtime is documented in +`.maestro/bitbox/README.md`. The workflow caps each job at 2x the +expected runtime to absorb runner-load variance. If a flow hits its +timeout repeatedly, increase the cap on a tracking PR — do NOT +quietly bump on the spot. + +| Flow | Expected runtime | Workflow timeout | +|------|------------------|------------------| +| M-1 | 2 min | 5 min | +| M-2 | 5 min | 12 min | +| M-3 | 8 min | 18 min | +| M-4 | 6 min | 14 min | +| M-5 | 4 min | 10 min | +| M-6 | 5 min | 12 min | +| M-7 | 10 min (incl. 65 s idle) | 22 min | + +## Disk-space + cache hygiene + +Maestro stores test artefacts (screenshots, logs, video) under +`~/.maestro/tests/`; a single Tier-3 run can write 100 MB+. The +DerivedData and CocoaPods caches also balloon over time. + +A daily `launchd` plist must run at 04:00 UTC (after the scheduled-daily +flows finish) executing: + +```bash +#!/usr/bin/env bash +set -euo pipefail +# Keep last 14 days of Maestro artefacts; delete older. +find ~/.maestro/tests -type d -mtime +14 -prune -exec rm -rf {} \; +# Prune Xcode DerivedData on overage; cap at 20 GB. +du -sk ~/Library/Developer/Xcode/DerivedData | awk '$1>20000000 {print "prune"}' | xargs -I{} rm -rf ~/Library/Developer/Xcode/DerivedData/* +# Prune CocoaPods cache if > 5 GB. +du -sk ~/Library/Caches/CocoaPods | awk '$1>5000000 {print "prune"}' | xargs -I{} pod cache clean --all +# Booted simulators: shutdown + erase any non-iPhone-17 device. +xcrun simctl shutdown all || true +``` + +Operator owns scheduling this via `launchctl load -w` once. + +## Known issues + workarounds + +- **Maestro 2.5.x driver hang on iOS 26.** Stay on 2.0.10. Tracked + upstream as mobile-dev-inc/maestro#3137. +- **BLE programmatic toggle.** iOS does not expose a CLI to toggle BLE + from outside an app. M-3 falls back to `xcrun simctl status_bar set + bluetooth-state airplane` — this updates the status bar but does NOT + actually drop the BLE link. M-3 documents this in its docblock and the + operator may need to airplane-mode the phone manually mid-flow until + realunit-app ships a DEV toggle. +- **Two-phone hardware reservation for M-5.** The workflow uses a + GitHub Actions `concurrency` mutex to serialise hardware-bound jobs + on the runner. Until the second iPhone is wired in (operator pending), + M-5 fails its precondition step with a clear error and the workflow + marks the job `skipped` rather than `failed`. +- **Factory-reset on M-6.** The BitBox device's factory-reset is a hold- + the-button physical action. Until the realunit-app DEV-reset rebuild + endpoint ships (BL-017 backlog item), M-6 prompts the operator to + reset the device manually via a `waitForAnimationToEnd` checkpoint + step the operator must walk through. +- **macos-latest hosted runner.** Do NOT migrate Tier-3 there. Per + TF #487 the hosted runner is 41 % green on Maestro 2.5.x and cannot + reach hardware. Tier-3 is self-hosted-only. + +## Health check + ping cron + +Mandate §5.3.6 calls for a 30-minute health-check cron. Implement as a +separate workflow `.github/workflows/runner-health.yaml` (NOT in scope +for this PR) that does `runs-on: [self-hosted, bitbox]` + `echo "alive +$(date)"` every 30 minutes. If two consecutive runs miss, the operator +is paged via the alert channel. + +## Operator quick-start (3-5 steps) + +1. Boot the runner Mac mini and unlock; verify the GitHub Actions runner + service is `running` (`launchctl list | grep actions.runner`). +2. Cable both iPhone(s) and (if running M-7) the Android device to the + runner; verify they appear in `xcrun simctl list devices booted` + (iOS) and `adb devices` (Android). +3. Power the BitBox 02 Nova and confirm it is BLE-discoverable from + the primary iPhone (open Settings → Bluetooth → see "BitBox02-XXXX"). +4. Log the firmware version and serial (last 4 chars only) into the + per-run journal entry. +5. Trigger the desired flow either via PR (PR-gate flows) or + `workflow_dispatch` on `.github/workflows/maestro-bitbox.yaml`. diff --git a/README.md b/README.md index 9b32365a..dd174e7f 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Non-BitBox code only needs Tier 0 + widget tests; Tier 1+ are reserved for hardw | Coverage | `flutter test --coverage` | Writes `coverage/lcov.info`. CI narrows it to the activated surface and hard-fails when scoped coverage drops below the floor in `.coverage-floor-lines` / `.coverage-floor-functions`. See "Coverage infrastructure roadmap" above for the ratchet protocol. | | Analyzer | `flutter analyze` | Dart static analysis per `analysis_options.yaml` | -Tier 1 specs live under `test/integration/**` and run inside the same `flutter test --coverage` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); the BitBox02 hardware variant remains deferred. +Tier 1 specs live under `test/integration/**` and run inside the same `flutter test --coverage` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); BitBox02 hardware flows are gated by the `tier3:bitbox` label and wired via [`maestro-bitbox.yaml`](.github/workflows/maestro-bitbox.yaml). ## CI/CD diff --git a/pubspec.yaml b/pubspec.yaml index 9877d964..41c3a5e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,8 @@ dependencies: url_launcher: ^6.3.1 web3dart: ^2.7.1 # The following adds the Cupertino Icons font to your application. + # Pin to an existing DFX remote tag so clean CI checkouts can resolve the + # BitBox simulator/testkit APIs without relying on local .dart_tool state. bitbox_flutter: git: url: https://github.com/DFXswiss/bitbox_flutter.git diff --git a/test/tool/generate_release_info_test.dart b/test/tool/generate_release_info_test.dart index 420247b6..2f42846e 100644 --- a/test/tool/generate_release_info_test.dart +++ b/test/tool/generate_release_info_test.dart @@ -11,6 +11,31 @@ import 'package:flutter_test/flutter_test.dart'; const _script = 'tool/generate_release_info.dart'; +String _dartExecutable() { + final flutterRoot = Platform.environment['FLUTTER_ROOT']; + if (flutterRoot != null && flutterRoot.isNotEmpty) { + final candidate = File('$flutterRoot/bin/cache/dart-sdk/bin/dart'); + if (candidate.existsSync()) return candidate.path; + } + + final currentExecutable = File(Platform.resolvedExecutable); + final executableName = currentExecutable.uri.pathSegments.last; + if (executableName == 'dart' || executableName == 'dart.exe') { + return currentExecutable.path; + } + + var dir = currentExecutable.parent; + for (var i = 0; i < 6; i++) { + final candidate = File('${dir.path}/bin/cache/dart-sdk/bin/dart'); + if (candidate.existsSync()) return candidate.path; + final parent = dir.parent; + if (parent.path == dir.path) break; + dir = parent; + } + + return 'dart'; +} + class _ReleaseInfo { _ReleaseInfo(this.tag, this.marketing, this.versionCode); final String tag; @@ -31,7 +56,7 @@ Future<_ReleaseInfo> _run({String? tag}) async { if (tag != null) '--tag=$tag', '--output=${outputFile.path}', ]; - final result = await Process.run('dart', args); + final result = await Process.run(_dartExecutable(), args); expect( result.exitCode, 0, @@ -54,7 +79,7 @@ Future _runRaw(List extraArgs) { // a non-zero exit so the file is never written anyway. final tempDir = Directory.systemTemp.createTempSync('release_info_test_'); final outputFile = File('${tempDir.path}/release_info.dart'); - return Process.run('dart', [ + return Process.run(_dartExecutable(), [ _script, ...extraArgs, '--output=${outputFile.path}', From ff7137025af8e9429e747cba96408e2d34e0106d Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 29 May 2026 11:53:00 +0200 Subject: [PATCH 2/3] Keep BitBox Maestro summary off hardware runner --- .github/workflows/maestro-bitbox.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maestro-bitbox.yaml b/.github/workflows/maestro-bitbox.yaml index 0cb85c6d..86580ba5 100644 --- a/.github/workflows/maestro-bitbox.yaml +++ b/.github/workflows/maestro-bitbox.yaml @@ -333,7 +333,10 @@ jobs: - m3-multi-page-ble-toggle - m5-channel-hash-mismatch - m6-factory-reset - runs-on: [self-hosted, macOS, arm64, bitbox] + # The summary only inspects GitHub's recorded job results. Keep it on a + # hosted runner so an unlabelled PR can complete the workflow immediately + # instead of waiting for scarce BitBox hardware just to report "skipped". + runs-on: ubuntu-latest steps: - name: Aggregate outcomes run: | From 568bd6af91a73262093a29716ea7bb2dc2fc27c4 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 29 May 2026 11:54:48 +0200 Subject: [PATCH 3/3] Avoid PR-triggered pending BitBox Maestro workflow --- .github/workflows/maestro-bitbox.yaml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/maestro-bitbox.yaml b/.github/workflows/maestro-bitbox.yaml index 86580ba5..48329f9c 100644 --- a/.github/workflows/maestro-bitbox.yaml +++ b/.github/workflows/maestro-bitbox.yaml @@ -16,9 +16,11 @@ name: Tier 3 — Maestro BitBox flows # `.maestro/bitbox/RUNNER.md`. # # TRIGGER MODEL: -# * `pull_request: develop` with the `tier3:bitbox` label gate -- the -# PR-gate subset (M-1 / M-3 / M-5 / M-6) runs. Hardware time is -# scarce; reviewers opt-in by label. +# * `workflow_dispatch` on a PR branch with `flow=pr-gate` -- the +# PR-gate subset (M-1 / M-3 / M-5 / M-6) runs on demand while this +# workflow is introduced. A `pull_request` label gate can be enabled +# after the workflow exists on `develop`; adding it in the same PR +# leaves GitHub with a pending check and no jobs. # * `push: develop` -- the PR-gate subset runs unconditionally as # post-merge truth check. # * `schedule: '0 2 * * *'` -- the daily/full subset (M-2 / M-4 / @@ -62,9 +64,6 @@ on: - M-7 push: branches: [develop] - pull_request: - branches: [develop] - types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] schedule: # 02:00 UTC -- avoids overlapping with the macos-latest hosted # tier3-handbook.yaml's typical run windows.