feat(ios): capture every URLSession request with open --capture-network
What
open <app> --capture-network on an iOS simulator injects a small dylib probe (apple/network-capture/ADNetworkCapture.m) into the app process through SIMCTL_CHILD_DYLD_INSERT_LIBRARIES. The probe observes each NSURLSessionTask to completion and appends one JSON line per request to the session's network.jsonl. network dump then reports every request with its own full URL, method, status, and duration — also on reused keep-alive connections — with no proxy.
Design recorded in ADR 0037 (docs/adr/0037-ios-simulator-network-capture-probe.md).
How
- Injection:
simctl launch --terminate-running-processwith exactly twoSIMCTL_CHILD_variables (dylib path + capture file).SIMCTL_CHILD_*is purely additive — the app's own environment is unchanged. A capture launch always terminates the running instance: dyld injects only at exec, so an already-running app would activate without the probe. - Probe build: shares the fold helper's content+toolchain-keyed native-build cache (
native-build/cache.ts), so the first capture launch builds once per toolchain and later launches serve the cached binary. ADEVELOPER_DIRswitch rebuilds. A probe that cannot be built fails the open withreason: network-capture-probe-build-failed— no silent launch-without-capture. - Two dump modes, not a fallback:
session.networkCapture(a boolean window recorded by the store viaSessionStore.setNetworkCapture; any capture-less open clears it) selects probe mode; the dump reportssource: 'probe' | 'app-log'either way. Probe mode never consults the app-log parser. The probe file path is derived from the session id once, in the composition root. - Bounded + redacted reads: the probe file read is byte-bounded (2 MB) before line-bounded, drops a partial first line like the app-log tail, and passes through the shared app-log
redact()with the operator'sAGENT_DEVICE_APP_LOG_REDACT_PATTERNS. The probe itself masksAuthorization/Cookie/Set-Cookieand authentication siblings before writing, and stops at a 32 MB file cap usingwriteData:error:(ENOSPC never crashes the app). - Refusals are typed, owner-based, and pre-device: contracts
assertOpenAdmitted(binding, execution)is the whole admission rule for--launch-consoleand--capture-network, called first in everyopenApplicationentry (openDirectApplication,openAndroidApplication,openAppleApplication) — so a provider-backed iOS simulator (Limrun, TestMu) or any non-simulator device is refused withUNSUPPORTED_OPERATIONand a typed reason (network-capture-owner-unadmitted,launch-console-owner-unadmitted) before any relaunch close, runner stop, or runtime-hint write; a refused open never touches the device. Only a local owner bound to an iOS simulator declares the capability. With a URL/deep-link open →INVALID_ARGS. - Probe-build machinery and capture messages stay off every eager import closure (function-scoped
await import+ the existinglaunch-console.tshome), so all eager-closure budgets hold at merge-base.
Live evidence (ios-sim-net simulator, SpikeApp dev.agentdevice.spikeapp, re-run at head c1ac863808)
The spike app fires 5 sequential URLSession requests over one keep-alive connection; nothing is emitted to Unified Logging. The /headers request carries Authorization, Cookie and a non-sensitive X-Trace-Id to prove the probe-side secret mask. One launch with both options:
== open --capture-network --launch-console (one launch, both options) ==
open rc=0
== network dump (summary) ==
source: probe | backend: ios-simulator | notes: []
GET http://httpbin.org/delay/1 -> 200 1109ms
GET http://httpbin.org/status/404 -> 404 418ms
GET http://httpbin.org/headers -> 200 108ms
POST http://httpbin.org/get?x=1 -> 405 113ms
GET http://httpbin.org/json -> 200 259ms
== network dump --include headers (probe-side secret mask) ==
secret token leaked: False
cookie leaked: False
non-sensitive header kept: True
== launch console file ==
console lines: 10
== raw probe file: no secret on disk ==
secret occurrences in network.jsonl: 0
REDACTED occurrences in network.jsonl: 1
== relaunch capture open on a LOCAL simulator stays admitted ==
relaunch+capture open rc: 0 | ok
source: probe | entries: 5
== refuse on a non-admitted open (web URL open) ==
code: UNSUPPORTED_OPERATION | reason: network-capture-owner-unadmitted | --capture-network is
supported only for a local iOS simulator app launch; this session owner cannot inject the capture
probe
All five requests appear with their own full paths, statuses and durations despite connection reuse — the case the app-log parser cannot name. Credential headers are [REDACTED] in the dump and absent from the on-disk file; X-Trace-Id survives. The console PTY capture and probe injection share one launch. open --relaunch --capture-network on the local simulator stays admitted (round 2's blocker: admission now runs before the relaunch close, so a local owner still closes-then-captures and the dump keeps reporting source: probe).
Contrast runs on the same simulator:
- Session opened without the flag →
source: app-log,entries: 0(the exact gap this PR closes). - Re-opening the capture session without the flag clears the window → dump reports
source: app-log, and the plain launch carries no injected environment. open https://example.test --capture-network --platform web→UNSUPPORTED_OPERATIONwith reasonnetwork-capture-owner-unadmitted(admitted before any device action), message--capture-network is supported only for a local iOS simulator app launch; this session owner cannot inject the capture probe.
Validation
- Review round 3 (see the reply thread): the launch-only request-shape refusals (positional
count, no-app-target, URL-pair, deep-link app position) moved into the admission step too —
assertOpenAdmittednow enforces owner capability and request shape before the lifecycle touches
the device, soopen <bundle> <scheme://x> --relaunch --clear-app-state --capture-networkis
refused withINVALID_ARGSbefore any close. A contract test drives exactly that command through
acloseBeforeRelaunchlocal iOS-simulator binding and asserts no open/close/setSetting call.
invokeApplicationOpenkeeps the same shape rule for its folded/follow-up dispatches plus the one
platform refusal (--launch-argson Linux). ADR 0037 rule 1 states both halves of admission. - Review round 2 (see the reply thread): admission is one contracts step,
assertOpenAdmitted(binding, execution), called before the lifecycle touches the device in all
threeopenApplicationentries; it replaced the ninesupportsLocalSimulatorLaunchpass-through
sites and the four stacked refusals, and both refusals carry typed reasons. The provider-shaped
contract test opens withrelaunch: trueon acloseBeforeRelaunch: trueowner and asserts the
typed code/reason and that close and open were never called. A capped probe file (32 MB) now
reports a dump note (atSizeCapon the capture read), and the cap check usesgetOffset:error:.
ADR 0037 records admission-before-device-action and fixes Apple dump precedence: probe, then
provider, then app-log. - Review round 1: capture admission moved to the lifecycle binding
(supportsLocalSimulatorLaunchderives from ownership + shape; provider bindings never claim it),
the redundantcaptureNetworkexecution field collapsed intonetworkCapturePath's presence, the
duplicatedredactCaptureTextreplaced by the exportedredact(), the console launch folded into
runSimulatorLaunch(launchArgs, { env, timeoutMs }), dump input and session field reduced to a
boolean mode, the probe masks credential headers and caps its file at 32 MB via
writeData:error:, and the reader drops the byte-window's partial first line. Contract tests pin
the provider-binding refusals; the provider scenario asserts--console-pty+ injected env on
one launch with both options. ADR 0037 amended (owner admission, probe-side mask, 32 MB cap,
crash-relaunch limitation as rule 7). pnpm check:affected --run --head 17c933f8c7→ all runnable checks passed,exactCommit=true,
all runnable gates including layering, fallow, coverage, coverage-crap, depgraph, wire-compat,
xctest-selection and packaged-runner-swift. (Round-2 receipt: same result atc1ac863808.)- Coverage-CRAP fix on top:
runIosSimulatorConsoleLaunchscored 30.5 (blocks at 30) because the
capture-env arm, the console capture-bound branch and the rethrow arm had no test. It now has
branch tests through the Apple tool provider seam (no exemption): console+capture spawns the
merged host env with theSIMCTL_CHILD_*pair, console-only spawns with no env, the bound keeps
the open alive with its diagnostic (with and without partial bytes), a non-bound failure
rethrows. Measured with the gate's own counters: 14/14 statements, 10/10 branches, CRAP 1.0. - Coverage-lane fix on top of the feature: the pre-existing
screenshot-diff.test.tsfull-resolution case (4.73s on main's Coverage lane, inside the
2× band) crossed vitest's 5s timeout under load on this PR's first run. It now encodes the
solid fixture PNGs unfiltered (filterType: 0, lossless — the decoder inverts it): 5.1s →
~0.27s. Not a skip. - Gate findings fixed at their owner (not suppressed):
networkCaptureclassified as a store-owned SessionState field (R7/R10 ratchet holds), spawn-env merge moved to thetool-provider.tscommand boundary (R86), new eager edges moved behindawait import/ existing closure homes, probe-parser duplication shared with the app-log reader (fallow), new provider subpath declared in the R11 inventory. - New provider-scenario coverage (
test/integration/provider-scenarios/ios-network-capture.test.ts): scripted build + injection env +--terminate-running-process+ cache reuse across two capture opens + probe dump (newest first) + reopen-without-capture mode switch. The probe source also compiles with-Werroragainst the real SDK in a darwin-only gate test.
Not verified
- Upload/download tasks and WebSocket tasks are not verified live; the ADR scopes capture to what is
proven (completed HTTP(S) data tasks). Extending the spike touploadTask/downloadTaskis a
follow-up on request. - Older simulator runtimes: verified live on the iOS 27 runtime only (Xcode-active SDK), not down to
the probe's iOS 15 floor. - Provider-backed iOS simulators (Limrun, TestMu): the pre-device refusal is proven by contract
tests over the scripted provider binding, not against a real provider device. - Physical iOS devices and other platforms are out of scope (flag refuses them).
- Non-URLSession stacks (e.g. Flutter dart:io) are not captured; the dump says so in its note.
- Cache-rebuild behavior on a real
DEVELOPER_DIRswitch is proven only against the scripted toolchain in the provider scenario.
🤖 Generated with Claude Code