Skip to content

feat: integrate opt-in SOCKS5 tunnel into WebDriverAgent - #29

Merged
Timo972 merged 25 commits into
masterfrom
timo/dro-2802-integrate-vpn-into-mobilerun-wda
Aug 27, 2026
Merged

feat: integrate opt-in SOCKS5 tunnel into WebDriverAgent#29
Timo972 merged 25 commits into
masterfrom
timo/dro-2802-integrate-vpn-into-mobilerun-wda

Conversation

@Timo972

@Timo972 Timo972 commented Aug 27, 2026

Copy link
Copy Markdown

Summary

  • add an opt-in WebDriverAgentTunnel packet-tunnel extension backed by vendored hev-socks5-tunnel
  • expose /mobilerun/socks5/connect, /disconnect, and /stats with credential redaction and strict URI/timeout validation
  • harden lifecycle deadlines, preference-save fencing, stale-save retry, startup/stop races, DNS and multi-address failover, and controller-route preservation
  • preserve scoped IPv6 proxy addresses through normalization, probing, engine configuration, and route exclusion
  • reject SOCKS5 credentials that cannot be represented safely in the generated YAML configuration
  • terminate the extension process if an abandoned process-global HEV quit request cannot finish before restart
  • clean tunnel-only appex and NetworkExtension entitlements when default schemes reuse tunnel DerivedData
  • support neutral signing defaults and Appium custom WDA bundle identifiers for the runner and nested extension
  • make packaged-source builds deterministic and serialize shared tunnel-engine builds

Validation

  • iOS UnitTests: 237 executed, 1 skipped, 0 failures
  • focused SOCKS5 suites: 39 executed, 0 failures
  • signed and unsigned shared-DerivedData tunnel-to-default builds remove the stale tunnel appex
  • signed cleanup verification removes the NetworkExtension entitlement and leaves a valid Runner.app signature
  • WebDriverAgentRunnerTunnel generic iOS device build with CODE_SIGNING_ALLOWED=NO
  • WebDriverAgentRunnerTunnel generic iOS simulator build
  • WebDriverAgentRunnerTunnel generic iOS build with WDA_PRODUCT_BUNDLE_IDENTIFIER=io.appium.customwda
  • WebDriverAgentLib_watchOS generic watchOS build
  • npm run build
  • npm test — 53 passing
  • npm run lint
  • shell syntax, Xcode scheme/project parsing, conflict-marker scan, and git diff --check

Validation limits

  • no signed physical-device VPN consent/install test was run; device builds used CODE_SIGNING_ALLOWED=NO
  • tvOS was not rebuilt for the latest follow-up because the local Xcode installation does not include the tvOS 26.4 platform

Timo972 and others added 25 commits June 12, 2026 14:42
…mework build script

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ev YAML builder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…action

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	WebDriverAgent.xcodeproj/project.pbxproj
Every NetworkExtension call from these handlers failed after exactly
FBSocks5PreferencesTimeout (10s) with "Cannot load the VPN preferences:
timed out", on both free and paid teams.

Without .onControlQueue, FBWebServer serves a route via
dispatch_sync(automationQueue) -> dispatch_sync(main), so the handler body
runs on the main thread inside a dispatch_sync'd main-queue block.
NETunnelProviderManager delivers loadAllFromPreferences completions on the
main queue, which cannot be drained while that sync block is spinning
FBRunLoopSpinner, so the completion never ran and the spinner always
timed out. The device log showed "NetworkExtension: Loading all
configurations" with no result line following it.

Serving these routes on the connection's own queue lets the main queue
drain the completion. /stats drops from 10.058s to 0.033s, and connect
now brings the tunnel up on device (verified on iPhone SE / iOS 26.5 with
traffic traversing an external SOCKS5 proxy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…firmed

The consent tap logged success while the alert stayed on screen, so
saveToPreferences never completed and connect failed with "Timed out
saving the VPN configuration. The consent alert was confirmed".

It diverged from FBBroadcastManager's dismissal tap in three ways, each
of which mattered:

  - It synthesized the event through the runner, while reading the button
    frame out of SpringBoard's coordinate space. The record is stamped
    with the receiver's interface orientation, so the tap could land
    somewhere else entirely. Synthesize via the system app instead.
  - It used the blocking variant inside a bounded spin. That
    acknowledgement can take the full event-synthesis timeout margin when
    the system sheds the event, which the spin's own deadline cannot
    interrupt. Dispatch fire-and-forget instead.
  - It latched after one apparently successful dispatch. A dispatched tap
    is not a landed tap, so a shed event meant the alert stood forever.
    Keep re-attempting while the alert is observed, paced by a 1s cooldown
    so a re-tap cannot land during the dismissal animation.

Verified on iPhone SE / iOS 26.5 against a fresh install: the first tap is
shed and the retry 1.09s later lands, the alert clears, and connect
returns connected in 2.2s.

The XCUI access also has to reach the main thread now that these routes
are served off it, so FBWebServer exposes the automation funnel it already
used for main-queue-served routes. Hopping straight to the main queue
would let the block run inside another handler's run-loop spin, which is
the reentrancy that funnel exists to prevent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three distribution gaps left the tunnel target unbuildable for everyone
who is not working in a recursive clone:

- The npm files allowlist shipped Scripts/build.sh but none of the inputs
  it now needs, so an installed appium-webdriveragent could not build WDA
  at all: the runner build exited on the missing engine source and Xcode
  referenced absent target files. Ship WebDriverAgentTunnel, the engine
  sources and the two new scripts.
- build-hev-socks5-tunnel.sh keyed its up-to-date stamp on the submodule's
  Git SHA, which does not exist in the npm tarball. Fall back to hashing
  the engine source tree, which keys the stamp on what is actually being
  compiled either way.
- No workflow checked out submodules, so every TARGET=runner job would hit
  build.sh's hard failure before reaching Xcode. The publish job needs them
  too, otherwise it uploads a tarball with an empty ThirdParty.

Also add the tunnel paths to the WDA Tests trigger, so a change confined to
WebDriverAgentTunnel/, ThirdParty/, .gitmodules or either new script cannot
merge without runner build coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verify the SOCKS5 path before reporting connected. hev does not dial the
proxy until tunneled traffic creates a session, so the provider reported
startup success as soon as the engine thread was spawned: NetworkExtension
reached NEVPNStatusConnected, the endpoint answered connected: true, and
every packet was blackholed. The provider now performs a SOCKS5 greeting
(plus username/password sub-negotiation when credentials are configured)
before installing any routes, holds startup pending until the engine has
survived its initialization window, and cancels the tunnel if the engine
later exits on its own. connectWithURI: additionally stops as soon as the
session settles back to disconnected instead of spinning out the caller's
deadline - an unreachable proxy now fails in ~2.5s with an accurate
message rather than after 40s claiming success.

Serialize the SOCKS5 lifecycle. .onControlQueue leaves these handlers on
their own per-connection queues and FBWebServer only funnels the
non-control routes, so a disconnect could return while an in-flight
connect went on to start the tunnel, and two connects could race different
proxy configurations through stop/save/reload/start. All three entry
points now run on one serial lifecycle queue, which also gives stats a
consistent snapshot.

Honor the connect timeout across every wait. loadAllManagers:,
waitUntilStopped: and the post-save reload each restarted a fixed 10s
stage, and both spinners floored their budget at one second, so a
one-second request could block for half a minute. Every wait now gets
whatever is left of the caller's deadline, capped by its own stage limit,
and refuses to start once the budget is gone.

Match the VPN consent alert before tapping. An app-wide
system.buttons[@"Allow"] query would select any other SpringBoard prompt
that happened to be up, silently granting an unrelated permission. The
button is now located inside a two-button alert, the way FBBroadcastManager
anchors its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Build the engine from the normal Appium launch path. The tunnel target
links a gitignored xcframework, so a fresh clone or npm install fails
immediately with "There is no XCFramework found"; Scripts/build.sh only
covers the CI and bundling flows. This cannot be fixed inside the Xcode
project - xcodebuild resolves XCFramework references while computing the
build graph, so neither a run-script phase on the target nor an aggregate
target it depends on ever runs (both were tried and still failed). Build
it from XcodeBuild.start instead, which is the path that was reported.

Stop IPv6 from bypassing the tunnel. Only IPv4 routes were installed, so
on a dual-stack device every AAAA-reachable destination kept using the
real egress while the tunnel was up. The provider now claims IPv6 as well,
with a /128 exclusion for an IPv6 proxy. hev has no IPv6 path, so such
traffic is dropped rather than leaked - failing closed, at the cost of
IPv6-only destinations being unreachable while connected. Verified that
normal browsing still works, falling back to IPv4 through the tunnel.

Identify the consent alert by more than its button count. Two buttons plus
an "Allow" label also describes other system permission prompts, so the
matcher could grant an unrelated permission. It now anchors on the alert's
own text as well.

Make the startup acknowledgement atomic. An engine exiting between the
settle wait and the flag being set left the exit callback suppressing
cancellation while startup reported success, advertising a connected
tunnel over a dead engine. Both paths now decide under one lock.

Let a disconnecting tunnel settle before reconfiguring it. An immediate
retry after a timed-out connect, or a connect racing an external VPN stop,
would rewrite and start the manager while its previous stop was in flight.

Disable SIGPIPE on the preflight socket. A proxy closing the connection
mid-handshake raised SIGPIPE on Darwin and terminated the extension: the
engine's process-wide ignore has not been installed during the preflight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build_static runs `make clean` in the shared submodule checkout, so two
preparations racing from one checkout - two WDA sessions starting at once -
could delete each other's archives mid-compile or while libtool was merging
them, giving nondeterministic failures or a corrupt xcframework.

Take a cross-process lock, and hold it across the stamp check as well as the
build: the loser then re-reads the stamp the winner just wrote and exits
early instead of rebuilding what already exists. The lock is a mkdir, which
is atomic on every filesystem this runs on, and records its owner pid so a
lock left behind by a killed build is reclaimed rather than blocking for the
full timeout.

Verified with two concurrent invocations (one builds, the other waits and
then reports up-to-date) and with a stale lock naming a dead pid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Returning from a timed-out saveToPreferences does not cancel it, so
NetworkExtension could still persist the manager afterwards. A follow-up
operation running before that landed would load no manager, build a second
one with the same provider id, and leave two persisted configurations.
ownManagerIn: returned whichever came first, so disconnect could pick the
disconnected duplicate and report success while the real tunnel kept
running - the VPN stayed up with nothing left pointing at it.

Two changes, because either alone still leaves a hole:

- Record the in-flight save and have the next locked connect/disconnect
  wait for it to settle before loading. Blocking here is safe from the
  lifecycle queue: the completion is delivered on the main queue, which
  that queue does not occupy. The wait is bounded by the caller's own
  deadline and clears the fence either way, so one stuck save cannot block
  every later call. The timed-out branch deliberately leaves it armed;
  every path past it has the completion in hand and disarms it.
- Make ownManagerIn: prefer a manager that is not idle. Duplicates can
  still appear transiently, and a stale one must never shadow a running
  tunnel when disconnect asks which manager is ours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Start the timeout before queueing. The deadline was created inside the
locked body, so time spent waiting for another operation to release the
lifecycle queue did not count: a connect with timeout:1 could sit behind a
30s connect and then be handed a fresh one-second budget. The wrapper now
stamps the deadline before enqueueing and passes it in.

Keep the mutation and its reported stats in one transaction. connect and
disconnect released the lifecycle queue before the endpoint asked for stats
separately, so an overlapping operation ran in between and a successful
connect could answer connected:false, or a disconnect could answer with a
newly connected tunnel. Both now return the snapshot taken while they still
held the lock. Verified with overlapping requests: each response describes
its own transaction.

Mirror hev's authentication method selection in the preflight.
hev_socks5_client_write_auth_methods always offers exactly one method -
username/password only when BOTH fields are set, no-auth otherwise - while
the probe offered both whenever a user was present. A no-auth-only proxy
with credentials, or a user-without-password URI, therefore passed
preflight and reported connected:true even though every real session is
rejected.

Bound the quit request itself. hev_socks5_tunnel_quit() can block waiting
for event_fds[1] when the engine has not initialized yet or is exiting
concurrently, and the semaphore timeout was only evaluated afterwards, so
stopAndWait: did not actually bound that path and the NetworkExtension stop
callback could hang forever. The quit is now issued off the caller's thread
so the deadline covers the request as well as the exit.

Let the stamp decide whether the engine needs rebuilding. Skipping the
script whenever the xcframework directory merely existed meant a stale
output - from an older checkout, a submodule bump, a script revision, or a
failed partial build - was linked indefinitely. The script already exits
cheaply when its SHA stamp is current.

Reclaim stale build locks without deleting a live owner. Two builders could
read the same dead pid and both rm -rf, so the slower one removed the lock
the faster one had just acquired, putting both inside the shared make
clean/build. Reclamation now claims the directory by rename - atomic, so
exactly one builder wins - and re-confirms the pid before deleting.
Verified with three concurrent builders against one stale lock: exactly one
reclaimed it, one built, the others waited and saw the fresh stamp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both preference waits folded "the completion never arrived in time" and "the
completion reported a failure" into FBSocks5TunnelManagerErrorInternal, which
responseWithTunnelManagerError: surfaces as an unknown error. A plain deadline
miss therefore did not produce the documented timeout response, and the
message read "Cannot load the VPN preferences: timed out" - an internal fault
describing a timeout.

Split the two on the initial load and on the post-save reload: no completion
means FBSocks5TunnelManagerErrorTimeout, and the internal classification is
reserved for an actual error handed back by NetworkExtension.

Verified on device: a connect with timeout:0.2 now answers
timeout / "Timed out reloading the saved VPN configuration" where it
previously answered unknown error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The WebDriverAgentTunnel appex was a hard target dependency of
WebDriverAgentRunner, so every runner build - plain xcodebuild, the
Appium driver, build.sh, CI - required the hev-socks5-tunnel submodule
and the engine xcframework, and every device build had to sign the
appex. That blocked merging to master for users without an Apple
developer subscription.

The tunnel is now opt-in via dedicated schemes:

- Drop the runner -> tunnel PBXTargetDependency; the default
  WebDriverAgentRunner(-nodebug) schemes build no appex, need neither
  the submodule nor the engine, and no longer carry the embed
  post-action (so a stale appex in shared DerivedData can never sneak
  into a default build).
- Add WebDriverAgentRunnerTunnel(-nodebug) schemes that build the appex
  alongside the runner and keep the embed post-action.
- build.sh: TARGET=tunnel_runner selects the tunnel scheme and is the
  only target that prepares the hev engine.
- xcodebuild.ts: drop prepareSocks5Engine - the driver always builds
  the default scheme, which no longer links the engine.
- connect now fails fast with 'unsupported operation' when the appex is
  not embedded in the host app, instead of a confusing NEVPN error
  (unit-tested via FBSocks5TunnelManager isTunnelExtensionEmbeddedInBundle:).
- CI: submodule checkout only where still needed (wda-tests build job,
  npm publish packing the vendored engine sources); new
  iOS_Tunnel_Build jobs keep the appex + engine + embed path covered.

Verified: fresh-DerivedData default-scheme build succeeds with the
xcframework absent and embeds no tunnel appex; the tunnel scheme embeds
it with the rewritten bundle id; UnitTests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stats deadline)

- Abort provider startup after a concurrent stop: stopTunnelWithReason:
  and the startup continuation now transition {isStopping, runner}
  under one lock, and the runner is started inside the section that
  publishes it. A stop that lands while the probe or
  setTunnelNetworkSettings is pending previously found no runner,
  completed as a no-op, and the continuation then started a late hev
  engine in a provider the system considered torn down.
- Try every resolved proxy address before failing: the pre-flight now
  probes all A/AAAA records in order (IPv4 first, deduplicated) and
  keeps the first that completes the SOCKS5 handshake. Unreachable
  endpoints move on to the next record; protocol/credential rejections
  still fail immediately since they come from the server itself. The
  probe's connect() is now bounded by the 8s probe deadline via a
  non-blocking connect + poll (SO_SNDTIMEO never covered connect, so a
  filtered endpoint could previously hang the start for the ~75s SYN
  retry window - per address, once several are tried).
- Keep the connect success snapshot within the caller's timeout: the
  stats round trip to the extension is now capped at the remaining
  connect deadline (and skipped when exhausted, falling back to zero
  counters) instead of a fixed extra 3s.

Two further findings from the same round were already resolved by the
opt-in change: the release workflows no longer need the hev
xcframework (they build the default tunnel-free schemes), and
prepareSocks5Engine no longer exists in lib/xcodebuild.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-wda

# Conflicts:
#	WebDriverAgentLib/Routing/FBWebServer.m
@Timo972
Timo972 merged commit 4f50f7e into master Aug 27, 2026
49 of 57 checks passed
@Timo972
Timo972 deleted the timo/dro-2802-integrate-vpn-into-mobilerun-wda branch August 27, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant