* fix(redact): block real all-caps URL passwords, not just shape-match urlPasswordIsPlaceholder skipped any password matching /^[A-Z][A-Z0-9_]*$/, so a real DSN like postgres://admin:PROD2026SECRET@db-prod.internal/app slipped the HIGH pre-push block. Replace the shape rule with an anchored, exact-match set of doc-convention placeholder tokens (PASSWORD, PASS, CHANGEME, ...), compared case-sensitively and never as a substring (PROD2026SECRET must not match SECRET). The USER:PASSWORD doc convention still suppresses; real all-caps and lowercase passwords block. Regression cases pinned both directions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): write self-contained .gstack/.gitignore unconditionally ensureStateDir only appended .gstack/ to the project .gitignore when that file already existed, skipped silently on ENOENT, and swallowed other append failures. With BROWSE_PERSIST_STATE=1, session-state.json (live cookies + localStorage/sessionStorage tokens) and browse-network.log / browse-audit.jsonl (request headers) then sat git-add-able under <git-root>/.gstack/. Write a self-contained <stateDir>/.gitignore containing "*" unconditionally, before return, so the state dir's contents can never be committed regardless of the project .gitignore. The project-.gitignore append is kept as redundant safety. The no-import-side-effects guard is relaxed to allow exactly this lone .gitignore guard file (still fails on browse.json / session-state.json / logs / listener binds) — the guard is written eagerly by ensureStateDir at import and is not leaked state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): restore Bun.spawn exited/drain/OOM-cap contract on Node polyfill The v1.65 fork-port squash silently dropped the `exited` promise, eager stdout/stderr drain, and 16MB GSTACK_SPAWN_MAX_BUFFER cap that v1.64 added (#2571), plus the five tests pinning them. On the Windows Node fallback, `await proc.exited` then resolved to undefined immediately — cookie-import, isBrowserRunning, and browser-skill children all read stdout before the child produced it, a silent failure. Re-land the block (keeping v1.65's windowsHide comment improvements) and re-add the pinning tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios-qa): compile the private-API touch bridge out of Release builds PR #2264 claimed DebugBridgeTouch.m (KIF-derived in-process touch synthesis using private UIKit/IOKit symbols: _touchesEvent, IOHIDEventCreateDigitizer*, _AXSSetAutomationEnabled) was "compiled out in Release," but the body was gated only by TARGET_OS_IOS, so a Release iOS build carried the private symbols (App Store rejection risk). The safety half of the fix (closed PR #2269) never landed. Gate the body on `#if TARGET_OS_IOS && DEBUG` and add the cSettings DEBUG define to the DebugBridgeTouch target so `#if DEBUG` is true in debug and false in release (mirrors the Core/UI swiftSettings). A free static tripwire pins both halves; the nm/strings symbol proof needs an iOS-SDK build and belongs in the device/periodic tier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(egress): state truncation/deletion of the ledger are out of scope gstack-egress verify catches in-place edits, reordering, and mid-chain deletion (the hash chain breaks) but not tail-truncation, whole-file re-fabrication, or deletion — a same-user local actor who owns the ledger defeats those and verify still exits 0. That matches the stated threat model (forensic observability, not an exfiltration control). Document it in the header threat model and the usage text rather than adding a count-sidecar, which would false-positive on every legitimate rotation and barely raise the bar. Head-anchoring stays the tracked rotation TODO in lib/egress-receipt.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ship): scope the App Store Connect key to one app and disclose it at exit The release flow minted a non-expiring APP_MANAGER key with allAppsVisible:true (standing authority over every app on the team) and was told never to mention any credential to the user, so the durable key never reached their revocation checklist. Scope the key to the app being released via the apps relationship (allAppsVisible:false + an explicit apps association — required, since a no-app key can see nothing and uploads fail), and disclose the key once in the closing report with its ASC revocation path. Carve the exit disclosure as the explicit exception to the mid-run no-credential-talk rule so the one-authorization-moment contract still holds. Edited the .tmpl source and regenerated the section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(browse): constant-time bearer-token comparison in validateAuth The loopback auth check compared the Authorization header with `===`, whose byte-by-byte early exit leaks the token prefix through response timing. Use crypto.timingSafeEqual with a length gate (the length is not secret). Behavior is unchanged for valid/invalid tokens; auth tests unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin the security-property regression guards from pre-landing review The pre-landing review found the fixes were correct but three regression guards were missing — each pins a property whose silent revert would keep behavior identical while reopening the hole: - validateAuth: a static tripwire asserting crypto.timingSafeEqual + the got.length===want.length gate + the null-header guard (a revert to `===` keeps accept/reject green but restores the timing side-channel). - redact: a table-driven loop over the exported URL_PASSWORD_PLACEHOLDER_WORDS so a typo or dropped entry can't silently start blocking a doc placeholder; plus a substring-can't-rescue-a-real-secret assertion. - config: assert the self-contained .gitignore is written even when git already ignores .gstack/, proving the write precedes the isIgnoredByGit early return. - bun-polyfill: cover the 128+signal exit branch (POSIX only). URL_PASSWORD_PLACEHOLDER_WORDS is exported so the table test can't drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.66.2.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: sync egress-verify scope and layered iOS Release guard into user docs ARCHITECTURE.md and README.md now carry the same gstack-egress verify scope disclosure the CLI ships (edits/reordering/mid-chain deletion detected; tail-truncation and ledger deletion out of scope for a forensic log). docs/howto-ios-testing-with-gstack.md documents the second Release-build guard: DebugBridgeTouch.m compiles out behind #if TARGET_OS_IOS && DEBUG via the cSettings DEBUG define. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(ios-qa): call the DebugBridge targets SwiftPM targets, not Swift targets DebugBridgeTouch is Objective-C (the same sentence says so); "Swift targets" was the wrong word. Cross-model doc review catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changelog): describe the all-caps DSN examples without a scannable URL shape The v1.66.2.0 entry quoted its own headline fix as three literal postgres://user:PASSWORD@host examples — which the branch's stricter HIGH gate now correctly flags, failing CI's quality scan on this very PR (the local pre-push hook passed because the installed gstack still runs the old engine). Rewrite the three mentions: the reproduce command uses a fully-braced shell interpolation (suppressed in the diff scan by design, expands to the real all-caps password at runtime, still exits 3 — verified), and the table row + Fixed bullet name the password token without the URL shape. Gate scan on the amended diff: 0 high. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(evals): pre-seed one-time preamble markers for PTY smokes Root cause of the documented intermittent scope-gate-question-NOT-observed failure (test/skill-e2e-plan-mode-no-op.test.ts, also PR #2593 rounds 3/11): on a fresh runner every one-time preamble marker is missing, so each PTY child runs first-run feature discovery before the behavior under test, and touching .feature-prompted-model-overlay under ~/.claude/skills/gstack/ trips Claude Code's sensitive-file permission prompt — the run stalls on that dialog (classified outcome=asked) and the scope gate never renders. Dev machines never reproduce it because the operator's markers exist. Seed ~/.gstack one-time markers (.activated, .first-loop-tip-shown, .telemetry-prompted, .proactive-prompted, .completeness-intro-seen, .plan-tune-nudge-shown) and both .feature-prompted-* markers (via the gstack root symlink into the checkout) in the PTY-smoke registration step, so no first-run prompt can preempt the assertion under test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-version release as v1.67.1.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: restore main's dependency manifest clobbered by the merge resolution The v1.67.0.0 merge resolved the package.json conflict wholesale --ours, which kept this branch's version stamp but erased main's dependency work (playwright 1.58->1.62 + its patchedDependencies entry, transformers 4.1->4.2, cross-spawn added, puppeteer-core removed — which is also why main dropped the basic-ftp pin test: the pinned package left the tree with it — marked/socks bumps, adm-zip override) while bun.lock auto-merged to main's side. Every CI job that runs `bun install --frozen-lockfile` failed on the mismatch (check-freshness, quality, free-tests, gate, windows x2). Take main's package.json + bun.lock verbatim, re-stamp the version through gstack-version-bump (1.67.1.0). bun.lock is now byte-identical to main's; frozen install verified locally; full free suite green for the branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
15 KiB
How to test iOS apps with GStack iOS
This is the end-to-end walkthrough for the iOS QA capability that ships with gstack: install the canonical Swift templates into your app, connect a real iPhone over USB, and drive it from any agent (Claude Code locally, or any HTTP-capable agent over Tailscale). No simulators, no XCTest harness, no WebDriverAgent.
Everything below has been verified end-to-end on a real iPhone 17 Pro Max running iOS 26.5. The same flow works on any iOS 16+ device.
What you'll need
- macOS with Xcode 16.0+ installed (
xcrun devicectl --versionmust succeed). Xcode 16 ships the CoreDevice tunneldevicectluses 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.
- 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 (
./setupcomplete;gstack-ios-qa-regenandgstack-ios-qa-daemonmust be on PATH). - Bun runtime on PATH (
bun --version). The Mac-side daemon is a bun process.
For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with /var/run/tailscale.sock readable.
Architecture in one breath
┌─────────────────┐ tailnet (opt) ┌──────────────────────┐ USB CoreDevice ┌─────────────────────┐
│ Remote agent │ ─────────────────▶ │ gstack-ios-qa-daemon │ ──────────────────▶ │ iOS app StateServer │
│ (Claude, GPT, │ bearer + session │ (Mac, bun/TS) │ IPv6 ULA tunnel │ (loopback only) │
│ OpenClaw, ...) │ │ │ │ │
└─────────────────┘ └──────────────────────┘ └─────────────────────┘
- iOS app embeds a
StateServer(DebugBridgeSPM library,#if DEBUGonly) listening on::1+127.0.0.1port 9999. Bearer-token gated. Boot token rotates within ~5 seconds of daemon spawn so anything scrapingos_logpast then sees a dead credential. - Mac daemon brokers traffic over the CoreDevice IPv6 tunnel that
xcrun devicectlopens 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.
The iOS StateServer is loopback-only always, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity.
Step 1: Generate the DebugBridge package
Run /ios-qa from the app root, or invoke the same deterministic regenerator directly:
gstack-ios-qa-regen \
--app-source "$PWD/Sources/YourApp" \
--bridge-dir "$PWD/DebugBridge"
The command copies an explicit allowlist of canonical templates into the local
DebugBridge/ Swift package, generates
DebugBridgeGenerated/StateAccessor.swift, and writes the installed version to
DebugBridgeGenerated/.gstack-version. It excludes generated output from its
own schema hash, so rerunning it with unchanged source is a fast, byte-stable
cache hit. It also removes the explicit legacy generated-file set from older
flat harness layouts so stale bridge sources cannot shadow the package.
-
Add
DebugBridge/as a local package dependency. Depend on theDebugBridgeUIproduct only in Debug configuration;DebugBridgeCoreandDebugBridgeTouchcome in transitively. -
Add
DebugBridgeGenerated/StateAccessor.swiftto the app target. -
In your
@mainApp init, install the UIKit resolvers before starting the server, then register the generated accessor. Replace the example state/accessor names with the type the generator found:#if DEBUG import DebugBridgeCore #if canImport(UIKit) import DebugBridgeUI #endif #endif // Inside App.init(), after appState is initialized: #if DEBUG #if canImport(UIKit) DebugBridgeUIWiring.installAll() #endif DebugBridgeManager.shared.start( appState: appState, register: AppStateAccessor.register ) #endif
The three SwiftPM targets split as: DebugBridgeCore is cross-platform (so swift build on a CI Mac host can validate the bulk of the code without UIKit), DebugBridgeUI and DebugBridgeTouch are iOS-only (they link UIKit). DebugBridgeTouch is Objective-C — it carries the KIF-derived UITouch synthesis with the iOS 18+ _UIHitTestContext fix that makes SwiftUI Button taps actually fire.
The structural Release-build guard is layered. The .when(configuration: .debug) clause in Package.swift means SwiftPM refuses to link any DebugBridge* target in a Release build, so the bridge cannot ship to TestFlight even if you forget to clean up. On top of that, DebugBridgeTouch.m's private-API touch synthesis is compiled behind #if TARGET_OS_IOS && DEBUG (with DEBUG defined for that target only in the debug configuration via cSettings in Package.swift), so a Release compile of the touch bridge emits an empty translation unit — zero private symbols in the binary even if the linker guard were bypassed.
Step 2: Build + install to the device
From the app's project directory:
xcodebuild \
-scheme YourAppScheme \
-configuration Debug \
-destination 'generic/platform=iOS' \
-derivedDataPath /tmp/build \
-allowProvisioningUpdates -allowProvisioningDeviceRegistration \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM=YOUR_TEAM_ID \
build
Then install + launch:
UDID=$(xcrun devicectl list devices 2>/dev/null | awk 'NR>2 && $0!="" {print $(NF-2); exit}')
xcrun devicectl device install app --device "$UDID" /tmp/build/Build/Products/Debug-iphoneos/YourApp.app
xcrun devicectl device process launch --device "$UDID" --terminate-existing your.bundle.id
If the phone is locked you'll get FBSOpenApplicationServiceErrorDomain error 1 — Locked. Unlock and retry. First-time installs surface a Trust dialog on the phone; tap Trust, then re-run.
Step 3: Start the Mac-side daemon
Two options.
Option A — let the skill spawn it. Run /ios-qa in Claude Code from anywhere; the skill spawns the daemon on demand, bootstraps the tunnel, rotates the boot token, and exposes the device through the proxy. Cleanest path for local-USB use.
Option B — start it yourself. Run:
gstack-ios-qa-daemon
The daemon prints READY: port=<n> pid=<pid> once both loopback listeners are bound. The default port is 9099. Spawners can read that line with a ~5 second timeout to confirm readiness; you can also point curl at the printed port.
Either way the daemon takes an exclusive flock on ~/.gstack/ios-qa-daemon.pid — running it twice from two Claude Code sessions is safe; the second invocation discovers the running daemon's port and joins.
Set these env vars to target a specific device or bundle:
GSTACK_IOS_TARGET_UDID=248C3A58-B843-5BDB-8F5D-89ADB7D7BF6A
GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
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.
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,
and rotates normally.
Step 4: Drive the device
Once the daemon is running, you have an HTTP surface at http://127.0.0.1:9099 (or [::1]:9099). The skill flow does this for you, but the raw endpoints are:
| Endpoint | What it does | Auth |
|---|---|---|
GET /healthz |
Version probe. | none (loopback) |
POST /auth/rotate |
Daemon-only; rotates the boot token to an in-memory-only value. | boot token |
POST /session/acquire |
Acquire the per-device session lock. Returns {session_id, ttl_seconds}. |
bearer |
POST /session/release |
Release the lock. | bearer + session |
GET /screenshot |
Capture a PNG of the active window. Returns {png_base64: "..."}. |
bearer |
GET /elements |
Accessibility-tree snapshot. | bearer |
GET /state/snapshot |
Dump every // @Snapshotable field as JSON. |
bearer |
POST /state/restore |
Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
POST /tap {x,y} |
Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier |
POST /swipe {from_x,from_y,to_x,to_y} |
Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier |
POST /type {text} |
Set text on the current first responder. | bearer + session, interact tier |
Mutating requests require both an Authorization: Bearer <token> header AND an X-Session-Id header. Read endpoints (/screenshot, /elements, GET /state/*) only need the bearer.
The state snapshot is opt-in per field via a standalone generator marker comment immediately above a property. It is intentionally not a property wrapper, so it compiles cleanly with Observation:
@Observable
final class AppState {
// @Snapshotable
var username: String = ""
var authToken: String = "" // never exported
}
Unmarked fields never appear in the snapshot, which keeps tokens, PII, and
auth state out of recorded fixtures by default. A marked field must be a
writable instance var on a file-scope observable class, with an explicit type
and an internal or public setter. Supported snapshot types are JSON-native
scalars (String, Bool, signed/unsigned integer widths, Float, Double,
CGFloat), arrays, String-keyed dictionaries, and Optional compositions of
those types. Snapshot keys must be unique across observable classes. The
generator reports and stops on invalid declarations, custom values, implicitly
unwrapped Optionals, nested observable classes, or duplicate keys instead of
emitting broken or lossy Swift. Restore uses two phases: every model validates
the complete input first, and only then are assignments applied on MainActor.
Step 5: Make remote agents work (optional)
To let an agent on another machine drive the device, run the daemon with --tailnet:
gstack-ios-qa-daemon --tailnet
The daemon probes /var/run/tailscale.sock first; if the socket is missing or unreadable, it refuses to open the tailnet listener at all (loopback still runs). Remote mode never half-starts.
Then mint a session token for the identity that should be able to connect:
gstack-ios-qa-mint grant --remote 'alice@example.com' --capability interact
gstack-ios-qa-mint grant --remote 'tag:ci' --capability mutate --ttl 86400 --note 'nightly'
gstack-ios-qa-mint list
Capability tiers are nested: observe (read endpoints only) ⊂ interact (taps, swipes, type) ⊂ mutate (POST /state/*) ⊂ restore (POST /state/restore). Pick the smallest tier that does the job. The allowlist file is at ~/.gstack/ios-qa-allowlist.json (mode 0600) — the daemon reads it on every /auth/mint request, so changes take effect immediately without restarting.
The remote agent then hits POST /auth/mint against the daemon's tailnet listener. The daemon canonicalizes the caller's identity via tailscaled's WhoIs endpoint, checks the allowlist, and returns a short-lived session token (1 hour default, 24 hour cap). Every authenticated mutating request lands in ~/.gstack/security/ios-qa-audit.jsonl; rejected requests land in ~/.gstack/security/attempts.jsonl.
Step 6: Ship a release build
Before you ship to TestFlight or the App Store, run /ios-clean. It removes the DebugBridge SPM dependency and strips the #if DEBUG wiring from your @main App. The structural guard in Package.swift (condition: .when(configuration: .debug)) means a Release build wouldn't link the bridge even if you forgot to clean up, but /ios-clean gives you a tidy diff to review and ship.
Common failures
| Symptom | What broke |
|---|---|
xcodebuild fails with Could not locate device support files for iOS X.Y |
Run xcodebuild -downloadPlatform iOS to fetch the device support package for your iPhone's iOS version (~8GB). |
Install succeeds, process launch fails with Locked |
The phone is locked. Unlock and retry. |
| 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. |
/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. |
SwiftUI Button tap returns ok:true but the action never fires |
You're on iOS 17 or older where _UIHitTestContext doesn't exist. The DebugBridgeTouch implementation falls back to plain hitTest: which doesn't resolve into SwiftUI's gesture container. Update to iOS 18+ on the device, or tap a UIKit control instead. |
What this gets you
You can write an agent loop in any language that speaks HTTP. Take a screenshot, ask a model what to do, send a tap. Capture state snapshots before and after to record deterministic fixtures for /ios-fix regression tests. Add a colleague to the allowlist and they drive your iPhone from their laptop over Tailscale without ever touching the hardware. Plug the same daemon into CI by minting a tag:ci session token with mutate-tier capability and a 24-hour TTL.
The whole stack is a Mac you already own, an iPhone you already own, a free Apple developer account, and gstack. No paid testing service. No simulator drift. The thing the user sees is what the agent drives.