diff --git a/CHANGELOG.md b/CHANGELOG.md index d215eff5d..f1624cca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [1.91.38.0] - 2026-10-07 + +**`/ios-qa` works on iPads, and a dropped USB route no longer restarts the app you are testing.** + +Three things went wrong on real devices. The daemon accepted only iPhones, so a paired iPad was refused with "not an iPhone" (#2743). Once a session was running, any blip in Xcode 26's CoreDevice tunnel made the daemon start over: the app had already deleted its one-use boot token, so the only way back in was `devicectl process launch --terminate-existing`, which threw away the app's in-memory QA state in the middle of a test flow (#1975). And if the app could not write that boot token in the first place, `StateServer` failed silently with `try?`, so the daemon kept relaunching an app that could never let it in (#1837). + +### The numbers that matter + +Measured with the simulated-device tests in `ios-qa/daemon/test/daemon-integration.test.ts` (a `devicectl` emulator plus a StateServer with the real token rules). Real iPhone and iPad runs have not been done for this release. + +| Check | Before | After | +|---|---|---| +| Paired iPad on USB, no target set | refused ("not an iPhone") | bootstraps | +| iPhone and iPad both on USB, no target set | silently drives whichever devicectl listed first | stops and lists both UDIDs with a ready `export GSTACK_IOS_TARGET_UDID=...` line | +| One route drop during a session | 1 `--terminate-existing` relaunch, in-app state lost | 0 relaunches, state kept, same bearer | +| Three requests failing on the same drop | one bootstrap and one relaunch | one shared recovery, no relaunch | +| App that cannot write its boot token | silent; daemon relaunches it once, then `boot_token_unavailable` with no cause | `NOT READY` in the device log; daemon names the cause and does not relaunch | + +### What this means for you + +Run `/gstack-upgrade`, then rerun `gstack-ios-qa-regen` in your app so the new `StateServer` lands in `DebugBridge/`. Plug in an iPad and run `/ios-qa`: it connects like an iPhone. With an iPhone and an iPad both plugged in, the daemon prints both UDIDs and the `export GSTACK_IOS_TARGET_UDID=` line to run. When the tunnel drops mid-session, the daemon log says `tunnel recovered: kept the session` and the app keeps its state. A restarted daemon still relaunches the app once, because a new daemon has no session bearer. + +### Itemized changes + +#### Fixed +- **Physical iPads are accepted** (#2743). Device selection takes iPhones and iPads (platform iOS or iPadOS); errors say "iPhone or iPad". Watches, Vision Pro and other devices are still refused. Contributed by @loulanyue (#2779), reported by @Artic0din. +- **Two devices are never guessed between.** When an iPhone and an iPad (or any two devices) tie for the default, bootstrap fails with `multiple_devices`, lists each device with its UDID, and prints the export line. +- **A route drop keeps the session** (#1975, finding 1). On `503 device_disconnected` or `504 upstream_timeout` the daemon re-selects the device with the same rules, re-resolves the tunnel address for the same UDID, checks the unauthenticated `/healthz` owner, and probes `/state/snapshot` with the bearer it already holds. If the app accepts it, the session continues. It bootstraps only when the app rejects the bearer (401, the app was relaunched), the app is confirmed not running (one normal launch), or a different device is now selected. The bearer is only sent to the address `devicectl` reports for that UDID, or to the address the session already used, never to another device. Concurrent failures share one recovery, and a tap or other mutation whose response was lost is still never replayed. Diagnosis by @jpb33333 in #1975; @Bmathews721 traced the same rotate-then-rebootstrap failure in #1796. +- **A failed boot-token write is loud** (#1837). `StateServer.start()` writes the 0600 token file in a `do`/`catch`, logs the path and error (never the token), logs `gstack-ios-qa-bootstrap NOT READY`, and reports `boot_token_error` on `/healthz`. The daemon turns that into `boot_token_unavailable` with the cause instead of relaunching the app. Reported by @aweevenson-sea. + +#### Docs +- `/ios-qa` no longer tells you to capture the boot token from `os_log`; the token left `os_log` in v1.65.0.0. The stale comments in `StateServer` are gone too (#1837, #1735 item 7). +- Source-control guidance for the generated `DebugBridge/` package: commit it or ignore it, never hand-edit it (#1735 item 7, reported by @frank-alvarado). +- A new "Known limits" section: in-process synthesized touches do not reach SwiftUI `DragGesture` on iOS 26 and `/swipe` only scrolls a `UIScrollView` (#1975 findings 2 and 3, @jpb33333); on iOS 26.3.1 `/elements` returns only the hosting views (device data from @sternryan in #1755); iPad Stage Manager is not verified. +- The iOS how-to, README and skill index cover iPads, `GSTACK_IOS_TARGET_UDID`, and the new failure rows. + +#### For contributors +- `selectDevice()` and `recoverTunnel()` in `ios-qa/daemon/src/tunnel-bootstrap.ts`; `refreshTunnel` in `startDaemon()` coalesces refreshes per failed tunnel. The CLI wiring is `deviceTunnelSource()` in `ios-qa/daemon/src/index.ts`, so tests drive exactly what the daemon runs. +- `ios-qa/daemon/test/fake-device.ts` simulates a device: a `devicectl` emulator plus a StateServer with the one-use token file, rotation, relaunch and black-holed requests. +- Pinned by `ios-qa/daemon/test/daemon-integration.test.ts` (live route drop, lost tap, concurrent drops, stopped app, Xcode relaunch, device change), `ios-qa/daemon/test/tunnel-bootstrap.test.ts` (iPad, Watch, multiple devices, recovery address and owner rules, token-write error) and `test/ios-qa-stateserver-hardening.test.ts` (no silent `try?` write, no os_log claim) on both the template and the fixture copy. +- Not verified here: Swift compilation of the changed `StateServer` (only `swiftc -parse` ran; no Apple SDK), and any iPhone or iPad run. + ## [1.91.36.0] - 2026-10-07 **Python and Rails repairs can now reach `runtime_tested` and replay, and a killed verification leaves nothing behind.** diff --git a/README.md b/README.md index 0d2defa87..11c7dee10 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,7 @@ and PR publication; the docs helper does not commit or push independently. | `/setup-gbrain` | **GBrain Onboarding** — from zero to running gbrain in under 5 minutes. PGLite local, Supabase existing URL, or auto-provision a new Supabase project via Management API. MCP registration for Claude Code + per-repo trust triad (read-write/read-only/deny). [Full guide](USING_GBRAIN_WITH_GSTACK.md). | | `/sync-gbrain` | **Keep Brain Current** — re-index this repo's code into gbrain via `gbrain sources add` + `gbrain sync --strategy code`, refresh the `## GBrain Search Guidance` block in CLAUDE.md, and auto-remove guidance when the capability check fails. `--incremental` (default), `--full`, `--dry-run`. Idempotent; safe to re-run. | | `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. | -| `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. | +| `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone or iPad over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. | | `/ios-fix`, `/ios-design-review`, `/ios-clean`, `/ios-sync` | iOS bug-fix loop, designer's-eye HIG audit, debug-bridge cleanup, and accessor resync. See `docs/skills.md`. End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | ### Standalone binaries @@ -374,7 +374,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](docs/skills.md#review-readiness-dashboard). | | `gstack-evidence` | **Verification-evidence ledger** — `run --label -- ` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint; `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. | | `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue `, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. | -| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | +| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | | `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. | | `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. | diff --git a/TODOS.md b/TODOS.md index 114d40036..c7a121990 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,36 @@ ## NEXT PRIORITY +### P2/P3: iOS QA fix-wave follow-ups (filed 2026-10-07, v1.91.38.0) + +Left open by the iPad and route-drop release, each with its reason. + +- **Device verification of v1.91.38.0** — the iPad bootstrap, route-drop + recovery on a real Xcode 26 tunnel drop, the multiple-devices error and the + `NOT READY` path were proven only against the simulated device; the changed + `StateServer` passed `swiftc -parse` but no Apple-SDK build. Run + `test/skill-e2e-ios-device.test.ts` and the Swift build lane on a Mac with an + iPhone and an iPad. **Effort:** S. **Priority:** P2. +- **`GSTACK_IOS_LAUNCH_ENV` (split from #1796, @Bmathews721)** — pass launch + environment to the app through `devicectl process launch`. Nothing equivalent + exists on main; land it as its own small PR with credit, then close #1796. + **Effort:** S. **Priority:** P2. +- **A restarted daemon still relaunches the app once** — a new daemon has no + session bearer and the one-use boot token is gone. Fixing it needs either a + device-side re-mint with proof of the old session or a persisted bearer under + the state root, and both change the token's security model. Needs a design. + **Effort:** M. **Priority:** P3. +- **iPad Stage Manager and multiple scenes** — overlay and window selection + (`frontmostWindow`, `searchRoots`) are unverified on iPad multi-window + layouts. **Effort:** S (verify) / M (fix). **Priority:** P3. +- **Stale "Session warm-start" phase in `/ios-qa`** — Phase 0 describes an + `ios-qa-session.json` cache that no daemon code writes. Implement it with the + state-root chain or remove the phase. **Effort:** S. **Priority:** P3. +- **Sanctioned input-routing hook for SwiftUI gestures** (#1975 finding 2) — + synthesized touches miss `DragGesture` on iOS 26; a documented `#if DEBUG` + hook would let gesture-driven apps route `/tap` and `/swipe` to their own + handlers. **Effort:** M. **Priority:** P3. + ### P2/P3: Oct 7 fix-wave follow-ups (filed 2026-10-07) Left open by the Oct 7 wave (docs/designs/FOLLOWUP_WAVE_2026_10_07.md), each with its reason. @@ -203,10 +233,10 @@ Deferred from the approved severe fix wave (docs/designs/SEVERE_FIX_WAVE_2026_10 - **Redaction repo allowlist (#2598)** — a per-repo `+++ b/` skip list (generated `*.svg`/`*.excalidraw`, #2827) for noise context rules cannot cover. New config surface, so out of the wave. **Effort:** M. **Priority:** P2. -- **Global-discover Codex session schema (#2750)**, **ios-qa boot-token fixes - (#1837, #1975; need a device)**, **absolute bun path for compiled browse - spawns (#931; macOS repro unclear)** and **GitLab CI `curl | bash` (#1713)** — - out of the wave's scope. **Priority:** P3 each. +- **Global-discover Codex session schema (#2750)**, **absolute bun path for + compiled browse spawns (#931; macOS repro unclear)** and **GitLab CI + `curl | bash` (#1713)** — out of the wave's scope. (The ios-qa boot-token + items #1837 and #1975 landed in v1.91.38.0.) **Priority:** P3 each. - **Upstream gbrain batch lookup** — a per-slug existence lookup and a separate `pending` list in `gbrain import --json`, so the landing check need not list a whole source and spawn `get` per page. **Effort:** M (upstream). **Priority:** P2. diff --git a/VERSION b/VERSION index fe477d414..542024a3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.36.0 +1.91.38.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 291e0e985..e65ae4003 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.36.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.38.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/howto-ios-testing-with-gstack.md b/docs/howto-ios-testing-with-gstack.md index 181937491..fd64e06aa 100644 --- a/docs/howto-ios-testing-with-gstack.md +++ b/docs/howto-ios-testing-with-gstack.md @@ -7,7 +7,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin ## What you'll need - macOS with Xcode 16.0+ installed (`xcrun devicectl --version` must succeed). Xcode 16 ships the CoreDevice tunnel `devicectl` uses to reach the device over USB. -- A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security. +- A real iPhone or iPad running iOS/iPadOS 16 or later (iPad Stage Manager and multi-window layouts are not device-verified yet). Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security. - An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`. - gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH). - Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process. @@ -24,7 +24,7 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca └─────────────────┘ └──────────────────────┘ └─────────────────────┘ ``` -- iOS app embeds a `StateServer` (`DebugBridge` SPM library, `#if DEBUG` only) listening on `::1` + `127.0.0.1` port 9999. Bearer-token gated. Boot token rotates within ~5 seconds of daemon spawn so anything scraping `os_log` past then sees a dead credential. +- iOS app embeds a `StateServer` (`DebugBridge` SPM library, `#if DEBUG` only) listening on `::1` + `127.0.0.1` port 9999. Bearer-token gated. The one-use boot token lives in a 0600 file in the app's `tmp/` (never `os_log`); the daemon copies it out and rotates it within ~5 seconds, which deletes the file, so a later copy is a dead credential. If the app cannot write the file, it logs `NOT READY` and `/healthz` carries `boot_token_error`. - Mac daemon brokers traffic over the CoreDevice IPv6 tunnel that `xcrun devicectl` opens automatically when a paired device is connected. - In Tailscale mode, the daemon exposes a separate listener bound to your tailnet IP, with capability tiers (observe / interact / mutate / restore) enforced per session token. Tokens are minted explicitly by the Mac owner via `gstack-ios-qa-mint`; remote callers never auto-allowlist. @@ -131,11 +131,18 @@ GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099 ``` If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired, -available iPhone. -Automatic selection is restricted to available iPhones and prefers a wired -phone. The daemon keeps a healthy rotated tunnel, then invalidates and -rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection -failure. +available iPhone or iPad, preferring a wired device. When two devices tie (an +iPhone and an iPad both on USB), it refuses to guess: the bootstrap error lists +each device with its UDID and prints a ready +`export GSTACK_IOS_TARGET_UDID=` line. +The daemon keeps a healthy rotated tunnel for its lifetime. When the CoreDevice +route drops (`503 device_disconnected` / `504 upstream_timeout`), it re-resolves +the tunnel address for the same UDID and probes the running app with the bearer +it already holds, so the app keeps its state. It bootstraps again only when the +app rejects that bearer (401, the app was relaunched), the app is not running +(one normal launch), or a different device is now selected; the old bearer is +never sent to another device. A tap or other mutation whose response was lost +is never replayed. If a newly started daemon reaches an already-running target whose one-use boot token was consumed by an earlier daemon, it verifies the bundle owner, force relaunches that target once, waits for a fresh token, verifies ownership again, @@ -222,6 +229,9 @@ Before you ship to TestFlight or the App Store, run `/ios-clean`. It removes the | First install on a paired device fails with no clear error | The phone needs to Trust the Mac. Open Settings → General → VPN & Device Management on the phone and confirm. | | `Developer Mode` toggle missing from Settings → Privacy | Connect the device to Xcode → Window → Devices and Simulators once, or try any `devicectl device install` against it. iOS will surface the toggle after the first attempt. | | `xcrun devicectl device copy from` returns ERROR 7000 | The source path is wrong — boot token lives at `tmp/gstack-ios-qa.token` inside the app's data container (NSTemporaryDirectory), not at the path's root. | +| Bootstrap fails with `multiple_devices` | More than one iPhone/iPad is connected. Run the printed `export GSTACK_IOS_TARGET_UDID=` and restart the daemon. | +| `boot_token_unavailable ... could not write tmp/gstack-ios-qa.token` | The app could not write its boot-token file. Fix the app's `tmp/` directory and relaunch the app. | +| A SwiftUI `DragGesture` or `Canvas` ignores taps and swipes on iOS 26 | Synthesized in-process touches do not reach SwiftUI gesture recognizers on iOS 26, and `/swipe` only scrolls a `UIScrollView`. See "Known limits" in the `/ios-qa` skill for the input-routing workaround. | | `/healthz` returns 200 but `/tap` returns ok:true with no UI change | The phone is paired but the StateServer port may have changed across launches. Re-resolve the CoreDevice IPv6 (`dscacheutil -q host -a name '.coredevice.local'`). | | `403 identity_not_allowed` from `/auth/mint` | The remote caller's identity isn't on the Mac's allowlist. Run `gstack-ios-qa-mint grant --remote --capability interact` on the Mac. | | Daemon won't open the tailnet listener | Tailscale isn't installed, or `/var/run/tailscale.sock` is unreadable. Fix Tailscale, then restart the daemon. Loopback still runs in the meantime. | diff --git a/docs/skills.md b/docs/skills.md index 7c332e51a..f027ab20a 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -60,7 +60,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples. | [`/gstack-upgrade`](#gstack-upgrade) | **Self-Updater** | Upgrade gstack to the latest version. Detects global vs vendored install, syncs both, shows what changed. | | [`/make-pdf`](#make-pdf) | **PDF Generator** | Turn any markdown file into a publication-quality PDF. Proper margins, page numbers, cover pages, clickable TOC. Mermaid/excalidraw fences render as vector diagrams; `--to html\|docx` for other formats. Prints through your Aside browser (macOS 15+), or gstack's bundled browser when Aside is absent. | | [`/diagram`](#diagram) | **Diagram Maker** | English in, diagram out: mermaid source + editable `.excalidraw` (open it on excalidraw.com, hand-drawn style) + rendered SVG/PNG. Fully offline, rendered through your Aside browser (macOS 15+) or gstack's bundled browser when Aside is absent. | -| [`/ios-qa`](#ios-qa) | **iOS QA Lead** | Live-device iOS QA via USB CoreDevice tunnel + embedded StateServer. Reads Swift source, codegens accessors, drives the real iPhone. Optionally exposes the device over Tailscale for remote agents. | +| [`/ios-qa`](#ios-qa) | **iOS QA Lead** | Live-device iOS QA via USB CoreDevice tunnel + embedded StateServer. Reads Swift source, codegens accessors, drives the real iPhone or iPad. Optionally exposes the device over Tailscale for remote agents. | | [`/ios-fix`](#ios-fix) | **iOS Autonomous Fixer** | Closes the find→fix→verify loop on a real iPhone. Captures a reproducing snapshot, fixes the source, rebuilds, redeploys, verifies. | | [`/ios-design-review`](#ios-design-review) | **iOS Designer's Eye** | 10-dimension Apple HIG audit on a real iPhone. Rates each screen, says what would make it a 10. | | [`/ios-clean`](#ios-clean) | **iOS Bridge Cleanup** | Convenience wrapper to strip DebugBridge SPM + `#if DEBUG` wiring. The structural Release-build guard is in Package.swift + CI; this skill is for guided manual removals. | diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index 5745c30fb..d9c68e49d 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -14,6 +14,7 @@ allowed-tools: triggers: - ios qa - test the iphone app + - test the ipad app - test my ios app - find bugs on the device - qa the ios app @@ -24,17 +25,17 @@ triggers: ## When to invoke this skill -Connects to a real iPhone via USB -CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then +Connects to a real iPhone or iPad via +USB CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then runs a vision-driven agent loop: screenshot → analyze → decide → act → verify → repeat. All interaction happens via HTTP to an embedded StateServer in the app under test. Optionally exposes the device over Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can run iOS QA from anywhere without touching the hardware. -Use when asked to "ios qa", "test my iPhone app", "find bugs on the device", -or "qa the iOS app". +Use when asked to "ios qa", "test my iPhone app", "test my iPad app", +"find bugs on the device", or "qa the iOS app". -Voice triggers (speech-to-text aliases): "iOS quality check", "test the iPhone app", "run iOS QA". +Voice triggers (speech-to-text aliases): "iOS quality check", "test the iPhone app", "test the iPad app", "run iOS QA". ## Preamble (run first) @@ -390,7 +391,7 @@ Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXI # Live-device iOS QA -This skill drives a real iPhone via USB. The agent reads your Swift source, +This skill drives a real iPhone or iPad via USB. The agent reads your Swift source, generates typed state accessors, deploys a debug bridge, and runs a closed find→fix→verify loop. No simulator, no XCTest, no WebDriverAgent. @@ -422,7 +423,11 @@ tokens (default 1h) for remote agents. ## Prerequisites - macOS (the daemon uses `devicectl` from Xcode). -- iPhone connected via USB, paired and trusted. +- iPhone or iPad connected via USB, paired and trusted. With more than one + connected, pick one before starting the daemon: + `export GSTACK_IOS_TARGET_UDID=` (`xcrun devicectl list devices` shows + UDIDs). Otherwise the daemon refuses to guess, lists each device with its + UDID, and prints that export line. - Xcode + Swift toolchain installed (`swift --version` reports >= 5.9). - App source available on disk, with at least one `@Observable` class. - For remote-control mode: Tailscale installed and the user logged in. @@ -490,6 +495,11 @@ fi The regenerator also removes the explicit obsolete flat-file set created by older ios-sync versions, preventing a stale second harness from remaining in the app target. + Source control: `DebugBridge/` is generated; never hand-edit it. Commit it + when teammates or CI build the Debug configuration without gstack (re-run + the regenerator after a gstack upgrade); otherwise add `DebugBridge/` to + `.gitignore` and have each developer run the regenerator. Tell the user + which one you picked. 2. Add the generated `DebugBridge` local SPM dependency to the app's `Package.swift`. The package ships three Debug-config-only library products: @@ -519,14 +529,17 @@ fi ``` 4. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. -5. Launch via `devicectl device process launch --device --console `. - Capture the boot token printed to `os_log` on first run. +5. Launch via `devicectl device process launch --device `. + On launch the StateServer writes a one-use boot token to a 0600 file in the + app's `tmp/`; the daemon copies it out with `devicectl`. The token is never + printed to `os_log`. If the app cannot write that file, it logs `NOT READY` + and the daemon reports `boot_token_unavailable` with the cause. 6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another daemon is alive, the second invocation discovers its port and connects. 7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a - fresh in-memory-only token. The boot token becomes useless ~5s later. - Anything scraping `os_log` past this point sees a dead credential. + fresh in-memory-only token. Rotation deletes the boot-token file, so a copy + taken after this point is a dead credential. If a fresh daemon finds the app running after another daemon consumed that one-use token, it verifies the bundle owner, relaunches the target once, waits for the new token, verifies ownership again, and then rotates. @@ -601,10 +614,34 @@ live. | `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed | | `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote ` on the Mac | | `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture | -| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists | +| `503 device_disconnected` / `504 upstream_timeout` from proxy | USB route dropped, app stopped, or app relaunched | Daemon probes the running app with its session bearer and keeps the session (no relaunch, app state intact). It bootstraps only when the app rejected the bearer (401), is not running, or a different device is now selected. A lost `/tap`/`/swipe`/`/type` response is never replayed: check the screen before retrying. If it persists, reconnect/unlock the device | +| `multiple_devices` at bootstrap | iPhone and iPad (or two devices) connected, no target set | Run the printed `export GSTACK_IOS_TARGET_UDID=`, then restart the daemon | +| `boot_token_unavailable ... could not write` | app's `tmp/` not writable | Fix the app container, relaunch the app | +| App relaunched after the daemon restarted | a new daemon has no session bearer and the one-use boot token is gone | Expected: the first bootstrap relaunches the app once; keep one daemon alive for a session | | `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies | | `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot | +## Known limits + +Device-verified by users, not fixable in the bridge today. Plan around them: + +- **SwiftUI gestures on iOS 26.** In-process synthesized touches report success + but never reach a SwiftUI `DragGesture` (for example a `Canvas` driven by + drag input), even with phase-separated touches (seen on iOS 26.5). Buttons and + UIKit controls still respond. For gesture-driven views, have the app expose + its input handlers to the bridge under `#if DEBUG` and drive them through a + state write, or cover the flow with an XCUITest harness. +- **`/swipe` scrolls only.** It moves the nearest enclosing `UIScrollView` and + returns `false` when there is none; it is not a drag. Custom pan or drag + views need the input-routing approach above. +- **`/elements` on iOS 26.** The in-process SwiftUI accessibility tree is often + not materialized: an iPhone 12 Pro on iOS 26.3.1 returned only the three + hosting views, with no identifiers or labels. Locate controls from the + screenshot and tap by coordinate. +- **iPad windows.** iPad sessions work like iPhone sessions, but the overlay + and window selection have not been verified with Stage Manager or multiple + scenes; report what you see. + ## Cleanup Use `/ios-clean` to remove the DebugBridge SPM dependency and all `#if DEBUG` diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl index 508230326..b14a3349c 100644 --- a/ios-qa/SKILL.md.tmpl +++ b/ios-qa/SKILL.md.tmpl @@ -3,18 +3,19 @@ name: ios-qa preamble-tier: 3 version: 1.0.0 description: | - Live-device iOS QA for SwiftUI apps. Connects to a real iPhone via USB - CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then + Live-device iOS QA for SwiftUI apps. Connects to a real iPhone or iPad via + USB CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then runs a vision-driven agent loop: screenshot → analyze → decide → act → verify → repeat. All interaction happens via HTTP to an embedded StateServer in the app under test. Optionally exposes the device over Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can run iOS QA from anywhere without touching the hardware. - Use when asked to "ios qa", "test my iPhone app", "find bugs on the device", - or "qa the iOS app". (gstack) + Use when asked to "ios qa", "test my iPhone app", "test my iPad app", + "find bugs on the device", or "qa the iOS app". (gstack) voice-triggers: - "iOS quality check" - "test the iPhone app" + - "test the iPad app" - "run iOS QA" allowed-tools: - Bash @@ -27,6 +28,7 @@ allowed-tools: triggers: - ios qa - test the iphone app + - test the ipad app - test my ios app - find bugs on the device - qa the ios app @@ -36,7 +38,7 @@ triggers: # Live-device iOS QA -This skill drives a real iPhone via USB. The agent reads your Swift source, +This skill drives a real iPhone or iPad via USB. The agent reads your Swift source, generates typed state accessors, deploys a debug bridge, and runs a closed find→fix→verify loop. No simulator, no XCTest, no WebDriverAgent. @@ -68,7 +70,11 @@ tokens (default 1h) for remote agents. ## Prerequisites - macOS (the daemon uses `devicectl` from Xcode). -- iPhone connected via USB, paired and trusted. +- iPhone or iPad connected via USB, paired and trusted. With more than one + connected, pick one before starting the daemon: + `export GSTACK_IOS_TARGET_UDID=` (`xcrun devicectl list devices` shows + UDIDs). Otherwise the daemon refuses to guess, lists each device with its + UDID, and prints that export line. - Xcode + Swift toolchain installed (`swift --version` reports >= 5.9). - App source available on disk, with at least one `@Observable` class. - For remote-control mode: Tailscale installed and the user logged in. @@ -136,6 +142,11 @@ fi The regenerator also removes the explicit obsolete flat-file set created by older ios-sync versions, preventing a stale second harness from remaining in the app target. + Source control: `DebugBridge/` is generated; never hand-edit it. Commit it + when teammates or CI build the Debug configuration without gstack (re-run + the regenerator after a gstack upgrade); otherwise add `DebugBridge/` to + `.gitignore` and have each developer run the regenerator. Tell the user + which one you picked. 2. Add the generated `DebugBridge` local SPM dependency to the app's `Package.swift`. The package ships three Debug-config-only library products: @@ -165,14 +176,17 @@ fi ``` 4. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. -5. Launch via `devicectl device process launch --device --console `. - Capture the boot token printed to `os_log` on first run. +5. Launch via `devicectl device process launch --device `. + On launch the StateServer writes a one-use boot token to a 0600 file in the + app's `tmp/`; the daemon copies it out with `devicectl`. The token is never + printed to `os_log`. If the app cannot write that file, it logs `NOT READY` + and the daemon reports `boot_token_unavailable` with the cause. 6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another daemon is alive, the second invocation discovers its port and connects. 7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a - fresh in-memory-only token. The boot token becomes useless ~5s later. - Anything scraping `os_log` past this point sees a dead credential. + fresh in-memory-only token. Rotation deletes the boot-token file, so a copy + taken after this point is a dead credential. If a fresh daemon finds the app running after another daemon consumed that one-use token, it verifies the bundle owner, relaunches the target once, waits for the new token, verifies ownership again, and then rotates. @@ -247,10 +261,34 @@ live. | `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed | | `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote ` on the Mac | | `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture | -| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists | +| `503 device_disconnected` / `504 upstream_timeout` from proxy | USB route dropped, app stopped, or app relaunched | Daemon probes the running app with its session bearer and keeps the session (no relaunch, app state intact). It bootstraps only when the app rejected the bearer (401), is not running, or a different device is now selected. A lost `/tap`/`/swipe`/`/type` response is never replayed: check the screen before retrying. If it persists, reconnect/unlock the device | +| `multiple_devices` at bootstrap | iPhone and iPad (or two devices) connected, no target set | Run the printed `export GSTACK_IOS_TARGET_UDID=`, then restart the daemon | +| `boot_token_unavailable ... could not write` | app's `tmp/` not writable | Fix the app container, relaunch the app | +| App relaunched after the daemon restarted | a new daemon has no session bearer and the one-use boot token is gone | Expected: the first bootstrap relaunches the app once; keep one daemon alive for a session | | `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies | | `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot | +## Known limits + +Device-verified by users, not fixable in the bridge today. Plan around them: + +- **SwiftUI gestures on iOS 26.** In-process synthesized touches report success + but never reach a SwiftUI `DragGesture` (for example a `Canvas` driven by + drag input), even with phase-separated touches (seen on iOS 26.5). Buttons and + UIKit controls still respond. For gesture-driven views, have the app expose + its input handlers to the bridge under `#if DEBUG` and drive them through a + state write, or cover the flow with an XCUITest harness. +- **`/swipe` scrolls only.** It moves the nearest enclosing `UIScrollView` and + returns `false` when there is none; it is not a drag. Custom pan or drag + views need the input-routing approach above. +- **`/elements` on iOS 26.** The in-process SwiftUI accessibility tree is often + not materialized: an iPhone 12 Pro on iOS 26.3.1 returned only the three + hosting views, with no identifiers or labels. Locate controls from the + screenshot and tap by coordinate. +- **iPad windows.** iPad sessions work like iPhone sessions, but the overlay + and window selection have not been verified with Stage Manager or multiple + scenes; report what you see. + ## Cleanup Use `/ios-clean` to remove the DebugBridge SPM dependency and all `#if DEBUG` diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts index d81dee978..b16643426 100644 --- a/ios-qa/daemon/src/index.ts +++ b/ios-qa/daemon/src/index.ts @@ -20,8 +20,8 @@ import { SessionTokenStore } from './session-tokens'; import { mintForCaller } from './auth-mint'; import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy'; import { writeAudit, writeAttempt, sanitizeReplacer, saltedHash } from './audit'; -import { bootstrapTunnel } from './tunnel-bootstrap'; -import { startTunnelKeepalive } from './devicectl'; +import { bootstrapTunnel, recoverTunnel, type TunnelRecovery } from './tunnel-bootstrap'; +import { startTunnelKeepalive, type SpawnImpl, type ResolveImpl } from './devicectl'; import type { Capability } from './types'; interface DaemonOptions { @@ -39,6 +39,8 @@ interface DaemonOptions { // Test injection proxyTimeoutMs?: number; tunnelProvider?: () => Promise; + /** Route-drop recovery (see recoverTunnel). Without it a route drop re-bootstraps. */ + tunnelRecovery?: (failed: DeviceTunnel) => Promise; whoIsImpl?: (addr: string) => Promise<{ identity: string; raw: unknown }>; probeImpl?: () => Promise<{ ok: boolean; reason?: string; ownIdentity?: string }>; } @@ -95,6 +97,35 @@ export async function startDaemon(opts: DaemonOptions): Promise } | null = null; + const refreshTunnel = (failedTunnel: DeviceTunnel, cause: RefreshCause): Promise => { + if (tunnel && tunnel !== failedTunnel) return Promise.resolve(tunnel); + if (refreshInFlight?.failed === failedTunnel) return refreshInFlight.promise; + if (!tunnel && tunnelInFlight) return tunnelInFlight; + + const promise: Promise = (async () => { + // A route drop leaves the app and its in-memory bearer alive. Probe it + // first: a full bootstrap would have to relaunch the app (the one-shot + // boot-token file is gone) and wipe its QA state. A 401 skips this. + if (cause === 'route_lost' && opts.tunnelRecovery) { + const recovery = await opts.tunnelRecovery(failedTunnel); + if (recovery.action === 'unavailable') return null; + if (recovery.action === 'reuse') { + if (tunnel === failedTunnel) tunnel = recovery.tunnel; + return tunnel ?? recovery.tunnel; + } + } + invalidateTunnel(failedTunnel); + return getTunnel(); + })().finally(() => { + if (refreshInFlight?.promise === promise) refreshInFlight = null; + }); + refreshInFlight = { failed: failedTunnel, promise }; + return promise; + }; + // 2. Tailnet probe (fail-closed). const probe = opts.tailnetEnabled ? (opts.probeImpl ? await opts.probeImpl() : await probeTailscale(opts.tailnetSocketPath)) @@ -107,7 +138,7 @@ export async function startDaemon(opts: DaemonOptions): Promise { - await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, proxyTimeoutMs: opts.proxyTimeoutMs }); + await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, proxyTimeoutMs: opts.proxyTimeoutMs }); }); // Use port 0 for OS-assigned port when test/random port collisions are a risk. const requestedPort = opts.loopbackPort; @@ -118,7 +149,7 @@ export async function startDaemon(opts: DaemonOptions): Promise { - await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, proxyTimeoutMs: opts.proxyTimeoutMs }); + await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, proxyTimeoutMs: opts.proxyTimeoutMs }); }); let v6Bound = false; try { @@ -140,6 +171,7 @@ export async function startDaemon(opts: DaemonOptions): Promise Promise; invalidateTunnel: (failedTunnel: DeviceTunnel) => void; + refreshTunnel: (failedTunnel: DeviceTunnel, cause: RefreshCause) => Promise; proxyTimeoutMs?: number; // Explicit security-log + allowlist paths (default to env-derived when undefined). auditPath?: string; @@ -213,6 +246,7 @@ interface HandlerCtx { } type DeviceProxyResponse = Awaited>; +type RefreshCause = 'unauthorized' | 'route_lost'; const RECOVERABLE_SOCKET_ERRORS = new Set([ 'ECONNABORTED', 'ECONNREFUSED', @@ -249,8 +283,9 @@ async function proxyAttempt(opts: Parameters[0]): Promise< function shouldRefreshTunnel(upstream: DeviceProxyResponse): boolean { // A relaunched app has a new in-memory bearer and rejects the daemon's old - // rotated token. A redeploy can instead leave the old CoreDevice route - // refusing connections or timing out. Both cases require a fresh bootstrap. + // rotated token: that needs a fresh bootstrap. A blip or redeploy can + // instead leave the CoreDevice route refusing connections or timing out: + // that goes through route-drop recovery first. if (upstream.status === 401) return true; if (upstream.status !== 503 && upstream.status !== 504) return false; try { @@ -278,6 +313,7 @@ async function proxyWithTunnelRecovery(opts: { agentIdentity?: string; getTunnel: HandlerCtx['getTunnel']; invalidateTunnel: HandlerCtx['invalidateTunnel']; + refreshTunnel: HandlerCtx['refreshTunnel']; timeoutMs?: number; }): Promise<{ tunnel: DeviceTunnel; upstream: DeviceProxyResponse } | null> { let tunnel = await opts.getTunnel(); @@ -297,8 +333,7 @@ async function proxyWithTunnelRecovery(opts: { const failedTunnel = tunnel; const replaySafe = canReplayAfterRefresh(opts.inbound, upstream); - opts.invalidateTunnel(tunnel); - const refreshed = await opts.getTunnel(); + const refreshed = await opts.refreshTunnel(tunnel, upstream.status === 401 ? 'unauthorized' : 'route_lost'); if (!refreshed) return replaySafe ? null : { tunnel: failedTunnel, upstream }; // The replacement is now cached for the next request, but never replay an @@ -308,9 +343,10 @@ async function proxyWithTunnelRecovery(opts: { tunnel = refreshed; upstream = await makeAttempt(tunnel); - // Do not loop forever if the replacement app is itself unavailable. Leave - // the cache empty so the next independent request can bootstrap again. - if (shouldRefreshTunnel(upstream)) opts.invalidateTunnel(tunnel); + // Do not loop forever if the replacement is itself unavailable. A rejected + // bearer empties the cache so the next request bootstraps; a route that is + // still down keeps it so the next request probes again instead. + if (upstream.status === 401) opts.invalidateTunnel(tunnel); return { tunnel, upstream }; } @@ -355,7 +391,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void { * loopback bind itself is the boundary). */ async function handleLoopback(ctx: HandlerCtx): Promise { - const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx; + const { req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel } = ctx; const url = parseUrl(req.url ?? '/'); const path = url.pathname ?? '/'; const method = req.method ?? 'GET'; @@ -418,6 +454,7 @@ async function handleLoopback(ctx: HandlerCtx): Promise { agentIdentity, getTunnel, invalidateTunnel, + refreshTunnel, timeoutMs: ctx.proxyTimeoutMs, }); if (!proxied) { @@ -440,7 +477,7 @@ interface TailnetCtx extends HandlerCtx { * Tailnet handler — locked allowlist + capability tiers. */ async function handleTailnet(ctx: TailnetCtx): Promise { - const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx; + const { req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx; const url = parseUrl(req.url ?? '/'); const path = url.pathname ?? '/'; const method = req.method ?? 'GET'; @@ -536,6 +573,7 @@ async function handleTailnet(ctx: TailnetCtx): Promise { agentIdentity: session.identity, getTunnel, invalidateTunnel, + refreshTunnel, timeoutMs: ctx.proxyTimeoutMs, }); if (!proxied) { @@ -565,6 +603,66 @@ async function handleTailnet(ctx: TailnetCtx): Promise { } } +/** + * Real-device tunnel wiring the CLI uses: bootstrap with the CoreDevice + * keepalive, plus route-drop recovery bound to the same target. Exported so + * tests drive exactly this wiring against a simulated device. + */ +export function deviceTunnelSource(opts: { + udid?: string; + bundleId: string; + port?: number; + spawnImpl?: SpawnImpl; + resolveImpl?: ResolveImpl; + fetchImpl?: typeof fetch; + keepalive?: (udid: string) => { stop: () => void }; + log?: (line: string) => void; +}): { + tunnelProvider: () => Promise; + tunnelRecovery: (failed: DeviceTunnel) => Promise; + stop: () => void; +} { + const log = opts.log ?? ((line: string) => process.stderr.write(`${line}\n`)); + const keepaliveFor = opts.keepalive ?? ((udid: string) => startTunnelKeepalive(udid)); + let keepalive: { stop: () => void } | null = null; + return { + tunnelProvider: async () => { + const result = await bootstrapTunnel({ + udid: opts.udid, + bundleId: opts.bundleId, + port: opts.port, + spawnImpl: opts.spawnImpl, + resolveImpl: opts.resolveImpl, + fetchImpl: opts.fetchImpl, + }); + if (!result.ok) { + log(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}`); + return null; + } + keepalive?.stop(); + keepalive = keepaliveFor(result.tunnel.udid); + return result.tunnel; + }, + tunnelRecovery: async (failed) => { + const recovery = await recoverTunnel(failed, { + udid: opts.udid, + bundleId: opts.bundleId, + spawnImpl: opts.spawnImpl, + resolveImpl: opts.resolveImpl, + fetchImpl: opts.fetchImpl, + }); + if (recovery.action === 'reuse') log(`tunnel recovered: kept the session at [${recovery.tunnel.ipv6Addr}]:${recovery.tunnel.port}`); + else if (recovery.action === 'bootstrap') log(`tunnel recovery: ${recovery.reason}; bootstrapping`); + else log(`tunnel recovery: ${recovery.reason}`); + return recovery; + }, + stop: () => { + keepalive?.stop(); + keepalive = null; + }, + }; +} + // CLI entry — runs when this file is executed directly, not when imported. if (import.meta.main) { const port = parseInt(process.env.GSTACK_IOS_DAEMON_PORT ?? '9099', 10); @@ -572,40 +670,21 @@ if (import.meta.main) { const targetUDID = process.env.GSTACK_IOS_TARGET_UDID; const bundleId = process.env.GSTACK_IOS_TARGET_BUNDLE_ID ?? 'com.gstack.iosqa.fixture'; - // Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with - // any connected paired device) is set, bootstrap a real CoreDevice tunnel. - // Otherwise return null (proxy will return 503 device_not_connected). - // - // After a successful bootstrap we spawn a periodic devicectl `info details` - // call to keep the CoreDevice tunnel session alive — Xcode 26's CoreDevice - // only holds the tunnel up while a devicectl command is in-flight, so - // without a poke every few seconds the IPv6 becomes unroutable. - let keepalive: { stop: () => void } | null = null; - const realTunnelProvider = async () => { - const result = await bootstrapTunnel({ - udid: targetUDID, - bundleId, - }); - if (!result.ok) { - process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`); - return null; - } - if (keepalive) keepalive.stop(); - keepalive = startTunnelKeepalive(result.tunnel.udid); - return result.tunnel; - }; - - const shutdown = () => { - if (keepalive) { keepalive.stop(); keepalive = null; } - }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - process.on('exit', shutdown); + // Bootstrap a real CoreDevice tunnel to GSTACK_IOS_TARGET_UDID, or to the + // single best connected paired iPhone/iPad. With no device the provider + // returns null and the proxy answers 503 device_not_connected. Xcode 26's + // CoreDevice only holds the tunnel up while a devicectl command is in + // flight, so a successful bootstrap also starts a periodic keepalive. + const source = deviceTunnelSource({ udid: targetUDID, bundleId }); + process.on('SIGINT', source.stop); + process.on('SIGTERM', source.stop); + process.on('exit', source.stop); startDaemon({ loopbackPort: port, tailnetEnabled: tailnet, - tunnelProvider: realTunnelProvider, + tunnelProvider: source.tunnelProvider, + tunnelRecovery: source.tunnelRecovery, }).then((d) => { if ('error' in d) { process.stderr.write(`daemon error: ${d.error}\n`); diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts index f1be1ca48..cf7c09ba5 100644 --- a/ios-qa/daemon/src/tunnel-bootstrap.ts +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -1,5 +1,6 @@ -// Bootstrap the CoreDevice tunnel to a connected iPhone running the iOS app -// under test. Orchestrates the full hand-rolled flow we verified end-to-end: +// Bootstrap the CoreDevice tunnel to a connected iPhone or iPad running the +// iOS app under test. Orchestrates the full hand-rolled flow we verified +// end-to-end: // // 1. find a paired, connected device via devicectl list devices // 2. launch the app on it (no-op if already running) @@ -11,9 +12,13 @@ // 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated // bearer that subsequent proxied requests carry // -// Step 5 is critical: after rotation, anything scraping os_log or the -// on-disk token file sees a dead credential. The Mac daemon holds the only -// live token, which it scopes per-tailnet-session via /auth/mint. +// Step 5 is critical: rotation deletes the on-disk token file, so anything +// that copied it sees a dead credential. The Mac daemon holds the only live +// token, which it scopes per-tailnet-session via /auth/mint. +// +// recoverTunnel() handles a dropped route afterwards without repeating this +// flow: the app still holds the rotated bearer, and a second bootstrap could +// only get a new token by relaunching the app. import { randomBytes } from 'crypto'; import { spawnSync } from 'child_process'; @@ -21,6 +26,8 @@ import type { DeviceTunnel } from './proxy'; import { listDevices, resolveTunnelIPv6, + getDeviceTunnelIPv6, + getDeviceTunnelIPv6FromDevicectl, isAppRunning, launchApp, copyFileFromAppContainer, @@ -30,7 +37,7 @@ import { } from './devicectl'; export interface BootstrapOptions { - /** Target iPhone UDID. If null, picks the best connected paired iPhone. */ + /** Target iPhone/iPad UDID. If null, picks the best connected paired device. */ udid?: string; /** Bundle ID of the iOS app hosting the StateServer. */ bundleId: string; @@ -54,6 +61,7 @@ export type BootstrapErrorReason = | 'no_devices' | 'no_paired_device' | 'device_not_found' + | 'multiple_devices' | 'launch_failed' | 'device_locked' | 'state_server_unreachable' @@ -62,7 +70,7 @@ export type BootstrapErrorReason = | 'rotate_failed' | 'resolve_failed'; -function isIPhoneDevice(device: DeviceEntry): boolean { +function isSupportedIOSDevice(device: DeviceEntry): boolean { const platform = device.platform.trim().toLowerCase(); const deviceType = device.deviceType.trim().toLowerCase(); const model = device.model.trim().toLowerCase(); @@ -70,8 +78,13 @@ function isIPhoneDevice(device: DeviceEntry): boolean { // productType is present even on older CoreDevice versions. Prefer the // explicit platform/type fields when available, but retain productType as // a compatibility fallback. An explicit non-iOS platform always loses. - if (platform && platform !== 'ios') return false; - return deviceType === 'iphone' || model.startsWith('iphone'); + if (platform && platform !== 'ios' && platform !== 'ipados') return false; + return ( + deviceType === 'iphone' + || deviceType === 'ipad' + || model.startsWith('iphone') + || model.startsWith('ipad') + ); } function isAvailableDevice(device: Pick): boolean { @@ -87,7 +100,7 @@ function isAvailableDevice(device: Pick): bo } function defaultDeviceRank(device: DeviceEntry): number { - if (!device.paired || !isIPhoneDevice(device) || !isAvailableDevice(device)) return -1; + if (!device.paired || !isSupportedIOSDevice(device) || !isAvailableDevice(device)) return -1; const state = device.state.trim().toLowerCase(); const transport = device.transport.trim().toLowerCase(); @@ -98,17 +111,60 @@ function defaultDeviceRank(device: DeviceEntry): number { + (state.startsWith('available') ? 1 : 0); } -function pickDefaultDevice(devices: DeviceEntry[]): DeviceEntry | undefined { - let best: DeviceEntry | undefined; - let bestRank = -1; - for (const device of devices) { - const rank = defaultDeviceRank(device); - if (rank > bestRank) { - best = device; - bestRank = rank; - } +type DeviceSelection = + | { ok: true; device: DeviceEntry } + | { ok: false; error: BootstrapErrorReason; detail?: string }; + +/** + * Choose the device a QA session targets: the explicit UDID when one is set, + * otherwise the best-ranked paired iPhone or iPad. Ranks that tie (an iPhone + * and an iPad both on USB, say) are ambiguous, so the caller gets every + * candidate with its UDID instead of a silent pick. `prefer` breaks a tie in + * favor of the device a live session already uses. + */ +export function selectDevice(devices: DeviceEntry[], udid?: string, prefer?: string): DeviceSelection { + if (devices.length === 0) return { ok: false, error: 'no_devices' }; + if (udid) { + const explicit = devices.find((d) => d.identifier === udid); + return explicit ? { ok: true, device: explicit } : { ok: false, error: 'device_not_found', detail: udid }; } - return best; + + const bestRank = Math.max(...devices.map(defaultDeviceRank)); + const best = bestRank < 0 ? [] : devices.filter((d) => defaultDeviceRank(d) === bestRank); + if (best.length === 1) return { ok: true, device: best[0]! }; + if (best.length > 1) { + const preferred = best.find((d) => d.identifier === prefer); + if (preferred) return { ok: true, device: preferred }; + const listing = best.map((d) => ` ${d.name} (${d.deviceType || d.model}): ${d.identifier}`).join('\n'); + return { + ok: false, + error: 'multiple_devices', + detail: `${best.length} iPhones/iPads are connected and none is selected:\n${listing}\n` + + `Pick one, then restart the daemon:\n export GSTACK_IOS_TARGET_UDID=${best[0]!.identifier}`, + }; + } + + const pairedIOS = devices.find((d) => d.paired && isSupportedIOSDevice(d)); + if (pairedIOS) { + return { + ok: false, + error: 'device_not_found', + detail: `paired device ${pairedIOS.name} (${pairedIOS.identifier}) is ${pairedIOS.state}; connect it over USB and unlock it`, + }; + } + const firstIOS = devices.find(isSupportedIOSDevice); + if (!firstIOS) { + return { + ok: false, + error: 'device_not_found', + detail: 'no iPhone or iPad is connected; non-iOS devices are not eligible for iOS QA', + }; + } + return { + ok: false, + error: 'no_paired_device', + detail: `device ${firstIOS.name} (${firstIOS.identifier}) is ${firstIOS.state}; run \`xcrun devicectl manage pair --device ${firstIOS.identifier}\` and tap Trust on the device`, + }; } const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, { @@ -150,51 +206,21 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise d.identifier === opts.udid) - : pickDefaultDevice(devices); - if (!target) { - if (opts.udid) { - return { ok: false, error: 'device_not_found', detail: opts.udid }; - } - const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d)); - if (pairedIPhone) { - return { - ok: false, - error: 'device_not_found', - detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`, - }; - } - const firstIPhone = devices.find(isIPhoneDevice); - if (!firstIPhone) { - return { - ok: false, - error: 'device_not_found', - detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA', - }; - } - return { - ok: false, - error: 'no_paired_device', - detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`, - }; - } - if (!isIPhoneDevice(target)) { + const selection = selectDevice(listDevices(spawn), opts.udid); + if (!selection.ok) return selection; + const target = selection.device; + if (!isSupportedIOSDevice(target)) { return { ok: false, error: 'device_not_found', - detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`, + detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone or iPad`, }; } if (!target.paired) { return { ok: false, error: 'no_paired_device', - detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the iPhone`, + detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the device`, }; } if (!isAvailableDevice(target)) { @@ -234,6 +260,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise => { const deadline = Date.now() + startupTimeoutMs; while (Date.now() < deadline) { @@ -242,7 +269,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise null) as { bundle_id?: string } | null; + const health = await r.json().catch(() => null) as { bundle_id?: string; boot_token_error?: string } | null; // Older bridges did not identify their bundle. Preserve compatibility, // but reject an explicit mismatch from current bridges: another debug // app already owns the fixed StateServer port on this device. @@ -253,6 +280,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise { + const spawn = opts.spawnImpl; + const fetchFn = opts.fetchImpl ?? fetch; + const timeoutMs = opts.probeTimeoutMs ?? 3_000; + + const selection = selectDevice(listDevices(spawn), opts.udid, failed.udid); + if (!selection.ok) { + return { action: 'unavailable', reason: `${selection.error}${selection.detail ? `: ${selection.detail}` : ''}` }; + } + const device = selection.device; + if (device.identifier !== failed.udid) { + return { action: 'bootstrap', reason: `target device changed from ${failed.udid} to ${device.identifier}` }; + } + + let address = getDeviceTunnelIPv6FromDevicectl(device.identifier, spawn); + if (!address) { + const byName = await getDeviceTunnelIPv6(device.name, opts.resolveImpl); + if (byName && byName !== failed.ipv6Addr) { + return { action: 'bootstrap', reason: `tunnel address changed to ${byName} and devicectl could not tie it to ${failed.udid}` }; + } + address = failed.ipv6Addr; + } + + const base = `http://[${address}]:${failed.port}`; + const appGone = (reason: string): TunnelRecovery => (isAppRunning(device.identifier, opts.bundleId, spawn) + ? { action: 'unavailable', reason: `${reason}; ${opts.bundleId} is still running, so the session is kept` } + : { action: 'bootstrap', reason: `${opts.bundleId} is not running on ${device.identifier}` }); + + let health: Response; + try { + health = await fetchFn(`${base}/healthz`, { signal: AbortSignal.timeout(timeoutMs) }); + } catch { + return appGone(`no /healthz response from ${base}`); + } + const owner = (await health.json().catch(() => null) as { bundle_id?: string } | null)?.bundle_id; + if (owner && owner !== opts.bundleId) { + return { action: 'bootstrap', reason: `StateServer at ${base} now belongs to ${owner}` }; + } + + let probe: Response; + try { + probe = await fetchFn(`${base}/state/snapshot`, { + headers: { 'Authorization': `Bearer ${failed.bootTokenRotated}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return appGone(`authenticated probe to ${base} failed`); + } + await probe.arrayBuffer().catch(() => undefined); + if (probe.status === 401) { + return { action: 'bootstrap', reason: `${opts.bundleId} rejected the session bearer (the app was relaunched)` }; + } + return { action: 'reuse', tunnel: { ...failed, ipv6Addr: address } }; +} diff --git a/ios-qa/daemon/test/daemon-integration.test.ts b/ios-qa/daemon/test/daemon-integration.test.ts index 58ee1f566..8db5bf5b0 100644 --- a/ios-qa/daemon/test/daemon-integration.test.ts +++ b/ios-qa/daemon/test/daemon-integration.test.ts @@ -8,6 +8,7 @@ // - Rate limit on /auth/mint // - Tailnet listener never binds 0.0.0.0 // - Boot token never leaks in responses +// - Route-drop recovery against a simulated device (#1975): no app relaunch import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import { createServer } from 'http'; @@ -15,7 +16,8 @@ import type { Server, IncomingMessage } from 'http'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { startDaemon, type RunningDaemon } from '../src/index'; +import { startDaemon, deviceTunnelSource, type RunningDaemon } from '../src/index'; +import { startFakeDevice, type FakeDevice } from './fake-device'; import { grantIdentity } from '../src/allowlist'; import type { DeviceTunnel } from '../src/proxy'; @@ -512,6 +514,149 @@ describe('daemon — loopback listener', () => { }); }); +// Route-drop recovery (#1975 finding 1) through the production wiring +// (deviceTunnelSource = bootstrapTunnel + recoverTunnel) against a simulated +// device. A black-holed request times out after PROXY_TIMEOUT_MS, which is how +// a dropped CoreDevice route looks to the daemon (504 upstream_timeout). +describe('daemon — route-drop recovery (simulated device)', () => { + const BUNDLE = 'com.test.recovery'; + const PROXY_TIMEOUT_MS = 300; + let recoveryDir: string; + const cleanups: Array<() => Promise> = []; + + beforeAll(() => { recoveryDir = mkdtempSync(join(tmpdir(), 'ios-qa-daemon-recovery-')); }); + afterAll(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + rmSync(recoveryDir, { recursive: true, force: true }); + }); + + async function startSession(name: string, fake: FakeDevice) { + const logs: string[] = []; + const source = deviceTunnelSource({ + bundleId: BUNDLE, + port: fake.port, + spawnImpl: fake.spawn, + keepalive: () => ({ stop() {} }), + log: (line) => logs.push(line), + }); + let recoveries = 0; + const d = await startDaemon({ + loopbackPort: 0, + tailnetEnabled: false, + pidfilePath: join(recoveryDir, `${name}.pid`), + proxyTimeoutMs: PROXY_TIMEOUT_MS, + tunnelProvider: source.tunnelProvider, + tunnelRecovery: async (failed) => { recoveries++; return source.tunnelRecovery(failed); }, + }); + if ('error' in d) throw new Error(d.error); + cleanups.push(() => d.close()); + return { base: `http://127.0.0.1:${d.loopbackPort}`, logs, recoveries: () => recoveries }; + } + + async function device(udid = 'FAKE-UDID-1', deviceType: 'iPhone' | 'iPad' = 'iPhone') { + const fake = await startFakeDevice({ bundleId: BUNDLE, udid, deviceType }); + cleanups.push(() => fake.close()); + return fake; + } + + const terminations = (fake: FakeDevice) => fake.calls.filter((c) => c.includes('--terminate-existing')).length; + const bootTokenReads = (fake: FakeDevice) => fake.calls.filter((c) => c.includes('device copy from')).length; + + test('live app: a route drop keeps the bearer and the app state, with no relaunch', async () => { + const fake = await device(); + const session = await startSession('live-route-drop', fake); + expect((await fetchWith('POST', `${session.base}/tap`, { body: '{}' })).status).toBe(200); + const bearer = fake.requests.at(-1)?.authorization; + + fake.dropConnections(1); + const after = await fetchWith('GET', `${session.base}/state/snapshot`); + expect(after.status).toBe(200); + expect(JSON.parse(after.bodyText).state).toEqual({ generation: 1, marker: 'tapped' }); + expect(terminations(fake)).toBe(0); + expect(bootTokenReads(fake)).toBe(1); + expect(session.recoveries()).toBe(1); + expect(fake.requests.at(-1)?.authorization).toBe(bearer); + expect(session.logs.some((l) => l.startsWith('tunnel recovered: kept the session'))).toBe(true); + }); + + test('a tap whose response was lost is not replayed; the recovered session takes the next tap', async () => { + const fake = await device(); + const session = await startSession('ambiguous-tap', fake); + expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200); + + fake.dropConnections(1); + const lost = await fetchWith('POST', `${session.base}/tap`, { body: '{}' }); + expect(lost.status).toBe(504); + expect(fake.requests.filter((r) => r.path === '/tap')).toHaveLength(0); + + expect((await fetchWith('POST', `${session.base}/tap`, { body: '{}' })).status).toBe(200); + expect(fake.requests.filter((r) => r.path === '/tap')).toHaveLength(1); + expect(terminations(fake)).toBe(0); + expect(fake.appState.generation).toBe(1); + }); + + test('concurrent requests during a route drop share one recovery', async () => { + const fake = await device(); + const session = await startSession('concurrent-recovery', fake); + expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200); + const listingsBefore = fake.calls.filter((c) => c.includes('list devices')).length; + + fake.dropConnections(3); + const responses = await Promise.all([1, 2, 3].map(() => fetchWith('GET', `${session.base}/screenshot`))); + expect(responses.map((r) => r.status)).toEqual([200, 200, 200]); + expect(session.recoveries()).toBe(1); + expect(fake.calls.filter((c) => c.includes('list devices')).length - listingsBefore).toBe(1); + expect(fake.requests.filter((r) => r.path === '/state/snapshot')).toHaveLength(1); + expect(terminations(fake)).toBe(0); + }); + + test('stopped app: one normal launch, never --terminate-existing', async () => { + const fake = await device(); + const session = await startSession('stopped-app', fake); + expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200); + + fake.stopApp(); + const after = await fetchWith('GET', `${session.base}/screenshot`); + expect(after.status).toBe(200); + expect(fake.calls.filter((c) => c.includes('device process launch'))).toHaveLength(2); + expect(terminations(fake)).toBe(0); + expect(fake.appState.generation).toBe(2); + expect(session.logs.some((l) => l.includes(`${BUNDLE} is not running`))).toBe(true); + }); + + test('app relaunched from Xcode during a drop: the probe gets 401 and bootstraps from the fresh token', async () => { + const fake = await device(); + const session = await startSession('relaunched-app', fake); + expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200); + + fake.dropConnections(1); + fake.relaunchExternally(); + expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200); + expect(bootTokenReads(fake)).toBe(2); + expect(terminations(fake)).toBe(0); + expect(session.logs.some((l) => l.includes('rejected the session bearer'))).toBe(true); + }); + + test('target device changed: full bootstrap on the new device, old bearer never sent to it', async () => { + const fake = await device('PHONE-UDID', 'iPhone'); + const session = await startSession('udid-change', fake); + expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200); + const oldBearer = fake.requests.at(-1)?.authorization; + + fake.dropConnections(1); + fake.replaceDevice('PAD-UDID', 'iPad'); + const after = await fetchWith('GET', `${session.base}/screenshot`); + expect(after.status).toBe(200); + expect(session.logs.some((l) => l.includes('target device changed from PHONE-UDID to PAD-UDID'))).toBe(true); + const padRequests = fake.requests.filter((r) => r.udid === 'PAD-UDID'); + expect(padRequests.length).toBeGreaterThan(0); + expect(padRequests.some((r) => r.authorization === oldBearer)).toBe(false); + expect(padRequests.at(-1)).toMatchObject({ path: '/screenshot' }); + expect(fake.calls.some((c) => c.includes('device copy from --device PAD-UDID'))).toBe(true); + expect(terminations(fake)).toBe(0); + }); +}); + describe('daemon — tailnet listener (mocked tailscaled)', () => { let stub: Awaited>; let daemon: RunningDaemon; diff --git a/ios-qa/daemon/test/fake-device.ts b/ios-qa/daemon/test/fake-device.ts new file mode 100644 index 000000000..99c0407f4 --- /dev/null +++ b/ios-qa/daemon/test/fake-device.ts @@ -0,0 +1,238 @@ +// Simulated iOS device for daemon recovery tests: a `devicectl` emulator plus +// a StateServer stand-in with the real auth state machine (one-shot boot +// token file, scrubbed on /auth/rotate; rotated bearer held in memory; a +// relaunch mints a new boot token and forgets the bearer and all app state). +// It lets tests drive the production bootstrap + recovery wiring end-to-end +// without Xcode or an iPhone. + +import { createServer, type Server } from 'http'; +import type { Socket } from 'net'; +import { writeFileSync } from 'fs'; +import type { SpawnImpl } from '../src/devicectl'; + +export interface FakeDeviceEntry { + identifier: string; + name: string; + productType: string; + platform: string; + deviceType: string; + tunnelState?: string; + transportType?: string; + paired?: boolean; +} + +export interface FakeDevice { + udid: string; + port: number; + /** Tunnel address `devicectl device info details` reports for `udid`. */ + address: string; + /** Entries `devicectl list devices` returns; mutate to simulate re-plugging. */ + devices: FakeDeviceEntry[]; + /** Every devicectl invocation, joined. */ + calls: string[]; + /** Every StateServer request, the bearer it carried, and the device it reached. */ + requests: Array<{ method: string; path: string; authorization?: string; udid: string }>; + running: boolean; + /** In-app QA state; lost whenever the app process is replaced. */ + appState: { generation: number; marker: string | null }; + spawn: SpawnImpl; + /** Black-hole the next `count` requests (CoreDevice route blip: no reply until the proxy times out). */ + dropConnections(count: number): void; + /** Simulate the app crashing or being stopped. */ + stopApp(): void; + /** Simulate Xcode re-running the app: new process, new boot-token file. */ + relaunchExternally(): void; + /** + * Unplug this device and plug in another one that answers at the same + * tunnel address and port (production StateServers all use one port). The + * new device starts with the app not running. + */ + replaceDevice(udid: string, deviceType: 'iPhone' | 'iPad'): void; + close(): Promise; +} + +const BUNDLE_PATH = (bundleId: string) => `/private/var/containers/Bundle/Application/FAKE/${bundleId}.app/`; + +function ret(status: number, stdout = '', stderr = ''): ReturnType { + return { + pid: 0, + output: [null, Buffer.from(stdout), Buffer.from(stderr)], + stdout: Buffer.from(stdout), + stderr: Buffer.from(stderr), + status, + signal: null, + } as ReturnType; +} + +function targetOf(args: string[]): string | undefined { + const flag = args.findIndex((a) => a === '--device' || a === '-d'); + return flag === -1 ? undefined : args[flag + 1]; +} + +function writeJson(args: string[], payload: unknown): void { + const at = args.indexOf('--json-output'); + if (at !== -1 && args[at + 1]) writeFileSync(args[at + 1]!, JSON.stringify(payload)); +} + +function writeListing(args: string[], entries: FakeDeviceEntry[]): void { + writeJson(args, { + result: { + devices: entries.map((d) => ({ + identifier: d.identifier, + connectionProperties: { + tunnelState: d.tunnelState ?? 'connected', + pairingState: d.paired === false ? 'unpaired' : 'paired', + transportType: d.transportType ?? 'wired', + }, + deviceProperties: { name: d.name }, + hardwareProperties: { productType: d.productType, platform: d.platform, deviceType: d.deviceType }, + })), + }, + }); +} + +export async function startFakeDevice(opts: { + bundleId: string; + udid?: string; + deviceType?: 'iPhone' | 'iPad'; +}): Promise { + const entryFor = (udid: string, deviceType: 'iPhone' | 'iPad'): FakeDeviceEntry => ({ + identifier: udid, + name: `Fake ${deviceType}`, + productType: deviceType === 'iPad' ? 'iPad14,5' : 'iPhone15,2', + platform: 'iOS', + deviceType, + }); + let bootToken: string | null = null; + let bootTokenFile: string | null = null; + let rotatedToken: string | null = null; + let dropping = 0; + const sockets = new Set(); + + const device: FakeDevice = { + udid: opts.udid ?? 'FAKE-UDID-1', + port: 0, + address: '::1', + devices: [], + calls: [], + requests: [], + running: false, + appState: { generation: 0, marker: null }, + spawn: undefined as unknown as SpawnImpl, + dropConnections(count) { dropping = count; }, + stopApp() { + device.running = false; + rotatedToken = null; + bootToken = null; + }, + relaunchExternally() { launch(); }, + replaceDevice(udid, deviceType) { + device.stopApp(); + device.udid = udid; + device.devices = [entryFor(udid, deviceType)]; + device.appState = { generation: 0, marker: null }; + }, + close: () => new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + server.close(() => resolve()); + }), + }; + + device.devices = [entryFor(device.udid, opts.deviceType ?? 'iPhone')]; + + const launch = () => { + device.running = true; + device.appState = { generation: device.appState.generation + 1, marker: null }; + bootToken = `boot-${device.appState.generation}-${Math.random().toString(36).slice(2)}`; + bootTokenFile = bootToken; + rotatedToken = null; + }; + + const server: Server = createServer((req, res) => { + if (!device.running) { + req.socket.destroy(); + return; + } + if (dropping > 0) { + dropping--; + return; + } + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const authorization = req.headers.authorization; + device.requests.push({ method: req.method ?? '', path: req.url ?? '', authorization, udid: device.udid }); + const send = (status: number, body: unknown) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + const bearer = authorization?.startsWith('Bearer ') ? authorization.slice(7) : null; + if (req.method === 'GET' && req.url === '/healthz') { + send(200, { version: '1.0.0', bundle_id: opts.bundleId }); + return; + } + if (req.method === 'POST' && req.url === '/auth/rotate') { + if (!bootToken || bearer !== bootToken) { send(401, { error: 'boot_token_invalid' }); return; } + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { new_token: string }; + rotatedToken = parsed.new_token; + bootToken = null; + bootTokenFile = null; + send(200, { ok: true }); + return; + } + if (!rotatedToken || bearer !== rotatedToken) { send(401, { error: 'unauthorized' }); return; } + if (req.url === '/state/snapshot') { send(200, { state: device.appState }); return; } + if (req.url === '/screenshot') { send(200, { png_base64: 'abc=' }); return; } + if (req.method === 'POST' && req.url === '/tap') { + device.appState.marker = 'tapped'; + send(200, { ok: true }); + return; + } + send(404, { error: 'not_found' }); + }); + }); + server.on('connection', (socket: Socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '::1', () => resolve()); + }); + device.port = (server.address() as { port: number }).port; + + + device.spawn = (cmd, args) => { + const joined = `${cmd} ${args.join(' ')}`; + device.calls.push(joined); + if (/devicectl list devices/.test(joined)) { + writeListing(args, device.devices); + return ret(0); + } + const target = targetOf(args); + if (target !== device.udid) return ret(1, '', `device ${target} not found`); + if (/devicectl device info details/.test(joined)) { + writeJson(args, { result: { connectionProperties: { tunnelIPAddress: device.address } } }); + return ret(0); + } + if (/devicectl device info processes/.test(joined)) { + writeJson(args, { + result: { runningProcesses: device.running ? [{ executable: `file://${BUNDLE_PATH(opts.bundleId)}App` }] : [] }, + }); + return ret(0); + } + if (/devicectl device process launch/.test(joined)) { + if (device.running && !args.includes('--terminate-existing')) return ret(0); + launch(); + return ret(0); + } + if (/devicectl device copy from/.test(joined)) { + if (!bootTokenFile) return ret(1, '', 'file not found'); + writeFileSync(args[args.indexOf('--destination') + 1]!, bootTokenFile); + return ret(0); + } + return ret(1, '', `unexpected devicectl call: ${joined}`); + }; + + return device; +} diff --git a/ios-qa/daemon/test/tunnel-bootstrap.test.ts b/ios-qa/daemon/test/tunnel-bootstrap.test.ts index 37af481be..99a3df3ae 100644 --- a/ios-qa/daemon/test/tunnel-bootstrap.test.ts +++ b/ios-qa/daemon/test/tunnel-bootstrap.test.ts @@ -3,7 +3,7 @@ // rotate_failed, success) without needing a real iPhone connected. import { describe, test, expect } from 'bun:test'; -import { bootstrapTunnel } from '../src/tunnel-bootstrap'; +import { bootstrapTunnel, recoverTunnel, selectDevice } from '../src/tunnel-bootstrap'; import { getDeviceTunnelIPv6FromDevicectl, resolveTunnelIPv6, @@ -158,7 +158,7 @@ describe('bootstrapTunnel', () => { if (r.ok) expect(r.tunnel.udid).toBe('IPHONE'); }); - test('fails clearly when only non-iPhone platforms are connected', async () => { + test('fails clearly when only non-iOS platforms are connected', async () => { const spawn = makeSpawn([ { argsMatch: /devicectl list devices/, @@ -175,13 +175,13 @@ describe('bootstrapTunnel', () => { }, }, { - identifier: 'IPAD', + identifier: 'WATCH', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'iPad' }, + deviceProperties: { name: 'Apple Watch' }, hardwareProperties: { - productType: 'iPad16,6', - platform: 'iOS', - deviceType: 'iPad', + productType: 'Watch7,5', + platform: 'watchOS', + deviceType: 'watch', }, }, ] }, @@ -193,11 +193,11 @@ describe('bootstrapTunnel', () => { expect(r.ok).toBe(false); if (!r.ok) { expect(r.error).toBe('device_not_found'); - expect(r.detail).toContain('no iPhone'); + expect(r.detail).toContain('no iPhone or iPad'); } }); - test('rejects an explicitly targeted non-iPhone device', async () => { + test('rejects an explicitly targeted non-iPhone/iPad device', async () => { const spawn = makeSpawn([ { argsMatch: /devicectl list devices/, @@ -220,10 +220,88 @@ describe('bootstrapTunnel', () => { expect(r.ok).toBe(false); if (!r.ok) { expect(r.error).toBe('device_not_found'); - expect(r.detail).toContain('not an iPhone'); + expect(r.detail).toContain('not an iPhone or iPad'); } }); + test('accepts a physical iPad alongside iPhones', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [{ + identifier: 'IPAD-14-5', + connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' }, + deviceProperties: { name: 'Test iPad' }, + hardwareProperties: { + productType: 'iPad14,5', + platform: 'iOS', + deviceType: 'iPad', + }, + }] }, + }, + }, + { + argsMatch: /devicectl device process launch --device IPAD-14-5/, + exitCode: 0, + }, + ]); + + // Fast-fail at resolution stage so we verify device acceptance + const r = await bootstrapTunnel({ + bundleId: 'com.test', + spawnImpl: spawn, + resolveImpl: () => Promise.reject(new Error('no mDNS record')), + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toBe('resolve_failed'); + } + }); + + test('iPhone and iPad both on USB: lists each UDID and a ready export line instead of guessing', async () => { + const wired = { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' }; + const spawn = makeSpawn([{ + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [ + { + identifier: 'IPHONE-UDID', + connectionProperties: wired, + deviceProperties: { name: 'Test iPhone' }, + hardwareProperties: { productType: 'iPhone15,2', platform: 'iOS', deviceType: 'iPhone' }, + }, + { + identifier: 'IPAD-UDID', + connectionProperties: wired, + deviceProperties: { name: 'Test iPad' }, + hardwareProperties: { productType: 'iPad14,5', platform: 'iOS', deviceType: 'iPad' }, + }, + ] }, + }, + }]); + const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn }); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error).toBe('multiple_devices'); + expect(r.detail).toContain('Test iPhone (iPhone): IPHONE-UDID'); + expect(r.detail).toContain('Test iPad (iPad): IPAD-UDID'); + expect(r.detail).toMatch(/^ export GSTACK_IOS_TARGET_UDID=IPHONE-UDID$/m); + }); + + test('selectDevice: explicit UDID picks the iPad; a live session breaks a tie for its own device', () => { + const entry = (identifier: string, deviceType: string) => ({ + identifier, name: identifier, model: `${deviceType}1,1`, platform: 'iOS', deviceType, + state: 'connected', transport: 'wired', paired: true, + }); + const devices = [entry('PHONE', 'iPhone'), entry('PAD', 'iPad')]; + expect(selectDevice(devices, 'PAD')).toMatchObject({ ok: true, device: { identifier: 'PAD' } }); + expect(selectDevice(devices, undefined, 'PAD')).toMatchObject({ ok: true, device: { identifier: 'PAD' } }); + expect(selectDevice(devices)).toMatchObject({ ok: false, error: 'multiple_devices' }); + expect(selectDevice([{ ...entry('PAD', 'iPad'), transport: '' }, entry('PHONE', 'iPhone')])) + .toMatchObject({ ok: true, device: { identifier: 'PHONE' } }); + }); + test('skips an unavailable paired device for a connected or available paired device', async () => { const spawn = makeSpawn([ { @@ -552,6 +630,47 @@ describe('bootstrapTunnel', () => { expect(fetchCalls.at(-1)?.authorization).toBe('Bearer FRESH-BOOT-TOKEN'); }); + test('an app that reports a failed boot-token write gets a clear error, not a relaunch (#1837)', async () => { + const calls: string[] = []; + const scripted = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [{ + identifier: 'TEST-UDID', + connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, + deviceProperties: { name: 'Test Device' }, + hardwareProperties: { productType: 'iPhone18,2' }, + }] }, + }, + }, + { + argsMatch: /devicectl device info processes/, + jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } }, + }, + { + argsMatch: /devicectl device info details/, + jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } }, + }, + { argsMatch: /devicectl device copy from/, exitCode: 1, stderr: 'source does not exist' }, + ]); + const spawn: SpawnImpl = (cmd, args) => { calls.push(args.join(' ')); return scripted(cmd, args); }; + + const r = await bootstrapTunnel({ + bundleId: 'com.test', + spawnImpl: spawn, + fetchImpl: (async () => new Response( + JSON.stringify({ bundle_id: 'com.test', boot_token_error: 'You don’t have permission to save the file.' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )) as unknown as typeof fetch, + startupTimeoutMs: 1_000, + }); + + expect(r).toMatchObject({ ok: false, error: 'boot_token_unavailable' }); + if (!r.ok) expect(r.detail).toContain('could not write tmp/gstack-ios-qa.token: You don’t have permission'); + expect(calls.some((c) => c.includes('--terminate-existing'))).toBe(false); + }); + test('rechecks bundle ownership after recovering a consumed boot token', async () => { const spawn = makeSpawn([ { @@ -675,6 +794,112 @@ describe('bootstrapTunnel', () => { }); }); +describe('recoverTunnel (route drop, #1975)', () => { + const failed = { udid: 'PHONE', ipv6Addr: 'fd00::1', port: 9999, bootTokenRotated: 'session-bearer-XXXXXXXX' }; + const listing = { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [{ + identifier: 'PHONE', + connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' }, + deviceProperties: { name: 'Test iPhone' }, + hardwareProperties: { productType: 'iPhone15,2', platform: 'iOS', deviceType: 'iPhone' }, + }] }, + }, + }; + const details = (addr: string | null) => addr + ? { argsMatch: /devicectl device info details --device PHONE/, jsonOutput: { result: { connectionProperties: { tunnelIPAddress: addr } } } } + : { argsMatch: /devicectl device info details --device PHONE/, exitCode: 1 }; + const processes = (running: boolean) => ({ + argsMatch: /devicectl device info processes -d PHONE/, + jsonOutput: { result: { runningProcesses: running ? [{ executable: 'file:///x/com.test.app/App' }] : [] } }, + }); + + function recordingFetch(reply: (url: string) => Response | Error) { + const seen: Array<{ url: string; authorization?: string }> = []; + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const headers = (init?.headers ?? {}) as Record; + seen.push({ url, authorization: headers['Authorization'] }); + const r = reply(url); + if (r instanceof Error) throw r; + return r; + }) as unknown as typeof fetch; + return { seen, fetchImpl }; + } + const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status }); + + test('keeps the bearer and follows a new address devicectl reports for the pinned UDID', async () => { + const { seen, fetchImpl } = recordingFetch((url) => url.endsWith('/healthz') + ? json(200, { bundle_id: 'com.test.app' }) + : json(200, { state: {} })); + const r = await recoverTunnel(failed, { + bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::2')]), fetchImpl, + }); + expect(r).toEqual({ action: 'reuse', tunnel: { ...failed, ipv6Addr: 'fd00::2' } }); + expect(seen.map((s) => s.url)).toEqual(['http://[fd00::2]:9999/healthz', 'http://[fd00::2]:9999/state/snapshot']); + expect(seen[0]!.authorization).toBeUndefined(); + expect(seen[1]!.authorization).toBe(`Bearer ${failed.bootTokenRotated}`); + }); + + test('an mDNS address devicectl cannot tie to the UDID gets no bearer: bootstrap instead', async () => { + const { seen, fetchImpl } = recordingFetch(() => new Error('must not be called')); + const r = await recoverTunnel(failed, { + bundleId: 'com.test.app', + spawnImpl: makeSpawn([listing, details(null)]), + resolveImpl: async () => ['fd99::7'], + fetchImpl, + }); + expect(r.action).toBe('bootstrap'); + expect(seen).toHaveLength(0); + }); + + test('a StateServer now owned by another bundle gets no bearer', async () => { + const { seen, fetchImpl } = recordingFetch(() => json(200, { bundle_id: 'com.other.app' })); + const r = await recoverTunnel(failed, { + bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::1')]), fetchImpl, + }); + expect(r.action).toBe('bootstrap'); + expect(seen.every((s) => s.authorization === undefined)).toBe(true); + }); + + test('401 from the probe means the app was replaced: bootstrap', async () => { + const { fetchImpl } = recordingFetch((url) => url.endsWith('/healthz') ? json(200, {}) : json(401, { error: 'unauthorized' })); + const r = await recoverTunnel(failed, { + bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::1')]), fetchImpl, + }); + expect(r).toMatchObject({ action: 'bootstrap', reason: expect.stringContaining('rejected the session bearer') }); + }); + + test('route still down: running app keeps the session, absent app bootstraps', async () => { + const down = () => new Error('connect EHOSTUNREACH'); + const running = await recoverTunnel(failed, { + bundleId: 'com.test.app', + spawnImpl: makeSpawn([listing, details('fd00::1'), processes(true)]), + fetchImpl: recordingFetch(down).fetchImpl, + }); + expect(running.action).toBe('unavailable'); + const absent = await recoverTunnel(failed, { + bundleId: 'com.test.app', + spawnImpl: makeSpawn([listing, details('fd00::1'), processes(false)]), + fetchImpl: recordingFetch(down).fetchImpl, + }); + expect(absent).toMatchObject({ action: 'bootstrap', reason: expect.stringContaining('is not running') }); + }); + + test('pinned UDID unplugged: surface the error and send nothing', async () => { + const { seen, fetchImpl } = recordingFetch(() => new Error('must not be called')); + const r = await recoverTunnel(failed, { + udid: 'PHONE', + bundleId: 'com.test.app', + spawnImpl: makeSpawn([{ argsMatch: /devicectl list devices/, jsonOutput: { result: { devices: [] } } }]), + fetchImpl, + }); + expect(r.action).toBe('unavailable'); + expect(seen).toHaveLength(0); + }); +}); + describe('getDeviceTunnelIPv6FromDevicectl', () => { test('extracts tunnelIPAddress from connectionProperties', () => { const spawn = makeSpawn([ diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template index 9696ff372..badad87c5 100644 --- a/ios-qa/templates/StateServer.swift.template +++ b/ios-qa/templates/StateServer.swift.template @@ -6,8 +6,8 @@ // // Threat model: this surface is reachable from the local Mac via the CoreDevice // IPv6 tunnel. It MUST refuse any caller without a current bearer token. The -// boot token is rotated within ~5 seconds of daemon spawn so anything scraping -// os_log past that window sees a dead credential. +// boot token is rotated within ~5 seconds of daemon spawn, and rotation deletes +// its file, so a copy taken past that window is a dead credential. import Foundation import Network @@ -34,9 +34,11 @@ public final class StateServer { private var ipv6Listener: NWListener? private var ipv4Listener: NWListener? - // Auth state. The boot token is what we wrote to os_log on first launch. - // It exists ONLY long enough for the daemon to call /auth/rotate. + // Auth state. The boot token is written to a 0600 file in tmp/ on launch + // (never to os_log). It exists ONLY long enough for the daemon to call + // /auth/rotate. A failed write is kept here and reported on /healthz. private var bootToken: String + private var bootTokenWriteError: String? private var rotatedToken: String? // set after first /auth/rotate private var bootTokenValid: Bool = true @@ -92,17 +94,28 @@ public final class StateServer { } public func start() { - // 1. Persist boot token to a 0600 file (best-effort fallback for the - // daemon if os_log scrape misses). - try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath) + // 1. Persist the boot token to a 0600 file. This is the ONLY way the + // daemon can authenticate (devicectl copies it out of tmp/), so a + // failed write is an error, not a silent skip. + let tokenFile = bootTokenPath + do { + try bootToken.write(toFile: tokenFile, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenFile) + } catch { + bootTokenWriteError = error.localizedDescription + logger.error("Boot token file write failed at \(tokenFile, privacy: .public): \(error.localizedDescription, privacy: .public). No daemon can authenticate until the app relaunches with a writable tmp/.") + } // 2. Announce bootstrap WITHOUT the token. The daemon reads the boot // token from the 0600 file above (copyFileFromAppContainer); the // os_log line that used to carry it had no consumer and handed a // live credential to anything reading the unified log during the // launch window. Port/build stay for diagnostics. - logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") + if let failure = bootTokenWriteError { + logger.error("gstack-ios-qa-bootstrap NOT READY port=\(self.port, privacy: .public): boot token file unwritable (\(failure, privacy: .public))") + } else { + logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") + } // 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6; // local tooling may use IPv4. Never bind 0.0.0.0 or ::. @@ -296,12 +309,14 @@ public final class StateServer { // 1. Public on loopback: /healthz. if request.method == "GET" && path == "/healthz" { - send(connection: connection, status: 200, body: [ + var health: JSONDict = [ "version": "1.0.0", "build": appBuildId, "accessor_hash": accessorHash, "bundle_id": Bundle.main.bundleIdentifier ?? "unknown", - ]) + ] + if let failure = bootTokenWriteError { health["boot_token_error"] = failure } + send(connection: connection, status: 200, body: health) return } diff --git a/package.json b/package.json index 8b401f41a..bb8a622d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.36", + "version": "1.91.38", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift index 9696ff372..badad87c5 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift @@ -6,8 +6,8 @@ // // Threat model: this surface is reachable from the local Mac via the CoreDevice // IPv6 tunnel. It MUST refuse any caller without a current bearer token. The -// boot token is rotated within ~5 seconds of daemon spawn so anything scraping -// os_log past that window sees a dead credential. +// boot token is rotated within ~5 seconds of daemon spawn, and rotation deletes +// its file, so a copy taken past that window is a dead credential. import Foundation import Network @@ -34,9 +34,11 @@ public final class StateServer { private var ipv6Listener: NWListener? private var ipv4Listener: NWListener? - // Auth state. The boot token is what we wrote to os_log on first launch. - // It exists ONLY long enough for the daemon to call /auth/rotate. + // Auth state. The boot token is written to a 0600 file in tmp/ on launch + // (never to os_log). It exists ONLY long enough for the daemon to call + // /auth/rotate. A failed write is kept here and reported on /healthz. private var bootToken: String + private var bootTokenWriteError: String? private var rotatedToken: String? // set after first /auth/rotate private var bootTokenValid: Bool = true @@ -92,17 +94,28 @@ public final class StateServer { } public func start() { - // 1. Persist boot token to a 0600 file (best-effort fallback for the - // daemon if os_log scrape misses). - try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath) + // 1. Persist the boot token to a 0600 file. This is the ONLY way the + // daemon can authenticate (devicectl copies it out of tmp/), so a + // failed write is an error, not a silent skip. + let tokenFile = bootTokenPath + do { + try bootToken.write(toFile: tokenFile, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenFile) + } catch { + bootTokenWriteError = error.localizedDescription + logger.error("Boot token file write failed at \(tokenFile, privacy: .public): \(error.localizedDescription, privacy: .public). No daemon can authenticate until the app relaunches with a writable tmp/.") + } // 2. Announce bootstrap WITHOUT the token. The daemon reads the boot // token from the 0600 file above (copyFileFromAppContainer); the // os_log line that used to carry it had no consumer and handed a // live credential to anything reading the unified log during the // launch window. Port/build stay for diagnostics. - logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") + if let failure = bootTokenWriteError { + logger.error("gstack-ios-qa-bootstrap NOT READY port=\(self.port, privacy: .public): boot token file unwritable (\(failure, privacy: .public))") + } else { + logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") + } // 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6; // local tooling may use IPv4. Never bind 0.0.0.0 or ::. @@ -296,12 +309,14 @@ public final class StateServer { // 1. Public on loopback: /healthz. if request.method == "GET" && path == "/healthz" { - send(connection: connection, status: 200, body: [ + var health: JSONDict = [ "version": "1.0.0", "build": appBuildId, "accessor_hash": accessorHash, "bundle_id": Bundle.main.bundleIdentifier ?? "unknown", - ]) + ] + if let failure = bootTokenWriteError { health["boot_token_error"] = failure } + send(connection: connection, status: 200, body: health) return } diff --git a/test/ios-qa-stateserver-hardening.test.ts b/test/ios-qa-stateserver-hardening.test.ts index 8a816206a..6d4aa5805 100644 --- a/test/ios-qa-stateserver-hardening.test.ts +++ b/test/ios-qa-stateserver-hardening.test.ts @@ -12,6 +12,10 @@ * solely on the per-connection peer check. IPv6 keeps the wildcard bind for * CoreDevice ULA peers by design. * + * #1837: the boot-token file is the daemon's only way in, so writing it must + * not be a silent `try?`. A failure is logged and reported on /healthz (the + * daemon then names the device-side cause instead of relaunching the app). + * * Pinned on BOTH the generated template and the fixture app copy so neither * can drift back independently. */ @@ -39,6 +43,20 @@ describe.each(COPIES)("StateServer hardening — %s", (rel) => { expect(src).not.toContain("gstack-ios-qa-bootstrap token="); }); + test("boot-token write failures are loud, not a silent try? (#1837)", () => { + expect(src).not.toMatch(/try\?\s*bootToken\.write/); + expect(src).not.toMatch(/try\?\s*FileManager\.default\.setAttributes\(\[\.posixPermissions: 0o600\]/); + const start = src.slice(src.indexOf("public func start()"), src.indexOf("public func register(")); + expect(start).toMatch(/do \{\s*try bootToken\.write\(toFile: tokenFile/); + expect(start).toMatch(/\} catch \{\s*bootTokenWriteError = error\.localizedDescription\s*logger\.error\(/); + expect(start).toContain("gstack-ios-qa-bootstrap NOT READY"); + expect(src).toContain('health["boot_token_error"] = failure'); + }); + + test("comments no longer claim os_log carries the boot token", () => { + expect(src).not.toMatch(/os_log scrape|wrote to os_log|scraping\s*\n?\/\/\s*os_log/); + }); + test("IPv4 listener binds loopback at the socket level (B5)", () => { expect(src).toContain("requiredLocalEndpoint"); expect(src).toContain('NWEndpoint.Host("127.0.0.1")');