From 28470ff7bcd76bfce730b8060df55d737c25db42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 23 Oct 2026 09:36:17 +0200 Subject: [PATCH 1/6] feat(ios): capture every URLSession request with open --capture-network On an iOS simulator, open --capture-network injects apple/network-capture/ADNetworkCapture.m through SIMCTL_CHILD_DYLD_INSERT_LIBRARIES. The probe observes each URLSessionTask to completion and appends one JSON line per request to the session's network.jsonl, so network dump reports every request with its own full URL, method, status, and duration, also on reused keep-alive connections, without a proxy. The dump states its source (probe or app-log); a probe that cannot be built fails the open with network-capture-probe-build-failed. ADR 0037. --- apple/network-capture/ADNetworkCapture.m | 160 +++++++++++++++ ...037-ios-simulator-network-capture-probe.md | 55 +++++ docs/adr/README.md | 1 + package.json | 1 + packages/capture-kit/package.json | 4 + .../src/network-probe-traffic.test.ts | 135 +++++++++++++ .../capture-kit/src/network-probe-traffic.ts | 156 ++++++++++++++ packages/capture-kit/src/network-traffic.ts | 46 +++-- .../src/flag-definitions-target.ts | 10 + .../application-lifecycle-interaction.test.ts | 34 ++++ .../src/application-lifecycle-interaction.ts | 12 ++ .../src/application-lifecycle-runtime.ts | 8 + packages/contracts/src/cli-flags.ts | 1 + packages/contracts/src/client-app.ts | 6 + .../contracts/src/facades/observability.ts | 3 + packages/contracts/src/interactor-types.ts | 6 + packages/contracts/src/launch-console.ts | 8 + packages/contracts/src/network-log.ts | 6 + packages/contracts/src/network-runtime.ts | 31 +++ packages/contracts/src/request-envelope.ts | 1 + .../src/session-artifact-paths.ts | 9 + packages/daemon/src/core/dispatch-context.ts | 1 + packages/daemon/src/core/session-state.ts | 6 + packages/daemon/src/core/session-store.ts | 18 ++ .../daemon/src/resources/macos-app-lease.ts | 1 + .../application-lifecycle-execution.ts | 3 + .../internal/session-open-execution.ts | 10 + .../__tests__/session-network.test.ts | 65 ++++++ .../internal/session-network.ts | 4 +- .../platform-apple/src/core/app-launch.ts | 81 ++++++-- .../platform-apple/src/core/tool-provider.ts | 18 ++ packages/platform-apple/src/interactor.ts | 1 + packages/platform-apple/src/lifecycle.ts | 3 + .../src/network/launch-env.test.ts | 44 ++++ .../platform-apple/src/network/launch-env.ts | 46 +++++ .../src/network/probe-cache.test.ts | 56 +++++ .../platform-apple/src/network/probe-cache.ts | 158 +++++++++++++++ .../src/network/runtime.test.ts | 83 ++++++++ .../platform-apple/src/network/runtime.ts | 41 ++++ scripts/integration-progress-model.ts | 1 + scripts/layering/package-boundaries.test.ts | 1 + scripts/layering/session-state.ts | 3 + src/commands/command-flags.ts | 1 + src/commands/management/app.ts | 5 + src/commands/observability/output.ts | 2 + src/commands/schema/cli-help-workflows.ts | 3 +- src/platform-runtime-app-log-output.ts | 9 +- src/platform-runtime-network-host.test.ts | 40 +++- src/platform-runtime-network-host.ts | 59 +++++- src/platform-runtime-operation-host.ts | 2 + .../ios-network-capture.test.ts | 191 ++++++++++++++++++ website/docs/docs/commands.md | 3 +- website/docs/docs/debugging-profiling.md | 8 + 53 files changed, 1628 insertions(+), 32 deletions(-) create mode 100644 apple/network-capture/ADNetworkCapture.m create mode 100644 docs/adr/0037-ios-simulator-network-capture-probe.md create mode 100644 packages/capture-kit/src/network-probe-traffic.test.ts create mode 100644 packages/capture-kit/src/network-probe-traffic.ts create mode 100644 packages/platform-apple/src/network/launch-env.test.ts create mode 100644 packages/platform-apple/src/network/launch-env.ts create mode 100644 packages/platform-apple/src/network/probe-cache.test.ts create mode 100644 packages/platform-apple/src/network/probe-cache.ts create mode 100644 test/integration/provider-scenarios/ios-network-capture.test.ts diff --git a/apple/network-capture/ADNetworkCapture.m b/apple/network-capture/ADNetworkCapture.m new file mode 100644 index 0000000000..c7768e64b8 --- /dev/null +++ b/apple/network-capture/ADNetworkCapture.m @@ -0,0 +1,160 @@ +// agent-device iOS Simulator network capture probe. +// +// Built at runtime by `packages/platform-apple/src/network/probe-cache.ts` and injected into the +// app under test through `SIMCTL_CHILD_DYLD_INSERT_LIBRARIES` (see ADR 0037). It replaces the +// implementation of `-[NSURLSessionTask resume]`, observes each task to completion, and appends one +// bounded JSON line per completed HTTP(S) task to the file named by +// `AGENT_DEVICE_NETWORK_CAPTURE_PATH`. Every request is read from the task itself, so a request that +// reused a keep-alive connection still carries its own full URL. +// +// The probe is a capture aid for simulator automation, not application code: it never blocks the +// app, never reads a response body, and exits silently whenever its environment is absent. +#import +#import +#import + +// An upper bound on one line's header and error text, so one pathological response cannot grow the +// capture file without limit. The reader truncates again with its own budget. +static const NSUInteger AdMaxFieldChars = 8192; + +static NSString *kAdOutputPath = nil; + +static void AdWriteLine(NSString *line) { + static NSFileHandle *handle = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *dir = [kAdOutputPath stringByDeletingLastPathComponent]; + [fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; + if (![fm fileExistsAtPath:kAdOutputPath]) { + [fm createFileAtPath:kAdOutputPath contents:nil attributes:nil]; + } + handle = [NSFileHandle fileHandleForWritingAtPath:kAdOutputPath]; + [handle seekToEndOfFile]; + }); + [handle writeData:[[line stringByAppendingString:@"\n"] dataUsingEncoding:NSUTF8StringEncoding]]; +} + +static NSString *AdBound(NSString *value) { + if (value.length <= AdMaxFieldChars) return value; + return [value substringToIndex:AdMaxFieldChars]; +} + +static NSString *AdJsonString(NSString *value) { + if (value == nil) return @"null"; + NSMutableString *out = [NSMutableString stringWithString:@"\""]; + for (NSUInteger i = 0; i < value.length; i++) { + unichar c = [value characterAtIndex:i]; + switch (c) { + case '"': [out appendString:@"\\\""]; break; + case '\\': [out appendString:@"\\\\"]; break; + case '\n': [out appendString:@"\\n"]; break; + case '\r': [out appendString:@"\\r"]; break; + case '\t': [out appendString:@"\\t"]; break; + default: + if (c < 0x20) [out appendFormat:@"\\u%04x", c]; + else [out appendFormat:@"%C", c]; + } + } + [out appendString:@"\""]; + return out; +} + +// A JSON object literal, so the reader receives structured headers rather than one escaped string. +static NSString *AdJsonHeaders(NSDictionary *headers) { + if (headers.count == 0) return @"null"; + NSMutableArray *parts = [NSMutableArray array]; + for (id key in [headers.allKeys sortedArrayUsingSelector:@selector(compare:)]) { + id value = headers[key]; + NSString *text = [value isKindOfClass:NSString.class] ? value : [value description]; + text = [text stringByReplacingOccurrencesOfString:@"\n" withString:@" "]; + [parts addObject:[NSString stringWithFormat:@"%@: %@", + AdJsonString([key description]), + AdJsonString(AdBound(text))]]; + } + return [NSString stringWithFormat:@"{%@}", [parts componentsJoinedByString:@","]]; +} + +static void AdRecordCompletedTask(NSURLSessionTask *task, NSDate *startedAt) { + NSURLRequest *request = task.currentRequest ?: task.originalRequest; + if (request.URL.scheme.length > 0 && + !([request.URL.scheme caseInsensitiveCompare:@"http"] == NSOrderedSame || + [request.URL.scheme caseInsensitiveCompare:@"https"] == NSOrderedSame)) { + return; + } + NSHTTPURLResponse *response = nil; + if ([task.response isKindOfClass:NSHTTPURLResponse.class]) response = (id)task.response; + NSNumber *duration = nil; + if (startedAt) duration = @((long long)(-[startedAt timeIntervalSinceNow] * 1000)); + NSString *line = [NSString stringWithFormat: + @"{\"method\":%@,\"url\":%@,\"status\":%@,\"duration_ms\":%@," + "\"timestamp_ms\":%@,\"sent\":%llu,\"recv\":%llu,\"error\":%@," + "\"request_headers\":%@,\"response_headers\":%@}", + AdJsonString(request.HTTPMethod ?: @"GET"), + AdJsonString(request.URL.absoluteString), + response ? [NSString stringWithFormat:@"%ld", (long)response.statusCode] : @"null", + duration ?: @"null", + @((long long)([startedAt timeIntervalSince1970] * 1000)), + task.countOfBytesSent, + task.countOfBytesReceived, + AdJsonString(AdBound(task.error.localizedDescription)), + AdJsonHeaders(request.allHTTPHeaderFields), + AdJsonHeaders(response.allHeaderFields)]; + AdWriteLine(line); +} + +@interface AdTaskObserver : NSObject +@property(nonatomic, weak) NSURLSessionTask *task; +@property(nonatomic, strong) NSDate *startedAt; +@end + +@implementation AdTaskObserver +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context { + NSURLSessionTask *task = self.task; + if (task.state == NSURLSessionTaskStateCompleted) { + AdRecordCompletedTask(task, self.startedAt); + @try { + [task removeObserver:self forKeyPath:@"state"]; + } @catch (NSException *exception) { + } + } +} +@end + +static void (*ad_original_resume)(id, SEL) = nil; +static const void *kAdStartedKey = &kAdStartedKey; +static const void *kAdObserverKey = &kAdObserverKey; + +static void ad_resume(id self, SEL _cmd) { + ad_original_resume(self, _cmd); + NSURLSessionTask *task = self; + if (objc_getAssociatedObject(task, kAdStartedKey)) return; + NSDate *startedAt = [NSDate date]; + objc_setAssociatedObject(task, kAdStartedKey, startedAt, OBJC_ASSOCIATION_RETAIN); + AdTaskObserver *observer = [AdTaskObserver new]; + observer.task = task; + observer.startedAt = startedAt; + // The task owns the observer, so the KVO registration can never outlive the task it observes. + objc_setAssociatedObject(task, kAdObserverKey, observer, OBJC_ASSOCIATION_RETAIN); + @try { + [task addObserver:observer forKeyPath:@"state" options:0 context:nil]; + } @catch (NSException *exception) { + AdWriteLine([NSString stringWithFormat:@"{\"probe_error\":%@}", + AdJsonString(AdBound(exception.reason))]); + } +} + +__attribute__((constructor)) static void ad_network_capture_init(void) { + kAdOutputPath = NSProcessInfo.processInfo.environment[@"AGENT_DEVICE_NETWORK_CAPTURE_PATH"]; + if (kAdOutputPath.length == 0) return; + Class taskClass = NSClassFromString(@"NSURLSessionTask"); + if (taskClass == nil) return; + Method resume = class_getInstanceMethod(taskClass, @selector(resume)); + if (resume == NULL) return; + // One capture file per app process: a relaunch replaces the previous window's file rather than + // appending to it, which keeps the file aligned with the session that launched this process. + [[NSFileManager defaultManager] removeItemAtPath:kAdOutputPath error:nil]; + ad_original_resume = (void (*)(id, SEL))method_setImplementation(resume, (IMP)ad_resume); +} diff --git a/docs/adr/0037-ios-simulator-network-capture-probe.md b/docs/adr/0037-ios-simulator-network-capture-probe.md new file mode 100644 index 0000000000..a000d4400a --- /dev/null +++ b/docs/adr/0037-ios-simulator-network-capture-probe.md @@ -0,0 +1,55 @@ +# ADR 0037: iOS Simulator Network Capture Probe + +## Status + +Accepted (2026-10-23). Applies to iOS simulator app launches only. + +## Rules at a glance + +1. `open --capture-network` on an iOS simulator injects `apple/network-capture/ADNetworkCapture.m` + (built on demand, cached per content hash and toolchain) into the app process through + `SIMCTL_CHILD_DYLD_INSERT_LIBRARIES`. Every other platform and every URL open refuse the flag. +2. The probe method-swizzles `-[NSURLSessionTask resume]`, observes each task to completion, and + appends one JSON line per completed HTTP(S) task to the file named by + `AGENT_DEVICE_NETWORK_CAPTURE_PATH` — the session's `network.jsonl`, one per session, replaced on + every probe-injected launch. Each line carries the request's own URL, method, status, and + duration, so a request that reused a keep-alive connection is named in full. +3. The capture launch always terminates a running instance first: injection happens at exec, so an + already-running app would be activated without the probe. +4. A probe that cannot be built fails the open with `network-capture-probe-build-failed`. An open + that asked for capture never silently launches without it. +5. `network dump` for a session with a capture window reads the probe file and reports + `source: 'probe'`; a session opened without capture parses the app log and reports + `source: 'app-log'`. These are the two modes, not a primary and a fallback. The dump states + which source it used. +6. The probe file is host-side text an untrusted app wrote: reads are byte-bounded at the tail and + pass through the operator's `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` before any response. + +## Reason + +CFNetwork logs a request URL only when a connection opens, so the app-log parser reports a +keep-alive request against its connection's origin with no path — the gap that forced the +`simctl log show` recovery pass and the unnamed-request notes. Observing each task inside the app +names every request the app itself made, without a proxy, without app changes, and without +entitlements; `simctl launch` forwards only `SIMCTL_CHILD_`-prefixed variables to the app, so +injection adds two variables and changes nothing else the app sees. + +## Rejected alternatives + +- **HTTP proxy (`simctl` supports none for the app's URLSession without app-side config).** A proxy + needs per-app trust and proxy settings and does not cover certificate-pinned traffic. +- **Tuning `network dump` harder on Unified Logging.** The path is simply absent from the logs for a + reused connection; no reader can recover it (ADR rationale already recorded in the parser notes). +- **Requiring the app to link a capture SDK.** This changes the artifact under test. +- **Launching without the probe when the build fails.** That silently answers the user's capture + request with the weaker source; `AGENTS.md` forbids unapproved fallbacks. + +## Consequences + +- Capture covers URLSession only. A Flutter `dart:io` or other non-URLSession stack writes nothing; + the dump's note says so. +- The probe binary builds with clang against `iphonesimulator` for both simulator architectures + through the shared native-build cache, so a `DEVELOPER_DIR` switch rebuilds rather than serving a + binary compiled against another SDK. +- `source` is a new field on the `network dump` response for Apple app-log dumps too, so a client + can tell the modes apart without parsing notes. diff --git a/docs/adr/README.md b/docs/adr/README.md index 67cbd4b30f..1898de0752 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 | +| [0037 iOS Simulator Network Capture Probe](0037-ios-simulator-network-capture-probe.md) | `open --capture-network` dylib injection, the session `network.jsonl`, and the two `network dump` sources | ADRs record rationale. The registries and gates they describe are the current source of truth. diff --git a/package.json b/package.json index 09c28871d6..ff74247098 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,7 @@ "apple/macos-helper/Tests/AgentDeviceMacOSInputTests/PackStub.swift", "apple/snapshot-bridge", "apple/fold-helper", + "apple/network-capture", "android/snapshot-helper/dist", "!android/snapshot-helper/dist/*.idsig", "!android/snapshot-helper/README.md", diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index 50eb31dafd..e455d4a8a4 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -58,6 +58,10 @@ "types": "./src/durable-capture/index.ts", "default": "./src/durable-capture/index.ts" }, + "./network-probe-traffic": { + "types": "./src/network-probe-traffic.ts", + "default": "./src/network-probe-traffic.ts" + }, "./durable-capture-admission-ledger": { "types": "./src/capture-admission/durable-capture-admission-ledger.ts", "default": "./src/capture-admission/durable-capture-admission-ledger.ts" diff --git a/packages/capture-kit/src/network-probe-traffic.test.ts b/packages/capture-kit/src/network-probe-traffic.test.ts new file mode 100644 index 0000000000..4c00748049 --- /dev/null +++ b/packages/capture-kit/src/network-probe-traffic.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from 'vitest'; +import { readNetworkProbeTrafficFromText } from './network-probe-traffic.ts'; + +const options = { + path: '/sessions/one/network.jsonl', + exists: true, + maxEntries: 25, + include: 'summary' as const, + maxPayloadChars: 2048, + maxScanLines: 4000, +}; + +function line(record: Record): string { + return JSON.stringify(record); +} + +test('reads probe lines newest first with url, method, status, and duration', () => { + const content = [ + line({ + method: 'GET', + url: 'https://api.example.test/v1/a', + status: 200, + duration_ms: 12, + timestamp_ms: 1_760_000_000_000, + }), + line({ + method: 'POST', + url: 'https://api.example.test/v1/b', + status: 201, + duration_ms: 34.6, + timestamp_ms: 1_760_000_001_000, + }), + ].join('\n'); + + const { dump } = readNetworkProbeTrafficFromText(content, options); + + expect(dump.exists).toBe(true); + expect(dump.scannedLines).toBe(2); + expect(dump.matchedLines).toBe(2); + expect(dump.entries[0]).toMatchObject({ + method: 'POST', + url: 'https://api.example.test/v1/b', + status: 201, + durationMs: 35, + timestamp: new Date(1_760_000_001_000).toISOString(), + line: 2, + }); + expect(dump.entries[1]).toMatchObject({ + method: 'GET', + url: 'https://api.example.test/v1/a', + status: 200, + durationMs: 12, + }); +}); + +test('skips a line without a URL and a malformed line without failing the dump', () => { + const content = [ + '{"method":"GET"}', + 'not json at all', + '{"url":"https://ok.example.test","status":204}', + '{"probe_error":"x"}', + ].join('\n'); + + const { dump } = readNetworkProbeTrafficFromText(content, options); + + expect(dump.entries).toHaveLength(1); + expect(dump.entries[0]).toMatchObject({ url: 'https://ok.example.test', status: 204 }); + expect(dump.entries[0]!.line).toBe(3); +}); + +test('carries the request error when the probe saw no response', () => { + const content = line({ + method: 'GET', + url: 'https://gone.example.test', + status: null, + error: 'The internet connection appears to be offline.', + }); + + const { dump } = readNetworkProbeTrafficFromText(content, options); + + expect(dump.entries[0]).toMatchObject({ + url: 'https://gone.example.test', + error: 'The internet connection appears to be offline.', + }); + expect(dump.entries[0]).not.toHaveProperty('status'); +}); + +test('formats headers only for an include mode that asks for them', () => { + const content = line({ + url: 'https://h.example.test', + status: 200, + request_headers: { Accept: 'application/json' }, + response_headers: { 'Content-Type': 'application/json' }, + }); + + const summary = readNetworkProbeTrafficFromText(content, options).dump; + expect(summary.entries[0]).not.toHaveProperty('headers'); + + const withHeaders = readNetworkProbeTrafficFromText(content, { + ...options, + include: 'headers', + }).dump; + expect(withHeaders.entries[0]?.headers).toContain('request:'); + expect(withHeaders.entries[0]?.headers).toContain('Accept: application/json'); + expect(withHeaders.entries[0]?.headers).toContain('response:'); +}); + +test('honours the entry bound against newer lines and reports missing files as empty', () => { + const content = Array.from({ length: 10 }, (_, i) => + line({ url: `https://api.example.test/v1/${i}`, status: 200 }), + ).join('\n'); + + const { dump } = readNetworkProbeTrafficFromText(content, { ...options, maxEntries: 3 }); + expect(dump.entries).toHaveLength(3); + expect(dump.entries.map((entry) => entry.url)).toEqual([ + 'https://api.example.test/v1/9', + 'https://api.example.test/v1/8', + 'https://api.example.test/v1/7', + ]); + + const missing = readNetworkProbeTrafficFromText('', { ...options, exists: false }).dump; + expect(missing.exists).toBe(false); + expect(missing.entries).toEqual([]); +}); + +test('keeps absolute file line numbers after a skipped-line offset', () => { + const content = line({ url: 'https://api.example.test/v1/late', status: 200 }); + + const { dump } = readNetworkProbeTrafficFromText(content, { + ...options, + lineNumberOffset: 400, + }); + + expect(dump.entries[0]?.line).toBe(401); +}); diff --git a/packages/capture-kit/src/network-probe-traffic.ts b/packages/capture-kit/src/network-probe-traffic.ts new file mode 100644 index 0000000000..a4cf6c478a --- /dev/null +++ b/packages/capture-kit/src/network-probe-traffic.ts @@ -0,0 +1,156 @@ +import type { + NetworkDump, + NetworkDumpParserOptions, +} from '@agent-device/contracts/network-traffic'; +import type { NetworkEntry } from '@agent-device/contracts/observability'; +import { resolveNetworkDumpLimits, truncateNetworkText } from './network-traffic.ts'; + +/** + * One line of the iOS simulator capture probe's file (ADR 0037). The probe writes one JSON object + * per completed URLSession task; every field is optional because the file is written by code + * injected into an app this package does not build, so a partial line is a line to skip, never a + * reason to fail the dump. + */ +type NetworkProbeRecord = Readonly<{ + method?: unknown; + url?: unknown; + status?: unknown; + duration_ms?: unknown; + timestamp_ms?: unknown; + request_headers?: unknown; + response_headers?: unknown; + error?: unknown; +}>; + +export type NetworkProbeScan = Readonly<{ + dump: NetworkDump; +}>; + +/** + * Reads the capture probe's JSON-lines window into the same `NetworkDump` shape the app-log parser + * produces, newest first, so the response and the renderer treat both sources alike. Unlike the + * log parser, this reader never guesses: a line without a URL is not traffic this dump can name, so + * it is skipped. + */ +export function readNetworkProbeTrafficFromText( + content: string, + options: NetworkDumpParserOptions, +): NetworkProbeScan { + const { include, limits } = resolveNetworkDumpLimits(options); + const { maxEntries, maxScanLines, maxPayloadChars } = limits; + if (!options.exists) { + return Object.freeze({ + dump: Object.freeze({ + path: options.path, + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: Object.freeze([]), + include, + limits, + }), + }); + } + const allLines = content.split('\n'); + const startIndex = Math.max(0, allLines.length - maxScanLines); + const lines = allLines.slice(startIndex); + const entries: NetworkEntry[] = []; + for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) { + const entry = parseProbeLine(lines[i] ?? '', options, startIndex + i, include, maxPayloadChars); + if (entry) entries.push(entry); + } + return Object.freeze({ + dump: Object.freeze({ + path: options.path, + exists: true, + scannedLines: lines.length, + matchedLines: entries.length, + entries: Object.freeze(entries), + include, + limits, + }), + }); +} + +function parseProbeLine( + line: string, + options: NetworkDumpParserOptions, + index: number, + include: NonNullable, + maxPayloadChars: number, +): NetworkEntry | undefined { + const record = line.trim() ? parseProbeRecord(line) : undefined; + const url = typeof record?.url === 'string' ? record.url.trim() : ''; + if (!url) return undefined; + const status = optionalInteger(record?.status, (value) => Number.isInteger(value)); + const durationMs = optionalInteger(record?.duration_ms, (value) => Number.isFinite(value), 0); + const error = typedText(record?.error); + const method = typedText(record?.method); + const timestamp = probeTimestamp(record); + const headers = + include === 'headers' || include === 'all' + ? formatProbeHeaders(record, maxPayloadChars) + : undefined; + const entry: NetworkEntry = { + url, + raw: line.trim(), + line: (options.lineNumberOffset ?? 0) + index + 1, + }; + if (method) entry.method = method; + if (status !== undefined) entry.status = status; + if (timestamp) entry.timestamp = timestamp; + if (durationMs !== undefined) entry.durationMs = durationMs; + if (error) entry.error = error; + if (headers) entry.headers = headers; + return Object.freeze(entry); +} + +/** The probe's own text field, present only when it carries a non-empty string. */ +function typedText(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} + +function optionalInteger( + value: unknown, + accepts: (number: number) => boolean, + roundTo?: number, +): number | undefined { + if (typeof value !== 'number' || !accepts(value)) return undefined; + return roundTo === undefined ? value : Math.max(roundTo, Math.round(value)); +} + +function parseProbeRecord(line: string): NetworkProbeRecord | undefined { + try { + const parsed: unknown = JSON.parse(line); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as NetworkProbeRecord) + : undefined; + } catch { + return undefined; + } +} + +function probeTimestamp(record: NetworkProbeRecord | undefined): string | undefined { + const value = record?.timestamp_ms; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined; + return new Date(value).toISOString(); +} + +function formatProbeHeaders( + record: NetworkProbeRecord | undefined, + maxPayloadChars: number, +): string | undefined { + const request = formatProbeHeaderGroup('request', record?.request_headers); + const response = formatProbeHeaderGroup('response', record?.response_headers); + const text = [request, response].filter(Boolean).join('\n'); + return text ? truncateNetworkText(text, maxPayloadChars) : undefined; +} + +function formatProbeHeaderGroup(label: string, value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) return ''; + const entries = Object.entries(value as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ); + if (entries.length === 0) return ''; + return [`${label}:`, ...entries.map(([key, header]) => ` ${key}: ${header}`)].join('\n'); +} diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts index 197b7a156d..40216bd854 100644 --- a/packages/capture-kit/src/network-traffic.ts +++ b/packages/capture-kit/src/network-traffic.ts @@ -96,10 +96,8 @@ export function readRecentNetworkTrafficFromText( content: string, options: NetworkDumpParserOptions, ): NetworkScan { - const maxEntries = clampInt(options.maxEntries, 25, 1, 200); - const include = options.include ?? 'summary'; - const maxPayloadChars = clampInt(options.maxPayloadChars, 2048, 64, 16_384); - const maxScanLines = clampInt(options.maxScanLines, 4000, 100, 20_000); + const { include, limits } = resolveNetworkDumpLimits(options); + const { maxEntries, maxScanLines, maxPayloadChars } = limits; const lineNumberOffset = requireLineNumberOffset(options.lineNumberOffset); if (!options.exists) { return Object.freeze({ @@ -111,7 +109,7 @@ export function readRecentNetworkTrafficFromText( entries: Object.freeze([]), unnamedRequests: 0, include, - limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + limits, }), unnamedRequestIds: Object.freeze([]), }); @@ -148,7 +146,7 @@ export function readRecentNetworkTrafficFromText( entries: Object.freeze(entries), unnamedRequests: unnamedRequestIds.length, include, - limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + limits, }), unnamedRequestIds: Object.freeze(unnamedRequestIds), }); @@ -269,7 +267,7 @@ function createNetworkEntry( timestamp: parseNetworkTimestamp(line), packetId: identity.packetId ?? parseAndroidPacketId(line) ?? undefined, durationMs: identity.durationMs ?? parseAndroidDurationMs(line) ?? undefined, - raw: truncate(line, maxPayloadChars), + raw: truncateNetworkText(line, maxPayloadChars), line: lineNumber, }; } @@ -283,7 +281,7 @@ function applyNetworkProjection( ): void { if (includesHeaders(include)) { const headers = readNetworkHeaders(line, maybeJson); - if (headers) result.headers = truncate(headers, maxPayloadChars); + if (headers) result.headers = truncateNetworkText(headers, maxPayloadChars); } if (includesBody(include)) { const requestBody = readNetworkBody(line, maybeJson, [ @@ -293,8 +291,8 @@ function applyNetworkProjection( 'request', ]); const responseBody = readNetworkBody(line, maybeJson, ['responseBody', 'response']); - if (requestBody) result.requestBody = truncate(requestBody, maxPayloadChars); - if (responseBody) result.responseBody = truncate(responseBody, maxPayloadChars); + if (requestBody) result.requestBody = truncateNetworkText(requestBody, maxPayloadChars); + if (responseBody) result.responseBody = truncateNetworkText(responseBody, maxPayloadChars); } } @@ -310,16 +308,40 @@ function networkEntryKey(entry: NetworkEntry): string { return `${entry.timestamp ?? ''}|${entry.method ?? ''}|${entry.url}|${entry.status ?? ''}|${entry.raw}`; } -function truncate(value: string, max: number): string { +/** Bounds one response field's text; shared by every network dump reader in this package. */ +export function truncateNetworkText(value: string, max: number): string { return value.length <= max ? value : `${value.slice(0, max)}...`; } -function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { +/** Resolves one dump limit against its default and bounds; shared by every dump reader. */ +function clampNetworkDumpInt( + value: number | undefined, + fallback: number, + min: number, + max: number, +): number { return value === undefined || !Number.isInteger(value) ? fallback : Math.max(min, Math.min(max, value)); } +/** + * The one resolution of dump parser options (include mode plus the bounded limits) every network + * dump reader in this package — app-log or capture probe — reports in its `limits`. + */ +export function resolveNetworkDumpLimits(options: NetworkDumpParserOptions): Readonly<{ + include: NonNullable; + limits: NetworkDump['limits']; +}> { + const maxEntries = clampNetworkDumpInt(options.maxEntries, 25, 1, 200); + const maxPayloadChars = clampNetworkDumpInt(options.maxPayloadChars, 2048, 64, 16_384); + const maxScanLines = clampNetworkDumpInt(options.maxScanLines, 4000, 100, 20_000); + return Object.freeze({ + include: options.include ?? 'summary', + limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + }); +} + /** * CFNetwork logs a request URL only on the `com.apple.network:connection` line * that opens a connection. A request that reuses a keep-alive connection emits diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 7ca52fcaa2..3f54a6a7c5 100644 --- a/packages/command-registry/src/flag-definitions-target.ts +++ b/packages/command-registry/src/flag-definitions-target.ts @@ -367,6 +367,16 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ projectConfig: false, recorded: false, }, + { + key: 'captureNetwork', + names: ['--capture-network'], + type: 'boolean', + usageLabel: '--capture-network', + usageDescription: + 'open: on an iOS simulator, inject the network capture probe so network dump lists every URLSession request with its own full URL, status, and duration. Requires the session app to be relaunched by this open. No other platform accepts it.', + projectConfig: false, + recorded: false, + }, { key: 'launchArgs', names: ['--launch-args'], diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 2e42310f7c..3718aed9d9 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -155,6 +155,27 @@ test.each([ execution: { clearAppState: true }, message: /requires an app target, not a deep link/, }, + { + name: 'network capture without an app', + device: IOS_SIMULATOR, + positionals: [], + execution: { captureNetwork: true }, + message: /direct app launch/, + }, + { + name: 'network capture outside an iOS simulator', + device: LINUX_DEVICE, + positionals: ['org.example.App'], + execution: { captureNetwork: true }, + message: /iOS simulator/i, + }, + { + name: 'network capture with a direct URL pair', + device: IOS_SIMULATOR, + positionals: ['com.example.app', 'example://home'], + execution: { captureNetwork: true }, + message: /direct app launch/, + }, ])('rejects invalid open input: $name', async ({ device, positionals, execution, message }) => { await expect( invokeApplicationOpen({ @@ -165,3 +186,16 @@ test.each([ }), ).rejects.toThrow(message); }); + +test('passes the session capture file to the interactor on a capture launch', async () => { + let seen: Parameters[1]; + await invokeApplicationOpen({ + device: IOS_SIMULATOR, + interactor: interactorWithOpen(async (_app, options) => { + seen = options; + }), + positionals: ['com.example.app'], + execution: { captureNetwork: true, networkCapturePath: '/sessions/one/network.jsonl' }, + }); + expect(seen).toMatchObject({ networkCapturePath: '/sessions/one/network.jsonl' }); +}); diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index 9567191f2c..473b003642 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -1,5 +1,7 @@ import { isDeepLinkTarget } from './open-target.ts'; import { + CAPTURE_NETWORK_DIRECT_APP_ONLY_MESSAGE, + CAPTURE_NETWORK_IOS_SIMULATOR_ONLY_MESSAGE, LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE, LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE, } from './launch-console.ts'; @@ -175,6 +177,9 @@ async function invokeDeviceOpen(params: DirectOpenParameters): Promise { if (params.execution.launchConsole) { throw new AppError('INVALID_ARGS', '--launch-console requires an app target'); } + if (params.execution.captureNetwork) { + throw new AppError('INVALID_ARGS', CAPTURE_NETWORK_DIRECT_APP_ONLY_MESSAGE); + } if (params.execution.launchArgs && params.execution.launchArgs.length > 0) { throw new AppError('INVALID_ARGS', '--launch-args requires an app target'); } @@ -188,6 +193,9 @@ function assertOpenDeviceSupport( if (execution.launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); } + if (execution.captureNetwork && (!isIosFamily(device) || device.kind !== 'simulator')) { + throw new AppError('UNSUPPORTED_OPERATION', CAPTURE_NETWORK_IOS_SIMULATOR_ONLY_MESSAGE); + } if (device.platform === 'linux' && execution.launchArgs && execution.launchArgs.length > 0) { throw new AppError('UNSUPPORTED_OPERATION', '--launch-args is not supported on Linux.'); } @@ -210,6 +218,9 @@ async function invokeApplicationUrlOpen( if (params.execution.launchConsole) { throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); } + if (params.execution.captureNetwork) { + throw new AppError('INVALID_ARGS', CAPTURE_NETWORK_DIRECT_APP_ONLY_MESSAGE); + } return await params.interactor.open(app, { activity: params.execution.activity, appBundleId: params.appBundleId, @@ -240,6 +251,7 @@ async function invokeApplicationTargetOpen( activity: execution.activity, appBundleId: params.appBundleId, launchConsole: execution.launchConsole, + networkCapturePath: execution.networkCapturePath, launchArgs: execution.launchArgs ? [...execution.launchArgs] : undefined, terminateRunningApp: params.terminateRunningApp, }); diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 243ee02451..c828857296 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -40,6 +40,14 @@ export type ApplicationLifecycleExecution = Readonly<{ activity?: string; launchConsole?: string; launchArgs?: readonly string[]; + /** The raw `open --capture-network` intent, kept so open validation can refuse it where unsupported. */ + captureNetwork?: boolean; + /** + * Where an iOS simulator launch should write the session's captured network lines (ADR 0037). Set + * only when the request asked for network capture; the Apple owner injects its capture probe so + * the app writes there, and `network dump` reads from there. + */ + networkCapturePath?: string; clearAppState?: boolean; iosXctestrunFile?: string; iosXctestDerivedDataPath?: string; diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 30cb73740f..424f3c347b 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -125,6 +125,7 @@ export type CliFlags = CloudProviderProfileFields & pattern?: SwipePattern; activity?: string; launchConsole?: string; + captureNetwork?: boolean; launchArgs?: string[]; header?: string[]; githubActionsArtifact?: string; diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index ef015bc78f..208113401a 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -68,6 +68,12 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & surface?: SessionSurface; activity?: string; launchConsole?: string; + /** + * On an iOS simulator, inject the network capture probe into the app launch so `network dump` + * lists every URLSession request with its own full URL, status, and duration. The session app + * must be (re)launched by this open. Refused on every other platform. + */ + captureNetwork?: boolean; launchArgs?: string[]; relaunch?: boolean; /** Startup budget in milliseconds: bounds the Simulator boot wait on a cold device. */ diff --git a/packages/contracts/src/facades/observability.ts b/packages/contracts/src/facades/observability.ts index 53ae6211ba..42ff49aa19 100644 --- a/packages/contracts/src/facades/observability.ts +++ b/packages/contracts/src/facades/observability.ts @@ -20,9 +20,12 @@ export type { } from '../debug-symbols.ts'; export type { DoctorCheck, DoctorCommandResult, DoctorKind, DoctorStatus } from '../doctor.ts'; export { + CAPTURE_NETWORK_DIRECT_APP_ONLY_MESSAGE, + CAPTURE_NETWORK_IOS_SIMULATOR_ONLY_MESSAGE, LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE, LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE, } from '../launch-console.ts'; + export { LOG_ACTION_VALUES } from '../logs.ts'; export type { LogAction, LogBackend } from '../logs.ts'; export type { NetworkEntry } from '../network-log.ts'; diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 8e6cee78fa..bec6f56911 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -321,6 +321,12 @@ export type Interactor = { activity?: string; appBundleId?: string; launchConsole?: string; + /** + * Session file the injected iOS simulator network capture probe writes to for this launch + * (ADR 0037). The Apple owner injects the probe and terminates a running instance so the app + * process really is a new one; every other owner ignores it. + */ + networkCapturePath?: string; launchArgs?: string[]; terminateRunningApp?: boolean; url?: string; diff --git a/packages/contracts/src/launch-console.ts b/packages/contracts/src/launch-console.ts index 4b9e3df614..8e3fccacd1 100644 --- a/packages/contracts/src/launch-console.ts +++ b/packages/contracts/src/launch-console.ts @@ -3,3 +3,11 @@ export const LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE = export const LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE = '--launch-console requires a direct app launch and cannot be used with URL opens'; + +// The refusal wording for the other simulator-only launch option lives beside these so every +// open