Skip to content

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

Open
thymikee wants to merge 6 commits into
mainfrom
feat/ios-sim-network-capture
Open

thymikee wants to merge 6 commits into
mainfrom
feat/ios-sim-network-capture

Conversation

@thymikee

@thymikee thymikee commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

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

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3928/

Built to branch gh-pages at 2026-10-23 17:13 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.58 MB 5.59 MB +19.1 kB
Package (unpacked) 5.58 MB 5.59 MB +19.1 kB
Package (download) 1.69 MB 1.70 MB +6.9 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 40.9 ms 39.8 ms -1.1 ms
CLI --help 79.2 ms 83.0 ms +3.7 ms

On an iOS simulator, open <app> --capture-network injects
apple/network-capture/ADNetworkCapture.m through
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES. The probe observes each URLSessionTask
to completion and appends one JSON line per request to the session's
network.jsonl, so network dump reports every request with its own full
URL, method, status, and duration, also on reused keep-alive connections,
without a proxy. The dump states its source (probe or app-log); a probe
that cannot be built fails the open with network-capture-probe-build-failed.

ADR 0037.
…he unit budget

The 375x812-vs-1080x2340 comparison spent ~0.8s of its wall clock encoding the
capture with pngjs' default scanline filters, which for a solid fill buy no
compression and only burn CPU under coverage instrumentation. On the Coverage
lane it crossed the 2x slow-test enforce line and vitest's 5s timeout (run
39928270661). filterType 0 is a lossless choice the decoder inverts, so every
compared pixel is identical.
@thymikee
thymikee force-pushed the feat/ios-sim-network-capture branch from 63acf39 to c3769fd Compare October 23, 2026 09:51
@thymikee

Copy link
Copy Markdown
Member Author

The live-run evidence in the body is useful, but I found two problems in c3769fd, and the Coverage check fails because of this change.

assertOpenDeviceSupport accepts --capture-network by device shape (iOS simulator), not by whether the owning lifecycle can inject the probe (https://github.com/callstack/agent-device/blob/c3769fd/packages/contracts/src/application-lifecycle-interaction.ts#L196). A Limrun iOS simulator has that shape, but its open goes through the generic lifecycle to LimrunIosInteractor.open, which ignores networkCapturePath. The open then succeeds and records session.networkCapture. The user believes capture is on, but network dump has no probe traffic. This contradicts ADR 0037 rules 1 and 4 ("every other platform refuses", "never silently launches without it"). TestMu virtual iOS looks like the same case, but I did not trace it. The rule should be that only the owner that injects the probe admits capture. Can the generic contracts open path refuse it, and the Apple lifecycle accept it for a local simulator? A contract test with a provider-shaped iOS simulator on the generic lifecycle should expect UNSUPPORTED_OPERATION.

redactCaptureText is a verbatim copy of redact() in src/platform-runtime-app-log-output.ts (https://github.com/callstack/agent-device/blob/c3769fd/src/platform-runtime-network-host.ts#L127). The PR already imports appLogRedactionPatterns from that module. Please export the existing applier and call it from readRecentNetworkCaptureLines, so one redaction policy has one implementation.

The Coverage job fails coverage-crap on runIosSimulatorConsoleLaunch (CRAP 30.5, cyclomatic 9, 36% coverage). The new launchEnv ternary in that function causes it (https://github.com/callstack/agent-device/blob/c3769fd/packages/platform-apple/src/core/app-launch.ts#L393). It duplicates runSimulatorLaunch. Giving runSimulatorLaunch an options parameter and calling it from the console path should clear the gate. A provider-scenario open with both launchConsole and captureNetwork should assert the injected env and --console-pty.

Not blocking, so take or leave: the dump input carries a host path that the Apple runtime uses only as a boolean, while captureProbe.readRecent derives the path again from the session id; the probe file has no size cap and keeps appending after a reopen without the flag or a session close; request and response headers (Authorization, Cookie, Set-Cookie) are written verbatim, and network dump --include headers returns them, so a fixed sensitive-header mask in the probe would be safer, with ADR 0037 rule 6 updated; readRecentNetworkCaptureLines keeps a partial first line and its line numbers are not absolute past 2 MB, although readAppLogOutputTail already handles the partial line; writeData: can raise on ENOSPC and crash the app under test, and writeData:error: is available at iOS 15; and after a crash and runner relaunch, session.networkCapture still selects probe mode while the new process has no probe, so the dump shows frozen entries with no note.

Could the plumbing be simpler? The probe, its cache and its parser look inherent to me. The rest could shrink to one execution field instead of captureNetwork plus networkCapturePath, admission only in the Apple lifecycle (the generic path refuses any capture, which also fixes the first finding), the network runtime reading the path the store recorded, the capture reader built on readRecentAppLogLines with a byte budget plus the existing redact(), and runIosSimulatorConsoleLaunch calling runSimulatorLaunch. That would remove roughly 60-100 production lines without changing behavior. First we need to decide where open-option admission lives: an owner-declared capability or device-shape inference. --launch-console has the same provider gap, so ideally it moves with this in one follow-up or an ADR 0037 amendment.

I did not run a simulator, so I have not verified the live run in the body; the body also does not name the SHA it ran on, and the production code at c3769fd matches the one feature commit 28470ff. Live evidence covers data tasks on iOS 27, so swizzling -[NSURLSessionTask resume] on the base class is unproven for upload, download and WebSocket tasks, and for older runtimes down to iOS 15. The first finding comes from reading the Limrun device shape and interactor, not from running it. The "all runnable checks passed" claim does not match the failing coverage-crap job.

Before merge, please refuse --capture-network on provider-backed iOS simulators with a contract test, replace redactCaptureText with the existing redact(), and fold the env branch into runSimulatorLaunch so the coverage gate passes.

The Coverage CRAP gate scored runIosSimulatorConsoleLaunch at 30.5 (blocks at 30):
the capture-launch env arm, the capture-bound timeout branch and the rethrow arm
had no test. All of them now run through the Apple tool provider seam:
console+capture spawns the merged host environment with the SIMCTL_CHILD pair,
console-only spawns with no environment, the 25s console bound keeps the open
alive and logs the partial bytes with its diagnostic (with and without output
on the error), and a non-bound failure rethrows. The fake tool provider now
records the exec options so the env merge is observable at the seam. The
console-capture budget moved to core/config.ts beside every other launch
timeout so the test names it without duplicating 25_000.
@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the update. The coverage-crap failure on runIosSimulatorConsoleLaunch is fixed by the new branch tests in app-launch-open.test.ts. Two issues from the earlier review at c3769fd are still present at 2c90df4, so the code is not ready yet.

CI is green at 2c90df4 (23 checks, none failing), and there are no conflicts. Before merge, the two issues below need to be fixed.

assertOpenDeviceSupport (application-lifecycle-interaction.ts:196) admits execution.captureNetwork by device shape (iOS family, kind simulator). A Limrun iOS device reports kind simulator (device.ts:56), so it passes. The generic lifecycle then hands networkCapturePath to interactor.open (line 254), and LimrunIosInteractor.open (ios.ts:189) drops it. The daemon still records session.networkCapture (session-open-execution.ts:395), so network dump picks probe mode over a file nobody writes. On a provider-backed iOS simulator, open --capture-network succeeds and the dump shows no traffic and no note. That breaks ADR 0037 rules 1 and 4. TestMu virtual iOS looks the same, but I did not trace it. The rule should be: only the owner that injects the probe admits capture. The generic contracts open path should refuse captureNetwork with UNSUPPORTED_OPERATION, and the Apple lifecycle should admit it for a local simulator (its guard at app-launch.ts:79 already exists). Please add a contract test that runs a provider-shaped iOS simulator through the generic lifecycle and expects UNSUPPORTED_OPERATION. The same rule should cover --launch-console at line 193.

redactCaptureText (platform-runtime-network-host.ts:127) is a verbatim copy of redact() in platform-runtime-app-log-output.ts:114, and the file already imports appLogRedactionPatterns from that module (line 15). Now two implementations hold one redaction policy, so a change to the mask or matching can reach the app log and miss the network dump. Please export the existing redact() (or a named applier) from that module, call it at line 119, and delete redactCaptureText.

Not blocking, take or leave: runIosSimulatorConsoleLaunch (app-launch.ts:400) still repeats the launchEnv ? runScopedSimctlCommandWithHostEnv : runXcrun branch from runSimulatorLaunch (lines 383-391 vs 400-408), and only timeoutMs differs. An options parameter ({ timeoutMs }) on runSimulatorLaunch would remove the copy, and the console tests can stay because they cover the bound and rethrow arms.

I did not run a simulator. The PR body's live run names no SHA; production code at 2c90df4 equals 28470ff plus a constant move, so I treat that run as covering this commit, but I did not reproduce it. The Limrun finding comes from reading the device shape and the open signature, not from a run. I did not rerun coverage-crap, so the CRAP 1.0 claim rests on your measurement and the green CI. Swizzling base-class -[NSURLSessionTask resume] is still unproven for upload, download and WebSocket tasks and for runtimes older than iOS 27.

Review round on #3928 (thymikee), fixed at the owners:

- A provider-backed iOS simulator (Limrun, TestMu) has the local simulator's
  device shape but opens through a lifecycle that cannot inject the probe, so
  its open accepted --capture-network, recorded the capture window, and dumped
  an empty probe source. The capability now rides the lifecycle binding:
  bindApplicationLifecycleInteractor derives supportsLocalSimulatorLaunch from
  ownership plus shape (a provider binding never claims it), and the canonical
  open refuses --capture-network and --launch-console without it. Contract tests
  drive a provider binding on a provider-shaped simulator and expect
  UNSUPPORTED_OPERATION with no interactor call.
- One execution field: the open-option intent is execution.networkCapturePath
  alone; the redundant captureNetwork boolean is gone. The dump input carries a
  boolean mode marker and the session field stores a boolean window; the probe
  file path is derived from the session id at the one place that reads it.
- One redaction implementation: redact() is exported and the capture-probe
  reader calls it instead of keeping a verbatim copy.
- The console launch calls runSimulatorLaunch with an options parameter, so the
  env/timeout spawn exists once; the provider scenario now opens with both
  launchConsole and captureNetwork and asserts --console-pty plus the injected
  env on the same launch.
- The probe masks Authorization, Cookie, Set-Cookie and their authentication
  siblings before writing, so a secret never enters network.jsonl; writes use
  writeData:error: under a 32 MB file cap, so ENOSPC cannot crash the app under
  test. ADR 0037 rules 1, 6 and a new rule 7 record the admission, the mask,
  and the crash-relaunch frozen-window limitation.
- The capture reader drops a partial first line at the byte-window start, the
  same posture as the app-log tail, and counts it as skipped.

Live evidence (ios-sim-net, this head): one open with --capture-network
--launch-console produced source=probe with five distinct httpbin URLs
(keep-alive reuse named individually), a 10-line console log, requestHeaders
showing Authorization/Cookie as [REDACTED] with X-Trace-Id intact, and zero
secret occurrences in the raw network.jsonl.
@thymikee

thymikee commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

All findings addressed at 02addda. Live evidence re-run at this head is quoted below and in the body.

1. assertOpenDeviceSupport admits by device shape, not owner (blocking) — Fixed at the binding, not the shape check. bindApplicationLifecycleInteractor now derives supportsLocalSimulatorLaunch from ownership + shape: a local binding on an iOS simulator claims it; a provider binding never can, whatever the device looks like. invokeApplicationOpen requires the flag as a required parameter and refuses --capture-network (and --launch-console, your same-provider-gap note) with UNSUPPORTED_OPERATION without it. Limrun and TestMu (webdriver-backed) bind through the provider path, so their opens now refuse instead of recording a capture window that yields an empty probe dump. Contract tests: the generic provider lifecycle refuses capture/launch console on a provider-shaped iOS simulator drive bindProviderApplicationLifecycleInteractor + bindDirectApplicationLifecycle on the simulator-shaped device and assert the refusal and that the interactor was never called. The local Apple lifecycle still admits: the provider scenario opens with capture end-to-end.

2. redactCaptureText verbatim copy (blocking) — redact() is exported from platform-runtime-app-log-output.ts and readRecentNetworkCaptureLines calls it; the copy is deleted. One policy, one implementation.

3. Coverage-crap on runIosSimulatorConsoleLaunch (blocking) — Folded as you suggested: runSimulatorLaunch takes an options parameter ({ env?, timeoutMs? }) and the console path calls it; the spawn-env ternary exists once. Plus the branch tests from the earlier coordinator round (console-only, console+capture env merge, capture-bound timeout with/without partial bytes, rethrow). The provider scenario now has an open with both launchConsole and captureNetwork asserting --console-pty and the injected SIMCTL_CHILD_* env on the same launch, per your note.

4. Simplify the plumbing — Adopted where it didn't change behavior:

  • One execution field: captureNetwork: boolean is gone; execution.networkCapturePath's presence is the request.
  • Admission lives in the owner-declared capability (the question you asked to settle first): the binding answers it, no device-shape inference in the contracts dispatch beyond the refusal message.
  • The dump input now carries networkCapture?: boolean — the host path the Apple runtime used only as a boolean is gone; the reader derives the path from the session id, once, in the composition root. SessionState.networkCapture is a boolean window for the same reason.
  • runIosSimulatorConsoleLaunch calls runSimulatorLaunch, as above.
  • Rejected on evidence: rebuilding the probe reader on readRecentAppLogLines. That function has no byte budget — its callers pass already-bounded text — and adopting it would read the whole probe file into memory on every dump, undoing the ADR 0037 rule-6 byte bound. Instead the reader now takes the partial-line posture from readAppLogOutputTail (drop the cut first line, count it skipped) and shares the real redact(). --launch-console moved with capture in this same commit, so no follow-up needed.

Non-blocking items, taken:

  • Sensitive headers: the probe masks Authorization, Proxy-Authorization, WWW-Authenticate, Proxy-Authenticate, Cookie, Set-Cookie before writing — the secret never enters network.jsonl, so --include headers can't echo it even with no operator patterns. ADR 0037 rule 6 updated. Live proof below.
  • writeData: → writeData:error: (available at the probe's iOS 15 floor): an ENOSPC failure is now silent instead of an uncaught exception killing the app under test.
  • Size cap: the probe stops appending at 32 MB (rule 6), and the reader already tails 2 MB — the window is the newest frame either way.
  • Partial first line + skipped counting: fixed as above, pinned by a byte-window read never hands the parser a partial first line. On the line numbers: the capture reader has no absolute line numbers at all — it reports skippedLines (window truncation + the dropped partial line), and the code states that this is exact only while the read covers the whole file; past the 2 MB budget the window is the newest frame by design.
  • Size cap / "keeps appending after a reopen without the flag": the 32 MB cap bounds the file; the reopen case is already handled — a capture-less reopen clears session.networkCapture, so the dump switches to source: app-log and never reads the stale file (provider scenario asserts the switch). The path is per-session (<session dir>/network.jsonl), derived from the session id at the one read site.

Not taken, documented instead — the crash/runner-relaunch frozen window: nothing in the daemon observes app-process relaunch, so no seam can distinguish "probe still running" from "relaunched without probe" without a new mechanism. ADR 0037 now states the limitation and the fix (reopen with --capture-network). If you want detection, the honest seam is the probe writing a fresh-start marker on init — say the word and I'll do it as a follow-up.

On the evidence and coverage claims — fair on both: the earlier body did not name a SHA, and "all runnable checks passed" predates the Coverage lane finishing on that commit. The body now names the head every evidence block ran on. The coverage-crap failure at c3769fd is what item 3 fixes; Coverage passes at 2c90df and the fold keeps the function below the gate at this head too.

Live evidence at 02addda (iPad-free: iPhone simulator ios-sim-net, Xcode-active SDK, spike app firing 5 keep-alive requests to httpbin incl. one with Authorization/Cookie/X-Trace-Id headers, one launch with --capture-network --launch-console):

== 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 1119ms
  GET http://httpbin.org/status/404 -> 404 108ms
  GET http://httpbin.org/headers -> 200 107ms
  POST http://httpbin.org/get?x=1 -> 405 454ms
  GET http://httpbin.org/json -> 200 317ms
== 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
== refuse on non-simulator ==
code: UNSUPPORTED_OPERATION | --capture-network is supported only for iOS simulator app launches

All five keep-alive-reused requests named with their own full URLs (the gap the probe exists for); secrets masked in the dump and absent from the on-disk file; the non-sensitive X-Trace-Id kept; console PTY captured on the same launch.

Unproven, as you said — swizzling -[NSURLSessionTask resume] on the base class is verified live for data tasks on the iOS 27 runtime only. Upload/download/WebSocket-task coverage and older runtimes down to iOS 15 remain unverified; the ADR scopes capture to what is proven (completed HTTP(S) tasks). Happy to extend the spike to uploadTask/downloadTask if you want that evidence before merge.

Validation: pnpm check:affected --run --head 02addda2d5 — all runnable checks passed, exactCommit=true (receipt in the body); provider-scenario suites green including the new both-options step; probe source compiles -Wall -Wextra -Werror against the real SDK.

@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the update. I reviewed 02addda against the earlier findings. The earlier problems are fixed, but one new problem remains. All 23 checks pass at this commit, and there are no conflicts.

A refused open still changes the device first. The new owner refusal runs inside invokeApplicationOpen (lines 231-235), after the lifecycle has already acted. For Limrun (closeBeforeRelaunch: true) and TestMu/webdriver, the relaunch close at line 356 runs before that call. So open <app> --relaunch --capture-network (or --launch-console) on a provider iOS simulator kills the running app and then fails with UNSUPPORTED_OPERATION. The flag help (flag-definitions-target.ts:376) steers users to exactly that command. Android and Apple have the same order: the relaunch close and runtime hints run before invokeApplicationOpen and dispatchAppleOpen. ADR 0037 says to refuse instead of launching, not to close and then refuse.

The rule should be: an open option the bound owner cannot honor is refused before the lifecycle touches the device, which covers the relaunch close, the runner stop and the runtime hints. Every lifecycle openApplication entry enumerates the sites: openDirectApplication, openAndroidApplication and openAppleApplication. Please add a provider-shaped contract test with relaunch: true, closeBeforeRelaunch: true and a target. It should assert that close was never called and that the code is UNSUPPORTED_OPERATION.

Could one admission step replace the nine supportsLocalSimulatorLaunch pass-through sites and the four stacked refusals? For example, assertOpenAdmitted(binding, execution) in contracts, called first in each of those three entries. It would refuse --launch-console and --capture-network with UNSUPPORTED_OPERATION and a typed reason unless the binding declares the capability. The local Apple binding would declare it for iOS simulators, and every other binding would default to false. The two shape-only refusals could go, and invokeApplicationOpen would keep only its positional and URL checks. That also fixes the ordering problem above. I did not find anything that must change first.

Not blocking, and fine to take or leave: the new provider-refusal tests match error text instead of the typed code, and both new errors lack a typed reason like the sibling provider-runtime-interactor-missing refusal; the 32 MB cap in ADNetworkCapture.m makes the probe stop appending silently while dumpCapturedProbeTraffic still reports source: 'probe', so a long session shows stale requests as current (a note when the file reaches the cap would fix it); the two new message constants sit between the invokeApplicationOpen JSDoc and the function, so the doc attaches to a constant; and the cap guard at ADNetworkCapture.m:52 reads handle.offsetInFile, which can raise on a failed seek, so getOffset:error: fits rule 6 better.

Before #3930 lands, ADR 0037 (or the source-selection ADR both PRs cite) should fix Apple dump precedence. I would suggest probe, then Metro provider, then app-log, because the probe is an explicit open opt-in and also sees React Native fetch, which runs on URLSession on iOS.

I did not run a simulator or a provider device. The live --capture-network --launch-console run at 02addda is the author's, and I read only the first part of it. The ordering problem comes from reading openDirectApplication and the Limrun and webdriver flags, not from a live run. I did not compile ADNetworkCapture.m. Swizzling -[NSURLSessionTask resume] for upload, download and WebSocket tasks, and on runtimes older than iOS 27, is still unproven.

Before merge, please refuse these options before any relaunch close or runtime-hint write, with the contract test above, and settle the probe and Metro precedence with #3930.

… device

Review round on #3928 (thymikee, second pass). A refused open still changed the
device first: the owner refusal ran inside invokeApplicationOpen, after the
relaunch close (closeBeforeRelaunch on Limrun/webdriver), the runner stop, and
the runtime-hint write. `open <app> --relaunch --capture-network` on a
provider iOS simulator killed the running app and then failed.

- One admission step: contracts `assertOpenAdmitted(binding, execution)` is the
  whole rule for `--launch-console` and `--capture-network`. It is the first call
  in openDirectApplication, openAndroidApplication and openAppleApplication, so
  an owner that cannot honor the option is refused with UNSUPPORTED_OPERATION
  before any device action. It replaces the nine
  `supportsLocalSimulatorLaunch` pass-through sites and the four stacked
  refusals in the canonical open; `invokeApplicationOpen` keeps only its
  positional, URL, and argument-shape checks.
- Both refusals carry a typed reason (`launch-console-owner-unadmitted`,
  `network-capture-owner-unadmitted`), matching the sibling
  `provider-runtime-interactor-missing` posture. Their messages stay module
  constants of the module that raises them, clear of any function's JSDoc (the
  shared message home's facade must stay exhaustive, and a re-export no
  consumer imports is dead code).
- The provider-shaped contract test now opens with `relaunch: true` against a
  `closeBeforeRelaunch: true` owner and asserts the typed code and reason and
  that neither open nor close ran. A second case pins the same typed refusal for
  a local binding on a non-simulator device.
- A probe file at its 32 MB cap no longer reads as current traffic: the capture
  read reports `atSizeCap` (the reader owns the cap constant mirroring the
  probe's AdMaxFileBytes) and the probe-mode dump says the window stopped
  recording. The cap check reads the handle offset through `getOffset:error:`
  instead of the raising `offsetInFile`, per ADR 0037 rule 6.
- ADR 0037 rule 1 now states admission-before-device-action, rule 6 the cap
  note and the offset API, and rule 5 fixes Apple dump precedence — probe, then
  provider, then app-log — as the answer to the #3930 Metro question.

Live evidence re-run at this head on ios-sim-net: one open with
--capture-network --launch-console produced source=probe with five distinct
httpbin URLs, a 10-line console log, masked Authorization/Cookie with X-Trace-Id
kept, zero secret occurrences in network.jsonl; `open --relaunch
--capture-network` on the local simulator stays admitted (relaunch close, then
capture, dump still probe); a non-admitted open refuses with
UNSUPPORTED_OPERATION and reason network-capture-owner-unadmitted.
@thymikee

Copy link
Copy Markdown
Member Author

All findings from this pass addressed at c1ac8638 (parent 02addda). pnpm check:affected --run --head c1ac8638 → all runnable checks passed, exactCommit=true. Live evidence re-run at this head (body).

1. A refused open still changes the device first (blocking) — Fixed exactly as proposed. assertOpenAdmitted(binding, execution) is a contracts export and the first statement of openDirectApplication, openAndroidApplication and openAppleApplication, so both launch-only options are refused before the relaunch close, the runner stop, and the runtime-hint write on every owner. open <app> --relaunch --capture-network (or --launch-console) on a Limrun/TestMu iOS simulator now fails with UNSUPPORTED_OPERATION and the device is untouched. Contract test: the generic provider lifecycle refuses capture/launch console on a provider-shaped iOS simulator before touching the device binds a provider interactor on the simulator-shaped device, runs bindDirectApplicationLifecycle({ closeBeforeRelaunch: true }) with relaunch: true and a target, and asserts the typed code, the typed reason, and that neither close nor open was called (calls stays []). Live, the mirrored local case still works: open --relaunch --capture-network on the local simulator stays admitted — close, capture-launch, dump source: probe.

2. One admission step replacing the nine pass-through sites and four stacked refusals — Adopted wholesale. invokeApplicationOpen no longer takes supportsLocalSimulatorLaunch at all; the flag stays on the binding (derived from ownership + shape) and only assertOpenAdmitted reads it. Both shape-only refusals are gone — a non-simulator device is refused by the same capability check, so one rule answers provider-shaped and non-shaped devices alike; the capability's message names what it means ("a local iOS simulator app launch"). invokeApplicationOpen keeps only the positional, URL-pair, and --launch-args-shape checks you said it should. Net: the nine pass-through sites and the four stacked refusals are gone (a few supportsLocalSimulatorLaunch parameters disappear from call-sites; rg supportsLocalSimulatorLaunch now finds only the binding derivation, the capability JSDoc, and the one check).

3. Non-blocking items — all taken:

  • Typed reasons: both refusals carry reason: 'launch-console-owner-unadmitted' / 'network-capture-owner-unadmitted' plus deviceId, matching the provider-runtime-interactor-missing posture. The contract tests assert error.code and error.details.reason, not message text.
  • Cap shown as stale: the capture read reports atSizeCap (file size ≥ the probe's 32 MB AdMaxFileBytes, mirrored as one exported constant the reader owns), and dumpCapturedProbeTraffic adds the note — "The capture probe reached its 32 MB file cap and stopped recording, so these entries are the newest window it wrote, not current traffic. Reopen with --capture-network to capture again." Pinned by two tests (atSizeCap at the boundary; the probe dump note).
  • Message constants between JSDoc and function: fixed, with a wrinkle worth knowing: moving them to launch-console.ts first ran into two of this repo's own gates — the facade must re-export its source module exhaustively, and fallow then fails the two new public re-exports as dead code. They are now module-private constants of application-lifecycle-interaction.ts, placed above their own JSDoc block and used only by assertOpenAdmitted; the four pre-existing shared messages are untouched.
  • ADNetworkCapture.m:52 offset read: the cap guard now uses [handle getOffset:&offset error:&offsetError] (iOS 13+, and the exact replacement offsetInFile is deprecated for) — a failed seek skips the write instead of raising, matching rule 6's posture. The -Werror warning gate compiles the changed probe against the real iphonesimulator SDK.

4. Dump precedence with #3930 — Settled in ADR 0037 rule 5, in your order: probe, then provider (Metro), then app-log — probe because it is an explicit per-open opt-in and sees React Native fetch (URLSession on iOS); the provider transport answers only with no probe window. #3930 should implement against that; happy to arbitrate on its PR.

What I ran / did not run — This pass I did run a simulator: the full live block re-executed at c1ac8638 on the iPhone simulator ios-sim-net (Xcode-active SDK, same device as the earlier runs): one --capture-network --launch-console launch → source: probe, five named httpbin URLs, 10-line console file, secrets masked in the dump and absent from network.jsonl; --relaunch --capture-network admitted on the local owner; a non-admitted open refusing with the typed code and reason; plus the app-log contrast runs. The provider scenarios (Limrun/TestMu ordering fix) remain contract-test-proven, not run against a real provider device. Still unproven, as you noted: upload/download/WebSocket tasks and runtimes older than iOS 27.

thymikee added a commit that referenced this pull request Oct 23, 2026
…licy

Settles the source-selection question raised against #3930 and #3928 in one
record: the platform runtime owns the order, the source enum is
probe/provider/app-log, no capture installs unless an explicit operator
command arms it, and the React Native provider injects only on the runtime
whose device the platform named uniquely among attached devices.
thymikee added a commit that referenced this pull request Oct 23, 2026
…licy

Settles the source-selection question raised against #3930 and #3928 in one
record: the platform runtime owns the order, the source enum is
probe/provider/app-log, no capture installs unless an explicit operator
command arms it, and the React Native provider injects only on the runtime
whose device the platform named uniquely among attached devices.
@thymikee

Copy link
Copy Markdown
Member Author

This PR is ready at c1ac863. The earlier finding is fixed: a refused --capture-network open now fails before any device side effect, and the relaunch contract test covers it. CI is green on this head (23 checks, none failing), and there are no conflicts.

Not blocking, take or leave: ADR 0037 rule 1 says a refused open never kills a running app and that refusal happens before every runtime-hint write. Only the owner refusal runs in assertOpenAdmitted. The URL-pair and no-app-target refusals still run inside invokeApplicationOpen, so open <bundle> <scheme://x> --relaunch --clear-app-state --capture-network on a local iOS simulator closes the app and then fails with INVALID_ARGS. Either scope the rule 1 sentence to the owner refusal, or move those launch-only checks into assertOpenAdmitted, since they depend only on positionals and execution.

I did not run a simulator or a provider device, compile ADNetworkCapture.m, exercise the getOffset:error: failure path, or rerun pnpm check:affected. The live run at this commit (capture, console, local --relaunch --capture-network, web refusal) is the author's. The Limrun and TestMu refusal order is covered by the contract test through the real bindDirectApplicationLifecycle. Swizzling -[NSURLSessionTask resume] is still unproven for upload, download and WebSocket tasks and on runtimes older than iOS 27, and the ADR scopes the claim to match.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 23, 2026
…ouches the device

Review round on #3928 (thymikee, third pass), non-blocking note taken as the
stronger option. ADR 0037 rule 1 says a refused open never kills a running app,
but only the owner refusal ran in assertOpenAdmitted: the URL-pair and
no-app-target refusals still ran inside invokeApplicationOpen, so
`open <bundle> <scheme://x> --relaunch --clear-app-state --capture-network` on a
local iOS simulator closed the app and then failed with INVALID_ARGS.

- One request-shape rule, `assertOpenRequestShape(positionals, execution)`,
  holds every launch-only check that needs no device: the positional count, the
  no-app-target refusals, the URL-pair refusals (first-argument, valid-URL,
  --launch-console, --capture-network) and the deep-link app refusals
  (--launch-console, --capture-network, --clear-app-state). assertOpenAdmitted
  enforces it, so every openApplication entry applies it before any relaunch
  close, runner stop, or runtime-hint write; invokeApplicationOpen keeps the
  same call for its folded and follow-up dispatches, and the open path now holds
  the one refusal that needs the platform itself (--launch-args on Linux).
- Owner capability is still checked before request shape: where an option works
  at all is the more actionable refusal than the shape it was paired with.
- Contract test: `a URL-pair relaunch carrying capture and clear-app-state is
  refused before any close` drives the review's exact command through a
  closeBeforeRelaunch local iOS-simulator binding and asserts INVALID_ARGS with
  no open, close, or setSetting call. Two table rows cover the deep-link app
  position for both launch-only options.
- ADR 0037 rule 1 now states admission covers the owner capability and the
  request shape, so its sentence is true as written for every refusal it names.
@thymikee

Copy link
Copy Markdown
Member Author

Thanks — taking the note as the stronger option, at 17c933f8.

Rule 1 vs the shape refusals — The launch-only checks that depend only on positionals and execution now live in the admission step. assertOpenRequestShape(positionals, execution) holds the positional-count check, the no-app-target refusals, the URL-pair refusals (first-argument, valid-URL, --launch-console, --capture-network), and the deep-link-app refusals (--launch-console, --capture-network, --clear-app-state); assertOpenAdmitted enforces it after the owner-capability checks (where an option works at all is the more actionable refusal than the shape it was paired with), so open <bundle> <scheme://x> --relaunch --clear-app-state --capture-network on a local iOS simulator now fails INVALID_ARGS before the close. ADR 0037 rule 1 states both halves of admission, and its "never kills a running app" sentence is true as written for every refusal it names. invokeApplicationOpen keeps the same shape call for its own folded and follow-up dispatches (the Apple URL fold and the runtime-launch-URL follow-up re-enter it), so the rule is one function enforced at both the entry and the dispatch, and the only refusal left inside the canonical open is the one that genuinely needs the platform (--launch-args on Linux).

Contract test — a URL-pair relaunch carrying capture and clear-app-state is refused before any close: a local iOS-simulator binding through bindDirectApplicationLifecycle({ closeBeforeRelaunch: true }), relaunch: true, positionals ['com.example.app', 'example://deal'], execution { clearAppState: true, networkCapturePath }; asserts INVALID_ARGS and that the interactor recorded no open, close, or setSetting call. Two table rows also cover the deep-link app position for both launch-only options.

Code shape note — the flat version of the shape rule breached fallow's complexity threshold at the exact function the previous round moved checks into, so it is split into one helper per positional shape (device-only / URL-pair / deep-link app), which is what the two extra table rows pin.

No live re-run: admitted-open behavior is unchanged — every moved refusal keeps its code and message, only its timing moved earlier; the commands the last live run admitted (capture, console, local --relaunch --capture-network) take the same path. pnpm check:affected --run --head 17c933f8 → all runnable checks passed, exactCommit=true. (First run hit the known load-variance timeout in the unrelated apple-platform-output-guard slow test while another agent's vitest run shared the machine; the retry passed — the test itself is 16.9 s at this head and 18.0 s at c1ac863.)

@thymikee

Copy link
Copy Markdown
Member Author

This PR is ready at 17c933f. The change since the earlier review (c1ac863) closes the note about refusal ordering: an open that carries --launch-console or --capture-network and is not a direct app target is now refused before anything closes, and a direct-app open passes the shape check unchanged. All 23 checks pass on this commit and there are no conflicts. Nothing else must happen before merge.

Not blocking, and fine to take or leave: the new doc block in packages/contracts/src/application-lifecycle-interaction.ts says --launch-console, --capture-network and --clear-app-state refuse every shape but a direct app target, but --clear-app-state is refused only for a single deep-link target (it is not checked for a URL pair or a device-only open). Listing only the first two flags in that sentence, and saying that --clear-app-state refuses a deep-link target, would match the code. The test title at application-lifecycle-interaction.test.ts:267 could also drop "clear-app-state", since only the capture flag triggers that refusal.

I read the code at both commits but did not run the contracts tests or any gate locally, and I did not do a live device run of the new refusal ordering. Your live run at c1ac863 still covers the capture happy path. There is also no Apple or Android lifecycle test of the URL-pair refusal before close, though the ordering follows from the admission check being the first statement in both entries. I read ADR 0038 from the unmerged branch for #3930, so it can still change before that lands.

thymikee added a commit that referenced this pull request Oct 24, 2026
…touches the device

Extracted from #3928. assertOpenAdmitted and assertOpenRequestShape run first in
openDirectApplication, openAndroidApplication and openAppleApplication, so an open option
the bound owner cannot honor is refused before any relaunch close, runner stop, or
runtime-hint write. On a provider iOS simulator (Limrun/TestMu shape), --relaunch
--launch-console used to close the running app and only then fail. The binding now carries
a supportsLocalSimulatorLaunch owner capability, and every refusal uses a typed reason.
thymikee added a commit that referenced this pull request Oct 24, 2026
…touches the device

Extracted from #3928. assertOpenAdmitted and assertOpenRequestShape run first in
openDirectApplication, openAndroidApplication and openAppleApplication, so an open option
the bound owner cannot honor is refused before any relaunch close, runner stop, or
runtime-hint write. On a provider iOS simulator (Limrun/TestMu shape), --relaunch
--launch-console used to close the running app and only then fail. The binding now carries
a supportsLocalSimulatorLaunch owner capability, and every refusal uses a typed reason.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant