diff --git a/docs/adr/0038-network-dump-source-selection.md b/docs/adr/0038-network-dump-source-selection.md new file mode 100644 index 0000000000..56bfc1ff99 --- /dev/null +++ b/docs/adr/0038-network-dump-source-selection.md @@ -0,0 +1,38 @@ +# 0038: Network dump source selection + +## Status + +Accepted for this PR's sources (provider, app-log) and the device-identity gate. ADR 0037 (the iOS Simulator network capture probe) is proposed in #3928 and names `probe` and `app-log`; the `probe` row below and the cross-PR ordering are this record's proposal to it. Before both land, one record must own the enum: the PR that lands second amends its own record to the single enum and owner named here, or records a different order it can defend. + +## Rules at a glance + +- `network dump` answers from exactly one source, reported in its `source` field: `probe` (injected iOS native probe, 0037), `provider` (injected React Native JS capture, this record), or `app-log` (session app log, all sessions). +- The platform runtime owns the ordering. Each platform runtime lists its sources once per dump, takes the first that answers with entries, and builds the app-log scan with its own platform recovery around the rest. A source never reaches into another source's state. +- Injection policy: no capture installs in the app under test unless an explicit operator command arms it, and the command that arms says so. `open --capture-network` arms the iOS probe at launch (0037); the first `network dump` of a React Native app session with a Metro binding arms the JS capture and reports the arming note. Nothing arms from a background path. +- The React Native provider injects into one runtime only: the dev-server runtime of the session's app on the device named by the platform-composed Metro device identity. The identity is granted only when the platform proves the name marks one attachable device. Android lists every serial adb reports, in any state including offline and unauthorized, and reads each serial's properties; any unreadable read withholds the identity. Apple lists booted simulators in every simulator set the host can name — the device's own set, the default set, and the daemon operator's set — and the connected physical devices devicectl reports; an unreadable listing, an unreadable physical-device answer, or a set the host cannot reconcile with its own configuration withholds the identity. A withheld identity fails the provider closed with `metro-device-mismatch`; the dump still answers from the app log. The identity probe runs lazily inside a dump, so only a session whose transport declared a provider dump pays for the probe. + +## Decision + +The React Native JS capture is a `provider` source, preferred over the app log and preferred under the probe. When both injected sources are armed for one session (an iOS simulator app opened with `--capture-network` that also loads its bundle from Metro), the probe answers first: it names every URLSession request, including the JS capture's own and any native-transport traffic. The JS capture adds headers and bodies observed at the JS layer, so it answers when the probe is not armed. The app log answers last and always remains available. + +Selection stays inside the platform runtime rather than a shared walker because the app-log fallbacks are not shareable: Apple recovers from `simctl log show`, Android from `adb logcat` with PID handling. A shared walker would need host slots per platform and would re-create that split behind an indirection. + +## Reason + +The provider answers with exact method, URL, status, duration, headers, and bodies, which the app log rarely names. The fail-closed device-identity rule exists because a runtime is selected by a name the dev server reports: two emulators of one system image, or two same-name simulators, report one name, and the weaker match let one session hook, drain, and report another device's traffic. The platform that owns the device inventory (adb device properties; `simctl list devices booted`) is the only layer that can say whether a name marks exactly one attached device. + +Lazy arming of the JS capture keeps injection honest: code enters the app under test only because a `network dump` asked for it, the first window reads the app log with an explanatory note, and the same rule generalizes to the probe, which arms only under an explicit open flag. + +## Rejected alternatives + +- Arming the JS capture at `open` when Metro is bound. It injects before any command asks for network data and needs an attach-retry policy for cold-start races with the dev server. +- Selecting runtimes by a shared display name, model, or alias list. A name two attached devices share is not an identity; the review of #3930 showed one session draining another device's buffer that way. The platform now passes a composed identity only when it checked every attached device for a collision. +- Matching the dev server's ` - - API ` report by model prefix. Same defect: same-image emulators collide on the prefix. +- Subscribing to the RN DevTools built-in Network CDP domain instead of injecting code. It needs a daemon-held CDP connection for the session's lifetime and drops React Native versions without the domain; the injected runtime is the version-agnostic core. + +## Consequences + +- A source field value is added only with its owner and its place in the order recorded here. +- A physical Apple device gets no Metro device identity (its dev-server-reported name has no host-side listing that proves which runtime it belongs to), so the React Native provider stays off there and the dump answers from the app log. Connected physical devices do count against a simulator's identity: a same-named one could attach to the same dev server. +- Accepted risk (Apple sets): `simctl` cannot enumerate simulator sets, so the identity check covers the sets the host can name — the device's own set, the default set, and the set `AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET` configures. A simulator booted in some other set whose path this host was never told about is invisible to every available tool and cannot be ruled out. Operators who share one dev server across simulator sets must keep simulator display names unique across those sets. +- The device-identity gate is a typed note on an `app-log` answer, never a command failure, so a selection problem degrades to the old capture instead of losing the dump. diff --git a/docs/adr/README.md b/docs/adr/README.md index 67cbd4b30f..47ba1ea328 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -38,6 +38,7 @@ | [0034 Provider plugin registry](0034-provider-plugin-registry.md) | provider plugins, the `agent-device/plugins` contract, plugin sources and order, duplicate provider ids, plugin failure isolation, the daemon plugin inventory and `daemon status` | | [0035 macOS App Devices](0035-macos-app-devices.md) | native macOS apps as devices of their own, the host/app-device overlap rule, concurrent app sessions, and `macos-app` lease claims | | [0036 Bounded Composite Resource Admission](0036-bounded-composite-resource-admission.md) | resource operations, nested tests, session disposal, and claim recovery inside bounded batch/replay steps | +| [0038 Network Dump Source Selection](0038-network-dump-source-selection.md) | the `network dump` source owner and enum, the lazy-arming rule, and the Metro device-identity gate | ADRs record rationale. The registries and gates they describe are the current source of truth. diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 46f606a3aa..1290ecdec4 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -19,6 +19,7 @@ It is intentionally small, but each surface is dense with durable accessibility - `Automation lab`: long-press, alert-result, app-event, app-state, appearance, orientation, permission-recovery, log canaries, a flattened (`accessible={true}`) text input, and an Apple Pay sheet hosted in `com.apple.PassbookUIService` - `WebView accessibility`: a deterministic semantic fixture plus live websites with varied HTML for native accessibility snapshot verification - `Perf lab`: seeded JavaScript render problems for `react-devtools profile`, one per scenario (see [Perf lab](#perf-lab)) +- `Network lab`: deterministic `fetch` and `XMLHttpRequest` traffic for `network dump` verification (see [Network lab](#network-lab)) - `Heavy feed`: a Bluesky-shaped home feed whose accessibility tree is deeper than 65 levels (see [Heavy feed](#heavy-feed)) Navigation uses Expo Router native bottom tabs, so the tab bar itself is also part of the test surface. @@ -53,6 +54,7 @@ These are the main case families this app can support without adding more screen - retryable error banners - cart counters and quantity changes - screenshot and recording proof capture +- in-app `network dump` on real `fetch` and `XMLHttpRequest` traffic from the network lab ## Run locally @@ -262,6 +264,20 @@ agent-device react-devtools profile stop `src/screens/PerfLabScreen.tsx` imports only React and React Native, so it can be copied into a bare React Native app to check the profiler on other React Native versions. +## Network lab + +`agent-device-test-app://network-lab` fires one real request per press: **Run fetch** (`network-fetch`) +GETs `httpbin.org/json`, **Run post** (`network-post`) POSTs a JSON body to `httpbin.org/post`, and +**Run XHR** (`network-xhr`) GETs through `XMLHttpRequest`. `network-lab-status` reports +`fetch:200:` once a request settles. Use it to verify an in-app `network dump`: + +```bash +agent-device open "agent-device-test-app://network-lab" --platform ios +agent-device network dump # first dump arms the in-app capture +agent-device click 'id="network-fetch"' +agent-device network dump --include all # reports the GET with status, headers, bodies +``` + ## Heavy feed **Settings → Open heavy feed** (`open-heavy-feed`) opens a JavaScript-only reproduction of a heavy diff --git a/examples/test-app/app/_layout.tsx b/examples/test-app/app/_layout.tsx index e452a411d2..f9b6a1d2b0 100644 --- a/examples/test-app/app/_layout.tsx +++ b/examples/test-app/app/_layout.tsx @@ -31,6 +31,7 @@ function RootLayoutContent() { + diff --git a/examples/test-app/app/network-lab.tsx b/examples/test-app/app/network-lab.tsx new file mode 100644 index 0000000000..e2cdb34b5a --- /dev/null +++ b/examples/test-app/app/network-lab.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const ENDPOINT = 'https://httpbin.org/json'; + +/** + * Network lab: deterministic in-app traffic for `network dump` verification. + * Each press performs one plain `fetch` or `XMLHttpRequest` so a dump can be + * checked against a request the harness just made. + */ +export default function NetworkLabRoute() { + const insets = useSafeAreaInsets(); + const [status, setStatus] = useState('idle'); + + const runFetch = async () => { + setStatus('running'); + try { + const response = await fetch(ENDPOINT, { + method: 'GET', + headers: { 'x-agent-device-lab': 'network', 'x-api-key': 'lab-secret-1' }, + }); + const body = await response.text(); + setStatus(`fetch:${response.status}:${body.length}`); + } catch (error) { + setStatus(`fetch:error:${(error as Error).message}`); + } + }; + + /** + * One failed fetch followed by a direct XHR to the same URL. The capture must report both: + * the failed fetch's claim on the URL must not hide the XHR that comes after it. + */ + const runFailedFetchThenXhr = async () => { + setStatus('running'); + const url = 'https://invalid.agent-device.test/after-failed-fetch'; + try { + await fetch(url); + } catch { + // The failure is the fixture; the XHR below is what the dump must still name. + } + const request = new XMLHttpRequest(); + request.open('GET', url); + request.onload = () => setStatus(`xhr-after-failed-fetch:${request.status}`); + request.onerror = () => setStatus(`xhr-after-failed-fetch:failed:${request.status}`); + request.send(); + }; + + const runPost = async () => { + setStatus('running'); + try { + const response = await fetch('https://httpbin.org/post', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ lab: 'network', seq: 1 }), + }); + setStatus(`post:${response.status}`); + } catch (error) { + setStatus(`post:error:${(error as Error).message}`); + } + }; + + const runXhr = () => { + setStatus('running'); + const request = new XMLHttpRequest(); + request.open('GET', 'https://httpbin.org/get?xhr=1'); + request.onload = () => setStatus(`xhr:${request.status}`); + request.onerror = () => setStatus('xhr:error'); + request.send(); + }; + + return ( + + Network lab + void runFetch()} style={styles.button} testID="network-fetch"> + Run fetch + + void runPost()} style={styles.button} testID="network-post"> + Run post + + + Run XHR + + void runFailedFetchThenXhr()} + style={styles.button} + testID="network-failed-fetch-xhr" + > + Run failed fetch then XHR + + + {status} + + + ); +} + +const styles = StyleSheet.create({ + content: { padding: 16, gap: 12 }, + button: { paddingVertical: 12 }, +}); diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index 50eb31dafd..caa912fac4 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -14,6 +14,10 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./network-provider-capture": { + "types": "./src/network-provider-capture.ts", + "default": "./src/network-provider-capture.ts" + }, "./android-replacement-surface-occlusion": { "types": "./src/snapshot/android-replacement-surface-occlusion.ts", "default": "./src/snapshot/android-replacement-surface-occlusion.ts" diff --git a/packages/capture-kit/src/network-provider-capture.test.ts b/packages/capture-kit/src/network-provider-capture.test.ts new file mode 100644 index 0000000000..d6bd87eda7 --- /dev/null +++ b/packages/capture-kit/src/network-provider-capture.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + NetworkDumpInput, + NetworkProviderDumpResult, + NetworkTransport, + NetworkTransportScope, +} from '@agent-device/contracts/network-runtime'; +import { captureNetworkProviderTraffic } from './network-provider-capture.ts'; + +const device: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'device-1', + name: 'Device', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +const input: NetworkDumpInput = { + sessionId: 'repro', + appBundleId: 'com.example.app', + stateDir: '/tmp/state', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 2_000, +}; + +function hostWith(transport: NetworkTransport | undefined, resolveError?: Error) { + const scopes: NetworkTransportScope[] = []; + return { + scopes, + host: { + networkTransports: { + resolve: async (_device: DeviceInfo, scope?: NetworkTransportScope) => { + if (resolveError) throw resolveError; + if (scope) scopes.push(scope); + if (!transport) throw new Error('no transport'); + return transport; + }, + }, + }, + }; +} + +test('the provider result is handed back with the session scope the transport needs', async () => { + const result: NetworkProviderDumpResult = { + backend: 'react-native-app', + entries: [{ url: 'https://api.test/x', method: 'GET', status: 200 }], + }; + let seen: { maxEntries: number; include: string } | undefined; + const fixture = hostWith({ + mode: 'transport-composed', + dump: async (dumpInput) => { + seen = dumpInput; + return result; + }, + }); + + const capture = await captureNetworkProviderTraffic( + fixture.host, + device, + input, + new AbortController().signal, + { resolveMetroDeviceIdentity: async () => 'Device' }, + ); + + assert.deepEqual(capture, { status: 'captured', result }); + assert.deepEqual(seen, { maxEntries: 25, include: 'summary' }); + assert.equal( + await fixture.scopes[0]?.resolveMetroDeviceIdentity?.(new AbortController().signal), + 'Device', + ); + assert.deepEqual( + { ...fixture.scopes[0], resolveMetroDeviceIdentity: undefined }, + { + sessionId: 'repro', + stateDir: '/tmp/state', + appBundleId: 'com.example.app', + maxPayloadChars: 2048, + resolveMetroDeviceIdentity: undefined, + }, + ); +}); + +test('a caller without a device identity resolver reaches the transport without one; the dump fails closed', async () => { + const fixture = hostWith({ + mode: 'transport-composed', + dump: async () => ({ backend: 'react-native-app', entries: [] }), + }); + + await captureNetworkProviderTraffic(fixture.host, device, input, new AbortController().signal); + + assert.equal(fixture.scopes[0]?.resolveMetroDeviceIdentity, undefined); +}); + +test('a transport that declares no dump never calls the identity resolver: the probe stays unpaid', async () => { + let probed = 0; + const fixture = hostWith({ mode: 'local' }); + + const capture = await captureNetworkProviderTraffic( + fixture.host, + device, + input, + new AbortController().signal, + { + resolveMetroDeviceIdentity: async () => { + probed += 1; + return 'Device'; + }, + }, + ); + + assert.deepEqual(capture, { status: 'unavailable', notes: [] }); + assert.equal(probed, 0); +}); + +test('a session without an app id never probes a provider: injection needs a named app', async () => { + const fixture = hostWith({ + mode: 'transport-composed', + dump: async () => ({ backend: 'x', entries: [] }), + }); + + const capture = await captureNetworkProviderTraffic( + fixture.host, + device, + { ...input, appBundleId: undefined }, + new AbortController().signal, + ); + + assert.deepEqual(capture, { status: 'unavailable', notes: [] }); + assert.equal(fixture.scopes.length, 0); +}); + +test('a provider that only armed its capture reports armed so the caller keeps its own source', async () => { + const fixture = hostWith({ + mode: 'transport-composed', + dump: async () => ({ + backend: 'react-native-app', + entries: [], + armed: true, + notes: ['Capture was installed by this dump.'], + }), + }); + + const capture = await captureNetworkProviderTraffic( + fixture.host, + device, + input, + new AbortController().signal, + ); + + assert.deepEqual(capture, { + status: 'armed', + notes: ['Capture was installed by this dump.'], + }); +}); + +test('an armed provider that already holds traffic is a capture, not an arming report', async () => { + const result: NetworkProviderDumpResult = { + backend: 'react-native-app', + entries: [{ url: 'https://api.test/x' }], + armed: true, + }; + const fixture = hostWith({ mode: 'transport-composed', dump: async () => result }); + + const capture = await captureNetworkProviderTraffic( + fixture.host, + device, + input, + new AbortController().signal, + ); + + assert.deepEqual(capture, { status: 'captured', result }); +}); + +test('a missing provider transport is unavailable without a note, not an error', async () => { + const fixture = hostWith({ mode: 'local' }); + + const capture = await captureNetworkProviderTraffic( + fixture.host, + device, + input, + new AbortController().signal, + ); + + assert.deepEqual(capture, { status: 'unavailable', notes: [] }); +}); + +test('a provider that cannot answer degrades to one typed note instead of throwing', async () => { + const fixture = hostWith({ + mode: 'transport-composed', + dump: async () => { + throw new Error('No React Native runtime is connected to the dev server.'); + }, + }); + + const capture = await captureNetworkProviderTraffic( + fixture.host, + device, + input, + new AbortController().signal, + ); + + assert.equal(capture.status, 'unavailable'); + const notes = capture.status === 'unavailable' ? capture.notes : []; + assert.equal(notes.length, 1); + assert.match(notes[0]!, /No React Native runtime is connected/); + assert.match(notes[0]!, /reads the session app log instead/); +}); + +test('a session abort still fails the dump instead of hiding it as unavailable', async () => { + const controller = new AbortController(); + const fixture = hostWith({ + mode: 'transport-composed', + dump: async (_dumpInput, signal) => { + controller.abort(); + signal?.throwIfAborted(); + throw new Error('aborted'); + }, + }); + + await assert.rejects( + captureNetworkProviderTraffic(fixture.host, device, input, controller.signal), + /aborted/, + ); +}); diff --git a/packages/capture-kit/src/network-provider-capture.ts b/packages/capture-kit/src/network-provider-capture.ts new file mode 100644 index 0000000000..5c8b1c135d --- /dev/null +++ b/packages/capture-kit/src/network-provider-capture.ts @@ -0,0 +1,140 @@ +import type { + NetworkAppLogRead, + NetworkDumpInput, + NetworkProviderDumpResult, + NetworkTransportScope, +} from '@agent-device/contracts/network-runtime'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { LogBackend } from '@agent-device/contracts/observability'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { redactDiagnosticData } from '@agent-device/kernel/redaction'; +import { readRecentNetworkTrafficFromText, type NetworkScan } from './network-traffic.ts'; + +/** + * A provider source answers a dump with entries, with nothing yet to report, or not at all. + * `unavailable` carries why: a session whose app-side capture broke mid-repro must not read as + * an empty traffic window. + */ +export type NetworkProviderCapture = + | Readonly<{ status: 'captured'; result: NetworkProviderDumpResult }> + | Readonly<{ status: 'armed'; notes: readonly string[] }> + | Readonly<{ + status: 'unavailable'; + /** The session names a provider source that could not answer; the note says why. */ + notes: readonly string[]; + }>; + +// A wedged app runtime must not cost the dump more than this; the session's second capture +// source answers after it, well inside the command envelope. +const PROVIDER_CAPTURE_BUDGET_MS = 12_000; + +/** + * Reads one session's provider network source, when its transport declares one. A provider that + * only armed its capture during this call reports `armed` with no entries, so the caller answers + * the current window from its own source and keeps the arming note. Every provider failure stays + * a typed `unavailable` note; this function never throws. The caller passes a resolver for the + * dev-server identity of exactly one device attached to this host + * (`resolveMetroDeviceIdentity`); a transport calls it only inside a dump, so a non-React-Native + * session never pays for the probe, and without an identity the provider fails closed. + */ +export async function captureNetworkProviderTraffic( + host: Pick, + device: DeviceInfo, + input: NetworkDumpInput, + signal: AbortSignal, + options: { + resolveMetroDeviceIdentity?: (signal: AbortSignal) => Promise; + } = {}, +): Promise { + // A provider dump injects into the runtime it selects, so it runs only for a session that names + // its app. The identity gate lives in the dump itself, which fails closed without one. + if (input.appBundleId === undefined) return { status: 'unavailable', notes: [] }; + const scope: NetworkTransportScope = { + sessionId: input.sessionId, + stateDir: input.stateDir, + appBundleId: input.appBundleId, + maxPayloadChars: input.maxPayloadChars, + ...(options.resolveMetroDeviceIdentity === undefined + ? {} + : { resolveMetroDeviceIdentity: options.resolveMetroDeviceIdentity }), + }; + let transport; + try { + transport = await host.networkTransports.resolve(device, scope); + } catch (error) { + return { status: 'unavailable', notes: [providerFailureNote(error)] }; + } + const dump = transport.dump; + if (!dump) return { status: 'unavailable', notes: [] }; + const bounded = AbortSignal.any([signal, AbortSignal.timeout(PROVIDER_CAPTURE_BUDGET_MS)]); + let result; + try { + // Racing the budget signal bounds the whole provider read: the transport's own steps can + // each carry longer internal timeouts, and an unbounded provider would push the dump past + // the command envelope while its work keeps running after capture-kit moved on. + result = await Promise.race([ + dump({ maxEntries: input.maxEntries, include: input.include }, bounded), + abortRejection(bounded, input.sessionId), + ]); + } catch (error) { + if (signal.aborted) throw error; + return { status: 'unavailable', notes: [providerFailureNote(error)] }; + } + if (result.armed && result.entries.length === 0) { + return { status: 'armed', notes: result.notes ?? [] }; + } + return { status: 'captured', result }; +} + +function abortRejection(signal: AbortSignal, sessionId: string): Promise { + return new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(new Error(`The in-app network capture exceeded its budget for session ${sessionId}.`)); + return; + } + signal.addEventListener( + 'abort', + () => + reject( + new Error(`The in-app network capture exceeded its budget for session ${sessionId}.`), + ), + { once: true }, + ); + }); +} + +function providerFailureNote(error: unknown): string { + // The message can carry text from a local dev server or the app runtime, so it passes through + // the diagnostic redaction owner before it reaches a response note. + const { message } = redactDiagnosticData({ + message: error instanceof Error ? error.message : String(error), + }); + return `In-app network capture could not answer this dump: ${message} This dump reads the session app log instead.`; +} + +/** A platform's app-log fallback window: the scan and the file identity its notes name. */ +export type NetworkAppLogFallback = Readonly<{ + scan: NetworkScan; + recent: NetworkAppLogRead; +}>; + +/** + * Reads the session app log and scans it for HTTP traffic: the fallback every platform answers + * from when its provider source has nothing to report. `backend` names the log family the parser + * applies. + */ +export async function scanNetworkAppLogFallback( + host: Pick, + input: NetworkDumpInput, + backend: LogBackend, +): Promise { + const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); + const scan = readRecentNetworkTrafficFromText(recent.text, { + ...input, + path: recent.path, + exists: recent.exists, + lineNumberOffset: recent.skippedLines, + backend, + }); + return { scan, recent }; +} diff --git a/packages/contracts/src/network-runtime.ts b/packages/contracts/src/network-runtime.ts index 6a8cc7403a..98ae78d5b3 100644 --- a/packages/contracts/src/network-runtime.ts +++ b/packages/contracts/src/network-runtime.ts @@ -15,12 +15,17 @@ export type NetworkDumpInput = Readonly<{ sessionId: string; appBundleId?: string; appLogSnapshot?: NetworkAppLogSnapshot; + /** Daemon state dir (parent of `sessions/`), so an in-app capture can find this session's Metro hints. */ + stateDir?: string; maxEntries: number; include: NetworkIncludeMode; maxPayloadChars: number; maxScanLines: number; }>; +/** Payload budget one `network dump` applies; the same bound pre-binds what a transport reads. */ +export const NETWORK_MAX_PAYLOAD_CHARS = 2048; + export type NetworkProviderEntry = Readonly<{ timestamp?: string; method?: string; @@ -38,6 +43,12 @@ export type NetworkProviderDumpResult = Readonly<{ backend: string; entries: readonly NetworkProviderEntry[]; notes?: readonly string[]; + /** + * The provider installed its capture during this call and its buffer was still empty, so this + * call arms later traffic instead of reporting this one. A caller with a second capture source + * answers the current window from that source and says so. + */ + armed?: boolean; }>; export type NetworkDumpResult = @@ -58,6 +69,24 @@ export type NetworkProviderDump = ( signal?: AbortSignal, ) => Promise; +/** Session facts a transport may need to reach one session's app-side traffic source. */ +export type NetworkTransportScope = Readonly<{ + sessionId: string; + /** Daemon state dir (parent of `sessions/`), where a session's Metro binding is recorded. */ + stateDir?: string; + appBundleId?: string; + /** Payload budget the dump will apply; a transport may pre-bound what it reads with it. */ + maxPayloadChars?: number; + /** + * Lazily composes the dev server's device name of exactly one device attached to this host. The + * platform that owns the device inventory provides it; a transport calls it only inside a dump, + * so a session whose transport declares none never pays for the probe (adb device properties or + * `simctl list devices booted`). It answers undefined when the host cannot name one attached + * device unambiguously; then the provider fails closed instead of guessing. + */ + resolveMetroDeviceIdentity?: (signal: AbortSignal) => Promise; +}>; + export type NetworkTransport = Readonly<{ mode: Extract; /** Absent when the selected narrow transport cannot provide network traffic. */ @@ -81,6 +110,6 @@ export type NetworkRuntimeHost = Readonly<{ readProcessMarker(sessionId: string): Promise; }>; networkTransports: Readonly<{ - resolve(device: DeviceInfo): Promise; + resolve(device: DeviceInfo, scope?: NetworkTransportScope): Promise; }>; }>; diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts index f49cb291d5..6b7bc6408c 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts @@ -96,6 +96,7 @@ test.each(NETWORK_RUNTIME_PROJECTION_PARITY)( expect(runtime.networkDump).toHaveBeenCalledWith({ sessionId: sessionName, appBundleId: 'com.example.app', + stateDir: sessionStore.resolveDaemonStateDir(), appLogSnapshot: { state: 'active', startedAt: 123 }, maxEntries: 7, include, diff --git a/packages/daemon/src/sessions/session-observability/internal/session-network.ts b/packages/daemon/src/sessions/session-observability/internal/session-network.ts index 95e1f73328..c18c01710d 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-network.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-network.ts @@ -1,5 +1,6 @@ import type { DaemonPlatformServices } from '../../../core/platform-services.ts'; import type { NetworkDumpResult } from '@agent-device/contracts/network-runtime'; +import { NETWORK_MAX_PAYLOAD_CHARS } from '@agent-device/contracts/network-runtime'; import { networkAdmissionUse, resolveNetworkRuntimePlan, @@ -19,7 +20,6 @@ import type { DaemonRequest } from '../../../core/daemon-request.ts'; const NETWORK_ACTIONS = ['dump', 'log'] as const; const NETWORK_ACTIONS_MESSAGE = `network requires ${NETWORK_ACTIONS.join(' or ')}`; const NETWORK_INCLUDE_MESSAGE = `network include mode must be one of: ${NETWORK_INCLUDE_MODES.join(', ')}`; -const NETWORK_MAX_PAYLOAD_CHARS = 2048; const NETWORK_MAX_SCAN_LINES = 4000; export type NetworkHandlerParams = Readonly<{ @@ -59,6 +59,7 @@ export async function handleNetworkCommand(params: NetworkHandlerParams): Promis const result = await runtime.operations.networkDump({ sessionId: params.sessionName, appBundleId: session.appBundleId, + stateDir: params.sessionStore.resolveDaemonStateDir(), ...(appLog ? { appLogSnapshot: { state: appLog.state, startedAt: appLog.startedAt } } : {}), maxEntries: request.maxEntries, include: plan.include, @@ -155,6 +156,7 @@ function networkDumpResponse( ok: true, data: { ...result.dump, + source: 'app-log', active, state: state ?? 'inactive', backend: result.backend, @@ -167,6 +169,9 @@ function networkDumpResponse( ok: true, data: { entries: result.entries, + source: 'provider', + // Reaching this branch means the app-side hook answered this very dump, so the capture is + // live by construction; the app-log process state says nothing about it. active: true, state: 'active', backend: result.backend, diff --git a/packages/kernel/src/redaction.test.ts b/packages/kernel/src/redaction.test.ts index 288f298f1f..52a54c01d6 100644 --- a/packages/kernel/src/redaction.test.ts +++ b/packages/kernel/src/redaction.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { redactDiagnosticData } from './redaction.ts'; +import { redactDiagnosticData, redactNetworkPayload } from './redaction.ts'; test('stderr retains nested command failure details beyond 400 characters', () => { const stderr = `Launch request denied\n${'Context line\n'.repeat(50)}Underlying reason: device locked`; @@ -46,3 +46,48 @@ test('other diagnostic strings retain the 400-character limit', () => { assert.ok(result.message.endsWith('...')); assert.ok(result.stdout.endsWith('...')); }); + +test('a network payload keeps its own budget instead of the diagnostic limit', () => { + const payload = 'p'.repeat(5000); + // The truncation marker counts against the budget, like the diagnostic bound. + assert.equal(redactNetworkPayload(payload, 2048).length, 2048); + assert.ok(redactNetworkPayload(payload, 2048).endsWith('...')); + assert.equal(redactNetworkPayload('p'.repeat(100), 2048), 'p'.repeat(100)); +}); + +test('a network payload masks secrets in each field syntax it can hold', () => { + assert.equal( + redactNetworkPayload('{"apiKey":"sk-live-1","user":"bob"}', 2048), + '{"apiKey":"[REDACTED]","user":"bob"}', + ); + assert.equal(redactNetworkPayload('token=head-secret', 2048), 'token=[REDACTED]'); + assert.equal(redactNetworkPayload('bearer abc.def', 2048), '[REDACTED]'); + assert.equal( + redactNetworkPayload('{"token":"adc_agent_..."}', 2048), + '{"token":"adc_agent_..."}', + ); + assert.equal( + redactNetworkPayload("curl --header 'api_key=sk-live-9999'", 2048), + "curl --header 'api_key=[REDACTED]", + ); + assert.equal( + redactNetworkPayload(String.raw`{"apiKey":"ab\"cd-secret"}`, 2048), + '{"apiKey":"[REDACTED]"}', + ); +}); + +test('a network payload truncation stays inside its budget', () => { + const redacted = redactNetworkPayload(`${'x'.repeat(3000)}token=abc123secret`, 2048); + assert.equal(redacted.length, 2048); + assert.ok(redacted.includes('truncated')); +}); + +test('a header with a sensitive name is replaced and its peers keep their text', () => { + assert.deepEqual( + redactDiagnosticData({ authorization: 'Bearer abc123', 'x-request-id': 'r-1' }), + { + authorization: '[REDACTED]', + 'x-request-id': 'r-1', + }, + ); +}); diff --git a/packages/kernel/src/redaction.ts b/packages/kernel/src/redaction.ts index 7889a19f04..6e871a9973 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -4,6 +4,14 @@ const SECRET_TOKEN_RE = /\b(?:bearer\s+[a-z0-9._-]+|adc_(?:agent|live|refresh|cli)_[a-z0-9._-]+)\b/gi; const SENSITIVE_ASSIGNMENT_RE = /\b([a-z0-9_-]*(?:api[_-]?key|token|secret|password|user[_-]?code|device[_-]?code|refresh[_-]?credential)[a-z0-9_-]*)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi; +/** + * A JSON secret assignment, where the key is quoted: `"apiKey":"sk-live"`. The value keeps its + * quotes so the payload stays valid JSON. It runs first, so the whitespace-tolerant rule above + * never meets the quoted form, whose key `token` sits one character after a quote the `\b` of the + * earlier rule cannot match the same way. + */ +const SENSITIVE_JSON_ASSIGNMENT_RE = + /(["'][a-z0-9_-]*(?:api[_-]?key|token|secret|password|user[_-]?code|device[_-]?code|refresh[_-]?credential)[a-z0-9_-]*["']\s*[=:]\s*)("(?:[^"\\]|\\.)*"|'[^']*')/gi; const URL_RE = /https?:\/\/[^\s"'<>]+/gi; const REDACTED_STRING_MAX_LENGTH = 400; const REDACTED_STDERR_MAX_LENGTH = 8192; @@ -13,6 +21,19 @@ export function redactDiagnosticData(input: T): T { return redactValue(input, new WeakSet()) as T; } +/** + * Redacts one captured network payload (a body) with the same secret rules diagnostics use, then + * bounds it to `maxLength` characters. Captured HTTP payloads carry their own agreed budget, so + * this bound replaces the shorter diagnostic string bound. + */ +export function redactNetworkPayload(value: string, maxLength: number): string { + const redacted = maskSecrets(value); + if (redacted.length <= maxLength) return redacted; + // The truncation marker counts against the budget, like the diagnostic bound. + const kept = Math.max(0, maxLength - TRUNCATION_SUFFIX.length); + return `${redacted.slice(0, kept)}${TRUNCATION_SUFFIX}`; +} + /** Sanitizes an untrusted structured cause before it crosses a process or client boundary. */ export function sanitizeErrorCause(cause: unknown): { message: string; code?: string } | undefined { if (!cause || typeof cause !== 'object') return undefined; @@ -50,11 +71,25 @@ function redactValue(value: unknown, seen: WeakSet, keyHint?: string): u } function redactString(value: string, keyHint?: string): string { + return boundRedactedString(maskSecrets(value, keyHint), keyHint); +} + +/** Applies the secret-masking rules without any length bound; callers set their own budget. */ +function maskSecrets(value: string, keyHint?: string): string { const trimmed = value.trim(); - if (!trimmed) return boundRedactedString(value, keyHint); + if (!trimmed) return value; if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]'; let output = redactUrls(trimmed); output = output.replace(SECRET_TOKEN_RE, '[REDACTED]'); + output = output.replace( + SENSITIVE_JSON_ASSIGNMENT_RE, + (match, assignment: string, rawValue: string) => { + const quote = rawValue[0]!; + const inner = rawValue.slice(1, -1); + if (isDocumentedTokenPlaceholder(inner)) return match; + return `${assignment}${quote}[REDACTED]${quote}`; + }, + ); output = output.replace( SENSITIVE_ASSIGNMENT_RE, (match, key: string, separator: string, rawValue: string, offset: number, input: string) => { @@ -63,7 +98,7 @@ function redactString(value: string, keyHint?: string): string { return `${key}${separator}[REDACTED]`; }, ); - return boundRedactedString(output, keyHint); + return output; } function boundRedactedString(value: string, keyHint?: string): string { diff --git a/packages/platform-android/src/inventory-parsers.ts b/packages/platform-android/src/inventory-parsers.ts index eb76f4f7ee..2b737ba301 100644 --- a/packages/platform-android/src/inventory-parsers.ts +++ b/packages/platform-android/src/inventory-parsers.ts @@ -13,6 +13,28 @@ export type AndroidDeviceEntry = Readonly<{ rawModel: string; }>; +/** One listed row of `adb devices` output in any state (device, offline, unauthorized, ...). */ +export type AndroidListedDeviceEntry = Readonly<{ + serial: string; + state: string; +}>; + +/** + * Every serial `adb devices` (or `adb devices -l`) listed, with its state. The listing keeps + * offline and unauthorized serials, which a caller proving host-wide device counts must see. + */ +export function parseAndroidListedDeviceEntries(rawOutput: string): AndroidListedDeviceEntry[] { + const devices: AndroidListedDeviceEntry[] = []; + for (const rawLine of rawOutput.split('\n')) { + const parts = rawLine.trim().split(/\s+/); + const serial = parts[0]; + const state = parts[1]; + if (!serial || !state || serial === 'List' || serial.startsWith('*')) continue; + devices.push({ serial, state }); + } + return devices; +} + export function parseAndroidDeviceEntries(rawOutput: string): AndroidDeviceEntry[] { const devices: AndroidDeviceEntry[] = []; for (const rawLine of rawOutput.split('\n')) { diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts index 6c0d714e4e..9f0a9e1342 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -1,6 +1,11 @@ import { expect, test, vi } from 'vitest'; import type { HostCommandRequest } from '@agent-device/contracts/platform-runtime-host'; -import type { NetworkDumpInput } from '@agent-device/contracts/network-runtime'; +import type { + NetworkDumpInput, + NetworkProviderDumpResult, + NetworkTransport, + NetworkTransportScope, +} from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { dumpAndroidNetworkTraffic } from './runtime.ts'; @@ -14,6 +19,203 @@ const device: DeviceInfo = { booted: true, }; +const providerResult: NetworkProviderDumpResult = { + backend: 'react-native-app', + entries: [ + { + url: 'https://api.example.test/v1/items?page=2', + method: 'POST', + status: 201, + durationMs: 41, + }, + ], +}; + +test('in-app capture answers with the full URL and status, and the app log is not read', async () => { + const runtimeHost = host({ + marker: { status: 'missing' }, + command: () => { + throw new Error('an in-app capture answer must not run adb'); + }, + logcat: '', + transport: { + mode: 'transport-composed', + dump: async (dumpInput) => { + expect(dumpInput).toEqual({ maxEntries: 25, include: 'summary' }); + return providerResult; + }, + }, + }); + const readRecent = vi.fn(runtimeHost.appLogs.readRecent); + const result = await dumpAndroidNetworkTraffic( + { ...runtimeHost, appLogs: { ...runtimeHost.appLogs, readRecent } }, + device, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + + expect(result).toEqual({ source: 'provider', ...providerResult }); + expect(readRecent).not.toHaveBeenCalled(); +}); + +async function identityOf(scope: NetworkTransportScope | undefined): Promise { + return await scope?.resolveMetroDeviceIdentity?.(new AbortController().signal); +} + +test('a second attached emulator of the same model takes the identity away from the provider', async () => { + // Two emulators of one system image report one `ro.product.model`; naming either one could hook + // and drain the other's runtime, so the platform passes no identity and the dump fails closed. + const scopes: NetworkTransportScope[] = []; + const runtimeHost = host({ + marker: { status: 'missing' }, + command: () => ({ stdout: '', stderr: '', exitCode: 0 }), + logcat: '', + appLogText: '', + attached: 'List of devices attached\nemulator-5554\tdevice\nemulator-5556\tdevice\n', + transport: { mode: 'transport-composed', dump: async () => providerResult }, + scopes, + }); + await dumpAndroidNetworkTraffic( + runtimeHost, + device, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBeUndefined(); +}); + +test('a listed serial whose properties cannot be read takes the identity away from the provider', async () => { + // An offline or unauthorized serial cannot answer getprop. It is an unknown device that could + // share the model, so the fail-closed rule withholds the identity for the whole host. + const scopes: NetworkTransportScope[] = []; + const runtimeHost = host({ + marker: { status: 'missing' }, + command: () => ({ stdout: '', stderr: '', exitCode: 0 }), + logcat: '', + appLogText: '', + attached: 'List of devices attached\nemulator-5554\tdevice\nZY22\toffline\n', + getpropFailures: new Set(['ZY22']), + transport: { mode: 'transport-composed', dump: async () => providerResult }, + scopes, + }); + await dumpAndroidNetworkTraffic( + runtimeHost, + device, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBeUndefined(); +}); + +test('one attached device gives the provider the composed dev-server name as its identity', async () => { + const scopes: NetworkTransportScope[] = []; + const runtimeHost = host({ + marker: { status: 'missing' }, + command: () => ({ stdout: '', stderr: '', exitCode: 0 }), + logcat: '', + appLogText: '', + transport: { mode: 'transport-composed', dump: async () => providerResult }, + scopes, + }); + await dumpAndroidNetworkTraffic( + runtimeHost, + device, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBe('sdk_gphone64_arm64 - 15 - API 35'); +}); + +test('the dump that only arms the in-app capture still reports the app log window', async () => { + const armingNote = 'In-app network capture was installed by this dump.'; + const result = await dumpAndroidNetworkTraffic( + host({ + marker: { status: 'missing' }, + command: () => { + throw new Error('an in-app capture answer must not run adb'); + }, + appLogText: '01-01 00:00:00.000 D/App (222): GET https://from-log.example.test status=200', + logcat: '', + transport: { + mode: 'transport-composed', + dump: async () => ({ + backend: 'react-native-app', + entries: [], + armed: true, + notes: [armingNote], + }), + }, + }), + device, + input({ appLogSnapshot: { state: 'active', startedAt: 1 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toEqual([ + expect.objectContaining({ url: 'https://from-log.example.test' }), + ]); + expect(result.notes).toContain(armingNote); +}); + +test('an armed provider over an inactive log stream keeps the lifecycle guidance beside the arming note', async () => { + const result = await dumpAndroidNetworkTraffic( + host({ + marker: { status: 'missing' }, + command: () => { + throw new Error('an in-app capture answer must not run adb'); + }, + appLogText: '', + logcat: '', + transport: { + mode: 'transport-composed', + dump: async () => ({ + backend: 'react-native-app', + entries: [], + armed: true, + notes: ['In-app network capture was installed by this dump.'], + }), + }, + }), + device, + input({ appLogSnapshot: { state: 'ended', startedAt: 1 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + const inactiveIndex = result.notes!.findIndex((note) => note.includes('inactive')); + const armingIndex = result.notes!.findIndex((note) => note.includes('installed by this dump')); + expect(inactiveIndex).toBeGreaterThanOrEqual(0); + expect(armingIndex).toBeGreaterThanOrEqual(0); + expect(inactiveIndex).toBeLessThan(armingIndex); +}); + +test('an in-app capture that cannot answer falls back to the app log with its reason', async () => { + const result = await dumpAndroidNetworkTraffic( + host({ + marker: { status: 'missing' }, + command: () => { + throw new Error('an in-app capture answer must not run adb'); + }, + appLogText: '01-01 00:00:00.000 D/App (222): GET https://from-log.example.test status=200', + logcat: '', + transport: { + mode: 'transport-composed', + dump: async () => { + throw new Error('No React Native runtime is connected to the dev server.'); + }, + }, + }), + device, + input({ appLogSnapshot: { state: 'active', startedAt: 1 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toHaveLength(1); + expect(result.notes[0]).toContain('In-app network capture could not answer'); +}); + test('recovers inactive Android traffic for current and previous package pids before stale entries', async () => { const result = await dumpAndroidNetworkTraffic( host({ @@ -122,12 +324,36 @@ function host(options: { command(request: HostCommandRequest): { stdout: string; stderr: string; exitCode: number }; appLogText?: string; logcat: string | Error; + transport?: NetworkTransport; + attached?: string; + getpropFailures?: ReadonlySet; + scopes?: NetworkTransportScope[]; }): PlatformRuntimeHost { return { ...unusedAppLogHost(), commands: { which: async () => undefined, run: async (request) => { + // The identity probe answers only when a test resolves the scope's identity; the harness + // answers it as a host with exactly these listed devices under one unique name. + if (request.args.includes('devices')) { + return { + stdout: options.attached ?? 'List of devices attached\nemulator-5554\tdevice\n', + stderr: '', + exitCode: 0, + }; + } + if (request.args.some((arg) => arg.includes('getprop ro.product.model'))) { + const serial = request.args[request.args.indexOf('-s') + 1]; + if (serial !== undefined && options.getpropFailures?.has(serial)) { + return { stdout: '', stderr: 'device offline', exitCode: 1 }; + } + return { + stdout: 'sdk_gphone64_arm64\n15\n35\n', + stderr: '', + exitCode: 0, + }; + } if (!request.args.includes('logcat')) return options.command(request); options.command(request); if (options.logcat instanceof Error) throw options.logcat; @@ -149,7 +375,12 @@ function host(options: { }), readProcessMarker: async () => options.marker, }, - networkTransports: { resolve: async () => ({ mode: 'local' }) }, + networkTransports: { + resolve: async (_device, scope) => { + options.scopes?.push(scope!); + return options.transport ?? { mode: 'local' }; + }, + }, }; } diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index 79c17ae743..40ff93a94b 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -5,9 +5,17 @@ import type { import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import { mergeNetworkScans, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import { + captureNetworkProviderTraffic, + scanNetworkAppLogFallback, +} from '@agent-device/capture-kit/network-provider-capture'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { assertAndroidLogPackageSafe } from '../logs/package-name.ts'; +import { + parseAndroidListedDeviceEntries, + type AndroidListedDeviceEntry, +} from '../inventory-parsers.ts'; type RecoveryContext = Readonly<{ reason: 'inactive' | 'stale-active'; @@ -20,14 +28,27 @@ export async function dumpAndroidNetworkTraffic( input: NetworkDumpInput, signal: AbortSignal, ): Promise { - const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); - let scan = readRecentNetworkTrafficFromText(recent.text, { - ...input, - path: recent.path, - exists: recent.exists, - lineNumberOffset: recent.skippedLines, - backend: 'android', - }); + // The identity probe costs adb calls, so it rides into the transport as a resolver and only a + // dump that reached a Metro binding pays for it. + const provider = await captureNetworkProviderTraffic( + host, + device, + input, + signal, + input.appBundleId === undefined + ? {} + : { + resolveMetroDeviceIdentity: (dumpSignal: AbortSignal) => + metroDeviceIdentity(host, device, dumpSignal), + }, + ); + if (provider.status === 'captured') { + return Object.freeze({ source: 'provider', ...provider.result }); + } + const fallback = await scanNetworkAppLogFallback(host, input, 'android'); + let scan = fallback.scan; + // Lifecycle and scan notes describe the app-log source alone; provider notes join last, so an + // arming or unavailable note never hides the inactive-stream guidance. const notes: string[] = []; const context = await recoveryContext(host, device, input, signal); if (context && input.appBundleId) { @@ -36,32 +57,21 @@ export async function dumpAndroidNetworkTraffic( if (recovered) { const recoveryScan = readRecentNetworkTrafficFromText(recovered.text, { ...input, - path: `${recent.path} (adb logcat recovery)`, + path: `${fallback.recent.path} (adb logcat recovery)`, exists: true, backend: 'android', }); if (recoveryScan.dump.entries.length > 0) { scan = mergeNetworkScans(recoveryScan, scan, input.maxEntries); - notes.push( - context.reason === 'stale-active' - ? `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${recovered.pids.join(', ')}.` - : `Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recovered.pids.join(', ')}.`, - ); + notes.push(androidRecoveryNote(context, recovered.pids)); } } } - if (!input.appLogSnapshot) { - notes.push( - 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', - ); - } else if (input.appLogSnapshot.state !== 'active' && notes.length === 0) { - notes.push( - 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', - ); - } + appendLifecycleNote(notes, input); if (scan.dump.entries.length === 0) { notes.push('No HTTP(s) entries were found in recent session app logs.'); } + notes.push(...provider.notes); return Object.freeze({ source: 'app-log', backend: 'android', @@ -70,6 +80,105 @@ export async function dumpAndroidNetworkTraffic( }); } +function androidRecoveryNote(context: RecoveryContext, pids: readonly string[]): string { + return context.reason === 'stale-active' + ? `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${pids.join(', ')}.` + : `Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${pids.join(', ')}.`; +} + +/** + * The dev server's device name of this one device, or nothing when another attached device would + * answer to it too. The dev server names an Android runtime ` - - API + * `; `ro.product.model` and `ro.build.version.release` compose the same string, and + * `ro.build.version.sdk` is the API level. Two devices of one system image share all three, so the + * model joins only when the whole name is unique across attached devices; otherwise the provider + * fails closed instead of reading and draining another device's runtime. + */ +async function metroDeviceIdentity( + host: PlatformRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, +): Promise { + const own = await readDeviceNameParts(host, device.id, signal); + if (own === undefined) return undefined; + const listed = await listAttachedDevices(host, signal); + // The listing must state this session's own serial: a listing that omits an attached device is + // an untrustworthy host view (a resetting adb server, a half-written listing), and one cannot + // prove collisions against devices it hides. + if (listed === undefined || !listed.some((entry) => entry.serial === device.id)) return undefined; + for (const entry of listed) { + if (entry.serial === device.id) continue; + // Every other serial adb lists — offline or unauthorized included — must answer with its own + // model. An unreadable serial is an unknown device, and an unknown device could share the + // name, so the whole identity is withheld. + const other = await readDeviceNameParts(host, entry.serial, signal); + if (other === undefined || other.joined === own.joined) return undefined; + } + return own.joined; +} + +const IDENTITY_PROBE_TIMEOUT_MS = 4_000; + +async function readDeviceNameParts( + host: PlatformRuntimeHost, + serial: string, + signal: AbortSignal, +): Promise<{ joined: string } | undefined> { + const result = await runOptionalRecoveryCommand( + host, + { + executable: 'adb', + args: deviceShellArgv( + 'adb', + 'shell', + [ + 'sh', + '-c', + 'getprop ro.product.model; getprop ro.build.version.release; getprop ro.build.version.sdk', + ], + ['-s', serial], + ), + allowFailure: true, + timeoutMs: IDENTITY_PROBE_TIMEOUT_MS, + }, + signal, + ); + if (!result || result.exitCode !== 0) return undefined; + const [model, release, sdk] = result.stdout.split('\n').map((line) => line.trim()); + if (!model || !release || !sdk) return undefined; + return { joined: `${model} - ${release} - API ${sdk}` }; +} + +async function listAttachedDevices( + host: PlatformRuntimeHost, + signal: AbortSignal, +): Promise { + const result = await runOptionalRecoveryCommand( + host, + { + executable: 'adb', + args: ['devices'], + allowFailure: true, + timeoutMs: IDENTITY_PROBE_TIMEOUT_MS, + }, + signal, + ); + if (!result || result.exitCode !== 0) return undefined; + return parseAndroidListedDeviceEntries(result.stdout); +} + +function appendLifecycleNote(notes: string[], input: NetworkDumpInput): void { + if (!input.appLogSnapshot) { + notes.push( + 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', + ); + } else if (input.appLogSnapshot.state !== 'active' && notes.length === 0) { + notes.push( + 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', + ); + } +} + async function runOptionalRecoveryCommand( host: PlatformRuntimeHost, request: HostCommandRequest, diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index db5e65485f..d3f3d3cb6b 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -73,7 +73,7 @@ export function runSimctlForDevice( return runXcrun(buildSimctlArgsForDevice(device, args), options); } -type SimctlListedDevice = { udid?: string; state?: string }; +type SimctlListedDevice = { udid?: string; state?: string; name?: string }; /** The runtime-keyed device lists of `simctl list devices -j` output; throws unless it is a JSON object. */ export function readSimctlDevicesByRuntime(stdout: string): Record { @@ -81,6 +81,25 @@ export function readSimctlDevicesByRuntime(stdout: string): Record entry.state === 'Booted') + .map((entry) => entry.name ?? ''); +} + /** The listed state of one simulator; null when the listing is unreadable or omits the device. */ export function readSimctlDeviceState(stdout: string, udid: string): string | null { try { diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index cef2922c2e..94718d50ff 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -1,5 +1,10 @@ import { expect, test, vi } from 'vitest'; -import type { NetworkDumpInput } from '@agent-device/contracts/network-runtime'; +import type { + NetworkDumpInput, + NetworkProviderDumpResult, + NetworkTransport, + NetworkTransportScope, +} from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { dumpAppleNetworkTraffic } from './runtime.ts'; @@ -14,6 +19,134 @@ const simulator: DeviceInfo = { booted: true, }; +const providerResult: NetworkProviderDumpResult = { + backend: 'react-native-app', + entries: [ + { + url: 'https://api.example.test/v1/items?page=2', + method: 'POST', + status: 201, + durationMs: 41, + }, + ], +}; + +test('in-app capture answers with the full URL and status, and the app log is not read', async () => { + const readRecent = vi.fn(async () => { + throw new Error('the app log must not be read once the provider answered'); + }); + const runtimeHost = host({ runSimctl: vi.fn() }); + const result = await dumpAppleNetworkTraffic( + { + ...runtimeHost, + appLogs: { ...runtimeHost.appLogs, readRecent }, + networkTransports: { + resolve: async (_device, scope) => { + expect(scope).toMatchObject({ + sessionId: 'one', + appBundleId: 'com.example.app', + maxPayloadChars: 2048, + }); + return { + mode: 'transport-composed', + dump: async () => providerResult, + }; + }, + }, + }, + simulator, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + + expect(result).toEqual({ source: 'provider', ...providerResult }); + expect(readRecent).not.toHaveBeenCalled(); +}); + +test('the dump that only arms the in-app capture still reports the app log window', async () => { + const armingNote = 'In-app network capture was installed by this dump.'; + const result = await dumpAppleNetworkTraffic( + host({ + text: '2026-08-10T10:00:00Z GET https://from-log.example.test status=200\n', + runSimctl: vi.fn(), + transport: { + mode: 'transport-composed', + dump: async () => ({ + backend: 'react-native-app', + entries: [], + armed: true, + notes: [armingNote], + }), + }, + }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toEqual([ + expect.objectContaining({ url: 'https://from-log.example.test' }), + ]); + expect(result.notes).toContain(armingNote); +}); + +test('an armed provider over an inactive log stream keeps the lifecycle guidance beside the arming note', async () => { + const result = await dumpAppleNetworkTraffic( + host({ + text: '', + runSimctl: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + transport: { + mode: 'transport-composed', + dump: async () => ({ + backend: 'react-native-app', + entries: [], + armed: true, + notes: ['In-app network capture was installed by this dump.'], + }), + }, + }), + simulator, + input({ appLogSnapshot: { state: 'ended', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.notes).toContainEqual( + expect.stringContaining('Session app log stream is inactive'), + ); + expect(result.notes).toContainEqual( + expect.stringContaining('In-app network capture was installed by this dump'), + ); + expect( + result.notes!.indexOf(result.notes!.find((note) => note.includes('inactive'))!), + ).toBeLessThan( + result.notes!.indexOf(result.notes!.find((note) => note.includes('installed by this dump'))!), + ); +}); + +test('an in-app capture that cannot answer falls back to the app log with its reason', async () => { + const result = await dumpAppleNetworkTraffic( + host({ + text: '2026-08-10T10:00:00Z GET https://from-log.example.test status=200\n', + runSimctl: vi.fn(), + transport: { + mode: 'transport-composed', + dump: async () => { + throw new Error('No React Native runtime is connected to the dev server.'); + }, + }, + }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toHaveLength(1); + expect(result.notes[0]).toContain('In-app network capture could not answer'); +}); + test('recovers an empty iOS simulator dump from bounded simctl log history', async () => { const runSimctl = vi.fn(async () => ({ stdout: [ @@ -142,16 +275,98 @@ function input(overrides: Partial = {}): NetworkDumpInput { function host(options: { text?: string; runSimctl: PlatformRuntimeHost['appleTools']['run']; + transport?: NetworkTransport; + /** Extra booted names in the session device's own set, besides the device itself. */ + bootedNames?: readonly string[]; + /** Booted names the OTHER reachable set reports (the default set for a tenant-set device). */ + otherSetBootedNames?: readonly string[]; + /** The session device lives in a tenant simulator set, not the default set. */ + tenantDevice?: boolean; + /** devicectl answers with these connected physical device names; the default is a bare host. */ + physicalNames?: readonly string[]; + devicectlFails?: boolean; + env?: Record; + scopes?: NetworkTransportScope[]; }): PlatformRuntimeHost { + // The identity probe answers only when a test resolves the scope's identity. The harness answers + // it as a default-set host with one simulator named after the session device, unless a test + // names more booted devices, another set's booted devices, or physical devices. + const runSimctl: PlatformRuntimeHost['appleTools']['run'] = async (request, signal) => { + if (request.tool === 'devicectl') { + if (options.devicectlFails) { + return { stdout: '', stderr: 'devicectl failed', exitCode: 1 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (request.args.includes('booted')) { + const own = [ + { udid: 'sim-1', name: 'iPhone', state: 'Booted' as const }, + ...(options.bootedNames ?? []).map((name, index) => ({ + udid: `other-${index}`, + name, + state: 'Booted' as const, + })), + ]; + const other = (options.otherSetBootedNames ?? []).map((name, index) => ({ + udid: `tenant-${index}`, + name, + state: 'Booted' as const, + })); + const scopedRequest = request.args.includes('--set'); + const devices = options.tenantDevice + ? scopedRequest + ? own + : other + : scopedRequest + ? other + : own; + return { + stdout: JSON.stringify({ + devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-27-0': devices }, + }), + stderr: '', + exitCode: 0, + }; + } + return options.runSimctl(request, signal); + }; + const physicalNames = options.physicalNames ?? []; return { ...unusedAppLogHost(), + hostEnvironment: { env: options.env ?? {}, cwd: '/' }, + temporaryFiles: { + create: async () => ({ + path: '/tmp/devicectl.json', + readText: async () => + JSON.stringify({ + result: { + devices: [ + // CoreDevice registers the booted session simulator itself as a connected device. + { + name: 'iPhone', + hardwareProperties: { platform: 'iOS', udid: 'sim-1', reality: 'simulated' }, + deviceProperties: { name: 'iPhone' }, + connectionProperties: { tunnelState: 'connected' }, + }, + ...physicalNames.map((name) => ({ + name, + hardwareProperties: { platform: 'iOS', udid: `phys-${name}` }, + connectionProperties: { tunnelState: 'connected' }, + })), + ], + }, + }), + writeText: async () => {}, + [Symbol.asyncDispose]: async () => {}, + }), + }, commands: { which: async () => undefined, run: async () => { throw new Error('generic command runner must not own Apple network recovery'); }, }, - appleTools: { isXcrunAvailable: async () => true, run: options.runSimctl }, + appleTools: { isXcrunAvailable: async () => true, run: runSimctl }, appLogs: { readRecent: async () => ({ path: '/sessions/one/app.log', @@ -161,10 +376,126 @@ function host(options: { }), readProcessMarker: async () => ({ status: 'missing' }), }, - networkTransports: { resolve: async () => ({ mode: 'local' }) }, + networkTransports: { + resolve: async (_device, scope) => { + options.scopes?.push(scope!); + return options.transport ?? { mode: 'local' }; + }, + }, }; } +async function identityOf(scope: NetworkTransportScope | undefined): Promise { + return await scope?.resolveMetroDeviceIdentity?.(new AbortController().signal); +} + +test('a simulator whose display name another booted simulator shares gets no identity', async () => { + const scopes: NetworkTransportScope[] = []; + await dumpAppleNetworkTraffic( + host({ + text: '', + runSimctl: vi.fn(), + transport: { mode: 'transport-composed', dump: async () => providerResult }, + bootedNames: ['iPhone'], + scopes, + }), + simulator, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBeUndefined(); +}); + +test('a same-named booted simulator in another reachable set takes the identity away', async () => { + // The session device lives in a tenant set; the default set on the same host boots a + // same-named simulator that could attach to the same dev server, so the name is no identity. + const scopes: NetworkTransportScope[] = []; + await dumpAppleNetworkTraffic( + host({ + text: '', + runSimctl: vi.fn(), + transport: { mode: 'transport-composed', dump: async () => providerResult }, + tenantDevice: true, + otherSetBootedNames: ['iPhone'], + scopes, + }), + { ...simulator, simulatorSetPath: '/tenant/set' }, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBeUndefined(); +}); + +test('a connected physical device with the same name takes the identity away', async () => { + const scopes: NetworkTransportScope[] = []; + await dumpAppleNetworkTraffic( + host({ + text: '', + runSimctl: vi.fn(), + transport: { mode: 'transport-composed', dump: async () => providerResult }, + physicalNames: ['iPhone'], + scopes, + }), + simulator, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBeUndefined(); +}); + +test('a host whose physical devices cannot be listed gets no identity', async () => { + // Unknown physical devices fail the identity closed: one of them could report this name. + const scopes: NetworkTransportScope[] = []; + await dumpAppleNetworkTraffic( + host({ + text: '', + runSimctl: vi.fn(), + transport: { mode: 'transport-composed', dump: async () => providerResult }, + devicectlFails: true, + scopes, + }), + simulator, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBeUndefined(); +}); + +test('a host configured for a set this device does not belong to gets no identity', async () => { + // The daemon operator scoped the host to one set; this device lives elsewhere, so the host + // cannot state which sets are reachable and the identity fails closed. + const scopes: NetworkTransportScope[] = []; + await dumpAppleNetworkTraffic( + host({ + text: '', + runSimctl: vi.fn(), + transport: { mode: 'transport-composed', dump: async () => providerResult }, + env: { AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET: '/operator/set' }, + scopes, + }), + simulator, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBeUndefined(); +}); + +test('one booted simulator gives the provider its display name as the identity', async () => { + const scopes: NetworkTransportScope[] = []; + await dumpAppleNetworkTraffic( + host({ + text: '', + runSimctl: vi.fn(), + transport: { mode: 'transport-composed', dump: async () => providerResult }, + scopes, + }), + simulator, + input({ stateDir: '/state' }), + new AbortController().signal, + ); + expect(await identityOf(scopes[0])).toBe('iPhone'); +}); + function unusedAppLogHost(): Omit< PlatformRuntimeHost, 'appleTools' | 'commands' | 'appLogs' | 'networkTransports' diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 0cb43d331c..69aba795f2 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -6,10 +6,138 @@ import { readRecentNetworkTrafficFromText, type NetworkScan, } from '@agent-device/capture-kit'; +import { + captureNetworkProviderTraffic, + scanNetworkAppLogFallback, +} from '@agent-device/capture-kit/network-provider-capture'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; -import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; +import { + scopeSimctlArgsForDevice, + simctlListBootedArgsForSet, + readSimctlBootedNames, +} from '../core/simctl.ts'; +import { + isSupportedAppleDevicectlDevice, + type DevicectlAppleDevice, +} from '../inventory-classification.ts'; import { backendForAppleDevice } from '../logs/backend.ts'; +const IDENTITY_SETS_ROOT_ENV = 'AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET'; +const IDENTITY_PROBE_TIMEOUT_MS = 4_000; + +/** + * The dev server's device name of this one simulator, or nothing when the name is not an identity. + * The dev server names an Apple runtime after the simulator's display name, so the name joins only + * when no other booted simulator on this host carries it — in this set or any other set the host + * can name — and no connected physical device is named the same. A shared name could select, hook + * and drain the other device's runtime. Any listing, physical-device answer, or set enumeration + * that cannot be read fails the identity closed. Simulators kept in set directories this host was + * never told about cannot be listed by any tool; that residue is the accepted risk of ADR 0038. + */ +async function metroDeviceIdentity( + host: PlatformRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, +): Promise { + if (!isIosFamily(device) || device.kind !== 'simulator') return undefined; + const sets = await identitySetPaths(host, device); + if (sets === undefined) return undefined; + const sameName = []; + for (const setPath of sets) { + const result = await host.appleTools.run( + { + tool: 'simctl', + args: simctlListBootedArgsForSet(setPath), + allowFailure: true, + timeoutMs: IDENTITY_PROBE_TIMEOUT_MS, + }, + signal, + ); + if (result.exitCode !== 0) return undefined; + let names: string[]; + try { + names = readSimctlBootedNames(result.stdout); + } catch { + return undefined; + } + sameName.push(...names.filter((name) => name === device.name)); + } + if (sameName.length !== 1) return undefined; + const otherNames = await connectedOtherDeviceNames(host, device, signal); + if (otherNames === undefined) return undefined; + if (otherNames.some((name) => name === device.name)) return undefined; + return device.name; +} + +/** + * Every simulator set this host can name: the device's own set, the default set, and the set the + * daemon operator configured. An enumeration that cannot conclude returns undefined: with + * `AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET` set, requests use only the operator's set and the + * identity must be proven there, so a device outside it is an unnameable host state. + */ +async function identitySetPaths( + host: PlatformRuntimeHost, + device: DeviceInfo, +): Promise { + const deviceSet = device.simulatorSetPath?.trim() || undefined; + const operatorSet = host.hostEnvironment.env[IDENTITY_SETS_ROOT_ENV]?.trim() || undefined; + if (operatorSet !== undefined && operatorSet !== deviceSet) return undefined; + if (deviceSet === undefined) return [undefined]; + return [deviceSet, undefined]; +} + +/** + * The names of connected devices devicectl reports under another udid than the session's — a + * same-named physical device could attach to the same dev server, and CoreDevice also registers + * booted simulators, so a same-named simulator this host's set listings never showed still shows + * here. A devicectl that fails, times out, or whose answer cannot be read returns undefined: + * unknown devices fail the identity closed. + */ +async function connectedOtherDeviceNames( + host: PlatformRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, +): Promise { + let file; + try { + file = await host.temporaryFiles.create({ + prefix: 'agent-device-network-identity-', + suffix: '.json', + }); + const result = await host.appleTools.run( + { + tool: 'devicectl', + args: ['list', 'devices', '--json-output', file.path], + allowFailure: true, + timeoutMs: IDENTITY_PROBE_TIMEOUT_MS, + }, + signal, + ); + if (result.exitCode !== 0) return undefined; + const payload = JSON.parse(await file.readText()) as { + result?: { devices?: DevicectlAppleDevice[] }; + }; + const devices = payload.result?.devices; + if (!Array.isArray(devices)) return undefined; + return devices + .filter( + (entry) => + isSupportedAppleDevicectlDevice(entry) && + entry.connectionProperties?.tunnelState === 'connected' && + (entry.hardwareProperties?.udid ?? entry.identifier) !== device.id, + ) + .map((entry) => entry.name ?? entry.deviceProperties?.name ?? ''); + } catch { + return undefined; + } finally { + try { + await file?.[Symbol.asyncDispose](); + } catch { + // An unreadable answer already failed the identity closed; cleanup must not mask that. + } + } +} + export async function dumpAppleNetworkTraffic( host: PlatformRuntimeHost, device: DeviceInfo, @@ -17,22 +145,42 @@ export async function dumpAppleNetworkTraffic( signal: AbortSignal, ): Promise { const backend = backendForAppleDevice(device); - const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); - let scan = readRecentNetworkTrafficFromText(recent.text, { - ...input, - path: recent.path, - exists: recent.exists, - lineNumberOffset: recent.skippedLines, - backend, - }); + // The identity probe costs simctl and devicectl calls, so it rides into the transport as a + // resolver and only a dump that reached a Metro binding pays for it. + const provider = await captureNetworkProviderTraffic( + host, + device, + input, + signal, + input.appBundleId === undefined + ? {} + : { + resolveMetroDeviceIdentity: (dumpSignal: AbortSignal) => + metroDeviceIdentity(host, device, dumpSignal), + }, + ); + if (provider.status === 'captured') { + return Object.freeze({ source: 'provider', ...provider.result }); + } + const fallback = await scanNetworkAppLogFallback(host, input, backend); + let scan = fallback.scan; + // Lifecycle and scan notes describe the app-log source alone; provider notes join last, so an + // arming or unavailable note never hides the inactive-stream guidance. const notes: string[] = []; if (canRecoverSimulator(device, input, scan.dump)) { - const recovery = await recoverSimulatorTraffic(host, device, input, recent.path, signal); + const recovery = await recoverSimulatorTraffic( + host, + device, + input, + fallback.recent.path, + signal, + ); if (recovery) scan = mergeRecoveredTraffic(notes, scan, recovery, input.maxEntries); } appendLifecycleNote(notes, device, input); appendUnnamedRequestNote(notes, scan.dump); if (scan.dump.entries.length === 0) notes.push(noEntriesNote(device)); + notes.push(...provider.notes); return Object.freeze({ source: 'app-log', backend, diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 747ebba33b..b00ac6383e 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -4,6 +4,8 @@ import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { resolveRuntimeTransportHints } from '@agent-device/host-kit/runtime-transport-hints'; import type { PrepareMetroRuntimeOptions, ReloadMetroOptions } from './metro/client-metro.ts'; import type { PrepareMetroRuntimeResult, ReloadMetroResult } from '@agent-device/contracts/remote'; +import type { NetworkProviderDumpResult } from '@agent-device/contracts/network-runtime'; +import type { ReactNativeNetworkDumpInput } from './network/network-capture-dump.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { ReactDevtoolsCommandOptions } from './react-devtools/react-devtools-connection.ts'; import type { ReactDevtoolsOutput } from './react-devtools/react-devtools-output.ts'; @@ -96,6 +98,14 @@ export async function readMetroSessionRuntime( return session.readMetroSessionRuntime(options); } +export async function dumpReactNativeAppNetwork( + input: ReactNativeNetworkDumpInput, + signal?: AbortSignal, +): Promise { + const capture = await import('./network/network-capture-dump.ts'); + return await capture.dumpReactNativeAppNetwork(input, signal); +} + export async function clearMetroSessionHintsQuietly(options: MetroSessionOptions): Promise { const session = await import('./metro/metro-session-hints.ts'); session.clearMetroSessionHintsQuietly(options); diff --git a/packages/react-native/src/network/network-capture-dump.fixtures.ts b/packages/react-native/src/network/network-capture-dump.fixtures.ts new file mode 100644 index 0000000000..6aed724688 --- /dev/null +++ b/packages/react-native/src/network/network-capture-dump.fixtures.ts @@ -0,0 +1,378 @@ +import vm from 'node:vm'; +import { createServer, type IncomingMessage } from 'node:http'; +import { once } from 'node:events'; +import { createHash } from 'node:crypto'; +import type { Duplex } from 'node:stream'; +import type { AddressInfo } from 'node:net'; + +type Frame = { opcode: number; fin: boolean; payload: Buffer }; + +/** + * Reads WebSocket frames from a byte stream, including the 16-bit and 64-bit extended lengths and + * the continuation frames undici emits when one send is split across TCP chunks. + */ +class FrameReader { + #buffer: Buffer = Buffer.alloc(0); + #fragments: Buffer[] = []; + #fragmentOpcode = 0; + + push(chunk: Buffer): Frame[] { + this.#buffer = Buffer.concat([this.#buffer, chunk]); + const frames: Frame[] = []; + for (;;) { + const read = this.#readFrame(); + if (!read) break; + this.#buffer = read.rest; + if (!read.frame.fin && read.frame.opcode !== 0) { + this.#fragmentOpcode = read.frame.opcode; + this.#fragments.push(read.frame.payload); + continue; + } + if (read.frame.opcode === 0) { + this.#fragments.push(read.frame.payload); + if (!read.frame.fin) continue; + frames.push({ + opcode: this.#fragmentOpcode, + fin: true, + payload: Buffer.concat(this.#fragments), + }); + this.#fragments = []; + continue; + } + frames.push(read.frame); + } + return frames; + } + + #readFrame(): { frame: Frame; rest: Buffer } | null { + const buffer = this.#buffer; + if (buffer.length < 2) return null; + const fin = (buffer[0]! & 0x80) !== 0; + const opcode = buffer[0]! & 0x0f; + if ((buffer[1]! & 0x80) === 0) return null; + const marker = buffer[1]! & 0x7f; + let length = marker; + let offset = 2; + if (marker === 126) { + if (buffer.length < 4) return null; + length = buffer.readUInt16BE(2); + offset = 4; + } else if (marker === 127) { + if (buffer.length < 10) return null; + length = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + if (buffer.length < offset + 4 + length) return null; + const mask = buffer.subarray(offset, offset + 4); + const payload = Buffer.from(buffer.subarray(offset + 4, offset + 4 + length)); + for (let i = 0; i < payload.length; i += 1) payload[i] = payload[i]! ^ mask[i % 4]!; + return { frame: { opcode, fin, payload }, rest: buffer.subarray(offset + 4 + length) }; + } +} + +function textFrame(text: string): Buffer { + const payload = Buffer.from(text); + const header = + payload.length < 126 + ? Buffer.from([0x81, payload.length]) + : Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]); + return Buffer.concat([header, payload]); +} + +/** The fetch/XMLHttpRequest surfaces and the app's own request entry point. */ +const APP_RUNTIME_SOURCE = ` + var Headers = function (init) { + var map = new Map(Object.keys(init || {}).map(function (name) { return [name.toLowerCase(), init[name]]; })); + this.get = function (name) { return map.get(String(name).toLowerCase()); }; + this.forEach = function (fn) { map.forEach(function (value, name) { fn(value, name); }); }; + }; + var Body = function (text) { this.text = function () { return Promise.resolve(text); }; }; + Body.prototype.clone = function () { return this; }; + globalThis.__pending = {}; + + // An XMLHttpRequest with the real surface the capture instruments: readyState transitions, + // readystatechange/load/error events, setRequestHeader accumulation, and getAllResponseHeaders. + var XHR = function () { + this.readyState = 0; + this.status = 0; + this.responseText = ''; + this.responseType = ''; + this._listeners = {}; + this._headers = {}; + }; + XHR.prototype.addEventListener = function (type, fn) { + (this._listeners[type] = this._listeners[type] || []).push(fn); + }; + XHR.prototype._emit = function (type) { + var listeners = this._listeners[type] || []; + for (var i = 0; i < listeners.length; i += 1) listeners[i].call(this); + }; + XHR.prototype.open = function (method, url) { + this._method = method; + this._url = url; + this.readyState = 1; + }; + XHR.prototype.setRequestHeader = function (name, value) { + this._headers[name] = value; + }; + XHR.prototype.getAllResponseHeaders = function () { + var spec = globalThis.__pending[this._url] || {}; + var lines = Object.keys(spec.responseHeaders || {}).map(function (name) { + return name + ': ' + spec.responseHeaders[name]; + }); + return lines.length ? lines.join('\\n') + '\\n' : ''; + }; + XHR.prototype.send = function () { + var xhr = this; + var spec = globalThis.__pending[xhr._url] || {}; + Promise.resolve().then(function () { + if (spec.reject) { + xhr.__failed = spec.reject; + xhr.readyState = 4; + xhr._emit('readystatechange'); + xhr._emit('error'); + return; + } + xhr.status = spec.status; + xhr.responseText = spec.body || ''; + xhr.readyState = 4; + xhr._emit('readystatechange'); + xhr._emit('load'); + }); + }; + globalThis.XMLHttpRequest = XHR; + + // fetch polyfilled on XMLHttpRequest, the way whatwg-fetch does it: open, headers, and send + // all run synchronously inside the call, and the promise settles on readystatechange. + globalThis.fetch = function (url, init) { + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open((init && init.method) || 'GET', url); + var headers = (init && init.headers) || {}; + Object.keys(headers).forEach(function (name) { + xhr.setRequestHeader(name, headers[name]); + }); + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState !== 4) return; + if (xhr.__failed) { + reject(new Error(xhr.__failed)); + return; + } + var spec = globalThis.__pending[url] || {}; + resolve({ + status: xhr.status, + url: url, + headers: new Headers(spec.responseHeaders), + clone: function () { return new Body(xhr.responseText); } + }); + }); + xhr.send(init && init.body); + }); + }; + globalThis.__settled = Promise.resolve('idle'); + globalThis.__request = function (url, options) { + var promise = fetch(url, options); + globalThis.__settled = promise.then(function () { return 'ok'; }, function () { return 'error'; }); + return promise; + }; + globalThis.__xhr = function (url) { + var request = new XMLHttpRequest(); + request.open('GET', url); + request.send(); + globalThis.__settled = new Promise(function (resolve) { + request.addEventListener('readystatechange', function () { + if (request.readyState === 4) resolve(request.status ? 'ok' : 'error'); + }); + }); + }; +`; + +const DEFAULT_XHR_RESPONSE: FakeAppResponse = { status: 200, body: 'xhr-ok' }; + +/** A scripted response the fake app can request. */ +export type FakeAppResponse = { + status?: number; + body?: string; + responseHeaders?: Record; + reject?: string; +}; + +/** The one app runtime behind every debugger connection this fake dev server accepts. */ +export type FakeAppRuntime = Readonly<{ + /** The app issues its own request for a scripted response and lets it finish. */ + request(url: string, response: FakeAppResponse, options?: unknown): Promise; + /** The app performs a direct XMLHttpRequest GET and lets it finish. */ + xhrRequest(url: string): Promise; + evaluate(expression: string): Promise; +}>; + +export type FakeMetroDevServer = Readonly<{ + serverUrl: string; + app: FakeAppRuntime; + /** Inspector page ids whose debugger connection ran an expression, in connection order. */ + hookedPages: string[]; + close(): Promise; +}>; + +/** + * A React Native dev server that lists inspector targets and answers `Runtime.evaluate` by running + * the expression in one in-process JavaScript runtime. Every debugger connection reaches that same + * runtime, the way several DevTools sessions reach one Hermes instance, so a test can dump, drive + * app traffic, and dump again over separate connections. `deviceNames` overrides the device each + * listed target reports; two entries may share one name, the way two emulators of one system + * image report one `Build.MODEL`-derived name. + */ +export async function fakeMetroDevServer( + options: { appId?: string; devices?: number; deviceNames?: readonly string[] } = {}, +): Promise { + const appId = options.appId ?? 'com.example.app'; + const devices = options.devices ?? 1; + const deviceName = (page: number) => options.deviceNames?.[page] ?? `Fake Device ${page + 1}`; + const hookedPages: string[] = []; + const app = new FakeAppRuntimeState(); + const sockets = new Set(); + const server = createServer((req, res) => { + if (req.url !== '/json/list') { + res.writeHead(404).end(); + return; + } + const port = (server.address() as AddressInfo).port; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify( + Array.from({ length: devices }, (_, page) => ({ + id: `device-${page + 1}-1`, + title: 'React Native', + description: 'React Native Bridgeless [C++ connection]', + appId, + deviceName: deviceName(page), + reactNative: { logicalDeviceId: `device-${page + 1}` }, + webSocketDebuggerUrl: `ws://127.0.0.1:${port}/inspector/debug?page=device-${page + 1}-1`, + })), + ), + ); + }); + server.on('upgrade', (req: IncomingMessage, socket: Duplex) => { + sockets.add(socket); + const page = /page=([^&]+)/.exec(req.url ?? '')?.[1] ?? req.url ?? ''; + if (!hookedPages.includes(page)) hookedPages.push(page); + const accept = createHash('sha1') + .update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + const reader = new FrameReader(); + socket.on('data', (chunk: Buffer) => { + for (const frame of reader.push(chunk)) { + if (frame.opcode === 0x8) { + socket.end(); + return; + } + const message = JSON.parse(frame.payload.toString('utf8')) as { + id: number; + method: string; + params?: { expression?: string }; + }; + if (message.method !== 'Runtime.evaluate') { + // Everything else answers with an empty result, the way a real runtime replies to + // Runtime.enable / Runtime.evaluate failures / Runtime.detach. + if (socket.writable) { + socket.write(textFrame(JSON.stringify({ id: message.id, result: {} }))); + } + continue; + } + void app + .evaluate(message.params?.expression ?? '') + .then( + (value) => ({ id: message.id, result: { result: { value } } }), + (error: unknown) => ({ + id: message.id, + result: { + exceptionDetails: { text: 'Uncaught', exception: { description: String(error) } }, + }, + }), + ) + .then((reply) => { + if (socket.writable) socket.write(textFrame(JSON.stringify(reply))); + }); + } + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const port = (server.address() as AddressInfo).port; + return { + serverUrl: `http://127.0.0.1:${port}`, + app, + hookedPages, + close: async () => { + for (const socket of sockets) socket.destroy(); + server.close(); + // Target-close leaves the target's keep-alive HTTP connection behind; without this the + // server waits out its 5s keep-alive timeout before 'close'. + server.closeAllConnections(); + await once(server, 'close'); + }, + }; +} + +class FakeAppRuntimeState implements FakeAppRuntime { + readonly #context: vm.Context; + + constructor() { + this.#context = vm.createContext({ + Date, + JSON, + Math, + Object, + String, + Array, + Promise, + Number, + RegExp, + Map, + URLSearchParams, + ArrayBuffer, + console, + }); + vm.runInContext('globalThis.globalThis = globalThis;', this.#context); + vm.runInContext(APP_RUNTIME_SOURCE, this.#context); + } + + async request( + url: string, + response: FakeAppResponse, + options: unknown = undefined, + ): Promise { + vm.runInContext( + `globalThis.__pending[${JSON.stringify(url)}] = ${JSON.stringify(response)}; + globalThis.__settled = Promise.resolve('pending'); + globalThis.__request(${JSON.stringify(url)}, ${JSON.stringify(options ?? null)});`, + this.#context, + ); + await this.evaluate('globalThis.__settled'); + } + + async xhrRequest(url: string): Promise { + vm.runInContext( + `globalThis.__pending[${JSON.stringify(url)}] = globalThis.__pending[${JSON.stringify(url)}] || ${JSON.stringify(DEFAULT_XHR_RESPONSE)}; + globalThis.__settled = Promise.resolve('pending'); + globalThis.__xhr(${JSON.stringify(url)});`, + this.#context, + ); + await this.evaluate('globalThis.__settled'); + } + + async evaluate(expression: string): Promise { + const value: unknown = vm.runInContext(expression, this.#context); + if ( + value !== null && + typeof value === 'object' && + typeof (value as { then?: unknown }).then === 'function' + ) { + return (await value) as T; + } + return value as T; + } +} diff --git a/packages/react-native/src/network/network-capture-dump.test.ts b/packages/react-native/src/network/network-capture-dump.test.ts new file mode 100644 index 0000000000..3b2b149e49 --- /dev/null +++ b/packages/react-native/src/network/network-capture-dump.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, expect, test } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; +import { dumpReactNativeAppNetwork } from './network-capture-dump.ts'; +import { fakeMetroDevServer, type FakeMetroDevServer } from './network-capture-dump.fixtures.ts'; + +let stateDir: string; +let server: FakeMetroDevServer; + +beforeEach(async () => { + stateDir = mkdtempForTestSync('rn-network-capture-'); + server = await fakeMetroDevServer(); + writeMetroHints(stateDir, 'repro', server.serverUrl); +}); + +afterEach(async () => { + await server.close(); +}); + +function writeMetroHints(root: string, session: string, controlBaseUrl: string): void { + const dir = path.join(root, 'metro-sessions'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, `${session}.json`), + `${JSON.stringify({ controlBaseUrl }, null, 2)}\n`, + 'utf8', + ); +} + +function dump(overrides: Partial<{ include: NetworkIncludeMode; maxEntries: number }> = {}) { + return dumpReactNativeAppNetwork({ + stateDir, + session: 'repro', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + // What the platform layer contributes for a host with one attached device of this name. + metroDeviceIdentity: 'Fake Device 1', + ...overrides, + }); +} + +test('the first dump installs the capture and reports it armed with no traffic of its own', async () => { + const result = await dump(); + + expect(result).toMatchObject({ + backend: 'react-native-app', + entries: [], + armed: true, + }); + expect(result.notes?.[0]).toContain('installed by this dump'); +}); + +test('a later dump returns the method, full URL, status, and duration the app runtime performed', async () => { + await dump(); + await server.app.request('https://api.example.test/v1/checkout?cart=42', { + status: 201, + body: '{"orderId":"o-1"}', + responseHeaders: { 'content-type': 'application/json' }, + }); + + const result = await dump({ include: 'body' }); + + expect(result.armed).toBeUndefined(); + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + method: 'GET', + url: 'https://api.example.test/v1/checkout?cart=42', + status: 201, + responseBody: '{"orderId":"o-1"}', + }); + expect(typeof result.entries[0]?.durationMs).toBe('number'); + expect(typeof result.entries[0]?.timestamp).toBe('string'); +}); + +test('a drained entry is never returned by a second dump', async () => { + await dump(); + await server.app.request('https://api.example.test/once', { status: 200, body: 'a' }); + expect((await dump()).entries).toHaveLength(1); + + const again = await dump(); + + expect(again.entries).toEqual([]); + expect(again.notes?.[0]).toContain('captured no request since the last dump'); +}); + +test.each([ + ['summary', { requestHeaders: undefined, responseBody: undefined }], + [ + 'headers', + { + requestHeaders: { 'content-type': 'application/json', 'x-trace': 't-1' }, + responseBody: undefined, + }, + ], + ['body', { requestHeaders: undefined, responseBody: '{"hits":2}' }], +] as const)('include %s projects exactly the detail it names', async (include, expected) => { + await dump(); + await server.app.request( + 'https://api.example.test/search', + { status: 200, body: '{"hits":2}', responseHeaders: { 'content-type': 'application/json' } }, + { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-trace': 't-1' }, + body: '{"q":"shoes"}', + }, + ); + + const entry = (await dump({ include })).entries[0]; + + expect(entry).toMatchObject({ method: 'POST', url: 'https://api.example.test/search' }); + expect(entry?.requestHeaders).toEqual(expected.requestHeaders); + expect(entry?.responseBody).toBe(expected.responseBody); + if (include === 'body') expect(entry?.requestBody).toBe('{"q":"shoes"}'); + else expect(entry?.requestBody).toBeUndefined(); +}); + +test('a sensitive header value is redacted and a body secret is masked', async () => { + await dump(); + await server.app.request( + 'https://api.example.test/account', + { + status: 200, + body: '{"token":"abc.def.ghi"}', + responseHeaders: { 'content-type': 'application/json' }, + }, + { + method: 'POST', + headers: { authorization: 'Bearer super-secret' }, + body: '{"apiKey":"sk-live-1"}', + }, + ); + + const result = await dump({ include: 'all' }); + + expect(result.entries[0]?.requestHeaders).toMatchObject({ authorization: '[REDACTED]' }); + expect(result.entries[0]?.requestBody).toBe('{"apiKey":"[REDACTED]"}'); + expect(result.entries[0]?.responseBody).toBe('{"token":"[REDACTED]"}'); +}); + +test('a failed request is reported with its error instead of a status', async () => { + await dump(); + await server.app.request('https://api.example.test/down', { reject: 'Network request failed' }); + + const result = await dump(); + + expect(result.entries[0]).toMatchObject({ + url: 'https://api.example.test/down', + metadata: { error: 'Network request failed', transport: 'fetch' }, + }); + expect(result.entries[0]?.status).toBeUndefined(); +}); + +test('a failed fetch releases its claim so a later XHR to the same URL still records', async () => { + await dump(); + await server.app.request('https://api.example.test/down', { reject: 'Network request failed' }); + await server.app.xhrRequest('https://api.example.test/down'); + + const result = await dump({ include: 'summary' }); + + const transports = result.entries.map((entry) => entry.metadata?.transport); + expect(transports).toEqual(['xhr', 'fetch']); + const xhrEntry = result.entries.find((entry) => entry.metadata?.transport === 'xhr'); + expect(xhrEntry?.url).toBe('https://api.example.test/down'); +}); + +test('a fetch polyfilled on XMLHttpRequest records exactly one entry per request', async () => { + await dump(); + await server.app.request('https://api.example.test/json', { status: 200, body: '{}' }); + + const result = await dump(); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]?.metadata?.transport).toBe('fetch'); +}); + +test('an XHR the app performs directly records with its own transport', async () => { + await dump(); + await server.app.xhrRequest('https://api.example.test/poll'); + + const result = await dump(); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + method: 'GET', + url: 'https://api.example.test/poll', + status: 200, + }); + expect(result.entries[0]?.metadata?.transport).toBe('xhr'); +}); + +test('a second connected device fails closed instead of guessing a runtime', async () => { + await server.close(); + server = await fakeMetroDevServer({ devices: 2 }); + writeMetroHints(stateDir, 'repro', server.serverUrl); + + // No identity the session could name: the provider fails closed before reaching the listing. + await expect( + dumpReactNativeAppNetwork({ + stateDir, + session: 'repro', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + }), + ).rejects.toMatchObject({ details: { reason: 'metro-device-mismatch' } }); +}); + +test('a session device no runtime reports fails closed instead of hooking another device', async () => { + // The fake server's runtimes name `Fake Device 1/2`; this session's device answers to neither, + // so the provider must refuse to inject anywhere rather than read a stranger's traffic. + await expect( + dumpReactNativeAppNetwork({ + stateDir, + session: 'repro', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + metroDeviceIdentity: 'rn-net ios', + }), + ).rejects.toMatchObject({ + details: { reason: 'metro-device-mismatch' }, + }); +}); + +test('only the runtime reporting the session identity is hooked, never the other device', async () => { + await server.close(); + server = await fakeMetroDevServer({ devices: 2 }); + writeMetroHints(stateDir, 'repro', server.serverUrl); + const result = await dumpReactNativeAppNetwork({ + stateDir, + session: 'repro', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + metroDeviceIdentity: 'Fake Device 2', + }); + expect(result.armed).toBe(true); + expect(server.hookedPages).toEqual(['device-2-1']); +}); + +test('an app-backed session whose device cannot be named fails closed without listing runtimes', async () => { + await expect( + dumpReactNativeAppNetwork({ + stateDir, + session: 'repro', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + }), + ).rejects.toMatchObject({ details: { reason: 'metro-device-mismatch' } }); + expect(server.hookedPages).toEqual([]); +}); + +test('a session without a Metro binding or an unreachable dev server fails with a typed reason', async () => { + await expect( + dumpReactNativeAppNetwork({ + stateDir, + session: 'no-metro', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + }), + ).rejects.toThrow(/no React Native dev-server binding/); + + await server.close(); + server = await fakeMetroDevServer(); + writeMetroHints(stateDir, 'repro', 'http://127.0.0.1:1/'); + await expect(dump()).rejects.toMatchObject({ + details: { reason: 'metro-server-unreachable' }, + }); +}); diff --git a/packages/react-native/src/network/network-capture-dump.ts b/packages/react-native/src/network/network-capture-dump.ts new file mode 100644 index 0000000000..8e81b27e43 --- /dev/null +++ b/packages/react-native/src/network/network-capture-dump.ts @@ -0,0 +1,152 @@ +import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; +import type { NetworkProviderDumpResult } from '@agent-device/contracts/network-runtime'; +import { AppError } from '@agent-device/kernel/errors'; +import { + readMetroSessionHints, + resolveSessionMetroServerUrl, +} from '../metro/metro-session-hints.ts'; +import { + debuggerUrlOn, + InspectorSession, + listMetroInspectorTargets, + selectReactNativeTarget, +} from '../react-devtools/metro-inspector.ts'; +import { captureBufferNotes, projectCapturedNetworkEntries } from './network-capture-entries.ts'; +import { + NETWORK_CAPTURE_HEADER_CHARS, + NETWORK_CAPTURE_MAX_ENTRIES, + NETWORK_CAPTURE_RUNTIME_GLOBAL, + NETWORK_CAPTURE_RUNTIME_SOURCE, + NETWORK_CAPTURE_RUNTIME_VERSION, +} from './network-capture-runtime.ts'; + +const NETWORK_CAPTURE_BACKEND = 'react-native-app'; + +export type ReactNativeNetworkDumpInput = Readonly<{ + stateDir: string; + session: string; + appBundleId?: string; + maxEntries: number; + include: NetworkIncludeMode; + maxPayloadChars: number; + /** The dev server's device name of exactly one attached device; see `RuntimeTargetFilter`. */ + metroDeviceIdentity?: string; +}>; + +function runtimeCall( + session: InspectorSession, + method: string, + argument?: string, +): Promise { + return session.evaluate(`${NETWORK_CAPTURE_RUNTIME_GLOBAL}.${method}(${argument ?? ''})`); +} + +function readJsonResult(value: unknown, method: string): Record { + if (typeof value !== 'string') { + throw new AppError( + 'COMMAND_FAILED', + `The app network capture returned no result for ${method}.`, + { reason: 'network-capture-result-unreadable' }, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new AppError( + 'COMMAND_FAILED', + `The app network capture returned unparseable JSON for ${method}.`, + { reason: 'network-capture-result-unreadable' }, + ); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new AppError( + 'COMMAND_FAILED', + `The app network capture returned a malformed result for ${method}.`, + { reason: 'network-capture-result-unreadable' }, + ); + } + return parsed as Record; +} + +function readBuffered(value: unknown): number | undefined { + const stats = readJsonResult(value, 'stats'); + return typeof stats.buffered === 'number' ? stats.buffered : undefined; +} + +/** + * Reads the in-app network capture of the React Native runtime this session's dev server lists. + * The first call installs the fetch/XMLHttpRequest capture, and an app that has requested nothing + * since then reports `armed` with no entries. The buffer drains: a second dump never repeats an + * entry, and an app reload resets the buffer together with its runtime. + */ +export async function dumpReactNativeAppNetwork( + input: ReactNativeNetworkDumpInput, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const scope = { stateDir: input.stateDir, session: input.session }; + if (readMetroSessionHints(scope) === undefined) { + throw new AppError( + 'COMMAND_FAILED', + 'This session has no React Native dev-server binding, so it has no in-app network capture.', + { reason: 'metro-binding-missing' }, + ); + } + const serverUrl = resolveSessionMetroServerUrl(scope); + if (input.appBundleId !== undefined && input.metroDeviceIdentity === undefined) { + // Without a name that marks exactly one attached device, any same-app runtime could be + // picked and hooked; the provider does not guess. + throw new AppError( + 'COMMAND_FAILED', + 'This session device cannot be named unambiguously among the devices attached to this host, so the in-app network capture stays off for it.', + { reason: 'metro-device-mismatch' }, + ); + } + const target = selectReactNativeTarget(await listMetroInspectorTargets(serverUrl, signal), { + appId: input.appBundleId, + ...(input.metroDeviceIdentity === undefined + ? {} + : { metroDeviceIdentity: input.metroDeviceIdentity }), + }); + const session = await InspectorSession.connect(debuggerUrlOn(serverUrl, target), signal); + try { + // The installing dump's payload budget becomes the in-app body cap, so the app-side copy is + // never larger than what the host would report anyway. + const installed = await session.evaluate( + `(${NETWORK_CAPTURE_RUNTIME_SOURCE})(${JSON.stringify(NETWORK_CAPTURE_RUNTIME_VERSION)}, ${NETWORK_CAPTURE_MAX_ENTRIES}, ${input.maxPayloadChars}, ${NETWORK_CAPTURE_HEADER_CHARS})`, + ); + const drained = readJsonResult( + await runtimeCall(session, 'drain', String(input.maxEntries)), + 'drain', + ); + if (drained.ok !== true || !Array.isArray(drained.entries)) { + throw new AppError('COMMAND_FAILED', 'The app network capture refused to drain.', { + reason: 'network-capture-drain-refused', + }); + } + const entries = projectCapturedNetworkEntries( + drained.entries, + input.include, + input.maxPayloadChars, + ); + const buffered = readBuffered(await runtimeCall(session, 'stats')); + const dropped = typeof drained.dropped === 'number' ? drained.dropped : 0; + const notes = [...captureBufferNotes({ buffered, dropped })]; + if (entries.length === 0) { + notes.push( + installed === 'installed' + ? 'In-app network capture was installed by this dump, so it holds no earlier traffic. Reproduce the requests now, then dump again for method, full URL, status, duration, headers, and bodies.' + : 'The app runtime captured no request since the last dump.', + ); + } + return Object.freeze({ + backend: NETWORK_CAPTURE_BACKEND, + entries, + notes: Object.freeze(notes), + ...(installed === 'installed' && entries.length === 0 ? { armed: true } : {}), + }); + } finally { + session.close(); + } +} diff --git a/packages/react-native/src/network/network-capture-entries.ts b/packages/react-native/src/network/network-capture-entries.ts new file mode 100644 index 0000000000..136dedff3c --- /dev/null +++ b/packages/react-native/src/network/network-capture-entries.ts @@ -0,0 +1,121 @@ +import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; +import type { NetworkProviderEntry } from '@agent-device/contracts/network-runtime'; +import { redactDiagnosticData, redactNetworkPayload } from '@agent-device/kernel/redaction'; +import { NETWORK_CAPTURE_MAX_ENTRIES } from './network-capture-runtime.ts'; + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function readHeaders(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const headers: Record = {}; + for (const [name, entry] of Object.entries(value as Record)) { + if (typeof entry === 'string') headers[name] = entry; + } + return Object.keys(headers).length > 0 + ? (redactDiagnosticData(headers) as Record) + : undefined; +} + +/** Metadata a drained entry earns beyond its request fields: the transport it ran on, its error. */ +function readMetadata( + entry: Record, + maxPayloadChars: number, +): Record | undefined { + const metadata: Record = {}; + const transport = readString(entry.transport); + if (transport) metadata.transport = transport; + const error = readString(entry.error); + if (error) metadata.error = redactNetworkPayload(error, maxPayloadChars); + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +function detailFields( + entry: Record, + include: NetworkIncludeMode, + maxPayloadChars: number, +): Record { + const detail: Record = {}; + if (includesHeaders(include)) { + const requestHeaders = readHeaders(entry.requestHeaders); + const responseHeaders = readHeaders(entry.responseHeaders); + if (requestHeaders) detail.requestHeaders = requestHeaders; + if (responseHeaders) detail.responseHeaders = responseHeaders; + } + if (includesBody(include)) { + const requestBody = readString(entry.requestBody); + const responseBody = readString(entry.responseBody); + if (requestBody !== undefined) + detail.requestBody = redactNetworkPayload(requestBody, maxPayloadChars); + if (responseBody !== undefined) + detail.responseBody = redactNetworkPayload(responseBody, maxPayloadChars); + } + return detail; +} + +/** + * Projects the raw entries one app runtime drained into the provider dump shape the + * `network dump` response carries. Each entry crosses the debugger boundary as untrusted JSON, so + * every field is read defensively. The newest entry comes first. Header and body detail rides + * along only as `include` asks for; header values and bodies are redacted with the shared secret + * rules and bounded to `maxPayloadChars`. The captured URL stays verbatim: the full URL is the + * reason the in-app capture exists. + */ +export function projectCapturedNetworkEntries( + entries: readonly unknown[], + include: NetworkIncludeMode, + maxPayloadChars: number, +): readonly NetworkProviderEntry[] { + const projected: NetworkProviderEntry[] = []; + for (const value of entries) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const entry = value as Record; + const url = readString(entry.url); + if (url === undefined) continue; + const metadata = readMetadata(entry, maxPayloadChars); + projected.push({ + timestamp: readString(entry.timestamp), + method: readString(entry.method), + url, + status: readNumber(entry.status), + durationMs: readNumber(entry.durationMs), + ...detailFields(entry, include, maxPayloadChars), + ...(metadata ? { metadata } : {}), + }); + } + return projected.reverse(); +} + +export type CaptureStats = Readonly<{ + buffered: number | undefined; + dropped: number; +}>; + +/** Notes the buffer's own accounting earns: what stays buffered, what the ring evicted. */ +export function captureBufferNotes(stats: CaptureStats): readonly string[] { + const notes: string[] = []; + if (stats.buffered !== undefined && stats.buffered > 0) { + notes.push( + `${stats.buffered} older captured request${stats.buffered === 1 ? '' : 's'} remain buffered; dump again or raise the limit.`, + ); + } + if (stats.dropped > 0) { + notes.push( + `${stats.dropped} captured request${stats.dropped === 1 ? '' : 's'} were evicted from the ${NETWORK_CAPTURE_MAX_ENTRIES}-entry app buffer before this dump.`, + ); + } + return notes; +} + +function includesHeaders(include: NetworkIncludeMode): boolean { + return include === 'headers' || include === 'all'; +} + +function includesBody(include: NetworkIncludeMode): boolean { + return include === 'body' || include === 'all'; +} diff --git a/packages/react-native/src/network/network-capture-runtime.ts b/packages/react-native/src/network/network-capture-runtime.ts new file mode 100644 index 0000000000..3b345d400f --- /dev/null +++ b/packages/react-native/src/network/network-capture-runtime.ts @@ -0,0 +1,326 @@ +import { createHash } from 'node:crypto'; + +/** Global the capture installs on inside the app's JavaScript runtime. */ +export const NETWORK_CAPTURE_RUNTIME_GLOBAL = '__AGENT_DEVICE_NETWORK__'; + +/** Entries the ring buffer holds; the oldest is dropped when it is full. */ +export const NETWORK_CAPTURE_MAX_ENTRIES = 200; + +/** Characters kept per header value inside the app, on top of the payload budget. */ +export const NETWORK_CAPTURE_HEADER_CHARS = 512; + +/** + * Source evaluated inside the app through CDP `Runtime.evaluate`. It installs + * `globalThis.__AGENT_DEVICE_NETWORK__` once per JavaScript runtime and patches the two network + * surfaces a React Native app uses — `fetch` and `XMLHttpRequest` — recording one bounded entry + * per completed request. Hermes evaluates it unbundled, so it stays ES5. + * + * Each entry keeps the request identity (method, full URL, status, duration, ISO timestamp), the + * header pairs, and both bodies capped at BODY_CAP for textual content types. Redaction never + * happens in the app: the host applies its own secret rules when it reads the buffer. `drain` + * removes and returns the entries it hands over, so two dumps of one session never repeat an + * entry, and an app reload resets the buffer together with the runtime that holds it. + */ +export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` +(function (VERSION, MAX_ENTRIES, BODY_CAP, HEADER_CAP) { + var g = globalThis; + var existing = g.__AGENT_DEVICE_NETWORK__; + if (existing && typeof existing.drain === 'function' && typeof existing.stats === 'function') { + return 'ready'; + } + var entries = []; + var captured = 0; + var dropped = 0; + var patchedFetch = false; + var patchedXhr = false; + var fetchInFlight = {}; + + // whatwg-fetch polyfills fetch on top of XMLHttpRequest. While a wrapped fetch() call runs + // synchronously, its URL is claimed; an XHR send inside that window belongs to that fetch and + // must not record a second entry. The window closes when fetch() returns. + function claimFetch(url) { + fetchInFlight[url] = (fetchInFlight[url] || 0) + 1; + return function () { + fetchInFlight[url] = (fetchInFlight[url] || 0) - 1; + if (fetchInFlight[url] <= 0) delete fetchInFlight[url]; + }; + } + + function fetchClaims(url) { + return patchedFetch && fetchInFlight[url] > 0; + } + + function cap(value) { + var text = String(value); + return text.length <= BODY_CAP ? text : text.slice(0, BODY_CAP); + } + + function capHeader(value) { + var text = String(value); + return text.length <= HEADER_CAP ? text : text.slice(0, HEADER_CAP); + } + + function isBinaryContentType(contentType) { + return contentType !== undefined && contentType !== '' && + !/text\/|json|xml|javascript|x-www-form-urlencoded|svg/i.test(String(contentType)); + } + + function bodyValue(body) { + if (body == null) return undefined; + if (typeof body === 'string') return cap(body); + try { + if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) { + return cap(body.toString()); + } + } catch (e) {} + if (typeof body !== 'object' || typeof body.getReader === 'function') return undefined; + if (typeof ArrayBuffer !== 'undefined' && (body instanceof ArrayBuffer || ArrayBuffer.isView(body))) { + return undefined; + } + if (typeof Blob !== 'undefined' && body instanceof Blob) return undefined; + if (typeof FormData !== 'undefined' && body instanceof FormData) return undefined; + try { + var json = JSON.stringify(body); + return json === undefined ? undefined : cap(json); + } catch (e) { + return undefined; + } + } + + function headerPairs(source) { + var out = {}; + if (!source) return out; + var count = 0; + if (typeof source.forEach === 'function') { + try { + source.forEach(function (value, name) { + if (count >= 64) return; + count += 1; + out[name] = capHeader(value); + }); + } catch (e) {} + return out; + } + try { + Object.keys(source).forEach(function (name) { + if (count >= 64) return; + count += 1; + out[name] = capHeader(source[name]); + }); + } catch (e) {} + return out; + } + + function record(entry) { + captured += 1; + entries.push(entry); + if (entries.length > MAX_ENTRIES) { + var evicted = entries.length - MAX_ENTRIES; + dropped += evicted; + entries.splice(0, evicted); + } + } + + function instrumentFetch(fetch) { + if (typeof fetch !== 'function' || fetch.__agentDeviceCapture === true) return false; + var wrapped = function (input, init) { + var url = typeof input === 'string' ? input : (input && input.url ? String(input.url) : String(input)); + var method = 'GET'; + if (init && init.method) method = String(init.method); + else if (input && typeof input === 'object' && input.method) method = String(input.method); + method = method.toUpperCase(); + var requestBody = bodyValue(init && init.body !== undefined ? init.body : undefined); + var requestHeaders = headerPairs(init && init.headers ? init.headers : (input && input.headers)); + var startedAt = Date.now(); + var began = new Date(startedAt).toISOString(); + var release = claimFetch(url); + var promise; + try { + promise = fetch.apply(this, arguments); + } catch (e) { + release(); + record({ + timestamp: began, + method: method, + url: url, + durationMs: Date.now() - startedAt, + requestHeaders: requestHeaders, + error: String((e && e.message) || e), + transport: 'fetch' + }); + throw e; + } + // The claim spans only this synchronous call: a fetch polyfilled on XHR builds and sends + // its request here, so the XHR sees the claim; a real async fetch never touches + // XMLHttpRequest during this window, and later XHRs to the same URL stay their own entry + // even while this request is in flight or after it fails. + release(); + if (!promise || typeof promise.then !== 'function') return promise; + return promise.then( + function (response) { + var entry = { + timestamp: began, + method: method, + url: url, + status: response && typeof response.status === 'number' ? response.status : undefined, + durationMs: Date.now() - startedAt, + requestHeaders: requestHeaders, + responseHeaders: headerPairs(response && response.headers), + transport: 'fetch' + }; + if (requestBody !== undefined) entry.requestBody = requestBody; + var contentType; + try { contentType = response && response.headers && response.headers.get('content-type'); } catch (e) {} + var clone = response && typeof response.clone === 'function' ? response.clone() : undefined; + if (clone && typeof clone.text === 'function' && !isBinaryContentType(contentType)) { + // Record in a detached chain: the app receives its response at once, and the entry + // joins the buffer when the body read settles. A drain taken inside that window + // reports this request on a later dump, never half-written, and never delays the app + // for the whole read. + clone.text().then( + function (text) { + entry.responseBody = cap(text); + record(entry); + }, + function () { + record(entry); + } + ); + return response; + } + record(entry); + return response; + }, + function (error) { + record({ + timestamp: began, + method: method, + url: url, + durationMs: Date.now() - startedAt, + requestHeaders: requestHeaders, + error: String((error && error.message) || error), + transport: 'fetch' + }); + throw error; + } + ); + }; + wrapped.__agentDeviceCapture = true; + return wrapped; + } + + function instrumentXHR(prototype) { + if (!prototype || typeof prototype.open !== 'function' || typeof prototype.send !== 'function') { + return false; + } + if (prototype.send.__agentDeviceCapture === true) return false; + var open = prototype.open; + var send = prototype.send; + var setRequestHeader = prototype.setRequestHeader; + prototype.open = function (method, url) { + this.__agentDeviceRequest = { + method: String(method || 'GET').toUpperCase(), + url: String(url), + headers: {} + }; + return open.apply(this, arguments); + }; + if (typeof setRequestHeader === 'function') { + prototype.setRequestHeader = function (name, value) { + if (this.__agentDeviceRequest) + this.__agentDeviceRequest.headers[name] = capHeader(value); + return setRequestHeader.apply(this, arguments); + }; + } + var wrapped = function (body) { + var state = this.__agentDeviceRequest; + // A fetch polyfilled on XHR calls send synchronously inside the fetch wrapper, while the + // wrapper's claim on this URL is held: that transport's XHR must not record a second entry. + if (state && fetchClaims(state.url)) state.ownedByFetch = true; + if (state && !state.ownedByFetch) { + state.startedAt = Date.now(); + state.began = new Date(state.startedAt).toISOString(); + state.requestBody = bodyValue(body); + var xhr = this; + this.addEventListener('readystatechange', function () { + if (xhr.readyState !== 4) return; + var entry = { + timestamp: state.began, + method: state.method, + url: state.url, + status: xhr.status, + durationMs: Date.now() - state.startedAt, + requestHeaders: state.headers, + transport: 'xhr' + }; + if (state.requestBody !== undefined) entry.requestBody = state.requestBody; + var responseType = xhr.responseType; + if (responseType === '' || responseType === 'text') { + try { entry.responseBody = cap(xhr.responseText); } catch (e) {} + } + try { + var lines = String(xhr.getAllResponseHeaders() || '').split('\n'); + for (var i = 0; i < lines.length && i < 64; i++) { + var split = lines[i].indexOf(':'); + if (split > 0) { + entry.responseHeaders = entry.responseHeaders || {}; + entry.responseHeaders[lines[i].slice(0, split).trim()] = capHeader( + lines[i].slice(split + 1).trim() + ); + } + } + } catch (e) {} + record(entry); + }); + } + return send.apply(this, arguments); + }; + wrapped.__agentDeviceCapture = true; + prototype.send = wrapped; + return true; + } + + var wrappedFetch = instrumentFetch(g.fetch); + if (wrappedFetch) { + g.fetch = wrappedFetch; + patchedFetch = true; + } + if (typeof XMLHttpRequest === 'function') { + patchedXhr = instrumentXHR(XMLHttpRequest.prototype); + } + + g.__AGENT_DEVICE_NETWORK__ = { + version: VERSION, + stats: function () { + return JSON.stringify({ + ok: true, + capturing: true, + fetchPatched: patchedFetch, + xhrPatched: patchedXhr, + buffered: entries.length, + captured: captured, + dropped: dropped + }); + }, + drain: function (limit) { + var count = Math.max(0, Math.min(limit || 0, entries.length)); + var drained = entries.splice(entries.length - count, count); + // Eviction accounting rides with the read it describes and resets here, so a later dump + // never re-reports requests an earlier dump already said the buffer gave up. + var evicted = dropped; + dropped = 0; + return JSON.stringify({ ok: true, entries: drained, dropped: evicted }); + } + }; + return 'installed'; +}) +`; + +/** + * Identity of this runtime source. A runtime that already holds a capture keeps it: one stable + * drain/stats shape serves every host build, so a reinstall never re-patches a patched surface. + */ +export const NETWORK_CAPTURE_RUNTIME_VERSION = createHash('sha256') + .update(NETWORK_CAPTURE_RUNTIME_SOURCE) + .digest('hex') + .slice(0, 16); diff --git a/packages/react-native/src/react-devtools/metro-inspector.test.ts b/packages/react-native/src/react-devtools/metro-inspector.test.ts index fff5e3f89a..ab100b5634 100644 --- a/packages/react-native/src/react-devtools/metro-inspector.test.ts +++ b/packages/react-native/src/react-devtools/metro-inspector.test.ts @@ -96,6 +96,65 @@ test('devices without a logical id are told apart by their target ids, not their assert.equal(selectReactNativeTarget(targets, { device: 'sim-a' }).id, 'sim-a-1'); }); +test('the Metro device identity selects only the runtime that reports it verbatim', () => { + const targets = [ + target('sim-a-1', { appId: 'com.app', deviceName: 'rn-net ios' }), + target('pixel-1', { appId: 'com.app', deviceName: 'sdk_gphone64_arm64 - 15 - API 35' }), + ]; + assert.equal( + selectReactNativeTarget(targets, { + appId: 'com.app', + metroDeviceIdentity: 'sdk_gphone64_arm64 - 15 - API 35', + }).id, + 'pixel-1', + ); + assert.equal( + selectReactNativeTarget(targets, { appId: 'com.app', metroDeviceIdentity: 'rn-net ios' }).id, + 'sim-a-1', + ); +}); + +test('a model-prefix or partial name is not an identity and fails closed', () => { + const targets = [target('pixel-1', { appId: 'com.app', deviceName: 'Pixel 9' })]; + // The platform owns the composed name; the matcher never widens it. + assert.throws( + () => selectReactNativeTarget(targets, { appId: 'com.app', metroDeviceIdentity: 'Pixel' }), + (error: { code: string; details: { reason: string } }) => { + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details.reason, 'metro-device-mismatch'); + return true; + }, + ); + assert.throws( + () => + selectReactNativeTarget(targets, { + appId: 'com.app', + metroDeviceIdentity: 'Pixel 9 - 15 - API 35', + }), + (error: { details: { reason: string } }) => error.details.reason === 'metro-device-mismatch', + ); + // Without an identity the filter keeps the plain app/device selection. + assert.equal(selectReactNativeTarget(targets, { appId: 'com.app' }).id, 'pixel-1'); +}); + +test('a shared device name among connected runtimes reaches the several-devices guard', () => { + // Two emulators of one system image report one name. Selection matches it verbatim on both + // targets, so neither is silently chosen; the platform that owns the inventory must not pass a + // name shared by attached devices in the first place, and the dump fails closed without one. + const targets = [ + target('emu-b-1', { appId: 'com.app', deviceName: 'sdk_gphone64_arm64 - 15 - API 35' }), + target('emu-a-1', { appId: 'com.app', deviceName: 'sdk_gphone64_arm64 - 15 - API 35' }), + ]; + assert.throws( + () => + selectReactNativeTarget(targets, { + appId: 'com.app', + metroDeviceIdentity: 'sdk_gphone64_arm64 - 15 - API 35', + }), + /several devices/, + ); +}); + test('distinct apps need --app; older dev servers report the app id as the description', () => { const targets = [ target('a-1', { appId: 'com.one' }), diff --git a/packages/react-native/src/react-devtools/metro-inspector.ts b/packages/react-native/src/react-devtools/metro-inspector.ts index d8c1376788..d01e68a01e 100644 --- a/packages/react-native/src/react-devtools/metro-inspector.ts +++ b/packages/react-native/src/react-devtools/metro-inspector.ts @@ -24,16 +24,24 @@ export function targetAppId(target: MetroInspectorTarget): string { return target.appId ?? target.description ?? target.title; } -export async function listMetroInspectorTargets(serverUrl: URL): Promise { +export async function listMetroInspectorTargets( + serverUrl: URL, + signal?: AbortSignal, +): Promise { const listUrl = new URL('json/list', withTrailingSlash(serverUrl)); let response: Response; try { - response = await fetch(listUrl, { signal: AbortSignal.timeout(TARGET_LIST_TIMEOUT_MS) }); + response = await fetch(listUrl, { + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(TARGET_LIST_TIMEOUT_MS)]) + : AbortSignal.timeout(TARGET_LIST_TIMEOUT_MS), + }); } catch (error) { throw new AppError( 'COMMAND_FAILED', `Could not reach the React Native dev server at ${serverUrl.origin}.`, { + reason: 'metro-server-unreachable', hint: 'Start Metro (or Expo/Re.Pack) for the app, or pass --metro-port/--metro-host when it does not run on localhost:8081.', }, error, @@ -86,6 +94,13 @@ export type RuntimeTargetFilter = { appId?: string; /** Device name or logical device id, as the dev server reports it. */ device?: string; + /** + * The dev server's device name of exactly one device attached to this host, composed by the + * platform package that owns the device inventory. A runtime is selected by it only when it + * reports that name verbatim; no candidate doing so fails closed. A name shared by several + * attached devices is not an identity, and the platform must not pass one. + */ + metroDeviceIdentity?: string; }; function isAppRuntime(target: MetroInspectorTarget): boolean { @@ -151,14 +166,30 @@ function deviceSelectors(devices: MetroInspectorTarget[]): string[] { /** * Picks the one React Native runtime to profile. Pages without the `reactNative` field are not app * runtimes, and worklet runtimes render no React tree. Several apps or several devices fail - * closed: the caller must narrow to exactly one runtime. + * closed: the caller must narrow to exactly one runtime. When `metroDeviceIdentity` is set, only a + * runtime reporting that name verbatim may be selected; none doing so fails closed, because + * selecting the same app id on another device would read and hook a runtime this session does not + * own. Two runtimes reporting the same shared device name both match and reach the several-devices + * guard: a name two devices share is not an identity, and the platform that owns the inventory + * must not pass one. */ export function selectReactNativeTarget( targets: MetroInspectorTarget[], filter: RuntimeTargetFilter = {}, ): MetroInspectorTarget { const runtimes = targets.filter(isAppRuntime); - const candidates = runtimes.filter((target) => matchesFilter(target, filter)); + let candidates = runtimes.filter((target) => matchesFilter(target, filter)); + if (filter.metroDeviceIdentity !== undefined && candidates.length > 0) { + const owned = candidates.filter((target) => target.deviceName === filter.metroDeviceIdentity); + if (owned.length === 0) { + throw new AppError( + 'COMMAND_FAILED', + "No React Native runtime connected to the dev server reports this session's device.", + { reason: 'metro-device-mismatch' }, + ); + } + candidates = owned; + } if (candidates.length === 0) throw noMatchingRuntime(runtimes, filter); const apps = new Set(candidates.map(targetAppId)); if (apps.size > 1) { @@ -211,12 +242,20 @@ export class InspectorSession { }); } - static async connect(webSocketDebuggerUrl: string): Promise { + static async connect( + webSocketDebuggerUrl: string, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); const url = new URL(webSocketDebuggerUrl); // Metro refuses debugger upgrades without a loopback Origin, and Expo's dev server terminates // any whose Origin host differs from its own 127.0.0.1: base URL. const origin = `http://127.0.0.1:${url.port || (url.protocol === 'wss:' ? '443' : '80')}`; const socket = new WebSocketWithHeaders(url, { headers: { origin } }); + // A caller budget must end this connection, not merely stop waiting on it: an aborted signal + // closes the socket, so no CDP session outlives the request that opened it. + const closeOnAbort = () => socket.close(); + signal?.addEventListener('abort', closeOnAbort, { once: true }); await new Promise((resolve, reject) => { const timer = setTimeout(() => { socket.close(); @@ -243,6 +282,17 @@ export class InspectorSession { }, { once: true }, ); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new AppError('COMMAND_FAILED', 'Network capture was cancelled.')); + }, + { once: true }, + ); + }).catch((error: unknown) => { + signal?.removeEventListener('abort', closeOnAbort); + throw error; }); return new InspectorSession(socket); } diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index cd942042f6..5742e03f96 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -496,6 +496,7 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/capture-kit/ios-snapshot-runtime', '@agent-device/capture-kit/ios-snapshot-tree', '@agent-device/capture-kit/mobile-snapshot-semantics', + '@agent-device/capture-kit/network-provider-capture', '@agent-device/capture-kit/observe-until', '@agent-device/capture-kit/perf-capture-admission-ledger', '@agent-device/capture-kit/perf-capture-recovery', diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index e0918f4c7b..fefa6429a8 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -79,6 +79,7 @@ export const ROOT_MODULE_ZONES: Readonly> = { 'src/platform-runtime-host.ts', 'src/platform-runtime-local-application-interactors.ts', 'src/platform-runtime-network-host.ts', + 'src/platform-runtime-network-react-native-transport.ts', 'src/platform-runtime-network-web-transport.ts', 'src/platform-runtime-operation-host.ts', 'src/platform-runtime-perf-capture-host.ts', diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index cd34adf6b0..775e29b7b5 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -35,7 +35,8 @@ const logsCommandDescription = 'Session app log info, start/stop streaming, diagnostics, and markers'; const eventsCommandDescription = 'Read the daemon-owned session event timeline as paged JSON-friendly entries'; -const networkCommandDescription = 'Dump recent HTTP(s) traffic parsed from the session app log'; +const networkCommandDescription = + 'Dump recent HTTP(s) traffic: in-app capture for React Native sessions, otherwise the session app log'; const audioCommandDescription = 'Measure browser or host-rendered simulator/emulator audio as compact dBFS buckets. Start a probe before requesting its status or stopping it.'; @@ -183,7 +184,7 @@ export const eventsCommandFacet = defineCommandFacet({ export const networkCommandFacet = defineCommandFacet({ name: NETWORK_COMMAND_NAME, text: { - summary: 'Inspect HTTP(S) traffic from session logs', + summary: 'Inspect HTTP(S) traffic from the app or session logs', }, metadata: networkCommandMetadata, run: (client, input) => client.observability.network(input), diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 2f08b8e929..2cf6bb6e92 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -58,6 +58,7 @@ type NetworkCliEntry = (BackendNetworkEntry | NetworkEntry) & { type NetworkCliResult = { path?: string; + source?: string; active?: boolean; state?: string; backend?: string; @@ -125,6 +126,7 @@ function networkCliOutput(data: NetworkCliResult): CliOutput { text: lines.join('\n'), stderr: joinDefinedLines([ formatKeyValueFields(data, [ + 'source', 'active', 'state', 'backend', diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts index 6f1b2febfa..7b64708657 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -9,7 +9,7 @@ test('commands topic includes concise command catalog entries', async () => { assert.match(usageText, /prepare\s{2,}Pre-warm platform helpers before automation/); assert.match(usageText, /metro\s{2,}Prepare the dev server or reload apps/); assert.match(usageText, /batch --steps \| --steps-file /); - assert.match(usageText, /network\s{2,}Inspect HTTP\(S\) traffic from session logs/); + assert.match(usageText, /network\s{2,}Inspect HTTP\(S\) traffic from the app or session logs/); assert.match(usageText, /clipboard read \| clipboard write /); assert.match(usageText, /keyboard \[action\]/); assert.match(usageText, /trigger-app-event\s{2,}Invoke an app-defined automation event/); diff --git a/src/commands/schema/cli-help-workflows.ts b/src/commands/schema/cli-help-workflows.ts index a042727d3b..24540f5b1e 100644 --- a/src/commands/schema/cli-help-workflows.ts +++ b/src/commands/schema/cli-help-workflows.ts @@ -89,11 +89,12 @@ Events: Events preserve command names, status, durations, bounded device/app inventory previews, lifecycle outcomes, artifact basenames, and structural action details such as scroll distance/direction, safe refs, and coordinates. User-entered text, clipboard contents, push/event payloads, selector values, free-form flags/messages/paths, and raw unknown command arguments are omitted or replaced with content-free placeholders. --no-record suppresses action.recorded entries, but request start/finish entries still record command, status, and timing. Network: - Use network dump for recent session HTTP traffic parsed from app logs. + Use network dump for recent session HTTP traffic. agent-device network dump --include headers agent-device network dump 20 --include all Use this instead of logs path when the question is request/response metadata. network log is a supported alias, but network dump --include headers is the clearest plan form. Do not write network log headers. + The response reports its source. source=provider means a capture inside the app answered the dump: on React Native sessions opened against a dev server, agent-device installs a fetch/XMLHttpRequest capture and reads method, full URL, status, duration, headers, and bodies from the app runtime. That capture starts at the first dump: the first dump arms it and answers from the app log (source=app-log, note says so), and every later dump returns the entries captured since the last one. source=app-log otherwise means traffic parsed from app logs, which depends on what the app logs. Audio: Use audio probe when the question is whether a browser page, macOS session, iOS simulator, or Android emulator produced audible output during a short observation window. diff --git a/src/platform-runtime-network-host.ts b/src/platform-runtime-network-host.ts index f78356c36f..469ce6e25d 100644 --- a/src/platform-runtime-network-host.ts +++ b/src/platform-runtime-network-host.ts @@ -7,6 +7,7 @@ import type { NetworkAppLogRead, NetworkRuntimeHost, NetworkTransport, + NetworkTransportScope, } from '@agent-device/contracts/network-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { openVerifiedFileForRead } from '@agent-device/host-kit/file'; @@ -19,7 +20,7 @@ export function createNetworkRuntimeHost( options: Readonly<{ resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; processes: ProcessMarkerReader; - resolveTransport(device: DeviceInfo): Promise; + resolveTransport(device: DeviceInfo, scope?: NetworkTransportScope): Promise; }>, ): Pick { return Object.freeze({ diff --git a/src/platform-runtime-network-react-native-transport.test.ts b/src/platform-runtime-network-react-native-transport.test.ts new file mode 100644 index 0000000000..2581da38ac --- /dev/null +++ b/src/platform-runtime-network-react-native-transport.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +const metroReads = vi.hoisted(() => vi.fn()); +const dumps = vi.hoisted(() => vi.fn()); + +vi.mock('@agent-device/react-native', () => ({ + readMetroSessionRuntime: metroReads, + dumpReactNativeAppNetwork: dumps, +})); + +const { resolveReactNativeNetworkTransport } = + await import('./platform-runtime-network-react-native-transport.ts'); + +const host = {} as HostEnvironment; + +function device(platform: DeviceInfo['platform'], kind: DeviceInfo['kind'] = 'simulator') { + return { platform, kind, id: 'device-1', name: 'device-1' } as DeviceInfo; +} + +const scope = { sessionId: 's1', stateDir: '/state' }; + +test.afterEach(() => { + metroReads.mockReset(); + dumps.mockReset(); +}); + +test('a non-mobile device never resolves a React Native network transport', async () => { + const transport = await resolveReactNativeNetworkTransport(device('web'), scope, host); + assert.equal(transport.dump, undefined); + assert.equal(metroReads.mock.calls.length, 0); +}); + +test('a session without a state dir has no Metro binding to read and is not probed', async () => { + const transport = await resolveReactNativeNetworkTransport( + device('apple'), + { sessionId: 's1' }, + host, + ); + assert.equal(transport.dump, undefined); + assert.equal(metroReads.mock.calls.length, 0); +}); + +test('a session without a Metro hint answers local with no provider dump', async () => { + metroReads.mockResolvedValue(undefined); + const transport = await resolveReactNativeNetworkTransport(device('android'), scope, host); + assert.equal(transport.dump, undefined); + assert.equal(dumps.mock.calls.length, 0); +}); + +test('a session with a Metro binding dumps through the app-side capture with the session scope', async () => { + metroReads.mockResolvedValue({ metroPort: 8081 }); + dumps.mockResolvedValue({ backend: 'react-native-app', entries: [] }); + const transport = await resolveReactNativeNetworkTransport( + device('apple'), + { ...scope, appBundleId: 'com.example.app', maxPayloadChars: 2048 }, + host, + ); + assert.ok(transport.dump); + const signal = new AbortController().signal; + await transport.dump({ maxEntries: 25, include: 'all' }, signal); + assert.deepEqual(dumps.mock.calls[0], [ + { + stateDir: '/state', + session: 's1', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'all', + maxPayloadChars: 2048, + }, + signal, + ]); +}); + +test('the identity resolver runs only inside the dump and its answer joins the dump input', async () => { + metroReads.mockResolvedValue({ metroPort: 8081 }); + dumps.mockResolvedValue({ backend: 'react-native-app', entries: [] }); + let resolved = 0; + const transport = await resolveReactNativeNetworkTransport( + device('android'), + { + ...scope, + appBundleId: 'com.example.app', + resolveMetroDeviceIdentity: async () => { + resolved += 1; + return 'sdk_gphone64_arm64 - 15 - API 35'; + }, + }, + host, + ); + assert.ok(transport.dump); + // Resolving the transport declares the dump; it does not yet pay for the probe. + assert.equal(resolved, 0); + await transport.dump({ maxEntries: 25, include: 'summary' }, new AbortController().signal); + assert.equal(resolved, 1); + const [dumpInput] = dumps.mock.calls[0] ?? []; + assert.equal( + (dumpInput as { metroDeviceIdentity?: string }).metroDeviceIdentity, + 'sdk_gphone64_arm64 - 15 - API 35', + ); +}); + +test('a resolver without a Metro binding is never called', async () => { + metroReads.mockResolvedValue(undefined); + let resolved = 0; + await resolveReactNativeNetworkTransport( + device('android'), + { + ...scope, + resolveMetroDeviceIdentity: async () => { + resolved += 1; + return 'Device'; + }, + }, + host, + ); + assert.equal(resolved, 0); +}); diff --git a/src/platform-runtime-network-react-native-transport.ts b/src/platform-runtime-network-react-native-transport.ts new file mode 100644 index 0000000000..63e6d9e774 --- /dev/null +++ b/src/platform-runtime-network-react-native-transport.ts @@ -0,0 +1,57 @@ +import type { + NetworkTransport, + NetworkTransportScope, +} from '@agent-device/contracts/network-runtime'; +import { NETWORK_MAX_PAYLOAD_CHARS } from '@agent-device/contracts/network-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +/** + * The session's React Native app runtime as a network transport. The gate is the session's own + * Metro binding: only a session opened with a dev-server runtime has an in-app capture to read, + * so a plain device session is never probed. What the capture itself reports — an unreachable dev + * server, several connected apps — reaches the dump as a typed note, not a transport failure. + */ +export async function resolveReactNativeNetworkTransport( + device: DeviceInfo, + scope: NetworkTransportScope, + host: HostEnvironment, +): Promise { + if (device.platform !== 'apple' && device.platform !== 'android') { + return Object.freeze({ mode: 'local' }); + } + if (scope.stateDir === undefined) return Object.freeze({ mode: 'local' }); + const { readMetroSessionRuntime, dumpReactNativeAppNetwork } = + await import('@agent-device/react-native'); + const runtime = await readMetroSessionRuntime({ + stateDir: scope.stateDir, + session: scope.sessionId, + host, + }); + if (runtime === undefined) return Object.freeze({ mode: 'local' }); + const stateDir = scope.stateDir; + const maxPayloadChars = scope.maxPayloadChars ?? NETWORK_MAX_PAYLOAD_CHARS; + return Object.freeze({ + mode: 'local', + dump: async (input, signal) => { + // The identity probe is the first thing the dump pays for: only a session that reached this + // point (a Metro binding exists) runs the platform's adb or simctl probe, and the dump's own + // budget signal bounds it. + const metroDeviceIdentity = await scope.resolveMetroDeviceIdentity?.( + signal ?? AbortSignal.timeout(30_000), + ); + return await dumpReactNativeAppNetwork( + { + stateDir, + session: scope.sessionId, + ...(scope.appBundleId === undefined ? {} : { appBundleId: scope.appBundleId }), + maxEntries: input.maxEntries, + include: input.include, + maxPayloadChars, + ...(metroDeviceIdentity === undefined ? {} : { metroDeviceIdentity }), + }, + signal, + ); + }, + }); +} diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index 1537603191..187d67ab06 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -85,11 +85,16 @@ export function createPlatformRuntimeHost(options: { const network = createNetworkRuntimeHost({ resolveSessionArtifacts: options.resolveSessionArtifacts, processes, - resolveTransport: async (device) => { - if (device.platform !== 'web') return Object.freeze({ mode: 'local' as const }); - const { resolveWebNetworkTransport } = - await import('./platform-runtime-network-web-transport.ts'); - return await resolveWebNetworkTransport(device, host); + resolveTransport: async (device, scope) => { + if (device.platform === 'web') { + const { resolveWebNetworkTransport } = + await import('./platform-runtime-network-web-transport.ts'); + return await resolveWebNetworkTransport(device, host); + } + if (scope === undefined) return Object.freeze({ mode: 'local' as const }); + const { resolveReactNativeNetworkTransport } = + await import('./platform-runtime-network-react-native-transport.ts'); + return await resolveReactNativeNetworkTransport(device, scope, host); }, }); const appleApplications = createAppleApplicationTools(env); diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index 7ce8f9d2c3..ebc63dc590 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -242,7 +242,11 @@ export function simctlListDevicesResult( devices: Array<{ name: string; udid: string; state?: string; isAvailable?: boolean }>, ): ExecResult | undefined { const listing = args.join(' '); - if (listing !== 'list devices -j' && listing !== 'list -j') { + if ( + listing !== 'list devices -j' && + listing !== 'list devices booted -j' && + listing !== 'list -j' + ) { return undefined; } return simctlListDevicesJson(runtime, devices); diff --git a/test/output-economy/output-economy.baseline.json b/test/output-economy/output-economy.baseline.json index bc7f624dc5..f512b8452c 100644 --- a/test/output-economy/output-economy.baseline.json +++ b/test/output-economy/output-economy.baseline.json @@ -119,11 +119,11 @@ "shape": "$.active:boolean|$.backend:string|$.entries:array|$.entries[].durationMs:number|$.entries[].headers:string|$.entries[].line:number|$.entries[].method:string|$.entries[].packetId:string|$.entries[].raw:string|$.entries[].requestBody:string|$.entries[].requestHeaders.authorization:string|$.entries[].requestHeaders.content-type:string|$.entries[].requestHeaders.x-request-id:string|$.entries[].requestHeaders:object|$.entries[].responseBody:string|$.entries[].responseHeaders.content-type:string|$.entries[].responseHeaders.x-request-id:string|$.entries[].responseHeaders.x-retry-after-ms:string|$.entries[].responseHeaders:object|$.entries[].status:number|$.entries[].timestamp:string|$.entries[].url:string|$.entries[]:object|$.exists:boolean|$.include:string|$.limits.maxEntries:number|$.limits.maxPayloadChars:number|$.limits.maxScanLines:number|$.limits:object|$.matchedLines:number|$.notes:array|$.notes[]:string|$.path:string|$.scannedLines:number|$.state:string|$:object" }, "selection.network.digest.json": { - "bytes": 1594, + "bytes": 1614, "lines": 1, "refs": 0, "hints": 0, - "shape": "$.active:boolean|$.backend:string|$.entries:array|$.entries[].durationMs:number|$.entries[].line:number|$.entries[].method:string|$.entries[].packetId:string|$.entries[].status:number|$.entries[].timestamp:string|$.entries[].url:string|$.entries[]:object|$.include:string|$.limits.maxEntries:number|$.limits.maxPayloadChars:number|$.limits.maxScanLines:number|$.limits:object|$.matchedLines:number|$.notes:array|$.notes[]:string|$.scannedLines:number|$.state:string|$:object" + "shape": "$.active:boolean|$.backend:string|$.entries:array|$.entries[].durationMs:number|$.entries[].line:number|$.entries[].method:string|$.entries[].packetId:string|$.entries[].status:number|$.entries[].timestamp:string|$.entries[].url:string|$.entries[]:object|$.include:string|$.limits.maxEntries:number|$.limits.maxPayloadChars:number|$.limits.maxScanLines:number|$.limits:object|$.matchedLines:number|$.notes:array|$.notes[]:string|$.scannedLines:number|$.source:string|$.state:string|$:object" }, "selection.events.default.json": { "bytes": 7686, diff --git a/test/output-economy/output-economy.waivers.json b/test/output-economy/output-economy.waivers.json index 812f7ddec0..82f3a060f8 100644 --- a/test/output-economy/output-economy.waivers.json +++ b/test/output-economy/output-economy.waivers.json @@ -1,4 +1,8 @@ { + "selection.network.digest.json": { + "bytes": 1614, + "reason": "network dump now names the capture source that answered (`source: \"provider\" | \"app-log\"`), so a caller never mistakes an armed in-app capture or a provider fallback for an empty traffic window (+20 bytes for one short field)." + }, "snapshot.digest.json": { "bytes": 214, "reason": "The public daemon route now supplies the live response-view input. Its production six-digit refsGeneration is four bytes larger than the old two-digit static fixture, matching the generation seed that prevents common cross-lifetime ref collisions." diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 86d34085c5..59ae4aa527 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1081,8 +1081,8 @@ agent-device network dump 25 --include headers --platform web # Browser requests - `logs start` appends to `app.log` and rotates at 5 MB. Override with `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES`; set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list for write-time redaction. On Apple simulators, `logs start` returns once the simulator log stream is running, so lines that the app logs after that are captured. If the stream does not start within 10 s, `logs start` fails with `details.reason: "simulator-log-stream-launch-timeout"`. - `open` prints `Session state: `; see [Sessions](/docs/sessions#find-a-sessions-logs-and-artifacts) for the files it contains. - `events` reads the session timeline, which leaves out user-entered content; `--no-record` suppresses `action.recorded` entries. `events.ndjson` rotates at 5 MB (`AGENT_DEVICE_EVENT_LOG_MAX_BYTES`) and keeps one generation. Cursors stay valid across rotation; a cursor older than the retained window fails with `details.reason: "EVENT_LOG_CURSOR_EXPIRED"` and `details.earliestCursor`. If the retained files were edited or deleted, `events` fails with `details.reason: "EVENT_LOG_WINDOW_UNVERIFIED"`. -- `network dump [limit] [summary|headers|body|all]` parses recent HTTP(s) entries from `app.log`, or from managed `agent-browser` request history on web; `network log` is an alias. Prefer `--include` over the positional detail level. It scans up to 4000 log lines, returns up to 200 entries, and truncates fields at 2048 characters. Web does not expose bodies. -- On iOS, `network dump` can miss requests the app does not log and cannot name every request on a reused connection, so treat a missing endpoint as unproven. See [Network inspection](/docs/debugging-profiling#network-inspection) for per-platform behavior. +- `network dump [limit] [summary|headers|body|all]` reads HTTP traffic for the session; `network log` is an alias. Prefer `--include` over the positional detail level. A React Native app session opened from its dev server answers from an in-app `fetch`/`XMLHttpRequest` capture (`source: "provider"`): method, full URL, status, and duration for every request it observes after arming, plus headers and bodies under `--include`. Traffic through other transports (WebSockets, native HTTP stacks) is outside this capture. The first dump installs the capture and reports it armed, so reproduce the requests and dump again; the app-side buffer holds 200 requests newest-first, drains as it is read, and resets when the app process restarts (a Metro reload keeps the capture armed). Other app/device sessions parse recent HTTP(s) entries from `app.log` (`source: "app-log"`), and web sessions from managed `agent-browser` request history. The log path scans up to 4000 log lines, returns up to 200 entries, and truncates fields at 2048 characters. Web does not expose bodies. +- On iOS, `network dump` can miss requests the app does not log and cannot name every request on a reused connection, so treat a missing endpoint as unproven. The in-app React Native capture names every request it observed, which is why it takes precedence when it is available. See [Network inspection](/docs/debugging-profiling#network-inspection) for per-platform behavior. **Crash symbols:** `debug symbols` symbolicates an Apple crash artifact against local dSYMs, writes the symbolicated artifact to `--out`, and prints a compact crash report. See [Crash symbolication](/docs/debugging-profiling#crash-symbolication). diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index ee1131cf1b..7317657dd1 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -216,7 +216,9 @@ agent-device network dump 25 --include headers agent-device network dump 25 --include all ``` -- `network dump` parses recent HTTP(s) entries from the session app log for app/device sessions and from managed `agent-browser` request history for web sessions. +- `network dump` reports where each answer came from in `source`. A React Native app session opened from its dev server answers from the in-app capture; every other app/device session parses recent HTTP(s) entries from the session app log, and web sessions from managed `agent-browser` request history. +- On a React Native app session (a development build loading its bundle from Metro, which in practice means a simulator or emulator), `network dump` installs a `fetch`/`XMLHttpRequest` capture inside the app runtime over the debugger connection. It reports the method, full URL, status, and duration of every request it observes after arming, with headers and bodies under `--include headers` / `--include all`. Traffic through other transports (WebSockets, or a native HTTP module such as OkHttp or URLSession) is outside this capture. The first dump installs the capture and reports it armed; reproduce the requests, then dump again. The buffer is bounded (200 entries, newest first) and drains as it is read. A Metro reload keeps the capture armed; restarting the app process resets it and the next dump arms again. Header values and bodies are redacted and bounded; the URL is reported verbatim, including its query string, so a secret sent as a query parameter appears in the dump as the app sent it. The capture is installed only for a session that names its app, and only on the runtime that reports the session's own device; when this host cannot name one attached device unambiguously, the dump says so and reads the app log instead. The check reads every booted simulator in the simulator sets this host can name and every connected physical device, so keep simulator display names unique when several devices share one dev server (ADR 0038 records the one residue it cannot see: a simulator in a set directory this host was never told about). +- If that in-app capture cannot answer (no runtime connected, or Metro not reachable), the dump falls back to the app log and its notes say why. - For app/device sessions, results depend on what the app writes to the platform log. - On Android, results include logcat timestamps, and status and duration can be filled in from adjacent GIBSDK packet lines when the URL is logged separately. Log streaming reattaches to the app's new process after a restart. - On iOS simulators, logs stream from inside the simulator, and `network dump` reads recent simulator log history when the live window is sparse.