From 77859d0a422682205157e76fc73aec35f4f234cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 09:46:21 +0200 Subject: [PATCH 01/13] feat(network): capture in-app HTTP traffic for React Native sessions network dump answers a React Native app session from an in-app fetch/XMLHttpRequest capture installed over the session's dev-server debugger connection, instead of only parsing what the app wrote to the platform log. The capture is an ES5 runtime source installed into the app's JavaScript runtime on the first dump. It records method, full URL, status, duration, headers, and text bodies into a bounded ring buffer, drains newest-first, and survives repeated dumps without repeating entries. The host projects untrusted entries, redacts headers and bodies with the shared kernel rules, and adds JSON secret masking so a payload secret no longer passes through. When no dev-server binding exists or the provider cannot answer, the dump falls back to the app-log path and its notes say why. - contracts: NetworkTransportScope carries the session facts a provider transport needs; NetworkDumpResult reports its source - capture-kit: shared non-throwing provider prelude with a 12s budget - react-native: runtime source, CDP drain/stats orchestration, projection - root composition gates the transport on the session's Metro binding - CLI help and website docs describe arming, draining, and fallback --- packages/capture-kit/src/index.ts | 4 + .../src/network-provider-capture.test.ts | 172 +++++++++++ .../src/network-provider-capture.ts | 70 +++++ packages/contracts/src/network-runtime.ts | 20 +- .../session-network-runtime-parity.test.ts | 1 + .../internal/session-network.ts | 3 + packages/kernel/src/redaction.test.ts | 32 +- packages/kernel/src/redaction.ts | 56 +++- .../src/network/runtime.test.ts | 106 ++++++- .../platform-android/src/network/runtime.ts | 12 +- .../src/network/runtime.test.ts | 103 ++++++- .../platform-apple/src/network/runtime.ts | 7 +- packages/react-native/src/index.ts | 10 + .../network/network-capture-dump.fixtures.ts | 278 ++++++++++++++++++ .../src/network/network-capture-dump.test.ts | 181 ++++++++++++ .../src/network/network-capture-dump.ts | 133 +++++++++ .../src/network/network-capture-entries.ts | 100 +++++++ .../src/network/network-capture-runtime.ts | 271 +++++++++++++++++ src/commands/observability/index.ts | 5 +- src/commands/observability/output.ts | 2 + src/commands/schema/cli-help-topics.test.ts | 2 +- src/commands/schema/cli-help-workflows.ts | 3 +- src/platform-runtime-network-host.ts | 3 +- ...-runtime-network-react-native-transport.ts | 48 +++ src/platform-runtime-operation-host.ts | 15 +- website/docs/docs/commands.md | 4 +- website/docs/docs/debugging-profiling.md | 4 +- 27 files changed, 1621 insertions(+), 24 deletions(-) create mode 100644 packages/capture-kit/src/network-provider-capture.test.ts create mode 100644 packages/capture-kit/src/network-provider-capture.ts create mode 100644 packages/react-native/src/network/network-capture-dump.fixtures.ts create mode 100644 packages/react-native/src/network/network-capture-dump.test.ts create mode 100644 packages/react-native/src/network/network-capture-dump.ts create mode 100644 packages/react-native/src/network/network-capture-entries.ts create mode 100644 packages/react-native/src/network/network-capture-runtime.ts create mode 100644 src/platform-runtime-network-react-native-transport.ts diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index 5c83dd4f4e..5d362fe4c5 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -31,3 +31,7 @@ export { readRecentNetworkTrafficFromText, type NetworkScan, } from './network-traffic.ts'; +export { + captureNetworkProviderTraffic, + type NetworkProviderCapture, +} from './network-provider-capture.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..5906527002 --- /dev/null +++ b/packages/capture-kit/src/network-provider-capture.test.ts @@ -0,0 +1,172 @@ +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, + ); + + assert.deepEqual(capture, { status: 'captured', result }); + assert.deepEqual(seen, { maxEntries: 25, include: 'summary' }); + assert.deepEqual(fixture.scopes[0], { + sessionId: 'repro', + stateDir: '/tmp/state', + appBundleId: 'com.example.app', + maxPayloadChars: 2048, + }); +}); + +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..84d4b969e9 --- /dev/null +++ b/packages/capture-kit/src/network-provider-capture.ts @@ -0,0 +1,70 @@ +import type { + NetworkDumpInput, + NetworkProviderDumpResult, + 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'; + +/** + * 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. + */ +export async function captureNetworkProviderTraffic( + host: Pick, + device: DeviceInfo, + input: NetworkDumpInput, + signal: AbortSignal, +): Promise { + const scope: NetworkTransportScope = { + sessionId: input.sessionId, + ...(input.stateDir === undefined ? {} : { stateDir: input.stateDir }), + ...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }), + maxPayloadChars: input.maxPayloadChars, + }; + 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 { + result = await dump({ maxEntries: input.maxEntries, include: input.include }, bounded); + } 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 providerFailureNote(error: unknown): string { + const 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.`; +} diff --git a/packages/contracts/src/network-runtime.ts b/packages/contracts/src/network-runtime.ts index 6a8cc7403a..eab6f50e14 100644 --- a/packages/contracts/src/network-runtime.ts +++ b/packages/contracts/src/network-runtime.ts @@ -15,6 +15,8 @@ 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; @@ -38,6 +40,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 +66,16 @@ 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; +}>; + export type NetworkTransport = Readonly<{ mode: Extract; /** Absent when the selected narrow transport cannot provide network traffic. */ @@ -81,6 +99,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..cea462d97d 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-network.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-network.ts @@ -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,7 @@ function networkDumpResponse( ok: true, data: { entries: result.entries, + source: 'provider', 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..2eae863399 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, redactNetworkHeaders, 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,33 @@ 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); + assert.equal(redactNetworkPayload(payload, 2048).length, 2048 + '...'.length); + 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_..."}', + ); +}); + +test('a header with a sensitive name is replaced and its peers keep their text', () => { + assert.deepEqual( + redactNetworkHeaders({ 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..48400fee8b 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,34 @@ export function redactDiagnosticData(input: T): T { return redactValue(input, new WeakSet()) as T; } +/** + * Redacts one captured network payload (a header value or a request/response 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; + return `${redacted.slice(0, maxLength)}${TRUNCATION_SUFFIX}`; +} + +/** + * Redacts captured HTTP headers with the same rules diagnostics use: a value whose header name is + * sensitive (authorization, cookie, an API key, a token) is replaced entirely; every other value + * keeps its text with secrets masked. + */ +export function redactNetworkHeaders( + headers: Readonly>, +): Readonly> { + const output: Record = {}; + for (const [name, value] of Object.entries(headers)) { + output[name] = SENSITIVE_KEY_RE.test(name) + ? '[REDACTED]' + : redactNetworkPayload(value, REDACTED_STRING_MAX_LENGTH); + } + return output; +} + /** 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,20 +86,36 @@ 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) => { + // A quoted key is the JSON form's job; masking it here would drop its quotes. + if (input[offset - 1] === '"' || input[offset - 1] === "'") return match; if (isSafeSetupUrlAssignment({ key, separator, rawValue, offset, input })) return match; if (isDocumentedTokenPlaceholder(rawValue)) return match; return `${key}${separator}[REDACTED]`; }, ); - return boundRedactedString(output, keyHint); + return output; } function boundRedactedString(value: string, keyHint?: string): string { diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts index 6c0d714e4e..697044964f 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -1,6 +1,10 @@ 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, +} 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 +18,103 @@ 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(); +}); + +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 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,6 +223,7 @@ function host(options: { command(request: HostCommandRequest): { stdout: string; stderr: string; exitCode: number }; appLogText?: string; logcat: string | Error; + transport?: NetworkTransport; }): PlatformRuntimeHost { return { ...unusedAppLogHost(), @@ -149,7 +251,7 @@ function host(options: { }), readProcessMarker: async () => options.marker, }, - networkTransports: { resolve: async () => ({ mode: 'local' }) }, + networkTransports: { resolve: async () => options.transport ?? { mode: 'local' } }, }; } diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index 79c17ae743..55a6ef7f30 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -4,7 +4,11 @@ import type { } from '@agent-device/contracts/platform-runtime-host'; 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, + mergeNetworkScans, + readRecentNetworkTrafficFromText, +} from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { assertAndroidLogPackageSafe } from '../logs/package-name.ts'; @@ -20,6 +24,10 @@ export async function dumpAndroidNetworkTraffic( input: NetworkDumpInput, signal: AbortSignal, ): Promise { + const provider = await captureNetworkProviderTraffic(host, device, input, signal); + if (provider.status === 'captured') { + return Object.freeze({ source: 'provider', ...provider.result }); + } const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); let scan = readRecentNetworkTrafficFromText(recent.text, { ...input, @@ -28,7 +36,7 @@ export async function dumpAndroidNetworkTraffic( lineNumberOffset: recent.skippedLines, backend: 'android', }); - const notes: string[] = []; + const notes: string[] = [...provider.notes]; const context = await recoveryContext(host, device, input, signal); if (context && input.appBundleId) { assertAndroidLogPackageSafe(input.appBundleId); diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index cef2922c2e..1cc4c97ccc 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -1,5 +1,9 @@ import { expect, test, vi } from 'vitest'; -import type { NetworkDumpInput } from '@agent-device/contracts/network-runtime'; +import type { + NetworkDumpInput, + NetworkProviderDumpResult, + NetworkTransport, +} 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 +18,100 @@ 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 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,6 +240,7 @@ function input(overrides: Partial = {}): NetworkDumpInput { function host(options: { text?: string; runSimctl: PlatformRuntimeHost['appleTools']['run']; + transport?: NetworkTransport; }): PlatformRuntimeHost { return { ...unusedAppLogHost(), @@ -161,7 +260,7 @@ function host(options: { }), readProcessMarker: async () => ({ status: 'missing' }), }, - networkTransports: { resolve: async () => ({ mode: 'local' }) }, + networkTransports: { resolve: async () => options.transport ?? { mode: 'local' } }, }; } diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 0cb43d331c..72e30dc3bb 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -2,6 +2,7 @@ import type { NetworkDump } from '@agent-device/contracts/network-traffic'; import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import { + captureNetworkProviderTraffic, mergeNetworkScans, readRecentNetworkTrafficFromText, type NetworkScan, @@ -17,6 +18,10 @@ export async function dumpAppleNetworkTraffic( signal: AbortSignal, ): Promise { const backend = backendForAppleDevice(device); + const provider = await captureNetworkProviderTraffic(host, device, input, signal); + if (provider.status === 'captured') { + return Object.freeze({ source: 'provider', ...provider.result }); + } const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); let scan = readRecentNetworkTrafficFromText(recent.text, { ...input, @@ -25,7 +30,7 @@ export async function dumpAppleNetworkTraffic( lineNumberOffset: recent.skippedLines, backend, }); - const notes: string[] = []; + const notes: string[] = [...provider.notes]; if (canRecoverSimulator(device, input, scan.dump)) { const recovery = await recoverSimulatorTraffic(host, device, input, recent.path, signal); if (recovery) scan = mergeRecoveredTraffic(notes, scan, recovery, input.maxEntries); 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..cd324be5ad --- /dev/null +++ b/packages/react-native/src/network/network-capture-dump.fixtures.ts @@ -0,0 +1,278 @@ +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 = {}; + globalThis.fetch = function (url) { + var spec = globalThis.__pending[url]; + if (spec.reject) return Promise.reject(new Error(spec.reject)); + return Promise.resolve({ + status: spec.status, + url: url, + headers: new Headers(spec.responseHeaders), + clone: function () { return new Body(spec.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; + }; +`; + +/** 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; + evaluate(expression: string): Promise; +}>; + +export type FakeMetroDevServer = Readonly<{ + serverUrl: string; + app: FakeAppRuntime; + /** Inspector targets the dev server lists; a second entry is a second connected device. */ + targets: 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. + */ +export async function fakeMetroDevServer( + options: { appId?: string; devices?: number } = {}, +): Promise { + const appId = options.appId ?? 'com.example.app'; + const devices = options.devices ?? 1; + 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: `Fake Device ${page + 1}`, + 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 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, + targets: Array.from({ length: devices }, (_, page) => `device-1-${page + 1}`), + 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 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..20a4cad058 --- /dev/null +++ b/packages/react-native/src/network/network-capture-dump.test.ts @@ -0,0 +1,181 @@ +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, + ...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 second connected device fails closed instead of guessing a runtime', async () => { + await server.close(); + server = await fakeMetroDevServer({ devices: 2 }); + writeMetroHints(stateDir, 'repro', server.serverUrl); + + await expect(dump()).rejects.toMatchObject({ + code: 'INVALID_ARGS', + details: { hint: expect.stringContaining('--metro-device') }, + }); +}); + +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.toThrow(/Could not reach the React Native dev server/); +}); 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..f16ad91c83 --- /dev/null +++ b/packages/react-native/src/network/network-capture-dump.ts @@ -0,0 +1,133 @@ +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, + type CaptureStats, +} from './network-capture-entries.ts'; +import { + NETWORK_CAPTURE_BODY_CHARS, + NETWORK_CAPTURE_MAX_ENTRIES, + NETWORK_CAPTURE_RUNTIME_GLOBAL, + NETWORK_CAPTURE_RUNTIME_SOURCE, + NETWORK_CAPTURE_RUNTIME_VERSION, +} from './network-capture-runtime.ts'; + +export const NETWORK_CAPTURE_BACKEND = 'react-native-app'; + +export type ReactNativeNetworkDumpInput = Readonly<{ + stateDir: string; + session: string; + appBundleId?: string; + maxEntries: number; + include: NetworkIncludeMode; + maxPayloadChars: number; +}>; + +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' }, + ); + } + const parsed: unknown = JSON.parse(value); + 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 readStats(value: unknown): CaptureStats | undefined { + const stats = readJsonResult(value, 'stats'); + const buffered = stats.buffered; + const dropped = stats.dropped; + if (typeof buffered !== 'number' || typeof dropped !== 'number') return undefined; + return { buffered, dropped }; +} + +/** + * 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); + const target = selectReactNativeTarget(await listMetroInspectorTargets(serverUrl), { + appId: input.appBundleId, + }); + const session = await InspectorSession.connect(debuggerUrlOn(serverUrl, target)); + try { + const installed = await session.evaluate( + `(${NETWORK_CAPTURE_RUNTIME_SOURCE})(${JSON.stringify(NETWORK_CAPTURE_RUNTIME_VERSION)}, ${NETWORK_CAPTURE_MAX_ENTRIES}, ${NETWORK_CAPTURE_BODY_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 stats = readStats(await runtimeCall(session, 'stats')); + const notes = [...captureBufferNotes(stats)]; + 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..d8a20cf504 --- /dev/null +++ b/packages/react-native/src/network/network-capture-entries.ts @@ -0,0 +1,100 @@ +import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; +import type { NetworkProviderEntry } from '@agent-device/contracts/network-runtime'; +import { redactNetworkHeaders, redactNetworkPayload } from '@agent-device/kernel/redaction'; + +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 ? redactNetworkHeaders(headers) : undefined; +} + +/** + * 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: Record = {}; + const transport = readString(entry.transport); + if (transport) metadata.transport = transport; + const error = readString(entry.error); + if (error) metadata.error = redactNetworkPayload(error, maxPayloadChars); + const requestBody = readString(entry.requestBody); + const responseBody = readString(entry.responseBody); + projected.push({ + timestamp: readString(entry.timestamp), + method: readString(entry.method), + url, + status: readNumber(entry.status), + durationMs: readNumber(entry.durationMs), + ...(includesHeaders(include) + ? { + requestHeaders: readHeaders(entry.requestHeaders), + responseHeaders: readHeaders(entry.responseHeaders), + } + : {}), + ...(includesBody(include) && requestBody !== undefined + ? { requestBody: redactNetworkPayload(requestBody, maxPayloadChars) } + : {}), + ...(includesBody(include) && responseBody !== undefined + ? { responseBody: redactNetworkPayload(responseBody, maxPayloadChars) } + : {}), + ...(Object.keys(metadata).length > 0 ? { metadata } : {}), + }); + } + return projected.reverse(); +} + +export type CaptureStats = Readonly<{ + buffered: number; + dropped: number; +}>; + +/** Notes the buffer's own accounting earns: what stays buffered, what the ring evicted. */ +export function captureBufferNotes(stats: CaptureStats | undefined): readonly string[] { + if (!stats) return []; + const notes: string[] = []; + if (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 200-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..01957e88ba --- /dev/null +++ b/packages/react-native/src/network/network-capture-runtime.ts @@ -0,0 +1,271 @@ +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 captured body; the host redacts and re-bounds each entry on read. */ +export const NETWORK_CAPTURE_BODY_CHARS = 16_384; + +/** + * 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) { + 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; + + function cap(value) { + var text = String(value); + return text.length <= BODY_CAP ? text : text.slice(0, BODY_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; + if (typeof source.forEach === 'function') { + try { source.forEach(function (value, name) { out[name] = String(value); }); } catch (e) {} + return out; + } + try { Object.keys(source).forEach(function (name) { out[name] = String(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 promise; + try { + promise = fetch.apply(this, arguments); + } catch (e) { + record({ + timestamp: began, + method: method, + url: url, + durationMs: Date.now() - startedAt, + requestHeaders: requestHeaders, + error: String((e && e.message) || e), + transport: 'fetch' + }); + throw e; + } + 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(entry); + return clone.text().then( + function (text) { + entry.responseBody = cap(text); + return response; + }, + function () { + 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] = String(value); + return setRequestHeader.apply(this, arguments); + }; + } + var wrapped = function (body) { + var state = this.__agentDeviceRequest; + if (state) { + 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++) { + var split = lines[i].indexOf(':'); + if (split > 0) { + entry.responseHeaders = entry.responseHeaders || {}; + entry.responseHeaders[lines[i].slice(0, split).trim()] = 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); + return JSON.stringify({ ok: true, entries: drained }); + } + }; + 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/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.ts b/src/platform-runtime-network-react-native-transport.ts new file mode 100644 index 0000000000..eb68d78e2c --- /dev/null +++ b/src/platform-runtime-network-react-native-transport.ts @@ -0,0 +1,48 @@ +import type { + NetworkTransport, + NetworkTransportScope, +} 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 ?? 2048; + return Object.freeze({ + mode: 'local', + dump: async (input, signal) => + await dumpReactNativeAppNetwork( + { + stateDir, + session: scope.sessionId, + ...(scope.appBundleId === undefined ? {} : { appBundleId: scope.appBundleId }), + maxEntries: input.maxEntries, + include: input.include, + maxPayloadChars, + }, + 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/website/docs/docs/commands.md b/website/docs/docs/commands.md index 86d34085c5..81b70e7f97 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, plus headers and bodies under `--include`. 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 on app reload. 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..c2b10fa777 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 (iOS simulator or Android emulator, Metro reachable), `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, with headers and bodies under `--include headers` / `--include all`. The first dump installs the capture and reports it armed; reproduce the requests, then dump again. The buffer is bounded (200 entries, newest first), drains as it is read, and resets when the app reloads. Header values and bodies are redacted and bounded; the URL is reported verbatim. +- 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. From f9fc8bbc3fe3be3a1c5db094283e0428f9018442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 10:11:55 +0200 Subject: [PATCH 02/13] test(examples): add a network lab route to the fixture app The route issues one plain fetch, one JSON POST, and one XMLHttpRequest per press, so a network dump can be verified against requests the harness just made. Live RN verification used it on an iOS simulator and an Android emulator. --- examples/test-app/app/network-lab.tsx | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 examples/test-app/app/network-lab.tsx diff --git a/examples/test-app/app/network-lab.tsx b/examples/test-app/app/network-lab.tsx new file mode 100644 index 0000000000..a5c326b0df --- /dev/null +++ b/examples/test-app/app/network-lab.tsx @@ -0,0 +1,79 @@ +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' }, + }); + const body = await response.text(); + setStatus(`fetch:${response.status}:${body.length}`); + } catch (error) { + setStatus(`fetch:error:${(error as Error).message}`); + } + }; + + 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()}> + Run fetch + + void runPost()}> + Run post + + + Run XHR + + + {status} + + + ); +} + +const styles = StyleSheet.create({ + content: { padding: 16, gap: 12 }, + button: { paddingVertical: 12 }, +}); From 13a3d2439f643af97a34882e230b6b61f4fd4a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 11:29:01 +0200 Subject: [PATCH 03/13] fix(network): bound, drain, and redact the in-app capture precisely Address review findings on the React Native network capture: - Eviction counts ride the drain result so buffered and dropped notes can never disagree with what a dump returned. - The in-app body cap takes the dump's payload budget; header values are capped at 512 characters across 64 entries at every capture seam. - JSON-quoted secrets with escaped quotes mask whole; the unquoted rule keeps masking 'api_key=value'; truncation markers count against the payload budget. - Drain JSON parsing fails with a typed reason instead of throwing raw. - whatwg-fetch-on-XHR double capture is suppressed by a synchronous URL claim held across the fetch() call. - The 2048 payload budget is one contract constant, shared by the daemon and the RN transport. - The test-app network lab is a registered route with README coverage. --- examples/test-app/README.md | 16 ++++ examples/test-app/app/_layout.tsx | 1 + examples/test-app/app/network-lab.tsx | 10 +-- packages/capture-kit/package.json | 4 + packages/capture-kit/src/index.ts | 4 - .../src/network-provider-capture.ts | 32 +++++++- packages/contracts/src/network-runtime.ts | 3 + .../internal/session-network.ts | 4 +- packages/kernel/src/redaction.test.ts | 17 ++++- packages/kernel/src/redaction.ts | 18 ++++- .../platform-android/src/network/runtime.ts | 7 +- .../platform-apple/src/network/runtime.ts | 2 +- .../src/network/network-capture-dump.ts | 35 +++++---- .../src/network/network-capture-entries.ts | 10 +-- .../src/network/network-capture-runtime.ts | 71 +++++++++++++++--- scripts/layering/package-boundaries.test.ts | 1 + scripts/layering/root-module-zones.ts | 1 + ...ime-network-react-native-transport.test.ts | 75 +++++++++++++++++++ ...-runtime-network-react-native-transport.ts | 3 +- .../output-economy.baseline.json | 4 +- .../output-economy.waivers.json | 4 + 21 files changed, 265 insertions(+), 57 deletions(-) create mode 100644 src/platform-runtime-network-react-native-transport.test.ts 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 index a5c326b0df..400ace694d 100644 --- a/examples/test-app/app/network-lab.tsx +++ b/examples/test-app/app/network-lab.tsx @@ -52,18 +52,18 @@ export default function NetworkLabRoute() { return ( Network lab - void runFetch()}> + void runFetch()} style={styles.button} testID="network-fetch"> Run fetch - void runPost()}> + void runPost()} style={styles.button} testID="network-post"> Run post - + Run XHR 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/index.ts b/packages/capture-kit/src/index.ts index 5d362fe4c5..5c83dd4f4e 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -31,7 +31,3 @@ export { readRecentNetworkTrafficFromText, type NetworkScan, } from './network-traffic.ts'; -export { - captureNetworkProviderTraffic, - type NetworkProviderCapture, -} from './network-provider-capture.ts'; diff --git a/packages/capture-kit/src/network-provider-capture.ts b/packages/capture-kit/src/network-provider-capture.ts index 84d4b969e9..6341767275 100644 --- a/packages/capture-kit/src/network-provider-capture.ts +++ b/packages/capture-kit/src/network-provider-capture.ts @@ -5,6 +5,7 @@ import type { } 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 { redactDiagnosticData } from '@agent-device/kernel/redaction'; /** * A provider source answers a dump with entries, with nothing yet to report, or not at all. @@ -53,7 +54,13 @@ export async function captureNetworkProviderTraffic( const bounded = AbortSignal.any([signal, AbortSignal.timeout(PROVIDER_CAPTURE_BUDGET_MS)]); let result; try { - result = await dump({ maxEntries: input.maxEntries, include: input.include }, bounded); + // 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)] }; @@ -64,7 +71,28 @@ export async function captureNetworkProviderTraffic( 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 { - const message = error instanceof Error ? error.message : String(error); + // 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.`; } diff --git a/packages/contracts/src/network-runtime.ts b/packages/contracts/src/network-runtime.ts index eab6f50e14..868bbfd28d 100644 --- a/packages/contracts/src/network-runtime.ts +++ b/packages/contracts/src/network-runtime.ts @@ -23,6 +23,9 @@ export type NetworkDumpInput = Readonly<{ 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; 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 cea462d97d..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<{ @@ -170,6 +170,8 @@ function networkDumpResponse( 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 2eae863399..d59b60efdf 100644 --- a/packages/kernel/src/redaction.test.ts +++ b/packages/kernel/src/redaction.test.ts @@ -49,7 +49,8 @@ test('other diagnostic strings retain the 400-character limit', () => { test('a network payload keeps its own budget instead of the diagnostic limit', () => { const payload = 'p'.repeat(5000); - assert.equal(redactNetworkPayload(payload, 2048).length, 2048 + '...'.length); + // 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)); }); @@ -65,6 +66,20 @@ test('a network payload masks secrets in each field syntax it can hold', () => { 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', () => { diff --git a/packages/kernel/src/redaction.ts b/packages/kernel/src/redaction.ts index 48400fee8b..db71c556fc 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -11,7 +11,7 @@ const SENSITIVE_ASSIGNMENT_RE = * 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; + /(["'][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; @@ -29,7 +29,9 @@ export function redactDiagnosticData(input: T): T { export function redactNetworkPayload(value: string, maxLength: number): string { const redacted = maskSecrets(value); if (redacted.length <= maxLength) return redacted; - return `${redacted.slice(0, maxLength)}${TRUNCATION_SUFFIX}`; + // 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}`; } /** @@ -108,8 +110,16 @@ function maskSecrets(value: string, keyHint?: string): string { output = output.replace( SENSITIVE_ASSIGNMENT_RE, (match, key: string, separator: string, rawValue: string, offset: number, input: string) => { - // A quoted key is the JSON form's job; masking it here would drop its quotes. - if (input[offset - 1] === '"' || input[offset - 1] === "'") return match; + // Only the exact JSON form (`"key":` with the key closing its quote before the separator) is + // the JSON rule's job; a quote merely in front of the key (`'api_key=value'`) is a shell-like + // assignment this rule has always masked. + const before = input[offset - 1]; + if ( + (before === '"' || before === "'") && + /^["']\s*[=:]/.test(input.slice(offset + key.length)) + ) { + return match; + } if (isSafeSetupUrlAssignment({ key, separator, rawValue, offset, input })) return match; if (isDocumentedTokenPlaceholder(rawValue)) return match; return `${key}${separator}[REDACTED]`; diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index 55a6ef7f30..fe6be5d250 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -4,11 +4,8 @@ import type { } from '@agent-device/contracts/platform-runtime-host'; import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import { - captureNetworkProviderTraffic, - mergeNetworkScans, - readRecentNetworkTrafficFromText, -} from '@agent-device/capture-kit'; +import { mergeNetworkScans, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import { captureNetworkProviderTraffic } 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'; diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 72e30dc3bb..acf1ec3081 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -2,11 +2,11 @@ import type { NetworkDump } from '@agent-device/contracts/network-traffic'; import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import { - captureNetworkProviderTraffic, mergeNetworkScans, readRecentNetworkTrafficFromText, type NetworkScan, } from '@agent-device/capture-kit'; +import { captureNetworkProviderTraffic } from '@agent-device/capture-kit/network-provider-capture'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; import { backendForAppleDevice } from '../logs/backend.ts'; diff --git a/packages/react-native/src/network/network-capture-dump.ts b/packages/react-native/src/network/network-capture-dump.ts index f16ad91c83..dfc9c7b31e 100644 --- a/packages/react-native/src/network/network-capture-dump.ts +++ b/packages/react-native/src/network/network-capture-dump.ts @@ -11,13 +11,9 @@ import { listMetroInspectorTargets, selectReactNativeTarget, } from '../react-devtools/metro-inspector.ts'; +import { captureBufferNotes, projectCapturedNetworkEntries } from './network-capture-entries.ts'; import { - captureBufferNotes, - projectCapturedNetworkEntries, - type CaptureStats, -} from './network-capture-entries.ts'; -import { - NETWORK_CAPTURE_BODY_CHARS, + NETWORK_CAPTURE_HEADER_CHARS, NETWORK_CAPTURE_MAX_ENTRIES, NETWORK_CAPTURE_RUNTIME_GLOBAL, NETWORK_CAPTURE_RUNTIME_SOURCE, @@ -51,7 +47,16 @@ function readJsonResult(value: unknown, method: string): Record { reason: 'network-capture-result-unreadable' }, ); } - const parsed: unknown = JSON.parse(value); + 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', @@ -62,12 +67,9 @@ function readJsonResult(value: unknown, method: string): Record return parsed as Record; } -function readStats(value: unknown): CaptureStats | undefined { +function readBuffered(value: unknown): number | undefined { const stats = readJsonResult(value, 'stats'); - const buffered = stats.buffered; - const dropped = stats.dropped; - if (typeof buffered !== 'number' || typeof dropped !== 'number') return undefined; - return { buffered, dropped }; + return typeof stats.buffered === 'number' ? stats.buffered : undefined; } /** @@ -95,8 +97,10 @@ export async function dumpReactNativeAppNetwork( }); const session = await InspectorSession.connect(debuggerUrlOn(serverUrl, target)); 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}, ${NETWORK_CAPTURE_BODY_CHARS})`, + `(${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)), @@ -112,8 +116,9 @@ export async function dumpReactNativeAppNetwork( input.include, input.maxPayloadChars, ); - const stats = readStats(await runtimeCall(session, 'stats')); - const notes = [...captureBufferNotes(stats)]; + 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' diff --git a/packages/react-native/src/network/network-capture-entries.ts b/packages/react-native/src/network/network-capture-entries.ts index d8a20cf504..4555020349 100644 --- a/packages/react-native/src/network/network-capture-entries.ts +++ b/packages/react-native/src/network/network-capture-entries.ts @@ -1,6 +1,7 @@ import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; import type { NetworkProviderEntry } from '@agent-device/contracts/network-runtime'; import { redactNetworkHeaders, 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; @@ -70,22 +71,21 @@ export function projectCapturedNetworkEntries( } export type CaptureStats = Readonly<{ - buffered: number; + buffered: number | undefined; dropped: number; }>; /** Notes the buffer's own accounting earns: what stays buffered, what the ring evicted. */ -export function captureBufferNotes(stats: CaptureStats | undefined): readonly string[] { - if (!stats) return []; +export function captureBufferNotes(stats: CaptureStats): readonly string[] { const notes: string[] = []; - if (stats.buffered > 0) { + 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 200-entry app buffer before this dump.`, + `${stats.dropped} captured request${stats.dropped === 1 ? '' : 's'} were evicted from the ${NETWORK_CAPTURE_MAX_ENTRIES}-entry app buffer before this dump.`, ); } return notes; diff --git a/packages/react-native/src/network/network-capture-runtime.ts b/packages/react-native/src/network/network-capture-runtime.ts index 01957e88ba..e007f6db0d 100644 --- a/packages/react-native/src/network/network-capture-runtime.ts +++ b/packages/react-native/src/network/network-capture-runtime.ts @@ -6,8 +6,8 @@ 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 captured body; the host redacts and re-bounds each entry on read. */ -export const NETWORK_CAPTURE_BODY_CHARS = 16_384; +/** 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 @@ -22,7 +22,7 @@ export const NETWORK_CAPTURE_BODY_CHARS = 16_384; * 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) { +(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') { @@ -33,12 +33,32 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` var dropped = 0; var patchedFetch = false; var patchedXhr = false; + var fetchInFlight = {}; + + // whatwg-fetch polyfills fetch on top of XMLHttpRequest; a request already claimed by an + // in-flight fetch URL must not also record that transport's own XHR. + 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)); @@ -69,11 +89,24 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` function headerPairs(source) { var out = {}; if (!source) return out; + var count = 0; if (typeof source.forEach === 'function') { - try { source.forEach(function (value, name) { out[name] = String(value); }); } catch (e) {} + 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) { out[name] = String(source[name]); }); } catch (e) {} + try { + Object.keys(source).forEach(function (name) { + if (count >= 64) return; + count += 1; + out[name] = capHeader(source[name]); + }); + } catch (e) {} return out; } @@ -99,10 +132,12 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` 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, @@ -117,6 +152,7 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` if (!promise || typeof promise.then !== 'function') return promise; return promise.then( function (response) { + release(); var entry = { timestamp: began, method: method, @@ -132,13 +168,16 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` 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(entry); + // Record only after the body settles: a drain between the response and its body + // would otherwise hand the host an entry it could still complete in the app. return clone.text().then( function (text) { entry.responseBody = cap(text); + record(entry); return response; }, function () { + record(entry); return response; } ); @@ -182,13 +221,17 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` }; if (typeof setRequestHeader === 'function') { prototype.setRequestHeader = function (name, value) { - if (this.__agentDeviceRequest) this.__agentDeviceRequest.headers[name] = String(value); + if (this.__agentDeviceRequest) + this.__agentDeviceRequest.headers[name] = capHeader(value); return setRequestHeader.apply(this, arguments); }; } var wrapped = function (body) { var state = this.__agentDeviceRequest; - if (state) { + // 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); @@ -211,11 +254,13 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` } try { var lines = String(xhr.getAllResponseHeaders() || '').split('\n'); - for (var i = 0; i < lines.length; i++) { + 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()] = lines[i].slice(split + 1).trim(); + entry.responseHeaders[lines[i].slice(0, split).trim()] = capHeader( + lines[i].slice(split + 1).trim() + ); } } } catch (e) {} @@ -254,7 +299,11 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` drain: function (limit) { var count = Math.max(0, Math.min(limit || 0, entries.length)); var drained = entries.splice(entries.length - count, count); - return JSON.stringify({ ok: true, entries: drained }); + // 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'; 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/platform-runtime-network-react-native-transport.test.ts b/src/platform-runtime-network-react-native-transport.test.ts new file mode 100644 index 0000000000..1e481ace2d --- /dev/null +++ b/src/platform-runtime-network-react-native-transport.test.ts @@ -0,0 +1,75 @@ +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, + ]); +}); diff --git a/src/platform-runtime-network-react-native-transport.ts b/src/platform-runtime-network-react-native-transport.ts index eb68d78e2c..d6f7a51685 100644 --- a/src/platform-runtime-network-react-native-transport.ts +++ b/src/platform-runtime-network-react-native-transport.ts @@ -2,6 +2,7 @@ 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'; @@ -29,7 +30,7 @@ export async function resolveReactNativeNetworkTransport( }); if (runtime === undefined) return Object.freeze({ mode: 'local' }); const stateDir = scope.stateDir; - const maxPayloadChars = scope.maxPayloadChars ?? 2048; + const maxPayloadChars = scope.maxPayloadChars ?? NETWORK_MAX_PAYLOAD_CHARS; return Object.freeze({ mode: 'local', dump: async (input, signal) => 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." From 10e240dc2d4a1a53870b7bff58174cd1c556e32b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 11:37:28 +0200 Subject: [PATCH 04/13] refactor(network): share the provider probe's app-log fallback scan The Apple and Android dumps read their fallback window with one capture-kit call instead of cloned lines, the projection splits its include branches into named helpers, and the capture backend name stops exporting what no consumer consumes. --- .../src/network-provider-capture.ts | 30 ++++++++++ .../platform-android/src/network/runtime.ts | 51 ++++++++-------- .../platform-apple/src/network/runtime.ts | 23 ++++---- .../src/network/network-capture-dump.ts | 2 +- .../src/network/network-capture-entries.ts | 59 ++++++++++++------- 5 files changed, 110 insertions(+), 55 deletions(-) diff --git a/packages/capture-kit/src/network-provider-capture.ts b/packages/capture-kit/src/network-provider-capture.ts index 6341767275..9b03cb19a1 100644 --- a/packages/capture-kit/src/network-provider-capture.ts +++ b/packages/capture-kit/src/network-provider-capture.ts @@ -1,11 +1,14 @@ 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. @@ -96,3 +99,30 @@ function providerFailureNote(error: unknown): string { }); 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/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index fe6be5d250..484f613079 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -5,7 +5,10 @@ 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 } from '@agent-device/capture-kit/network-provider-capture'; +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'; @@ -25,14 +28,8 @@ export async function dumpAndroidNetworkTraffic( if (provider.status === 'captured') { return Object.freeze({ source: 'provider', ...provider.result }); } - 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', - }); + const fallback = await scanNetworkAppLogFallback(host, input, 'android'); + let scan = fallback.scan; const notes: string[] = [...provider.notes]; const context = await recoveryContext(host, device, input, signal); if (context && input.appBundleId) { @@ -41,29 +38,17 @@ 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.'); } @@ -75,6 +60,24 @@ 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(', ')}.`; +} + +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/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index acf1ec3081..d93c56fe1a 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -6,7 +6,10 @@ import { readRecentNetworkTrafficFromText, type NetworkScan, } from '@agent-device/capture-kit'; -import { captureNetworkProviderTraffic } from '@agent-device/capture-kit/network-provider-capture'; +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 { backendForAppleDevice } from '../logs/backend.ts'; @@ -22,17 +25,17 @@ export async function dumpAppleNetworkTraffic( if (provider.status === 'captured') { return Object.freeze({ source: 'provider', ...provider.result }); } - 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, - }); + const fallback = await scanNetworkAppLogFallback(host, input, backend); + let scan = fallback.scan; const notes: string[] = [...provider.notes]; 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); diff --git a/packages/react-native/src/network/network-capture-dump.ts b/packages/react-native/src/network/network-capture-dump.ts index dfc9c7b31e..ed51547ed1 100644 --- a/packages/react-native/src/network/network-capture-dump.ts +++ b/packages/react-native/src/network/network-capture-dump.ts @@ -20,7 +20,7 @@ import { NETWORK_CAPTURE_RUNTIME_VERSION, } from './network-capture-runtime.ts'; -export const NETWORK_CAPTURE_BACKEND = 'react-native-app'; +const NETWORK_CAPTURE_BACKEND = 'react-native-app'; export type ReactNativeNetworkDumpInput = Readonly<{ stateDir: string; diff --git a/packages/react-native/src/network/network-capture-entries.ts b/packages/react-native/src/network/network-capture-entries.ts index 4555020349..8958c85d47 100644 --- a/packages/react-native/src/network/network-capture-entries.ts +++ b/packages/react-native/src/network/network-capture-entries.ts @@ -20,6 +20,42 @@ function readHeaders(value: unknown): Record | undefined { return Object.keys(headers).length > 0 ? redactNetworkHeaders(headers) : 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 @@ -39,32 +75,15 @@ export function projectCapturedNetworkEntries( const entry = value as Record; const url = readString(entry.url); if (url === undefined) continue; - 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); - const requestBody = readString(entry.requestBody); - const responseBody = readString(entry.responseBody); + const metadata = readMetadata(entry, maxPayloadChars); projected.push({ timestamp: readString(entry.timestamp), method: readString(entry.method), url, status: readNumber(entry.status), durationMs: readNumber(entry.durationMs), - ...(includesHeaders(include) - ? { - requestHeaders: readHeaders(entry.requestHeaders), - responseHeaders: readHeaders(entry.responseHeaders), - } - : {}), - ...(includesBody(include) && requestBody !== undefined - ? { requestBody: redactNetworkPayload(requestBody, maxPayloadChars) } - : {}), - ...(includesBody(include) && responseBody !== undefined - ? { responseBody: redactNetworkPayload(responseBody, maxPayloadChars) } - : {}), - ...(Object.keys(metadata).length > 0 ? { metadata } : {}), + ...detailFields(entry, include, maxPayloadChars), + ...(metadata ? { metadata } : {}), }); } return projected.reverse(); From 0bca4adf563769ad7a0b602d9859a431a3fe9b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 13:54:22 +0200 Subject: [PATCH 05/13] fix(network): attribute, order, and release the in-app capture honestly - Release the fetch URL claim when fetch() returns: the claim marks only the synchronous window a whatwg-fetch XHR is built in, so a failed or in-flight fetch no longer hides later XHRs to the same URL. - Record fetch entries in a detached chain: the app gets its response at once even behind a streaming global fetch. - Hook only a runtime that reports the session's own device: the transport scope carries device aliases, selection narrows to positively-matching runtimes, and a mismatch or a session without an app id fails the provider closed with a typed reason instead of injecting into a stranger's app. - Build lifecycle and scan notes from the app-log source alone and append provider notes last, so an arming or unavailable note never hides the inactive-stream guidance. - Close the CDP socket on abort and thread the signal through the target list. - The fixture runs a real XMLHttpRequest shape with fetch polyfilled over it; new tests cover direct XHR capture, a failed fetch followed by an XHR to the same URL, and two-device attribution. - Drop redactNetworkHeaders for redactDiagnosticData and the dead JSON-form guard; type the unreachable-dev-server failure; state the capture's real scope, device kinds, and verbatim query strings in the docs. --- .../src/network-provider-capture.test.ts | 19 +++ .../src/network-provider-capture.ts | 19 ++- packages/contracts/src/network-runtime.ts | 12 ++ packages/kernel/src/redaction.test.ts | 4 +- packages/kernel/src/redaction.ts | 28 +---- .../src/network/runtime.test.ts | 32 +++++ .../platform-android/src/network/runtime.ts | 15 ++- .../src/network/runtime.test.ts | 34 ++++++ .../platform-apple/src/network/runtime.ts | 5 +- .../network/network-capture-dump.fixtures.ts | 112 ++++++++++++++++-- .../src/network/network-capture-dump.test.ts | 75 +++++++++++- .../src/network/network-capture-dump.ts | 16 ++- .../src/network/network-capture-entries.ts | 6 +- .../src/network/network-capture-runtime.ts | 21 ++-- .../src/react-devtools/metro-inspector.ts | 82 ++++++++++++- ...-runtime-network-react-native-transport.ts | 4 + website/docs/docs/commands.md | 2 +- website/docs/docs/debugging-profiling.md | 2 +- 18 files changed, 428 insertions(+), 60 deletions(-) diff --git a/packages/capture-kit/src/network-provider-capture.test.ts b/packages/capture-kit/src/network-provider-capture.test.ts index 5906527002..5f825baf95 100644 --- a/packages/capture-kit/src/network-provider-capture.test.ts +++ b/packages/capture-kit/src/network-provider-capture.test.ts @@ -74,9 +74,28 @@ test('the provider result is handed back with the session scope the transport ne stateDir: '/tmp/state', appBundleId: 'com.example.app', maxPayloadChars: 2048, + deviceAliases: ['Device', 'device-1'], + requireDeviceMatch: true, }); }); +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', diff --git a/packages/capture-kit/src/network-provider-capture.ts b/packages/capture-kit/src/network-provider-capture.ts index 9b03cb19a1..88adb418d1 100644 --- a/packages/capture-kit/src/network-provider-capture.ts +++ b/packages/capture-kit/src/network-provider-capture.ts @@ -33,18 +33,33 @@ const PROVIDER_CAPTURE_BUDGET_MS = 12_000; * 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. + * `extraDeviceAliases` carries device identities only the calling platform knows (a probed model + * name); the session device's own name and id always name it. `requireDeviceMatch` decides whether + * a runtime whose reported device matches none of them fails closed (the mapping between the OS + * device name and the dev server's report is trusted) or falls back to the app-id match alone. */ export async function captureNetworkProviderTraffic( host: Pick, device: DeviceInfo, input: NetworkDumpInput, signal: AbortSignal, + options: { + extraDeviceAliases?: readonly string[]; + requireDeviceMatch?: boolean; + } = {}, ): Promise { + // A provider dump injects into the runtime it selects, so it runs only for a session that names + // its app and may hook only a runtime that reports this session's device. + if (input.appBundleId === undefined) return { status: 'unavailable', notes: [] }; const scope: NetworkTransportScope = { sessionId: input.sessionId, - ...(input.stateDir === undefined ? {} : { stateDir: input.stateDir }), - ...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }), + stateDir: input.stateDir, + appBundleId: input.appBundleId, maxPayloadChars: input.maxPayloadChars, + deviceAliases: [ + ...new Set([device.name, device.id, ...(options.extraDeviceAliases ?? [])].filter(Boolean)), + ], + requireDeviceMatch: options.requireDeviceMatch ?? true, }; let transport; try { diff --git a/packages/contracts/src/network-runtime.ts b/packages/contracts/src/network-runtime.ts index 868bbfd28d..2e47a122b9 100644 --- a/packages/contracts/src/network-runtime.ts +++ b/packages/contracts/src/network-runtime.ts @@ -77,6 +77,18 @@ export type NetworkTransportScope = Readonly<{ appBundleId?: string; /** Payload budget the dump will apply; a transport may pre-bound what it reads with it. */ maxPayloadChars?: number; + /** + * Names the session device answers to (display name, id). A provider dump hooks only a runtime + * whose reported device matches one of them; a runtime naming another device is not this + * session's app and fails the provider closed instead of being read. + */ + deviceAliases?: readonly string[]; + /** + * Refuse every candidate that matches none of `deviceAliases` instead of falling back to the + * undistinguished set. Set it where the runtime copies the OS device name faithfully (Apple); + * leave it unset where the dev server's name is not proven to equal the session's own. + */ + requireDeviceMatch?: boolean; }>; export type NetworkTransport = Readonly<{ diff --git a/packages/kernel/src/redaction.test.ts b/packages/kernel/src/redaction.test.ts index d59b60efdf..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, redactNetworkHeaders, redactNetworkPayload } 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`; @@ -84,7 +84,7 @@ test('a network payload truncation stays inside its budget', () => { test('a header with a sensitive name is replaced and its peers keep their text', () => { assert.deepEqual( - redactNetworkHeaders({ authorization: 'Bearer abc123', 'x-request-id': 'r-1' }), + 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 db71c556fc..80d1e3f45d 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -22,9 +22,9 @@ export function redactDiagnosticData(input: T): T { } /** - * Redacts one captured network payload (a header value or a request/response 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. + * 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); @@ -39,18 +39,6 @@ export function redactNetworkPayload(value: string, maxLength: number): string { * sensitive (authorization, cookie, an API key, a token) is replaced entirely; every other value * keeps its text with secrets masked. */ -export function redactNetworkHeaders( - headers: Readonly>, -): Readonly> { - const output: Record = {}; - for (const [name, value] of Object.entries(headers)) { - output[name] = SENSITIVE_KEY_RE.test(name) - ? '[REDACTED]' - : redactNetworkPayload(value, REDACTED_STRING_MAX_LENGTH); - } - return output; -} - /** 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; @@ -110,16 +98,6 @@ function maskSecrets(value: string, keyHint?: string): string { output = output.replace( SENSITIVE_ASSIGNMENT_RE, (match, key: string, separator: string, rawValue: string, offset: number, input: string) => { - // Only the exact JSON form (`"key":` with the key closing its quote before the separator) is - // the JSON rule's job; a quote merely in front of the key (`'api_key=value'`) is a shell-like - // assignment this rule has always masked. - const before = input[offset - 1]; - if ( - (before === '"' || before === "'") && - /^["']\s*[=:]/.test(input.slice(offset + key.length)) - ) { - return match; - } if (isSafeSetupUrlAssignment({ key, separator, rawValue, offset, input })) return match; if (isDocumentedTokenPlaceholder(rawValue)) return match; return `${key}${separator}[REDACTED]`; diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts index 697044964f..1d32a0bb78 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -89,6 +89,38 @@ test('the dump that only arms the in-app capture still reports the app log windo 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({ diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index 484f613079..81b7e95db8 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -24,13 +24,23 @@ export async function dumpAndroidNetworkTraffic( input: NetworkDumpInput, signal: AbortSignal, ): Promise { - const provider = await captureNetworkProviderTraffic(host, device, input, signal); + // The dev server reports a runtime's device as `Build.MODEL`; the session device carries that + // same model from the inventory probe, so it joins the aliases the provider may hook. + const provider = await captureNetworkProviderTraffic( + host, + device, + input, + signal, + device.model === undefined ? {} : { extraDeviceAliases: [device.model] }, + ); if (provider.status === 'captured') { return Object.freeze({ source: 'provider', ...provider.result }); } const fallback = await scanNetworkAppLogFallback(host, input, 'android'); let scan = fallback.scan; - const notes: string[] = [...provider.notes]; + // 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) { assertAndroidLogPackageSafe(input.appBundleId); @@ -52,6 +62,7 @@ export async function dumpAndroidNetworkTraffic( 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', diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 1cc4c97ccc..78337c2337 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -90,6 +90,40 @@ test('the dump that only arms the in-app capture still reports the app log windo 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({ diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index d93c56fe1a..bad491b37c 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -27,7 +27,9 @@ export async function dumpAppleNetworkTraffic( } const fallback = await scanNetworkAppLogFallback(host, input, backend); let scan = fallback.scan; - const notes: string[] = [...provider.notes]; + // 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, @@ -41,6 +43,7 @@ export async function dumpAppleNetworkTraffic( 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/network/network-capture-dump.fixtures.ts b/packages/react-native/src/network/network-capture-dump.fixtures.ts index cd324be5ad..a7e21289ef 100644 --- a/packages/react-native/src/network/network-capture-dump.fixtures.ts +++ b/packages/react-native/src/network/network-capture-dump.fixtures.ts @@ -89,14 +89,84 @@ const APP_RUNTIME_SOURCE = ` var Body = function (text) { this.text = function () { return Promise.resolve(text); }; }; Body.prototype.clone = function () { return this; }; globalThis.__pending = {}; - globalThis.fetch = function (url) { - var spec = globalThis.__pending[url]; - if (spec.reject) return Promise.reject(new Error(spec.reject)); - return Promise.resolve({ - status: spec.status, - url: url, - headers: new Headers(spec.responseHeaders), - clone: function () { return new Body(spec.body); } + + // 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'); @@ -105,8 +175,20 @@ const APP_RUNTIME_SOURCE = ` 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; @@ -119,6 +201,8 @@ export type FakeAppResponse = { 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; }>; @@ -127,6 +211,7 @@ export type FakeMetroDevServer = Readonly<{ app: FakeAppRuntime; /** Inspector targets the dev server lists; a second entry is a second connected device. */ targets: string[]; + deviceNameFor(page: number): string; close(): Promise; }>; @@ -216,6 +301,7 @@ export async function fakeMetroDevServer( serverUrl: `http://127.0.0.1:${port}`, app, targets: Array.from({ length: devices }, (_, page) => `device-1-${page + 1}`), + deviceNameFor: (page) => `Fake Device ${page}`, close: async () => { for (const socket of sockets) socket.destroy(); server.close(); @@ -264,6 +350,16 @@ class FakeAppRuntimeState implements FakeAppRuntime { 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 ( diff --git a/packages/react-native/src/network/network-capture-dump.test.ts b/packages/react-native/src/network/network-capture-dump.test.ts index 20a4cad058..9134b4cc1b 100644 --- a/packages/react-native/src/network/network-capture-dump.test.ts +++ b/packages/react-native/src/network/network-capture-dump.test.ts @@ -152,6 +152,44 @@ test('a failed request is reported with its error instead of a status', async () 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 }); @@ -163,6 +201,39 @@ test('a second connected device fails closed instead of guessing a runtime', asy }); }); +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, + deviceAliases: ['rn-net ios', 'com.apple.CoreSimulator.42'], + requireDeviceMatch: true, + }), + ).rejects.toMatchObject({ + details: { reason: 'metro-device-mismatch' }, + }); +}); + +test('a runtime reporting the session device is hooked even among other devices', async () => { + const result = await dumpReactNativeAppNetwork({ + stateDir, + session: 'repro', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + deviceAliases: ['Fake Device 1'], + requireDeviceMatch: true, + }); + expect(result.armed).toBe(true); +}); + test('a session without a Metro binding or an unreachable dev server fails with a typed reason', async () => { await expect( dumpReactNativeAppNetwork({ @@ -177,5 +248,7 @@ test('a session without a Metro binding or an unreachable dev server fails with await server.close(); server = await fakeMetroDevServer(); writeMetroHints(stateDir, 'repro', 'http://127.0.0.1:1/'); - await expect(dump()).rejects.toThrow(/Could not reach the React Native dev server/); + 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 index ed51547ed1..e10e0303c2 100644 --- a/packages/react-native/src/network/network-capture-dump.ts +++ b/packages/react-native/src/network/network-capture-dump.ts @@ -29,6 +29,10 @@ export type ReactNativeNetworkDumpInput = Readonly<{ maxEntries: number; include: NetworkIncludeMode; maxPayloadChars: number; + /** Names the session device answers to; see `NetworkTransportScope.deviceAliases`. */ + deviceAliases?: readonly string[]; + /** Refuse candidates matching none of `deviceAliases` instead of falling back. */ + requireDeviceMatch?: boolean; }>; function runtimeCall( @@ -92,10 +96,18 @@ export async function dumpReactNativeAppNetwork( ); } const serverUrl = resolveSessionMetroServerUrl(scope); - const target = selectReactNativeTarget(await listMetroInspectorTargets(serverUrl), { + const target = selectReactNativeTarget(await listMetroInspectorTargets(serverUrl, signal), { appId: input.appBundleId, + ...(input.deviceAliases === undefined + ? {} + : { + deviceAliases: input.deviceAliases, + ...(input.requireDeviceMatch === undefined + ? {} + : { requireDeviceMatch: input.requireDeviceMatch }), + }), }); - const session = await InspectorSession.connect(debuggerUrlOn(serverUrl, target)); + 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. diff --git a/packages/react-native/src/network/network-capture-entries.ts b/packages/react-native/src/network/network-capture-entries.ts index 8958c85d47..136dedff3c 100644 --- a/packages/react-native/src/network/network-capture-entries.ts +++ b/packages/react-native/src/network/network-capture-entries.ts @@ -1,6 +1,6 @@ import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; import type { NetworkProviderEntry } from '@agent-device/contracts/network-runtime'; -import { redactNetworkHeaders, redactNetworkPayload } from '@agent-device/kernel/redaction'; +import { redactDiagnosticData, redactNetworkPayload } from '@agent-device/kernel/redaction'; import { NETWORK_CAPTURE_MAX_ENTRIES } from './network-capture-runtime.ts'; function readString(value: unknown): string | undefined { @@ -17,7 +17,9 @@ function readHeaders(value: unknown): Record | undefined { for (const [name, entry] of Object.entries(value as Record)) { if (typeof entry === 'string') headers[name] = entry; } - return Object.keys(headers).length > 0 ? redactNetworkHeaders(headers) : undefined; + 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. */ diff --git a/packages/react-native/src/network/network-capture-runtime.ts b/packages/react-native/src/network/network-capture-runtime.ts index e007f6db0d..6ab72d06e0 100644 --- a/packages/react-native/src/network/network-capture-runtime.ts +++ b/packages/react-native/src/network/network-capture-runtime.ts @@ -35,8 +35,9 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` var patchedXhr = false; var fetchInFlight = {}; - // whatwg-fetch polyfills fetch on top of XMLHttpRequest; a request already claimed by an - // in-flight fetch URL must not also record that transport's own XHR. + // 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 () { @@ -149,10 +150,14 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` }); 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) { - release(); var entry = { timestamp: began, method: method, @@ -168,19 +173,19 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` 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 only after the body settles: a drain between the response and its body - // would otherwise hand the host an entry it could still complete in the app. - return clone.text().then( + // 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 in that window reports the + // request without its body instead of delaying the app for the whole read. + clone.text().then( function (text) { entry.responseBody = cap(text); record(entry); - return response; }, function () { record(entry); - return response; } ); + return response; } record(entry); return response; diff --git a/packages/react-native/src/react-devtools/metro-inspector.ts b/packages/react-native/src/react-devtools/metro-inspector.ts index d8c1376788..a8a9a26e31 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,17 @@ export type RuntimeTargetFilter = { appId?: string; /** Device name or logical device id, as the dev server reports it. */ device?: string; + /** + * Names the caller's own device answers to (display name, serial, UDID). When none of the + * candidates reports any of them, the filter's outcome depends on `requireDeviceMatch`. + */ + deviceAliases?: readonly string[]; + /** + * Refuse candidates whose reported device matches none of `deviceAliases`. Set it where the + * runtime copies the OS device name faithfully; leave it unset where the mapping is not proven, + * so a name mismatch alone does not disable the capture. + */ + requireDeviceMatch?: boolean; }; function isAppRuntime(target: MetroInspectorTarget): boolean { @@ -148,17 +167,51 @@ function deviceSelectors(devices: MetroInspectorTarget[]): string[] { ); } +/** Names the target reports for its device: display name and logical device id. */ +function targetDeviceNames(target: MetroInspectorTarget): string[] { + return [target.deviceName, deviceKey(target)].filter((name): name is string => Boolean(name)); +} + /** * 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 `deviceAliases` name the caller's + * own device, candidates that positively match one are the only candidates considered next; when + * none matches and `requireDeviceMatch` is set, selection fails closed instead of profiling a + * runtime that may belong to another device. */ 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.deviceAliases !== undefined && candidates.length > 0) { + const aliases = new Set(filter.deviceAliases.filter(Boolean)); + const identified = candidates.filter((target) => targetDeviceNames(target).length > 0); + // Targets that report no device identity cannot be distinguished; the several-devices guard + // below still fails a multi-device Metro closed, and a lone candidate keeps its app-id match. + if (identified.length > 0) { + const owned = identified.filter((target) => + targetDeviceNames(target).some((name) => aliases.has(name)), + ); + if (owned.length > 0) { + candidates = owned; + } else if (filter.requireDeviceMatch) { + throw new AppError( + 'COMMAND_FAILED', + "No React Native runtime connected to the dev server reports this session's device.", + { reason: 'metro-device-mismatch' }, + ); + } + } else if (filter.requireDeviceMatch) { + throw new AppError( + 'COMMAND_FAILED', + 'No React Native runtime connected to the dev server reports its device.', + { reason: 'metro-device-mismatch' }, + ); + } + } if (candidates.length === 0) throw noMatchingRuntime(runtimes, filter); const apps = new Set(candidates.map(targetAppId)); if (apps.size > 1) { @@ -211,12 +264,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 +304,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/src/platform-runtime-network-react-native-transport.ts b/src/platform-runtime-network-react-native-transport.ts index d6f7a51685..3961f92829 100644 --- a/src/platform-runtime-network-react-native-transport.ts +++ b/src/platform-runtime-network-react-native-transport.ts @@ -42,6 +42,10 @@ export async function resolveReactNativeNetworkTransport( maxEntries: input.maxEntries, include: input.include, maxPayloadChars, + ...(scope.deviceAliases === undefined ? {} : { deviceAliases: scope.deviceAliases }), + ...(scope.requireDeviceMatch === undefined + ? {} + : { requireDeviceMatch: scope.requireDeviceMatch }), }, signal, ), diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 81b70e7f97..0504f69dfb 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1081,7 +1081,7 @@ 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]` 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, plus headers and bodies under `--include`. 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 on app reload. 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. +- `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 on app reload. 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 c2b10fa777..c9d4855f4a 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -217,7 +217,7 @@ agent-device network dump 25 --include all ``` - `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 (iOS simulator or Android emulator, Metro reachable), `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, with headers and bodies under `--include headers` / `--include all`. The first dump installs the capture and reports it armed; reproduce the requests, then dump again. The buffer is bounded (200 entries, newest first), drains as it is read, and resets when the app reloads. Header values and bodies are redacted and bounded; the URL is reported verbatim. +- 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, native HTTP stacks like axios's) 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), drains as it is read, and resets when the app reloads. 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. - 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. From e9ad63e97859e340a619e8916dc716faa94b151c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 14:06:01 +0200 Subject: [PATCH 06/13] test(examples): the network lab can prove a failed fetch cannot hide an XHR --- examples/test-app/app/network-lab.tsx | 28 ++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/examples/test-app/app/network-lab.tsx b/examples/test-app/app/network-lab.tsx index 400ace694d..e2cdb34b5a 100644 --- a/examples/test-app/app/network-lab.tsx +++ b/examples/test-app/app/network-lab.tsx @@ -18,7 +18,7 @@ export default function NetworkLabRoute() { try { const response = await fetch(ENDPOINT, { method: 'GET', - headers: { 'x-agent-device-lab': 'network' }, + headers: { 'x-agent-device-lab': 'network', 'x-api-key': 'lab-secret-1' }, }); const body = await response.text(); setStatus(`fetch:${response.status}:${body.length}`); @@ -27,6 +27,25 @@ export default function NetworkLabRoute() { } }; + /** + * 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 { @@ -66,6 +85,13 @@ export default function NetworkLabRoute() { Run XHR + void runFailedFetchThenXhr()} + style={styles.button} + testID="network-failed-fetch-xhr" + > + Run failed fetch then XHR + {status} From 83895be3bf1e8b6730a21d1d9a4d88a87783b163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 14:36:48 +0200 Subject: [PATCH 07/13] fix(react-native): match a session's device against the dev server's Android name An Android runtime reports its device as ' - - API ', while the session knows the model from the inventory probe. Match the model as a prefix so a session's own Android runtime survives device attribution. Identification now requires a reported device name; a synthetic logical device id alone cannot match a session alias. --- .../react-devtools/metro-inspector.test.ts | 63 +++++++++++++++++++ .../src/react-devtools/metro-inspector.ts | 27 +++++--- 2 files changed, 80 insertions(+), 10 deletions(-) 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..525b7f2510 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,69 @@ 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('device aliases narrow two devices to the session own runtime', () => { + 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' }), + ]; + // The dev server reports an Android runtime as ` - - API `, so the + // model name the inventory probe knows matches it as a prefix. + assert.equal( + selectReactNativeTarget(targets, { + appId: 'com.app', + deviceAliases: ['rn-net ios', 'com.apple.CoreSimulator.42'], + requireDeviceMatch: true, + }).id, + 'sim-a-1', + ); + assert.equal( + selectReactNativeTarget(targets, { + appId: 'com.app', + deviceAliases: ['ReactNative API 35', 'emulator-5554', 'sdk_gphone64_arm64'], + requireDeviceMatch: true, + }).id, + 'pixel-1', + ); +}); + +test('requireDeviceMatch fails closed when no runtime reports the session device', () => { + const targets = [target('pixel-1', { appId: 'com.app', deviceName: 'Pixel 9' })]; + assert.throws( + () => + selectReactNativeTarget(targets, { + appId: 'com.app', + deviceAliases: ['rn-net ios'], + requireDeviceMatch: true, + }), + (error: { code: string; details: { reason: string } }) => { + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details.reason, 'metro-device-mismatch'); + return true; + }, + ); + // Without the requirement — where the name mapping is not trusted — the app-id match still wins. + assert.equal( + selectReactNativeTarget(targets, { appId: 'com.app', deviceAliases: ['rn-net ios'] }).id, + 'pixel-1', + ); +}); + +test('targets that report no device identity fall back to the several-devices guard', () => { + const targets = [ + target('a-1', { appId: 'com.app', reactNative: {} }), + target('b-1', { appId: 'com.app', reactNative: {} }), + ]; + assert.throws( + () => + selectReactNativeTarget(targets, { + appId: 'com.app', + deviceAliases: ['rn-net ios'], + requireDeviceMatch: true, + }), + /Several React Native apps|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 a8a9a26e31..c17bcad504 100644 --- a/packages/react-native/src/react-devtools/metro-inspector.ts +++ b/packages/react-native/src/react-devtools/metro-inspector.ts @@ -172,6 +172,15 @@ function targetDeviceNames(target: MetroInspectorTarget): string[] { return [target.deviceName, deviceKey(target)].filter((name): name is string => Boolean(name)); } +/** + * Whether a reported device name names the alias's device. An Android runtime reports + * ` - - API `, so a ` - ` prefix is the same device; an Apple + * runtime reports the simulator name verbatim. + */ +function deviceNameMatchesAlias(name: string, alias: string): boolean { + return name === alias || name.startsWith(`${alias} - `); +} + /** * 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 @@ -188,12 +197,16 @@ export function selectReactNativeTarget( let candidates = runtimes.filter((target) => matchesFilter(target, filter)); if (filter.deviceAliases !== undefined && candidates.length > 0) { const aliases = new Set(filter.deviceAliases.filter(Boolean)); - const identified = candidates.filter((target) => targetDeviceNames(target).length > 0); - // Targets that report no device identity cannot be distinguished; the several-devices guard - // below still fails a multi-device Metro closed, and a lone candidate keeps its app-id match. + // A target counts as identified only when the dev server names its device: the logical device + // id alone is synthetic and cannot be matched against a session's device. Without any name the + // several-devices guard below still fails a multi-device Metro closed, and a lone candidate + // keeps its app-id match. + const identified = candidates.filter((target) => target.deviceName !== undefined); if (identified.length > 0) { const owned = identified.filter((target) => - targetDeviceNames(target).some((name) => aliases.has(name)), + targetDeviceNames(target).some((name) => + [...aliases].some((alias) => deviceNameMatchesAlias(name, alias)), + ), ); if (owned.length > 0) { candidates = owned; @@ -204,12 +217,6 @@ export function selectReactNativeTarget( { reason: 'metro-device-mismatch' }, ); } - } else if (filter.requireDeviceMatch) { - throw new AppError( - 'COMMAND_FAILED', - 'No React Native runtime connected to the dev server reports its device.', - { reason: 'metro-device-mismatch' }, - ); } } if (candidates.length === 0) throw noMatchingRuntime(runtimes, filter); From be1c9da07f4c80441a69b7f15d2ee4d651dddfcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 15:00:44 +0200 Subject: [PATCH 08/13] docs(network): a Metro reload keeps the in-app capture armed Live verification showed a reload does not wipe the JS global, so only an app process restart resets the capture and re-arms it. --- website/docs/docs/commands.md | 2 +- website/docs/docs/debugging-profiling.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 0504f69dfb..59ae4aa527 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1081,7 +1081,7 @@ 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]` 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 on app reload. 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. +- `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 c9d4855f4a..62959f1355 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -217,7 +217,7 @@ agent-device network dump 25 --include all ``` - `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, native HTTP stacks like axios's) 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), drains as it is read, and resets when the app reloads. 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. +- 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, native HTTP stacks like axios's) 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. - 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. From 3dcb556a83c653ecf77f47a6c67a6c082226c018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 16:54:37 +0200 Subject: [PATCH 09/13] fix(network): select Metro runtimes only by a device-unique identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared model or simulator name is not an identity: two emulators of one system image (or two same-name simulators) could match each other's runtimes, so one session could hook, drain, and report another device's traffic. The platform that owns the device inventory now composes the dev-server identity only when exactly one attached device answers to it — Android the composed ' - - API ' after checking every attached serial, Apple the simulator display name after checking booted simulators — and an app-backed dump without one fails closed with metro-device-mismatch instead of guessing. This replaces the alias list, the model-prefix rule, and the requireDeviceMatch seam. --- .../src/network-provider-capture.test.ts | 15 +++- .../src/network-provider-capture.ts | 22 ++---- packages/contracts/src/network-runtime.ts | 15 ++-- packages/kernel/src/redaction.ts | 5 -- .../src/network/runtime.test.ts | 67 +++++++++++++++- .../platform-android/src/network/runtime.ts | 76 ++++++++++++++++++- .../src/network/runtime.test.ts | 67 +++++++++++++++- .../platform-apple/src/network/runtime.ts | 47 +++++++++++- .../network/network-capture-dump.fixtures.ts | 20 +++-- .../src/network/network-capture-dump.test.ts | 43 ++++++++--- .../src/network/network-capture-dump.ts | 24 +++--- .../src/network/network-capture-runtime.ts | 5 +- .../react-devtools/metro-inspector.test.ts | 54 ++++++------- .../src/react-devtools/metro-inspector.ts | 67 +++++----------- ...-runtime-network-react-native-transport.ts | 5 +- website/docs/docs/debugging-profiling.md | 2 +- 16 files changed, 385 insertions(+), 149 deletions(-) diff --git a/packages/capture-kit/src/network-provider-capture.test.ts b/packages/capture-kit/src/network-provider-capture.test.ts index 5f825baf95..2918129454 100644 --- a/packages/capture-kit/src/network-provider-capture.test.ts +++ b/packages/capture-kit/src/network-provider-capture.test.ts @@ -65,6 +65,7 @@ test('the provider result is handed back with the session scope the transport ne device, input, new AbortController().signal, + { metroDeviceIdentity: 'Device' }, ); assert.deepEqual(capture, { status: 'captured', result }); @@ -74,11 +75,21 @@ test('the provider result is handed back with the session scope the transport ne stateDir: '/tmp/state', appBundleId: 'com.example.app', maxPayloadChars: 2048, - deviceAliases: ['Device', 'device-1'], - requireDeviceMatch: true, + metroDeviceIdentity: 'Device', }); }); +test('a caller without a device identity 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]?.metroDeviceIdentity, undefined); +}); + test('a session without an app id never probes a provider: injection needs a named app', async () => { const fixture = hostWith({ mode: 'transport-composed', diff --git a/packages/capture-kit/src/network-provider-capture.ts b/packages/capture-kit/src/network-provider-capture.ts index 88adb418d1..f6999b21cd 100644 --- a/packages/capture-kit/src/network-provider-capture.ts +++ b/packages/capture-kit/src/network-provider-capture.ts @@ -32,34 +32,28 @@ 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. - * `extraDeviceAliases` carries device identities only the calling platform knows (a probed model - * name); the session device's own name and id always name it. `requireDeviceMatch` decides whether - * a runtime whose reported device matches none of them fails closed (the mapping between the OS - * device name and the dev server's report is trusted) or falls back to the app-id match alone. + * a typed `unavailable` note; this function never throws. The caller passes the dev-server identity + * of exactly one device attached to this host (`metroDeviceIdentity`); without one the provider + * fails closed, because a runtime could then only be guessed at. */ export async function captureNetworkProviderTraffic( host: Pick, device: DeviceInfo, input: NetworkDumpInput, signal: AbortSignal, - options: { - extraDeviceAliases?: readonly string[]; - requireDeviceMatch?: boolean; - } = {}, + options: { metroDeviceIdentity?: string } = {}, ): Promise { // A provider dump injects into the runtime it selects, so it runs only for a session that names - // its app and may hook only a runtime that reports this session's device. + // 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, - deviceAliases: [ - ...new Set([device.name, device.id, ...(options.extraDeviceAliases ?? [])].filter(Boolean)), - ], - requireDeviceMatch: options.requireDeviceMatch ?? true, + ...(options.metroDeviceIdentity === undefined + ? {} + : { metroDeviceIdentity: options.metroDeviceIdentity }), }; let transport; try { diff --git a/packages/contracts/src/network-runtime.ts b/packages/contracts/src/network-runtime.ts index 2e47a122b9..a4d019960f 100644 --- a/packages/contracts/src/network-runtime.ts +++ b/packages/contracts/src/network-runtime.ts @@ -78,17 +78,12 @@ export type NetworkTransportScope = Readonly<{ /** Payload budget the dump will apply; a transport may pre-bound what it reads with it. */ maxPayloadChars?: number; /** - * Names the session device answers to (display name, id). A provider dump hooks only a runtime - * whose reported device matches one of them; a runtime naming another device is not this - * session's app and fails the provider closed instead of being read. + * The dev server's device name of exactly one device attached to this host, composed by the + * platform package that owns the device inventory. The provider hooks only a runtime reporting + * that name verbatim. Absent when the platform cannot name one attached device unambiguously; + * then the provider fails closed instead of guessing. */ - deviceAliases?: readonly string[]; - /** - * Refuse every candidate that matches none of `deviceAliases` instead of falling back to the - * undistinguished set. Set it where the runtime copies the OS device name faithfully (Apple); - * leave it unset where the dev server's name is not proven to equal the session's own. - */ - requireDeviceMatch?: boolean; + metroDeviceIdentity?: string; }>; export type NetworkTransport = Readonly<{ diff --git a/packages/kernel/src/redaction.ts b/packages/kernel/src/redaction.ts index 80d1e3f45d..6e871a9973 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -34,11 +34,6 @@ export function redactNetworkPayload(value: string, maxLength: number): string { return `${redacted.slice(0, kept)}${TRUNCATION_SUFFIX}`; } -/** - * Redacts captured HTTP headers with the same rules diagnostics use: a value whose header name is - * sensitive (authorization, cookie, an API key, a token) is replaced entirely; every other value - * keeps its text with secrets masked. - */ /** 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; diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts index 1d32a0bb78..297c368d90 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -4,6 +4,7 @@ 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'; @@ -57,6 +58,47 @@ test('in-app capture answers with the full URL and status, and the app log is no expect(readRecent).not.toHaveBeenCalled(); }); +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(scopes[0]?.metroDeviceIdentity).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(scopes[0]?.metroDeviceIdentity).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( @@ -256,12 +298,30 @@ function host(options: { appLogText?: string; logcat: string | Error; transport?: NetworkTransport; + attached?: string; + scopes?: NetworkTransportScope[]; }): PlatformRuntimeHost { return { ...unusedAppLogHost(), commands: { which: async () => undefined, run: async (request) => { + // The provider's device-identity probe runs before the transport is resolved; the harness + // answers it as a host with exactly this one attached device 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'))) { + 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; @@ -283,7 +343,12 @@ function host(options: { }), readProcessMarker: async () => options.marker, }, - networkTransports: { resolve: async () => options.transport ?? { 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 81b7e95db8..aff6034710 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -24,14 +24,13 @@ export async function dumpAndroidNetworkTraffic( input: NetworkDumpInput, signal: AbortSignal, ): Promise { - // The dev server reports a runtime's device as `Build.MODEL`; the session device carries that - // same model from the inventory probe, so it joins the aliases the provider may hook. + // Probing the identity costs adb calls, and only an app-backed session has a provider to name. const provider = await captureNetworkProviderTraffic( host, device, input, signal, - device.model === undefined ? {} : { extraDeviceAliases: [device.model] }, + input.appBundleId === undefined ? {} : await metroDeviceIdentity(host, device, signal), ); if (provider.status === 'captured') { return Object.freeze({ source: 'provider', ...provider.result }); @@ -77,6 +76,77 @@ function androidRecoveryNote(context: RecoveryContext, pids: readonly string[]): : `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<{ metroDeviceIdentity?: string }> { + const own = await readDeviceNameParts(host, device.id, signal); + if (own === undefined) return {}; + const serials = await listAttachedSerials(host, signal); + if (serials === undefined) return {}; + for (const serial of serials) { + if (serial === device.id) continue; + const other = await readDeviceNameParts(host, serial, signal); + if (other !== undefined && other.joined === own.joined) return {}; + } + return { metroDeviceIdentity: own.joined }; +} + +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, + }, + 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 listAttachedSerials( + host: PlatformRuntimeHost, + signal: AbortSignal, +): Promise { + const result = await runOptionalRecoveryCommand( + host, + { executable: 'adb', args: ['devices'], allowFailure: true }, + signal, + ); + if (!result || result.exitCode !== 0) return undefined; + return result.stdout + .split('\n') + .slice(1) + .map((line) => /^\s*(\S+)\s+device\b/.exec(line)?.[1]) + .filter((serial): serial is string => Boolean(serial)); +} + function appendLifecycleNote(notes: string[], input: NetworkDumpInput): void { if (!input.appLogSnapshot) { notes.push( diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 78337c2337..eb9e3b264a 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -3,6 +3,7 @@ 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'; @@ -275,7 +276,31 @@ function host(options: { text?: string; runSimctl: PlatformRuntimeHost['appleTools']['run']; transport?: NetworkTransport; + bootedNames?: readonly string[]; + scopes?: NetworkTransportScope[]; }): PlatformRuntimeHost { + // The provider's identity probe lists booted simulators before the transport resolves; the + // harness answers it with the default one-simulator host unless a test names more. + const runSimctl: PlatformRuntimeHost['appleTools']['run'] = async (request, signal) => { + if (request.args.includes('booted')) { + const devices = [ + { udid: 'sim-1', name: 'iPhone', state: 'Booted' }, + ...(options.bootedNames ?? []).map((name, index) => ({ + udid: `other-${index}`, + name, + state: 'Booted' as const, + })), + ]; + return { + stdout: JSON.stringify({ + devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-27-0': devices }, + }), + stderr: '', + exitCode: 0, + }; + } + return options.runSimctl(request, signal); + }; return { ...unusedAppLogHost(), commands: { @@ -284,7 +309,7 @@ function host(options: { 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', @@ -294,10 +319,48 @@ function host(options: { }), readProcessMarker: async () => ({ status: 'missing' }), }, - networkTransports: { resolve: async () => options.transport ?? { mode: 'local' } }, + networkTransports: { + resolve: async (_device, scope) => { + options.scopes?.push(scope!); + return options.transport ?? { mode: 'local' }; + }, + }, }; } +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(scopes[0]?.metroDeviceIdentity).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(scopes[0]?.metroDeviceIdentity).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 bad491b37c..cb1808681e 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -14,6 +14,45 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; import { backendForAppleDevice } from '../logs/backend.ts'; +/** + * 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 exactly one booted simulator in this set carries it; a shared name could select and drain + * the other simulator's runtime. A physical device has no host-side listing that proves its name + * is unique among attached devices, so it gets no identity and the provider stays off. + */ +async function metroDeviceIdentity( + host: PlatformRuntimeHost, + device: DeviceInfo, + signal: AbortSignal, +): Promise<{ metroDeviceIdentity?: string }> { + if (!isIosFamily(device) || device.kind !== 'simulator') return {}; + const result = await host.appleTools.run( + { + tool: 'simctl', + args: scopeSimctlArgsForDevice(device, ['list', 'devices', 'booted', '-j']), + allowFailure: true, + timeoutMs: 4_000, + }, + signal, + ); + if (result.exitCode !== 0) return {}; + let names: string[]; + try { + const payload = JSON.parse(result.stdout) as { + devices?: Record>; + }; + names = Object.values(payload.devices ?? {}) + .flat() + .filter((entry) => entry.state === 'Booted') + .map((entry) => entry.name ?? ''); + } catch { + return {}; + } + const sameName = names.filter((name) => name === device.name).length; + return sameName === 1 ? { metroDeviceIdentity: device.name } : {}; +} + export async function dumpAppleNetworkTraffic( host: PlatformRuntimeHost, device: DeviceInfo, @@ -21,7 +60,13 @@ export async function dumpAppleNetworkTraffic( signal: AbortSignal, ): Promise { const backend = backendForAppleDevice(device); - const provider = await captureNetworkProviderTraffic(host, device, input, signal); + const provider = await captureNetworkProviderTraffic( + host, + device, + input, + signal, + input.appBundleId === undefined ? {} : await metroDeviceIdentity(host, device, signal), + ); if (provider.status === 'captured') { return Object.freeze({ source: 'provider', ...provider.result }); } diff --git a/packages/react-native/src/network/network-capture-dump.fixtures.ts b/packages/react-native/src/network/network-capture-dump.fixtures.ts index a7e21289ef..6aed724688 100644 --- a/packages/react-native/src/network/network-capture-dump.fixtures.ts +++ b/packages/react-native/src/network/network-capture-dump.fixtures.ts @@ -209,9 +209,8 @@ export type FakeAppRuntime = Readonly<{ export type FakeMetroDevServer = Readonly<{ serverUrl: string; app: FakeAppRuntime; - /** Inspector targets the dev server lists; a second entry is a second connected device. */ - targets: string[]; - deviceNameFor(page: number): string; + /** Inspector page ids whose debugger connection ran an expression, in connection order. */ + hookedPages: string[]; close(): Promise; }>; @@ -219,13 +218,17 @@ export type FakeMetroDevServer = Readonly<{ * 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. + * 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 } = {}, + 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) => { @@ -242,7 +245,7 @@ export async function fakeMetroDevServer( title: 'React Native', description: 'React Native Bridgeless [C++ connection]', appId, - deviceName: `Fake Device ${page + 1}`, + deviceName: deviceName(page), reactNative: { logicalDeviceId: `device-${page + 1}` }, webSocketDebuggerUrl: `ws://127.0.0.1:${port}/inspector/debug?page=device-${page + 1}-1`, })), @@ -251,6 +254,8 @@ export async function fakeMetroDevServer( }); 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'); @@ -300,8 +305,7 @@ export async function fakeMetroDevServer( return { serverUrl: `http://127.0.0.1:${port}`, app, - targets: Array.from({ length: devices }, (_, page) => `device-1-${page + 1}`), - deviceNameFor: (page) => `Fake Device ${page}`, + hookedPages, close: async () => { for (const socket of sockets) socket.destroy(); server.close(); diff --git a/packages/react-native/src/network/network-capture-dump.test.ts b/packages/react-native/src/network/network-capture-dump.test.ts index 9134b4cc1b..3b2b149e49 100644 --- a/packages/react-native/src/network/network-capture-dump.test.ts +++ b/packages/react-native/src/network/network-capture-dump.test.ts @@ -37,6 +37,8 @@ function dump(overrides: Partial<{ include: NetworkIncludeMode; maxEntries: numb 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, }); } @@ -195,10 +197,17 @@ test('a second connected device fails closed instead of guessing a runtime', asy server = await fakeMetroDevServer({ devices: 2 }); writeMetroHints(stateDir, 'repro', server.serverUrl); - await expect(dump()).rejects.toMatchObject({ - code: 'INVALID_ARGS', - details: { hint: expect.stringContaining('--metro-device') }, - }); + // 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 () => { @@ -212,15 +221,17 @@ test('a session device no runtime reports fails closed instead of hooking anothe maxEntries: 25, include: 'summary', maxPayloadChars: 2048, - deviceAliases: ['rn-net ios', 'com.apple.CoreSimulator.42'], - requireDeviceMatch: true, + metroDeviceIdentity: 'rn-net ios', }), ).rejects.toMatchObject({ details: { reason: 'metro-device-mismatch' }, }); }); -test('a runtime reporting the session device is hooked even among other devices', async () => { +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', @@ -228,10 +239,24 @@ test('a runtime reporting the session device is hooked even among other devices' maxEntries: 25, include: 'summary', maxPayloadChars: 2048, - deviceAliases: ['Fake Device 1'], - requireDeviceMatch: true, + 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 () => { diff --git a/packages/react-native/src/network/network-capture-dump.ts b/packages/react-native/src/network/network-capture-dump.ts index e10e0303c2..8e81b27e43 100644 --- a/packages/react-native/src/network/network-capture-dump.ts +++ b/packages/react-native/src/network/network-capture-dump.ts @@ -29,10 +29,8 @@ export type ReactNativeNetworkDumpInput = Readonly<{ maxEntries: number; include: NetworkIncludeMode; maxPayloadChars: number; - /** Names the session device answers to; see `NetworkTransportScope.deviceAliases`. */ - deviceAliases?: readonly string[]; - /** Refuse candidates matching none of `deviceAliases` instead of falling back. */ - requireDeviceMatch?: boolean; + /** The dev server's device name of exactly one attached device; see `RuntimeTargetFilter`. */ + metroDeviceIdentity?: string; }>; function runtimeCall( @@ -96,16 +94,20 @@ export async function dumpReactNativeAppNetwork( ); } 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.deviceAliases === undefined + ...(input.metroDeviceIdentity === undefined ? {} - : { - deviceAliases: input.deviceAliases, - ...(input.requireDeviceMatch === undefined - ? {} - : { requireDeviceMatch: input.requireDeviceMatch }), - }), + : { metroDeviceIdentity: input.metroDeviceIdentity }), }); const session = await InspectorSession.connect(debuggerUrlOn(serverUrl, target), signal); try { diff --git a/packages/react-native/src/network/network-capture-runtime.ts b/packages/react-native/src/network/network-capture-runtime.ts index 6ab72d06e0..3b345d400f 100644 --- a/packages/react-native/src/network/network-capture-runtime.ts +++ b/packages/react-native/src/network/network-capture-runtime.ts @@ -174,8 +174,9 @@ export const NETWORK_CAPTURE_RUNTIME_SOURCE = String.raw` 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 in that window reports the - // request without its body instead of delaying the app for the whole read. + // 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); 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 525b7f2510..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,66 +96,62 @@ 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('device aliases narrow two devices to the session own runtime', () => { +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' }), ]; - // The dev server reports an Android runtime as ` - - API `, so the - // model name the inventory probe knows matches it as a prefix. assert.equal( selectReactNativeTarget(targets, { appId: 'com.app', - deviceAliases: ['rn-net ios', 'com.apple.CoreSimulator.42'], - requireDeviceMatch: true, + metroDeviceIdentity: 'sdk_gphone64_arm64 - 15 - API 35', }).id, - 'sim-a-1', + 'pixel-1', ); assert.equal( - selectReactNativeTarget(targets, { - appId: 'com.app', - deviceAliases: ['ReactNative API 35', 'emulator-5554', 'sdk_gphone64_arm64'], - requireDeviceMatch: true, - }).id, - 'pixel-1', + selectReactNativeTarget(targets, { appId: 'com.app', metroDeviceIdentity: 'rn-net ios' }).id, + 'sim-a-1', ); }); -test('requireDeviceMatch fails closed when no runtime reports the session device', () => { +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', - deviceAliases: ['rn-net ios'], - requireDeviceMatch: true, - }), + () => 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; }, ); - // Without the requirement — where the name mapping is not trusted — the app-id match still wins. - assert.equal( - selectReactNativeTarget(targets, { appId: 'com.app', deviceAliases: ['rn-net ios'] }).id, - 'pixel-1', + 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('targets that report no device identity fall back to the several-devices guard', () => { +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('a-1', { appId: 'com.app', reactNative: {} }), - target('b-1', { appId: 'com.app', reactNative: {} }), + 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', - deviceAliases: ['rn-net ios'], - requireDeviceMatch: true, + metroDeviceIdentity: 'sdk_gphone64_arm64 - 15 - API 35', }), - /Several React Native apps|several devices/, + /several devices/, ); }); diff --git a/packages/react-native/src/react-devtools/metro-inspector.ts b/packages/react-native/src/react-devtools/metro-inspector.ts index c17bcad504..d01e68a01e 100644 --- a/packages/react-native/src/react-devtools/metro-inspector.ts +++ b/packages/react-native/src/react-devtools/metro-inspector.ts @@ -95,16 +95,12 @@ export type RuntimeTargetFilter = { /** Device name or logical device id, as the dev server reports it. */ device?: string; /** - * Names the caller's own device answers to (display name, serial, UDID). When none of the - * candidates reports any of them, the filter's outcome depends on `requireDeviceMatch`. + * 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. */ - deviceAliases?: readonly string[]; - /** - * Refuse candidates whose reported device matches none of `deviceAliases`. Set it where the - * runtime copies the OS device name faithfully; leave it unset where the mapping is not proven, - * so a name mismatch alone does not disable the capture. - */ - requireDeviceMatch?: boolean; + metroDeviceIdentity?: string; }; function isAppRuntime(target: MetroInspectorTarget): boolean { @@ -167,27 +163,15 @@ function deviceSelectors(devices: MetroInspectorTarget[]): string[] { ); } -/** Names the target reports for its device: display name and logical device id. */ -function targetDeviceNames(target: MetroInspectorTarget): string[] { - return [target.deviceName, deviceKey(target)].filter((name): name is string => Boolean(name)); -} - -/** - * Whether a reported device name names the alias's device. An Android runtime reports - * ` - - API `, so a ` - ` prefix is the same device; an Apple - * runtime reports the simulator name verbatim. - */ -function deviceNameMatchesAlias(name: string, alias: string): boolean { - return name === alias || name.startsWith(`${alias} - `); -} - /** * 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. When `deviceAliases` name the caller's - * own device, candidates that positively match one are the only candidates considered next; when - * none matches and `requireDeviceMatch` is set, selection fails closed instead of profiling a - * runtime that may belong to another device. + * 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[], @@ -195,29 +179,16 @@ export function selectReactNativeTarget( ): MetroInspectorTarget { const runtimes = targets.filter(isAppRuntime); let candidates = runtimes.filter((target) => matchesFilter(target, filter)); - if (filter.deviceAliases !== undefined && candidates.length > 0) { - const aliases = new Set(filter.deviceAliases.filter(Boolean)); - // A target counts as identified only when the dev server names its device: the logical device - // id alone is synthetic and cannot be matched against a session's device. Without any name the - // several-devices guard below still fails a multi-device Metro closed, and a lone candidate - // keeps its app-id match. - const identified = candidates.filter((target) => target.deviceName !== undefined); - if (identified.length > 0) { - const owned = identified.filter((target) => - targetDeviceNames(target).some((name) => - [...aliases].some((alias) => deviceNameMatchesAlias(name, alias)), - ), + 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' }, ); - if (owned.length > 0) { - candidates = owned; - } else if (filter.requireDeviceMatch) { - 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)); diff --git a/src/platform-runtime-network-react-native-transport.ts b/src/platform-runtime-network-react-native-transport.ts index 3961f92829..c13ead5ae1 100644 --- a/src/platform-runtime-network-react-native-transport.ts +++ b/src/platform-runtime-network-react-native-transport.ts @@ -42,10 +42,9 @@ export async function resolveReactNativeNetworkTransport( maxEntries: input.maxEntries, include: input.include, maxPayloadChars, - ...(scope.deviceAliases === undefined ? {} : { deviceAliases: scope.deviceAliases }), - ...(scope.requireDeviceMatch === undefined + ...(scope.metroDeviceIdentity === undefined ? {} - : { requireDeviceMatch: scope.requireDeviceMatch }), + : { metroDeviceIdentity: scope.metroDeviceIdentity }), }, signal, ), diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 62959f1355..675b557ccf 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -217,7 +217,7 @@ agent-device network dump 25 --include all ``` - `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, native HTTP stacks like axios's) 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. +- 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. - 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. From e4603deb9cef6ac25360a8deb5dd7c06df54dc38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 17:13:24 +0200 Subject: [PATCH 10/13] docs(adr): name one network-dump source owner, enum, and injection policy Settles the source-selection question raised against #3930 and #3928 in one record: the platform runtime owns the order, the source enum is probe/provider/app-log, no capture installs unless an explicit operator command arms it, and the React Native provider injects only on the runtime whose device the platform named uniquely among attached devices. --- .../adr/0038-network-dump-source-selection.md | 37 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 38 insertions(+) create mode 100644 docs/adr/0038-network-dump-source-selection.md 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..3eacff7630 --- /dev/null +++ b/docs/adr/0038-network-dump-source-selection.md @@ -0,0 +1,37 @@ +# 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. An identity that does not name exactly one attached host device — or none — fails the provider closed with `metro-device-mismatch`; the dump still answers from the app log. + +## 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 (no host-side listing proves name uniqueness among attached devices), so the React Native provider stays off there and the dump answers from the app log. +- 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. From d0eda055d4ded144223a14f96a7bf2531d3c61e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 17:27:57 +0200 Subject: [PATCH 11/13] test(integration): the fake simctl provider answers the booted-devices listing The network provider's device-identity probe lists booted simulators before resolving the transport; the shared listing helper now recognizes that argv. --- test/integration/provider-scenarios/providers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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); From 91a1e90a5f5f5c1eeeec1c6e78b3f386d631c6ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 18:51:54 +0200 Subject: [PATCH 12/13] fix(network): fail the Metro device identity closed on every unknown device The identity probe granted a name while devices it could not see remained possible. Android now lists every serial adb reports, in any state, and withholds the identity when any serial's properties cannot be read. Apple now lists booted simulators in every set the host can name and the connected physical devices devicectl reports, and fails closed when either answer is unreadable or the daemon's configured set contradicts the device. The probe runs lazily inside a dump, so a session whose transport declares no provider dump never pays for it. --- .../adr/0038-network-dump-source-selection.md | 5 +- .../src/network-provider-capture.test.ts | 48 ++++-- .../src/network-provider-capture.ts | 15 +- packages/contracts/src/network-runtime.ts | 11 +- .../platform-android/src/inventory-parsers.ts | 22 +++ .../src/network/runtime.test.ts | 40 ++++- .../platform-android/src/network/runtime.ts | 58 ++++--- packages/platform-apple/src/core/simctl.ts | 21 ++- .../src/network/runtime.test.ts | 138 ++++++++++++++++- .../platform-apple/src/network/runtime.ts | 143 ++++++++++++++---- ...ime-network-react-native-transport.test.ts | 44 ++++++ ...-runtime-network-react-native-transport.ts | 17 ++- website/docs/docs/debugging-profiling.md | 2 +- 13 files changed, 477 insertions(+), 87 deletions(-) diff --git a/docs/adr/0038-network-dump-source-selection.md b/docs/adr/0038-network-dump-source-selection.md index 3eacff7630..56bfc1ff99 100644 --- a/docs/adr/0038-network-dump-source-selection.md +++ b/docs/adr/0038-network-dump-source-selection.md @@ -9,7 +9,7 @@ Accepted for this PR's sources (provider, app-log) and the device-identity gate. - `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. An identity that does not name exactly one attached host device — or none — fails the provider closed with `metro-device-mismatch`; the dump still answers from the app log. +- 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 @@ -33,5 +33,6 @@ Lazy arming of the JS capture keeps injection honest: code enters the app under ## 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 (no host-side listing proves name uniqueness among attached devices), so the React Native provider stays off there and the dump answers from the app log. +- 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/packages/capture-kit/src/network-provider-capture.test.ts b/packages/capture-kit/src/network-provider-capture.test.ts index 2918129454..d6bd87eda7 100644 --- a/packages/capture-kit/src/network-provider-capture.test.ts +++ b/packages/capture-kit/src/network-provider-capture.test.ts @@ -65,21 +65,28 @@ test('the provider result is handed back with the session scope the transport ne device, input, new AbortController().signal, - { metroDeviceIdentity: 'Device' }, + { resolveMetroDeviceIdentity: async () => 'Device' }, ); assert.deepEqual(capture, { status: 'captured', result }); assert.deepEqual(seen, { maxEntries: 25, include: 'summary' }); - assert.deepEqual(fixture.scopes[0], { - sessionId: 'repro', - stateDir: '/tmp/state', - appBundleId: 'com.example.app', - maxPayloadChars: 2048, - metroDeviceIdentity: 'Device', - }); + 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 reaches the transport without one; the dump fails closed', async () => { +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: [] }), @@ -87,7 +94,28 @@ test('a caller without a device identity reaches the transport without one; the await captureNetworkProviderTraffic(fixture.host, device, input, new AbortController().signal); - assert.equal(fixture.scopes[0]?.metroDeviceIdentity, undefined); + 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 () => { diff --git a/packages/capture-kit/src/network-provider-capture.ts b/packages/capture-kit/src/network-provider-capture.ts index f6999b21cd..5c8b1c135d 100644 --- a/packages/capture-kit/src/network-provider-capture.ts +++ b/packages/capture-kit/src/network-provider-capture.ts @@ -32,16 +32,19 @@ 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 the dev-server identity - * of exactly one device attached to this host (`metroDeviceIdentity`); without one the provider - * fails closed, because a runtime could then only be guessed at. + * 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: { metroDeviceIdentity?: string } = {}, + 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. @@ -51,9 +54,9 @@ export async function captureNetworkProviderTraffic( stateDir: input.stateDir, appBundleId: input.appBundleId, maxPayloadChars: input.maxPayloadChars, - ...(options.metroDeviceIdentity === undefined + ...(options.resolveMetroDeviceIdentity === undefined ? {} - : { metroDeviceIdentity: options.metroDeviceIdentity }), + : { resolveMetroDeviceIdentity: options.resolveMetroDeviceIdentity }), }; let transport; try { diff --git a/packages/contracts/src/network-runtime.ts b/packages/contracts/src/network-runtime.ts index a4d019960f..98ae78d5b3 100644 --- a/packages/contracts/src/network-runtime.ts +++ b/packages/contracts/src/network-runtime.ts @@ -78,12 +78,13 @@ export type NetworkTransportScope = Readonly<{ /** Payload budget the dump will apply; a transport may pre-bound what it reads with it. */ maxPayloadChars?: number; /** - * The dev server's device name of exactly one device attached to this host, composed by the - * platform package that owns the device inventory. The provider hooks only a runtime reporting - * that name verbatim. Absent when the platform cannot name one attached device unambiguously; - * then the provider fails closed instead of guessing. + * 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. */ - metroDeviceIdentity?: string; + resolveMetroDeviceIdentity?: (signal: AbortSignal) => Promise; }>; export type NetworkTransport = Readonly<{ 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 297c368d90..9f0a9e1342 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -58,6 +58,10 @@ test('in-app capture answers with the full URL and status, and the app log is no 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. @@ -77,7 +81,30 @@ test('a second attached emulator of the same model takes the identity away from input({ stateDir: '/state' }), new AbortController().signal, ); - expect(scopes[0]?.metroDeviceIdentity).toBeUndefined(); + 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 () => { @@ -96,7 +123,7 @@ test('one attached device gives the provider the composed dev-server name as its input({ stateDir: '/state' }), new AbortController().signal, ); - expect(scopes[0]?.metroDeviceIdentity).toBe('sdk_gphone64_arm64 - 15 - API 35'); + 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 () => { @@ -299,6 +326,7 @@ function host(options: { logcat: string | Error; transport?: NetworkTransport; attached?: string; + getpropFailures?: ReadonlySet; scopes?: NetworkTransportScope[]; }): PlatformRuntimeHost { return { @@ -306,8 +334,8 @@ function host(options: { commands: { which: async () => undefined, run: async (request) => { - // The provider's device-identity probe runs before the transport is resolved; the harness - // answers it as a host with exactly this one attached device under one unique name. + // 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', @@ -316,6 +344,10 @@ function host(options: { }; } 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: '', diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index aff6034710..40ff93a94b 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -12,6 +12,10 @@ import { 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'; @@ -24,13 +28,19 @@ export async function dumpAndroidNetworkTraffic( input: NetworkDumpInput, signal: AbortSignal, ): Promise { - // Probing the identity costs adb calls, and only an app-backed session has a provider to name. + // 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 ? {} : await metroDeviceIdentity(host, device, signal), + input.appBundleId === undefined + ? {} + : { + resolveMetroDeviceIdentity: (dumpSignal: AbortSignal) => + metroDeviceIdentity(host, device, dumpSignal), + }, ); if (provider.status === 'captured') { return Object.freeze({ source: 'provider', ...provider.result }); @@ -88,19 +98,27 @@ async function metroDeviceIdentity( host: PlatformRuntimeHost, device: DeviceInfo, signal: AbortSignal, -): Promise<{ metroDeviceIdentity?: string }> { +): Promise { const own = await readDeviceNameParts(host, device.id, signal); - if (own === undefined) return {}; - const serials = await listAttachedSerials(host, signal); - if (serials === undefined) return {}; - for (const serial of serials) { - if (serial === device.id) continue; - const other = await readDeviceNameParts(host, serial, signal); - if (other !== undefined && other.joined === own.joined) return {}; + 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 { metroDeviceIdentity: own.joined }; + return own.joined; } +const IDENTITY_PROBE_TIMEOUT_MS = 4_000; + async function readDeviceNameParts( host: PlatformRuntimeHost, serial: string, @@ -121,6 +139,7 @@ async function readDeviceNameParts( ['-s', serial], ), allowFailure: true, + timeoutMs: IDENTITY_PROBE_TIMEOUT_MS, }, signal, ); @@ -130,21 +149,22 @@ async function readDeviceNameParts( return { joined: `${model} - ${release} - API ${sdk}` }; } -async function listAttachedSerials( +async function listAttachedDevices( host: PlatformRuntimeHost, signal: AbortSignal, -): Promise { +): Promise { const result = await runOptionalRecoveryCommand( host, - { executable: 'adb', args: ['devices'], allowFailure: true }, + { + executable: 'adb', + args: ['devices'], + allowFailure: true, + timeoutMs: IDENTITY_PROBE_TIMEOUT_MS, + }, signal, ); if (!result || result.exitCode !== 0) return undefined; - return result.stdout - .split('\n') - .slice(1) - .map((line) => /^\s*(\S+)\s+device\b/.exec(line)?.[1]) - .filter((serial): serial is string => Boolean(serial)); + return parseAndroidListedDeviceEntries(result.stdout); } function appendLifecycleNote(notes: string[], input: NetworkDumpInput): void { 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 eb9e3b264a..747c66c9c1 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -276,21 +276,50 @@ 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 provider's identity probe lists booted simulators before the transport resolves; the - // harness answers it with the default one-simulator host unless a test names more. + // 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 devices = [ - { udid: 'sim-1', name: 'iPhone', state: '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 }, @@ -301,8 +330,27 @@ function host(options: { } 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: physicalNames.map((name) => ({ + name, + hardwareProperties: { platform: 'iOS', udid: `phys-${name}` }, + connectionProperties: { tunnelState: 'connected' }, + })), + }, + }), + writeText: async () => {}, + [Symbol.asyncDispose]: async () => {}, + }), + }, commands: { which: async () => undefined, run: async () => { @@ -328,6 +376,10 @@ function host(options: { }; } +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( @@ -342,7 +394,81 @@ test('a simulator whose display name another booted simulator shares gets no ide input({ stateDir: '/state' }), new AbortController().signal, ); - expect(scopes[0]?.metroDeviceIdentity).toBeUndefined(); + 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 () => { @@ -358,7 +484,7 @@ test('one booted simulator gives the provider its display name as the identity', input({ stateDir: '/state' }), new AbortController().signal, ); - expect(scopes[0]?.metroDeviceIdentity).toBe('iPhone'); + expect(await identityOf(scopes[0])).toBe('iPhone'); }); function unusedAppLogHost(): Omit< diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index cb1808681e..f0543ef957 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -11,46 +11,128 @@ import { 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 exactly one booted simulator in this set carries it; a shared name could select and drain - * the other simulator's runtime. A physical device has no host-side listing that proves its name - * is unique among attached devices, so it gets no identity and the provider stays off. + * 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<{ metroDeviceIdentity?: string }> { - if (!isIosFamily(device) || device.kind !== 'simulator') return {}; - const result = await host.appleTools.run( - { - tool: 'simctl', - args: scopeSimctlArgsForDevice(device, ['list', 'devices', 'booted', '-j']), - allowFailure: true, - timeoutMs: 4_000, - }, - signal, - ); - if (result.exitCode !== 0) return {}; - let names: string[]; +): 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 physicalNames = await connectedPhysicalDeviceNames(host, signal); + if (physicalNames === undefined) return undefined; + if (physicalNames.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 physical iOS devices devicectl currently reports as connected — a same-named one + * could attach to the same dev server and answer to this session's identity. A devicectl that + * fails, times out, or whose answer cannot be read returns undefined: unknown physical devices + * fail the identity closed. + */ +async function connectedPhysicalDeviceNames( + host: PlatformRuntimeHost, + signal: AbortSignal, +): Promise { + let file; try { - const payload = JSON.parse(result.stdout) as { - devices?: Record>; + 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[] }; }; - names = Object.values(payload.devices ?? {}) - .flat() - .filter((entry) => entry.state === 'Booted') - .map((entry) => entry.name ?? ''); + const devices = payload.result?.devices; + if (!Array.isArray(devices)) return undefined; + return devices + .filter( + (entry) => + isSupportedAppleDevicectlDevice(entry) && + entry.connectionProperties?.tunnelState === 'connected', + ) + .map((entry) => entry.name ?? entry.deviceProperties?.name ?? ''); } catch { - return {}; + return undefined; + } finally { + try { + await file?.[Symbol.asyncDispose](); + } catch { + // An unreadable answer already failed the identity closed; cleanup must not mask that. + } } - const sameName = names.filter((name) => name === device.name).length; - return sameName === 1 ? { metroDeviceIdentity: device.name } : {}; } export async function dumpAppleNetworkTraffic( @@ -60,12 +142,19 @@ export async function dumpAppleNetworkTraffic( signal: AbortSignal, ): Promise { const backend = backendForAppleDevice(device); + // 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 ? {} : await metroDeviceIdentity(host, device, signal), + input.appBundleId === undefined + ? {} + : { + resolveMetroDeviceIdentity: (dumpSignal: AbortSignal) => + metroDeviceIdentity(host, device, dumpSignal), + }, ); if (provider.status === 'captured') { return Object.freeze({ source: 'provider', ...provider.result }); diff --git a/src/platform-runtime-network-react-native-transport.test.ts b/src/platform-runtime-network-react-native-transport.test.ts index 1e481ace2d..bfd01cb4e4 100644 --- a/src/platform-runtime-network-react-native-transport.test.ts +++ b/src/platform-runtime-network-react-native-transport.test.ts @@ -73,3 +73,47 @@ test('a session with a Metro binding dumps through the app-side capture with the 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); + assert.equal( + (dumps.mock.calls[0]?.[0] 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 index c13ead5ae1..63e6d9e774 100644 --- a/src/platform-runtime-network-react-native-transport.ts +++ b/src/platform-runtime-network-react-native-transport.ts @@ -33,8 +33,14 @@ export async function resolveReactNativeNetworkTransport( const maxPayloadChars = scope.maxPayloadChars ?? NETWORK_MAX_PAYLOAD_CHARS; return Object.freeze({ mode: 'local', - dump: async (input, signal) => - await dumpReactNativeAppNetwork( + 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, @@ -42,11 +48,10 @@ export async function resolveReactNativeNetworkTransport( maxEntries: input.maxEntries, include: input.include, maxPayloadChars, - ...(scope.metroDeviceIdentity === undefined - ? {} - : { metroDeviceIdentity: scope.metroDeviceIdentity }), + ...(metroDeviceIdentity === undefined ? {} : { metroDeviceIdentity }), }, signal, - ), + ); + }, }); } diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 675b557ccf..7317657dd1 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -217,7 +217,7 @@ agent-device network dump 25 --include all ``` - `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. +- 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. From 3271f8738e37d1f02b9faac74035920dfea83b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 19:00:08 +0200 Subject: [PATCH 13/13] fix(network): count CoreDevice's own connected registrations against the Apple identity devicectl registers the host's booted simulators as connected devices under their own udids, so the identity probe that lists connected devices to rule a simulator name in among physical devices sees the session simulator itself. The live run caught it: the first dump failed closed on this host because the session's own booted simulator answered as a connected other device. The probe now compares the device identity across CoreDevice's registrations and skips only the session's own udid, so a same-named booted twin is still counted. --- .../src/network/runtime.test.ts | 19 ++++++++++++----- .../platform-apple/src/network/runtime.ts | 21 +++++++++++-------- ...ime-network-react-native-transport.test.ts | 3 ++- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 747c66c9c1..94718d50ff 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -340,11 +340,20 @@ function host(options: { readText: async () => JSON.stringify({ result: { - devices: physicalNames.map((name) => ({ - name, - hardwareProperties: { platform: 'iOS', udid: `phys-${name}` }, - connectionProperties: { tunnelState: 'connected' }, - })), + 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 () => {}, diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index f0543ef957..69aba795f2 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -63,9 +63,9 @@ async function metroDeviceIdentity( sameName.push(...names.filter((name) => name === device.name)); } if (sameName.length !== 1) return undefined; - const physicalNames = await connectedPhysicalDeviceNames(host, signal); - if (physicalNames === undefined) return undefined; - if (physicalNames.some((name) => name === device.name)) 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; } @@ -87,13 +87,15 @@ async function identitySetPaths( } /** - * The names of physical iOS devices devicectl currently reports as connected — a same-named one - * could attach to the same dev server and answer to this session's identity. A devicectl that - * fails, times out, or whose answer cannot be read returns undefined: unknown physical devices - * fail the identity closed. + * 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 connectedPhysicalDeviceNames( +async function connectedOtherDeviceNames( host: PlatformRuntimeHost, + device: DeviceInfo, signal: AbortSignal, ): Promise { let file; @@ -121,7 +123,8 @@ async function connectedPhysicalDeviceNames( .filter( (entry) => isSupportedAppleDevicectlDevice(entry) && - entry.connectionProperties?.tunnelState === 'connected', + entry.connectionProperties?.tunnelState === 'connected' && + (entry.hardwareProperties?.udid ?? entry.identifier) !== device.id, ) .map((entry) => entry.name ?? entry.deviceProperties?.name ?? ''); } catch { diff --git a/src/platform-runtime-network-react-native-transport.test.ts b/src/platform-runtime-network-react-native-transport.test.ts index bfd01cb4e4..2581da38ac 100644 --- a/src/platform-runtime-network-react-native-transport.test.ts +++ b/src/platform-runtime-network-react-native-transport.test.ts @@ -95,8 +95,9 @@ test('the identity resolver runs only inside the dump and its answer joins the d 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( - (dumps.mock.calls[0]?.[0] as { metroDeviceIdentity?: string }).metroDeviceIdentity, + (dumpInput as { metroDeviceIdentity?: string }).metroDeviceIdentity, 'sdk_gphone64_arm64 - 15 - API 35', ); });