mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
v1.91.38.0 fix(ios-qa): iPads work, a dropped route no longer relaunches the app, loud boot-token write (#3076)
* fix(ios-qa): allow physical iPad devices in tunnel bootstrap (#2743)
(cherry picked from commit d6d43934ef)
* fix(ios-qa): iPhone + iPad on USB names each UDID instead of picking one
With GSTACK_IOS_TARGET_UDID unset, tied default ranks used to pick the first
device devicectl listed, so a QA session could drive the wrong device. Tied
candidates now fail with multiple_devices, list each device and UDID, and
print a ready export line. Device selection moves into selectDevice() so the
route-drop recovery can re-resolve with the same rules.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ios-qa): a dropped CoreDevice route no longer relaunches the app (#1975)
Rotation deletes the app's one-shot boot-token file, so the daemon's old
503/504 path (invalidate + full bootstrap) could only get a new token by
running devicectl launch --terminate-existing, wiping the in-app QA state at
every route blip.
A route failure now goes through recoverTunnel(): re-select the device with
the bootstrap rules, re-resolve the tunnel address for the pinned UDID, check
the unauthenticated /healthz owner, then probe /state/snapshot with the cached
bearer. Outcomes:
- bearer accepted: keep the session (new address if it moved)
- 401: full bootstrap from the fresh boot token (app was relaunched)
- app confirmed absent: bootstrap, which launches it normally once
- different device or an address devicectl cannot tie to the UDID:
bootstrap; the old bearer is never sent there
- route still down with the app running: surface the error, keep the session
Concurrent failures on one tunnel share a single refresh. Mutations whose
response was lost are still never replayed. The CLI wiring moves into
deviceTunnelSource() so tests drive it against a simulated device.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ios-qa): a failed boot-token write is loud instead of silent (#1837)
StateServer.start() wrote the boot-token file with try?, so a failed write
left nothing on disk and no trace anywhere, and the daemon then relaunched the
app trying to mint a token that could never be written. The write is now a
do/catch that logs the path and error (never the token), logs a NOT READY
bootstrap line, and reports boot_token_error on /healthz. bootstrapTunnel
reads that field and returns boot_token_unavailable naming the device-side
cause instead of running --terminate-existing.
Also drops the stale comments claiming the token was written to os_log.
Template and fixture stay byte-identical.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ios-qa): type the iPad acceptance test's mDNS stub
ResolveImpl returns Promise<string[]>; resolving null tripped the test-code
typecheck ratchet. A rejected lookup exercises the same resolve_failed path.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(ios-qa): iPad support, GSTACK_IOS_TARGET_UDID, route-drop recovery, known limits
- iPhone-only description, triggers, and prerequisites now cover iPads, and
say how to pick one device with GSTACK_IOS_TARGET_UDID.
- Phase 2 no longer tells you to capture a boot token from os_log (it has
not been logged for many releases); it describes the 0600 tmp/ file and
the NOT READY signal (#1837, #1735).
- Source-control guidance for the generated DebugBridge/ package (#1735).
- Failure table: route drops keep the session, multiple_devices, unwritable
token file, and the one relaunch a daemon restart still costs.
- Known limits: SwiftUI DragGesture and /swipe on iOS 26 (#1975), the
iOS 26.3.1 /elements data from PR #1755, unverified iPad Stage Manager.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: iOS QA how-to and skill index cover iPads and route-drop recovery
The how-to still said the boot token was readable from os_log and that a
route drop re-bootstraps; both are now accurate, with iPad selection,
GSTACK_IOS_TARGET_UDID, and three new failure rows.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.37.0 fix(ios-qa): iPads work, a route drop no longer relaunches the app, loud boot-token write
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(ios-qa): keep issue numbers out of model-read skill text
The archaeology lint rejects issue references in generated SKILL.md text;
the Known limits entry keeps the iOS version and drops the number.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: re-slot to v1.91.38.0 (v1.91.37.0 claimed by #3075)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ios-qa): device-change recovery test without an IPv4-mapped address
The Windows lane could not reach a fake StateServer through
::ffff:127.0.0.1, so the iPad half of the device-change test timed out. The
fake device now swaps identity on its one ::1 listener, which is also closer
to production: every StateServer answers on the same port.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: loulanyue <260355617@qq.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
18 files changed
+1186
-173
No files matched your search
@@ -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=<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.**
|
||||
|
||||
@@ -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 <lane> -- <cmd>` 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 <n>`, `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. |
|
||||
|
||||
|
||||
@@ -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/<path>` 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=<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=<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 '<DeviceName>.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 <identity> --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. |
|
||||
|
||||
+1
-1
@@ -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. |
|
||||
|
||||
+49
-12
@@ -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=<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 <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
Capture the boot token printed to `os_log` on first run.
|
||||
5. Launch via `devicectl device process launch --device <UDID> <bundle-id>`.
|
||||
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 <identity>` 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=<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`
|
||||
|
||||
+49
-11
@@ -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=<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 <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
Capture the boot token printed to `os_log` on first run.
|
||||
5. Launch via `devicectl device process launch --device <UDID> <bundle-id>`.
|
||||
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 <identity>` 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=<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`
|
||||
|
||||
+122
-43
@@ -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<DeviceTunnel | null>;
|
||||
/** Route-drop recovery (see recoverTunnel). Without it a route drop re-bootstraps. */
|
||||
tunnelRecovery?: (failed: DeviceTunnel) => Promise<TunnelRecovery>;
|
||||
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<RunningDaemon |
|
||||
if (tunnel === failedTunnel) tunnel = null;
|
||||
};
|
||||
|
||||
// Every request that failed on the same cached tunnel shares one refresh,
|
||||
// so concurrent failures cause one probe or one bootstrap, never several.
|
||||
let refreshInFlight: { failed: DeviceTunnel; promise: Promise<DeviceTunnel | null> } | null = null;
|
||||
const refreshTunnel = (failedTunnel: DeviceTunnel, cause: RefreshCause): Promise<DeviceTunnel | null> => {
|
||||
if (tunnel && tunnel !== failedTunnel) return Promise.resolve(tunnel);
|
||||
if (refreshInFlight?.failed === failedTunnel) return refreshInFlight.promise;
|
||||
if (!tunnel && tunnelInFlight) return tunnelInFlight;
|
||||
|
||||
const promise: Promise<DeviceTunnel | null> = (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<RunningDaemon |
|
||||
|
||||
// 3. Loopback listener (full surface).
|
||||
const loopbackServer = createServer(async (req, res) => {
|
||||
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<RunningDaemon |
|
||||
// mode this can collide; we try the actualPort first and skip ipv6 if it
|
||||
// fails (tests don't exercise ::1 explicitly).
|
||||
const loopbackServerV6 = createServer(async (req, res) => {
|
||||
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<RunningDaemon |
|
||||
tokenStore,
|
||||
getTunnel,
|
||||
invalidateTunnel,
|
||||
refreshTunnel,
|
||||
proxyTimeoutMs: opts.proxyTimeoutMs,
|
||||
auditPath: opts.auditPath,
|
||||
attemptsPath: opts.attemptsPath,
|
||||
@@ -205,6 +237,7 @@ interface HandlerCtx {
|
||||
tokenStore: SessionTokenStore;
|
||||
getTunnel: () => Promise<DeviceTunnel | null>;
|
||||
invalidateTunnel: (failedTunnel: DeviceTunnel) => void;
|
||||
refreshTunnel: (failedTunnel: DeviceTunnel, cause: RefreshCause) => Promise<DeviceTunnel | null>;
|
||||
proxyTimeoutMs?: number;
|
||||
// Explicit security-log + allowlist paths (default to env-derived when undefined).
|
||||
auditPath?: string;
|
||||
@@ -213,6 +246,7 @@ interface HandlerCtx {
|
||||
}
|
||||
|
||||
type DeviceProxyResponse = Awaited<ReturnType<typeof proxyToDevice>>;
|
||||
type RefreshCause = 'unauthorized' | 'route_lost';
|
||||
const RECOVERABLE_SOCKET_ERRORS = new Set([
|
||||
'ECONNABORTED',
|
||||
'ECONNREFUSED',
|
||||
@@ -249,8 +283,9 @@ async function proxyAttempt(opts: Parameters<typeof proxyToDevice>[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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
agentIdentity: session.identity,
|
||||
getTunnel,
|
||||
invalidateTunnel,
|
||||
refreshTunnel,
|
||||
timeoutMs: ctx.proxyTimeoutMs,
|
||||
});
|
||||
if (!proxied) {
|
||||
@@ -565,6 +603,66 @@ async function handleTailnet(ctx: TailnetCtx): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DeviceTunnel | null>;
|
||||
tunnelRecovery: (failed: DeviceTunnel) => Promise<TunnelRecovery>;
|
||||
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`);
|
||||
|
||||
@@ -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<DeviceEntry, 'state' | 'transport'>): boolean {
|
||||
@@ -87,7 +100,7 @@ function isAvailableDevice(device: Pick<DeviceEntry, 'state' | 'transport'>): 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<Bootstrap
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
|
||||
// Step 1: pick a device
|
||||
const devices = listDevices(spawn);
|
||||
if (devices.length === 0) {
|
||||
return { ok: false, error: 'no_devices' };
|
||||
}
|
||||
const target = opts.udid
|
||||
? devices.find((d) => 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<Bootstrap
|
||||
|
||||
// Step 4: wait for StateServer to become reachable, then scrape boot token.
|
||||
// Probe /healthz with retries (the listener can take a moment to bind).
|
||||
let bootTokenWriteError: string | undefined;
|
||||
const waitForStateServer = async (): Promise<BootstrapResult | null> => {
|
||||
const deadline = Date.now() + startupTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -242,7 +269,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
if (r.ok) {
|
||||
const health = await r.json().catch(() => 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<Bootstrap
|
||||
detail: `expected ${opts.bundleId} but StateServer port ${port} belongs to ${health.bundle_id}; terminate the other debug app`,
|
||||
};
|
||||
}
|
||||
bootTokenWriteError = health?.boot_token_error;
|
||||
return null;
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
@@ -276,6 +304,15 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
});
|
||||
|
||||
let bootToken = readBootToken();
|
||||
if (!bootToken && bootTokenWriteError) {
|
||||
// The app reported that it could not write the file. A relaunch would
|
||||
// only fail the same way and wipe the app's state, so stop here.
|
||||
return {
|
||||
ok: false,
|
||||
error: 'boot_token_unavailable',
|
||||
detail: `${opts.bundleId} could not write ${tokenPath}: ${bootTokenWriteError}; fix the app's tmp/ directory, then relaunch the app`,
|
||||
};
|
||||
}
|
||||
if (!bootToken) {
|
||||
// A healthy running app can lack a boot token when an earlier daemon
|
||||
// already rotated it. A new daemon has no way to recover that in-memory
|
||||
@@ -336,3 +373,87 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type TunnelRecovery =
|
||||
/** The app still holds the bearer; keep the session at this (maybe new) address. */
|
||||
| { action: 'reuse'; tunnel: DeviceTunnel }
|
||||
/** Full bootstrap needed. The old bearer was not sent to anything unproven. */
|
||||
| { action: 'bootstrap'; reason: string }
|
||||
/** Route still down with the app running: surface the error, keep the session. */
|
||||
| { action: 'unavailable'; reason: string };
|
||||
|
||||
export interface RecoveryOptions {
|
||||
/** Same explicit UDID the bootstrap used, if any. */
|
||||
udid?: string;
|
||||
bundleId: string;
|
||||
probeTimeoutMs?: number;
|
||||
spawnImpl?: SpawnImpl;
|
||||
resolveImpl?: ResolveImpl;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how to recover a cached tunnel after a route failure (503
|
||||
* device_disconnected / 504 upstream_timeout) without relaunching the app.
|
||||
* CoreDevice routes blip while the app keeps running with the rotated bearer
|
||||
* in memory, and the one-shot boot-token file is already gone, so a full
|
||||
* bootstrap would have to relaunch the app and wipe its QA state (#1975).
|
||||
*
|
||||
* The bearer is only ever sent to the address `devicectl` reports for the
|
||||
* pinned UDID, or to the address this session already used. Only a 401 (the
|
||||
* app was replaced) or a confirmed app-absent state asks for a bootstrap.
|
||||
*/
|
||||
export async function recoverTunnel(failed: DeviceTunnel, opts: RecoveryOptions): Promise<TunnelRecovery> {
|
||||
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 } };
|
||||
}
|
||||
@@ -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<void>> = [];
|
||||
|
||||
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<ReturnType<typeof startStubStateServer>>;
|
||||
let daemon: RunningDaemon;
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const BUNDLE_PATH = (bundleId: string) => `/private/var/containers/Bundle/Application/FAKE/${bundleId}.app/`;
|
||||
|
||||
function ret(status: number, stdout = '', stderr = ''): ReturnType<SpawnImpl> {
|
||||
return {
|
||||
pid: 0,
|
||||
output: [null, Buffer.from(stdout), Buffer.from(stderr)],
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
status,
|
||||
signal: null,
|
||||
} as ReturnType<SpawnImpl>;
|
||||
}
|
||||
|
||||
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<FakeDevice> {
|
||||
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<Socket>();
|
||||
|
||||
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<void>((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<void>((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;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+26
-11
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")');
|
||||
|
||||
Reference in new issue
Block a user