Detect host-native browser tools before offering the isolated local Chromium fallback, add a common readiness fixture, harden managed browser startup, and verify standards installs expose one canonical QA skill.
10 KiB
Host-neutral runtime bindings
These assignments select stable paths only; they do not install anything or grant consent:
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
GSTACK_ROOT="$GSTACK_HOME"
GSTACK_STATE_ROOT="$GSTACK_HOME"
GSTACK_BIN="$GSTACK_HOME/bin"
BUN_CMD="$GSTACK_BIN/bun"
B="$GSTACK_BIN/browse"
D="$GSTACK_BIN/gstack-design"
P="$GSTACK_BIN/make-pdf"
Live-device iOS QA
This skill drives a real iPhone 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
devicectlfrom Xcode). - iPhone connected via USB, paired and trusted.
- Xcode + Swift toolchain installed (
swift --versionreports >= 5.9). - App source available on disk, with at least one
@Observableclass. - For remote-control mode: Tailscale installed and the user logged in.
Phase 0: Session warm-start (optional)
If "${GSTACK_HOME:-$HOME/.gstack}"/ios-qa-session.json exists and the device is still connected,
skip Phase 1-2 and jump to Phase 3. The session cache holds the rotated token,
UDID, tunnel address, and accessor hash. Invalidate the cache when:
- The user passes
--coldto force a full bootstrap. - The accessor hash mismatch is detected on first state query.
- The daemon reports the cached UDID is no longer connected.
SESSION="${GSTACK_HOME:-$HOME/.gstack}/ios-qa-session.json"
if [ -f "$SESSION" ] && [ "$COLD" != "1" ]; then
CACHED_UDID=$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('$SESSION'))); print(d['udid'])")
CACHED_PORT=$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('$SESSION'))); print(d['daemon_port'])")
if curl -sf "http://127.0.0.1:$CACHED_PORT/healthz" > /dev/null; then
echo "Warm start: daemon alive, device $CACHED_UDID connected"
fi
fi
Phase 1: Read source, plan codegen
- Walk the app source (passed as
--source <dir>) and identify all@Observableclasses. Note any property marked with the@Snapshotablewrapper — those are the snapshot-eligible fields. - Run
swift run --package-path "$($GSTACK_BIN/gstack runtime path ios-qa/scripts/gen-accessors-tool)" gen-accessors --input <source-dir>. First invocation builds the swift-syntax dependency tree (cold: 2-5 min). Subsequent runs are content-hash-cached and finish in ~50ms. - 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
- Add the
DebugBridgeSPM dependency to the app'sPackage.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+_UIHitTestContextSwiftUI hit-testing.DebugBridgeUI(Swift, iOS-only) — Screenshot / Elements / Mutation bridge implementations. The app target depends onDebugBridgeUIwith.when(configuration: .debug)(transitively pulls in Core + Touch). Release builds refuse to link these targets.
- Wire the bridges from the
@mainApp init, gated on#if DEBUG:#if DEBUG import DebugBridgeCore StateServer.shared.start() #if canImport(UIKit) import DebugBridgeUI DebugBridgeUIWiring.installAll() #endif #endif - Build + deploy to the device with
xcodebuild -scheme <SchemeName> -destination 'platform=iOS,id=<UDID>' build install. - Launch via
devicectl device process launch --device <UDID> --console <bundle-id>. Capture the boot token printed toos_logon first run. - Spawn the Mac-side daemon (on-demand) —
gstack-ios-qa-daemon. Daemon acquires an exclusive flock on"${GSTACK_HOME:-$HOME/.gstack}"/ios-qa-daemon.pid. If another daemon is alive, the second invocation discovers its port and connects. - Daemon immediately calls
POST /auth/rotateon the iOS StateServer with a fresh in-memory-only token. The boot token becomes useless ~5s later. Anything scrapingos_logpast this point sees a dead credential.
Phase 3: Vision-driven agent loop
Each iteration:
GET /screenshot(via daemon) → save PNG.GET /elements→ accessibility tree.GET /state/snapshot(only@Snapshotablefields) → current state.- Decide next action based on what's on the screen vs the test goal.
POST /session/acquireto grab the device lock.- Execute
POST /tap,/swipe,/type, orPOST /state/<key>write. - Re-screenshot; compare; record finding if buggy.
POST /session/releaseonce the iteration is done.
Each authenticated mutating request through the tailnet listener (if remote
mode is active) writes an audit row to
"${GSTACK_HOME:-$HOME/.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 references/artifacts/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 $qa --mode Report --module 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 tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
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 |
Cleanup
Use $ship --mode Prepare --module 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.
Upstream judgment port: PR #679
User-language rule
Write questions, progress updates, reports, and artifacts in the language used by the user. Source material, code identifiers, commands, and quotations may remain in their original language when translating them would reduce accuracy.