Files
gstack/ios-qa/SKILL.md.tmpl
Garry Tanandcapy-ai[bot] d875313071 v1.91.171.0 fix(ios-qa): a restarted daemon resumes the device session instead of relaunching the app (#3132)
* fix(ios-qa): a restarted daemon resumes the persisted session bearer instead of relaunching the app

The boot token is one-use, so a new daemon process could only get back in by
relaunching the app with --terminate-existing, wiping its QA state (#1796).
The daemon now persists the rotated bearer to
$GSTACK_STATE_ROOT/ios-qa-session.json (0600, fresh temp file + rename),
bound to UDID, bundle ID and port, and resumes it once per process through
recoverTunnel's new pinned-address mode: devicectl-reported address only,
/healthz bundle check first. Mismatched binding, a foreign device or app, no
devicectl address, or a non-private file bootstraps without sending the
bearer. The file is cleared before every bootstrap, so a rejected bearer is
never replayed. /ios-qa Phase 0 now describes the real file.

Diagnosis and reuse scenarios from #1796 by @Bmathews721.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: take draft #3132 off the version slot (VERSION back to main's 1.91.84.0, CHANGELOG [Unreleased])

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.171.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-20 20:23:27 +00:00

302 lines
15 KiB
Cheetah

---
name: ios-qa
preamble-tier: 3
version: 1.0.0
description: |
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", "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
- Read
- Write
- Edit
- Grep
- Glob
- AskUserQuestion
triggers:
- ios qa
- test the iphone app
- test the ipad app
- test my ios app
- find bugs on the device
- qa the ios app
---
{{PREAMBLE}}
# Live-device iOS QA
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.
## Architecture
```
┌──────────────────────┐ USB CoreDevice (IPv6) ┌──────────────────┐
│ gstack-ios-qa daemon │ ────────────────────────▶ │ iOS app │
│ (Mac, bun/TS) │ bearer + X-Session-Id │ StateServer │
│ │ │ (loopback only) │
│ - boot token rotate │ │ - /tap /swipe │
│ - session minting │ │ - /type /state │
│ - audit + redact │ │ - /snapshot │
└──────────────────────┘ └──────────────────┘
▲
│ Tailscale (optional, --tailnet)
│
┌──────────────────────┐
│ Remote agent │
│ (OpenClaw, etc.) │
└──────────────────────┘
```
The iOS app's `StateServer` binds loopback only (`::1` + `127.0.0.1`). Tailnet
ingress is exclusively the Mac daemon's job. The daemon validates Tailscale
identities via the local `tailscaled` socket and mints short-lived session
tokens (default 1h) for remote agents.
## Prerequisites
- macOS (the daemon uses `devicectl` from Xcode).
- 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.
## Phase 0: Session warm-start (optional)
After each successful bootstrap the daemon writes the rotated StateServer
bearer to `$GSTACK_STATE_ROOT/ios-qa-session.json` (mode 0600), bound to the
device UDID, bundle ID and StateServer port. A restarted daemon resumes that
bearer on its first device request, so the app keeps its QA state instead of
being relaunched. It bootstraps normally instead when the app was relaunched
(the app rejects the bearer), another device or app answers, devicectl reports
no tunnel address for the bound UDID, or other users can read the file.
If the file exists and the daemon answers, the bridge is already installed:
skip Phase 1-2 and jump to Phase 3. `--cold` deletes the file so the next
daemon start runs a full bootstrap (the app relaunches once); a daemon that is
already running keeps its in-memory session.
```bash
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
SESSION="$GSTACK_STATE_ROOT/ios-qa-session.json"
PORT="${GSTACK_IOS_DAEMON_PORT:-9099}"
if [ "$COLD" = "1" ]; then
rm -f "$SESSION"
elif [ -f "$SESSION" ] && curl -sf "http://127.0.0.1:$PORT/healthz" > /dev/null; then
echo "Warm start: daemon alive on port $PORT with a persisted device session"
fi
```
## Phase 1: Read source, plan codegen
1. Before changing the app or replacing an installed build, verify that the
bridge is compatible with the project:
- The generator currently supports file-scope `@Observable` classes only;
`ObservableObject`, `@StateObject`, and other observation models do not
produce accessors.
- The documented dependency wiring assumes a SwiftPM app manifest. For an
`.xcodeproj` or `.xcworkspace`, do not invent package or target wiring.
If either requirement is unmet, stop the bridge bootstrap without modifying
the app. Preserve any installed production or TestFlight build. Prefer an
existing real-device XCUITest harness; when a separate QA build is needed,
use an isolated bundle identifier and non-production entitlements so it can
coexist with the production app. Report fixture-driven state, provider UI,
and actual external-provider success as distinct evidence tiers.
2. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
classes. Note any property immediately preceded by the generator marker
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
marker is a comment so it composes with the `@Observable` macro. Each
marked field must belong to a file-scope observable class and be a writable
instance `var` with an explicit type and an internal or public setter.
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
Optional compositions. Keys must be unique across observable classes.
Codegen stops with a source diagnostic instead of emitting a broken or
lossy harness when any of these constraints is violated.
3. Show the user the accessor list and ask whether to install the DebugBridge
SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge
1. Generate the canonical local bridge package, typed accessors, and installed
version marker with one deterministic command:
```bash
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
--app-source "<source-dir>" \
--bridge-dir "<source-dir>/DebugBridge"
```
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:
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
synthesis with iOS 18+ `_UIHitTestContext` SwiftUI hit-testing.
- `DebugBridgeUI` (Swift, iOS-only) — Screenshot / Elements / Mutation
bridge implementations.
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
```swift
#if DEBUG
import DebugBridgeCore
#if canImport(UIKit)
import DebugBridgeUI
// Install resolvers before StateServer opens its listener.
DebugBridgeUIWiring.installAll()
#endif
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
DebugBridgeManager.shared.start(
appState: appState,
register: AppStateAccessor.register
)
#endif
```
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> <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. 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.
## Phase 3: Vision-driven agent loop
Each iteration:
1. `GET /screenshot` (via daemon) → save PNG.
2. `GET /elements` → accessibility tree.
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
4. Decide next action based on what's on the screen vs the test goal.
5. `POST /session/acquire` to grab the device lock.
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
7. Re-screenshot; compare; record finding if buggy.
8. `POST /session/release` once the iteration is done.
Each authenticated mutating request through the tailnet listener (if remote
mode is active) writes an audit row to
`~/.gstack/security/ios-qa-audit.jsonl`.
## Modes
**Local-USB mode (default).** Daemon binds loopback only; no Tailscale
required. The spawning skill gets full-surface access. Best for solo
development.
**Tailnet mode (`--tailnet`).** Daemon additionally binds the Tailscale
interface (never `0.0.0.0`). Requires `tailscaled` to be running locally and
the daemon to be able to read `/var/run/tailscale.sock`. Fails closed if the
socket is missing, permission-denied, or returns an unparseable WhoIs
response. Remote agents hit `POST /auth/mint` over tailnet, daemon
canonicalizes identity via WhoIs, checks the allowlist file, mints a
session token. See `ios-qa/docs/tailscale-acl-example.md`.
**Capability tiers (tailnet mode).** Minted tokens default to `interact`
(taps, swipes, types). Higher tiers require explicit owner mint:
- **observe:** `/screenshot`, `/elements`, `GET /state/*`, `/healthz`,
`/session/heartbeat`.
- **interact:** observe + `/tap`, `/swipe`, `/type`.
- **mutate:** interact + `POST /state/<key>`.
- **restore:** mutate + `POST /state/restore`.
Owner mints via `gstack-ios-qa-mint --remote <identity> --capability <tier>`
on the Mac. Self-service mint over tailnet only succeeds for already-allowlisted
identities.
**Recording mode (`--recording`).** DebugOverlay renders a small diagonal
"AGENT DEMO" watermark in a corner so screencasts are unambiguous about the
device being agent-driven.
## Demo mode
If the user says "demo", "demo mode", "show me", or "I want to see it
working", run in **DEMO MODE**. This changes how the agent interacts with
the app:
**DEMO MODE OVERRIDES ALL OTHER RULES.** When demo mode is active, the
agent MUST drive every action through visible UI (`/tap`, `/swipe`, `/type`)
and NEVER use `POST /state/*` writes to skip steps. Viewers see the agent
type every key, tap every button. The on-device DebugOverlay attribution
chip shows "Driven by Claude Code (demo)" or the remote agent identity.
In demo mode, the screencap rate is bumped to 4fps so the recording feels
live.
## Failure modes + recovery
| Symptom | Likely cause | Action |
|---|---|---|
| `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` / `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`
wiring before a Release build. This is a convenience flow; the structural
Release-build guard (Package.swift `.when(configuration: .debug)` + CI
`swift build -c release` check) is the safety-critical path.