feat(ios): capture every URLSession request with open --capture-network

TH
18h ago
+2345-96

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-process with exactly two SIMCTL_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. A DEVELOPER_DIR switch rebuilds. A probe that cannot be built fails the open with reason: 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 via SessionStore.setNetworkCapture; any capture-less open clears it) selects probe mode; the dump reports source: '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's AGENT_DEVICE_APP_LOG_REDACT_PATTERNS. The probe itself masks Authorization/Cookie/Set-Cookie and authentication siblings before writing, and stops at a 32 MB file cap using writeData: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-console and --capture-network, called first in every openApplication entry (openDirectApplication, openAndroidApplication, openAppleApplication) — so a provider-backed iOS simulator (Limrun, TestMu) or any non-simulator device is refused with UNSUPPORTED_OPERATION and 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 existing launch-console.ts home), 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_OPERATION with reason network-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 —
    assertOpenAdmitted now enforces owner capability and request shape before the lifecycle touches
    the device, so open <bundle> <scheme://x> --relaunch --clear-app-state --capture-network is
    refused with INVALID_ARGS before any close. A contract test drives exactly that command through
    a closeBeforeRelaunch local iOS-simulator binding and asserts no open/close/setSetting call.
    invokeApplicationOpen keeps the same shape rule for its folded/follow-up dispatches plus the one
    platform refusal (--launch-args on 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
    three openApplication entries; it replaced the nine supportsLocalSimulatorLaunch pass-through
    sites and the four stacked refusals, and both refusals carry typed reasons. The provider-shaped
    contract test opens with relaunch: true on a closeBeforeRelaunch: true owner 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 (atSizeCap on the capture read), and the cap check uses getOffset: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
    (supportsLocalSimulatorLaunch derives from ownership + shape; provider bindings never claim it),
    the redundant captureNetwork execution field collapsed into networkCapturePath's presence, the
    duplicated redactCaptureText replaced by the exported redact(), 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 at c1ac863808.)
  • Coverage-CRAP fix on top: runIosSimulatorConsoleLaunch scored 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 the SIMCTL_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.ts full-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): networkCapture classified as a store-owned SessionState field (R7/R10 ratchet holds), spawn-env merge moved to the tool-provider.ts command boundary (R86), new eager edges moved behind await 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 -Werror against 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 to uploadTask/downloadTask is 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_DIR switch is proven only against the scripted toolchain in the provider scenario.

🤖 Generated with Claude Code