diff --git a/apple/network-capture/ADNetworkCapture.m b/apple/network-capture/ADNetworkCapture.m new file mode 100644 index 0000000000..162dee74f6 --- /dev/null +++ b/apple/network-capture/ADNetworkCapture.m @@ -0,0 +1,195 @@ +// 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; + +// Credentials that must never reach the capture file, whatever the app put on the wire. The dump's +// --include headers mode would otherwise echo them (ADR 0037 rule 6). +static BOOL AdIsSensitiveHeader(NSString *name) { + for (NSString *sensitive in @[ + @"Authorization", @"Proxy-Authorization", @"WWW-Authenticate", @"Proxy-Authenticate", + @"Cookie", @"Set-Cookie", + ]) { + if ([name caseInsensitiveCompare:sensitive] == NSOrderedSame) return YES; + } + return NO; +} + +static NSString *kAdOutputPath = nil; + +// Bound on the capture file itself. The probe stops recording once the file reaches it, so a +// long-running session cannot grow the file without limit; the daemon's reader takes the newest +// window from the tail. +static const unsigned long long AdMaxFileBytes = 32 * 1024 * 1024; + +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]; + }); + // `offsetInFile` raises when the seek underneath it fails (and is deprecated for exactly that + // reason); `getOffset:error:` reports the same offset through an out-parameter (iOS 13+), so a + // cap check on a handle whose offset cannot be read skips the write instead of raising in the + // app under test (ADR 0037 rule 6). + unsigned long long offset = 0; + NSError *offsetError = nil; + if (![handle getOffset:&offset error:&offsetError] || offsetError != nil) return; + if (offset >= AdMaxFileBytes) return; + // The error: form (available at the probe's iOS 15 floor): a plain writeData: raises an + // Objective-C exception on ENOSPC, which would crash the app under test. A failed capture write + // must never be the app's failure. + NSError *error = nil; + NSData *data = [[line stringByAppendingString:@"\n"] dataUsingEncoding:NSUTF8StringEncoding]; + [handle writeData:data error:&error]; +} + +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:)]) { + NSString *name = [key description]; + if (AdIsSensitiveHeader(name)) { + [parts addObject:[NSString stringWithFormat:@"%@: \"%@\"", AdJsonString(name), @"[REDACTED]"]]; + continue; + } + id value = headers[key]; + NSString *text = [value isKindOfClass:NSString.class] ? value : [value description]; + text = [text stringByReplacingOccurrencesOfString:@"\n" withString:@" "]; + [parts addObject:[NSString stringWithFormat:@"%@: %@", + AdJsonString(name), + 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..dc2a97f7d0 --- /dev/null +++ b/docs/adr/0037-ios-simulator-network-capture-probe.md @@ -0,0 +1,77 @@ +# 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`. Admission follows the owner that dispatches the open, not + the device shape, and it happens before the lifecycle touches the device: `assertOpenAdmitted` + runs first in every `openApplication` entry, enforcing both the owner capability and the + request shape of the launch-only options (the positional and URL-pair checks that need no + device), so an owner that cannot inject the probe, or a launch-only option on a request that + cannot carry one, is refused before its relaunch close, runner stop, or runtime-hint write — a + refused open never kills a running app. Only the local Apple lifecycle on an iOS simulator + declares the capability, so a provider-backed iOS simulator (Limrun, TestMu) refuses the flag + instead of launching without capture. Every other platform and every URL open refuse the flag. + `--launch-console` admits the same way, and the same local-Simulator declaration gates both. +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. Apple source precedence is fixed here for every Apple dump, including the + Metro-backed provider dump added later: **probe, then provider, then app-log**. The probe wins + because the operator asked for it on this open and it also sees React Native `fetch`, which runs + on URLSession on iOS; the provider transport answers only when no probe window is open. +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. The probe + masks credential headers itself (`Authorization`, `Cookie`, `Set-Cookie`, and their + authentication siblings) before writing, so a secret never enters the file and survives a dump + run without operator patterns. File writes use `writeData:error:` and the cap check reads the + handle offset through `getOffset:error:` rather than the raising `offsetInFile`: a capture + failure must never crash the app under test. Writes stop at a 32 MB file cap, and the dump + reports a note when the file has reached it, so a capped window is never read as current traffic. +7. The capture window names the app process the probe was injected into. If that app crashes and + the runner relaunches it, the new process has no probe and writes nothing more; the dump of such + a session keeps reporting `source: 'probe'` over the frozen entries. Reopen with + `--capture-network` to capture again. + +## 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/capture-kit/src/screenshot-diff.test.ts b/packages/capture-kit/src/screenshot-diff.test.ts index 008ae05291..ef6ab7c533 100644 --- a/packages/capture-kit/src/screenshot-diff.test.ts +++ b/packages/capture-kit/src/screenshot-diff.test.ts @@ -12,7 +12,13 @@ function tmpDir(): string { return mkdtempForTestSync('agent-device-screenshot-diff-'); } -/** Create a solid-color PNG and write it to disk. */ +/** + * Create a solid-color PNG and write it to disk. `filterType: 0` skips the + * per-scanline deflate filters: for a solid image they only make the encoder + * slower (the full-resolution cases spent ~0.8s encoding under coverage). + * Filtering is a lossless transform the decoder inverts, so the decoded + * pixels — everything these tests compare — are unchanged. + */ function writeSolidPng( filePath: string, width: number, @@ -26,7 +32,7 @@ function writeSolidPng( png.data[i + 2] = color.b; png.data[i + 3] = 255; } - fs.writeFileSync(filePath, PNG.sync.write(png)); + fs.writeFileSync(filePath, PNG.sync.write(png, { filterType: 0 })); } /** Encode a solid-color JPEG, so a test can hand the comparison a lossy container. */ diff --git a/packages/command-registry/src/flag-definitions-target.ts b/packages/command-registry/src/flag-definitions-target.ts index 7ca52fcaa2..fc7fab2597 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 a local 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 or provider-backed device 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..eec58271f7 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -1,10 +1,12 @@ import { expect, test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor } from './interactor-types.ts'; import type { OpenApplicationInput } from './application-lifecycle-runtime.ts'; import { bindDirectApplicationLifecycle, bindLocalApplicationLifecycleInteractor, + bindProviderApplicationLifecycleInteractor, invokeApplicationOpen, } from './application-lifecycle-interaction.ts'; @@ -33,12 +35,16 @@ const WEB_DEVICE: DeviceInfo = { booted: true, }; -function interactorWithOpen(open: Interactor['open'] = async () => undefined): Interactor { +function interactorWithOpen( + open: Interactor['open'] = async () => undefined, + overrides: Partial> = {}, +): Interactor { return { open, openDevice: async () => undefined, close: async () => undefined, setSetting: async () => undefined, + ...overrides, } as unknown as Interactor; } @@ -113,13 +119,6 @@ test.each([ execution: { launchArgs: ['--flag'] }, message: /launch-args requires an app target/, }, - { - name: 'launch console outside an iOS simulator', - device: LINUX_DEVICE, - positionals: ['org.example.App'], - execution: { launchConsole: 'stdout' }, - message: /iOS simulator/i, - }, { name: 'launch arguments on Linux', device: LINUX_DEVICE, @@ -155,6 +154,34 @@ test.each([ execution: { clearAppState: true }, message: /requires an app target, not a deep link/, }, + { + name: 'launch console with a deep-link app target', + device: IOS_SIMULATOR, + positionals: ['example://home'], + execution: { launchConsole: 'stdout' }, + message: /direct app/i, + }, + { + name: 'network capture with a deep-link app target', + device: IOS_SIMULATOR, + positionals: ['example://home'], + execution: { networkCapturePath: '/sessions/one/network.jsonl' }, + message: /direct app launch/, + }, + { + name: 'network capture without an app', + device: IOS_SIMULATOR, + positionals: [], + execution: { networkCapturePath: '/sessions/one/network.jsonl' }, + message: /direct app launch/, + }, + { + name: 'network capture with a direct URL pair', + device: IOS_SIMULATOR, + positionals: ['com.example.app', 'example://home'], + execution: { networkCapturePath: '/sessions/one/network.jsonl' }, + message: /direct app launch/, + }, ])('rejects invalid open input: $name', async ({ device, positionals, execution, message }) => { await expect( invokeApplicationOpen({ @@ -165,3 +192,137 @@ test.each([ }), ).rejects.toThrow(message); }); + +for (const [name, execution, reason] of [ + [ + 'capture', + { networkCapturePath: '/sessions/one/network.jsonl' }, + 'network-capture-owner-unadmitted', + ], + [ + 'launch console', + { launchConsole: '/tmp/launch-console.log' }, + 'launch-console-owner-unadmitted', + ], +] as const) { + test(`the generic provider lifecycle refuses ${name} on a provider-shaped iOS simulator before touching the device`, async () => { + // Limrun's iOS simulator has the local shape but dispatches through this generic provider + // lifecycle, whose interactor would silently ignore the request; with closeBeforeRelaunch it + // would also close the running app first. Binding a provider interactor derives no + // local-simulator capability, so the open must refuse with the typed code and reason before + // its relaunch close or open dispatch runs. + const calls: string[] = []; + const binding = bindProviderApplicationLifecycleInteractor({ + device: IOS_SIMULATOR, + signal: new AbortController().signal, + resolveInteractor: () => + interactorWithOpen( + async () => { + calls.push('open'); + }, + { + close: async () => { + calls.push('close'); + }, + }, + ), + }); + const lifecycle = bindDirectApplicationLifecycle({ + binding, + owner: 'Provider', + openTargetIdentity: 'bundle-id', + closeBeforeRelaunch: true, + }); + const refusal = await lifecycle.openApplication(openInput({ relaunch: true, execution })).then( + () => undefined, + (error: unknown) => error, + ); + expect(refusal).toBeInstanceOf(AppError); + expect((refusal as AppError).code).toBe('UNSUPPORTED_OPERATION'); + expect((refusal as AppError).details?.reason).toBe(reason); + expect(calls).toEqual([]); + }); + + test(`a local binding on a non-simulator device refuses ${name} with the typed code`, async () => { + const binding = bindLocalApplicationLifecycleInteractor({ + device: LINUX_DEVICE, + signal: new AbortController().signal, + resolveInteractor: async () => interactorWithOpen(), + }); + const lifecycle = bindDirectApplicationLifecycle({ + binding, + owner: 'Linux', + openTargetIdentity: 'app-name', + }); + const refusal = await lifecycle.openApplication(openInput({ execution })).then( + () => undefined, + (error: unknown) => error, + ); + expect(refusal).toBeInstanceOf(AppError); + expect((refusal as AppError).code).toBe('UNSUPPORTED_OPERATION'); + expect((refusal as AppError).details?.reason).toBe(reason); + }); +} + +test('a URL-pair relaunch carrying capture and clear-app-state is refused before any close', async () => { + // `open --relaunch --clear-app-state --capture-network`: the launch-only + // options cannot ride a URL pair, and admission answers that from positionals and execution + // alone — before the closeBeforeRelaunch owner closes the running app. + const calls: string[] = []; + const binding = bindLocalApplicationLifecycleInteractor({ + device: IOS_SIMULATOR, + signal: new AbortController().signal, + resolveInteractor: async () => + interactorWithOpen( + async () => { + calls.push('open'); + }, + { + close: async () => { + calls.push('close'); + }, + setSetting: async () => { + calls.push('setSetting'); + }, + }, + ), + }); + const lifecycle = bindDirectApplicationLifecycle({ + binding, + owner: 'Local', + openTargetIdentity: 'bundle-id', + closeBeforeRelaunch: true, + }); + const refusal = await lifecycle + .openApplication( + openInput({ + relaunch: true, + positionals: ['com.example.app', 'example://deal'], + execution: { + clearAppState: true, + networkCapturePath: '/sessions/one/network.jsonl', + }, + }), + ) + .then( + () => undefined, + (error: unknown) => error, + ); + expect(refusal).toBeInstanceOf(AppError); + expect((refusal as AppError).code).toBe('INVALID_ARGS'); + expect((refusal as AppError).message).toMatch(/direct app launch/); + expect(calls).toEqual([]); +}); + +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: { 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..2aca470c08 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -1,7 +1,7 @@ import { isDeepLinkTarget } from './open-target.ts'; import { + CAPTURE_NETWORK_DIRECT_APP_ONLY_MESSAGE, LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE, - LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE, } from './launch-console.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isIosFamily } from '@agent-device/kernel/device'; @@ -26,6 +26,14 @@ import type { export type ApplicationLifecycleInteractorBinding = Readonly<{ device: DeviceInfo; signal: AbortSignal; + /** + * True only for a local owner bound to an iOS simulator it controls, i.e. an owner whose direct + * app launch can read simctl's console PTY and inject the network capture probe. The shared open + * refuses `--launch-console` and `--capture-network` without it, so a provider-backed simulator + * — same device shape, provider-owned lifecycle — refuses rather than silently ignoring the + * request (ADR 0037). + */ + supportsLocalSimulatorLaunch: boolean; resolveInteractor( execution: ApplicationLifecycleExecution, appBundleId?: string, @@ -84,6 +92,10 @@ function bindApplicationLifecycleInteractor( return Object.freeze({ device, signal, + // A local owner bound to an iOS simulator controls simctl and can read the console PTY and + // inject the capture probe; a provider owner never can, whatever the device shape says. + supportsLocalSimulatorLaunch: + ownership === 'local' && isIosFamily(device) && device.kind === 'simulator', resolveInteractor: async (execution, appBundleId) => { const runner = applicationLifecycleRunnerContext(execution, appBundleId, signal); if (ownership === 'local') return await params.resolveLocalInteractor(device, runner); @@ -129,6 +141,126 @@ export function bindProviderApplicationLifecycleInteractor( }); } +// The two owner-admission refusals are private to the admission rule that raises them — the +// shared message home's façade must stay exhaustive, and an unused public message is dead code. +const LAUNCH_CONSOLE_UNADMITTED_MESSAGE = + '--launch-console is supported only for a local iOS simulator app launch; this session owner cannot capture the launch console'; + +const CAPTURE_NETWORK_UNADMITTED_MESSAGE = + '--capture-network is supported only for a local iOS simulator app launch; this session owner cannot inject the capture probe'; + +/** + * The request-shape rule for the launch-only open options: what only `positionals` and `execution` + * can answer, checked once and enforced twice — by {@link assertOpenAdmitted} before the lifecycle + * touches the device, and by {@link invokeApplicationOpen} for its own folded and follow-up calls. + * One helper per positional shape: no app target, a URL pair, or a single deep-link app target. + * `--launch-console`, `--capture-network` and `--clear-app-state` act on the app the open launches, + * so they refuse every shape but a direct app target, before any close, clear, or launch runs. + */ +function assertOpenRequestShape( + positionals: readonly string[], + execution: ApplicationLifecycleExecution, +): void { + if (positionals.length > 2) { + throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: [url]'); + } + const app = positionals[0]; + const url = positionals[1]; + if (app === undefined) { + assertOpenDeviceOnlyRequest(execution); + return; + } + if (url !== undefined) { + assertOpenUrlPairRequest(app, url, execution); + return; + } + if (isDeepLinkTarget(app)) { + assertOpenDeepLinkAppRequest(execution); + } +} + +function assertOpenDeviceOnlyRequest(execution: ApplicationLifecycleExecution): void { + if (execution.launchConsole) { + throw new AppError('INVALID_ARGS', '--launch-console requires an app target'); + } + if (execution.networkCapturePath) { + throw new AppError('INVALID_ARGS', CAPTURE_NETWORK_DIRECT_APP_ONLY_MESSAGE); + } + if (execution.launchArgs && execution.launchArgs.length > 0) { + throw new AppError('INVALID_ARGS', '--launch-args requires an app target'); + } +} + +function assertOpenUrlPairRequest( + app: string, + url: string, + execution: ApplicationLifecycleExecution, +): void { + if (isDeepLinkTarget(app)) { + throw new AppError( + 'INVALID_ARGS', + 'open requires an app target as the first argument', + ); + } + if (!isDeepLinkTarget(url)) { + throw new AppError('INVALID_ARGS', 'open requires a valid URL target'); + } + if (execution.launchConsole) { + throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); + } + if (execution.networkCapturePath) { + throw new AppError('INVALID_ARGS', CAPTURE_NETWORK_DIRECT_APP_ONLY_MESSAGE); + } +} + +function assertOpenDeepLinkAppRequest(execution: ApplicationLifecycleExecution): void { + if (execution.launchConsole) { + throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); + } + if (execution.networkCapturePath) { + throw new AppError('INVALID_ARGS', CAPTURE_NETWORK_DIRECT_APP_ONLY_MESSAGE); + } + if (execution.clearAppState) { + throw new AppError( + 'INVALID_ARGS', + 'Clearing app state requires an app target, not a deep link.', + ); + } +} + +/** + * One admission step for the open command, called by every `openApplication` entry before the + * lifecycle touches the device. It enforces the owner capability and the request shape of the + * launch-only options: `--launch-console` and `--capture-network` need an owner that declared it + * can read simctl's console PTY or inject the capture probe, and every other owner — any provider, + * any non-iOS-simulator device — is refused with `UNSUPPORTED_OPERATION` and a typed reason; the + * positional and URL-pair checks in {@link assertOpenRequestShape} then refuse a launch-only option + * on a request that cannot carry one, with `INVALID_ARGS`. The owner answers first because where an + * option works at all is the more actionable refusal. So no refusal — of an owner that cannot honor + * an option or of a malformed launch-only request — ever follows a relaunch close, a runner stop, + * or a runtime-hint write (ADR 0037). The canonical open keeps the same shape rule for its own + * folded and follow-up dispatches, plus the one refusal that needs the platform itself. + */ +export function assertOpenAdmitted( + binding: Pick, + execution: ApplicationLifecycleExecution, + positionals: readonly string[], +): void { + if (execution.launchConsole && !binding.supportsLocalSimulatorLaunch) { + throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_UNADMITTED_MESSAGE, { + reason: 'launch-console-owner-unadmitted', + deviceId: binding.device.id, + }); + } + if (execution.networkCapturePath && !binding.supportsLocalSimulatorLaunch) { + throw new AppError('UNSUPPORTED_OPERATION', CAPTURE_NETWORK_UNADMITTED_MESSAGE, { + reason: 'network-capture-owner-unadmitted', + deviceId: binding.device.id, + }); + } + assertOpenRequestShape(positionals, execution); +} + /** * Canonical direct open semantics used only after a package lifecycle binding has admitted the * operation. It intentionally has no device-resolution or platform-mechanics branches. @@ -144,11 +276,11 @@ export async function invokeApplicationOpen( }>, ): Promise { const { device, positionals, execution } = params; - assertOpenPositionals(positionals); + assertOpenRequestShape(positionals, execution); const app = positionals[0]; const url = positionals[1]; if (!app) { - await invokeDeviceOpen(params); + await params.interactor.openDevice(); return; } assertOpenDeviceSupport(device, execution); @@ -165,29 +297,12 @@ type DirectOpenParameters = Readonly<{ terminateRunningApp?: boolean; }>; -function assertOpenPositionals(positionals: readonly string[]): void { - if (positionals.length > 2) { - throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: [url]'); - } -} - -async function invokeDeviceOpen(params: DirectOpenParameters): Promise { - if (params.execution.launchConsole) { - throw new AppError('INVALID_ARGS', '--launch-console requires an app target'); - } - if (params.execution.launchArgs && params.execution.launchArgs.length > 0) { - throw new AppError('INVALID_ARGS', '--launch-args requires an app target'); - } - await params.interactor.openDevice(); -} - function assertOpenDeviceSupport( device: DeviceInfo, execution: ApplicationLifecycleExecution, ): void { - if (execution.launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { - throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); - } + // The launch-only options were admitted by shape and by owner before this point + // (assertOpenAdmitted); what remains here is the one refusal that needs the platform itself. if (device.platform === 'linux' && execution.launchArgs && execution.launchArgs.length > 0) { throw new AppError('UNSUPPORTED_OPERATION', '--launch-args is not supported on Linux.'); } @@ -198,18 +313,6 @@ async function invokeApplicationUrlOpen( app: string, url: string, ): Promise { - if (isDeepLinkTarget(app)) { - throw new AppError( - 'INVALID_ARGS', - 'open requires an app target as the first argument', - ); - } - if (!isDeepLinkTarget(url)) { - throw new AppError('INVALID_ARGS', 'open requires a valid URL target'); - } - if (params.execution.launchConsole) { - throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); - } return await params.interactor.open(app, { activity: params.execution.activity, appBundleId: params.appBundleId, @@ -224,22 +327,14 @@ async function invokeApplicationTargetOpen( app: string, ): Promise { const { execution, interactor } = params; - if (execution.launchConsole && isDeepLinkTarget(app)) { - throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); - } if (execution.clearAppState) { - if (isDeepLinkTarget(app)) { - throw new AppError( - 'INVALID_ARGS', - 'Clearing app state requires an app target, not a deep link.', - ); - } await interactor.setSetting('clear-app-state', 'clear', app); } return await interactor.open(app, { activity: execution.activity, appBundleId: params.appBundleId, launchConsole: execution.launchConsole, + networkCapturePath: execution.networkCapturePath, launchArgs: execution.launchArgs ? [...execution.launchArgs] : undefined, terminateRunningApp: params.terminateRunningApp, }); @@ -302,6 +397,7 @@ async function openDirectApplication( input: OpenApplicationInput, ): Promise { const { binding } = params; + assertOpenAdmitted(binding, input.execution, input.positionals); const interactor = await binding.resolveInteractor(input.execution, input.appBundleId); if (params.closeBeforeRelaunch && input.relaunch && input.target !== undefined) { await invokeApplicationClose({ @@ -328,6 +424,7 @@ async function openDirectApplication( ...input.execution, clearAppState: undefined, launchConsole: undefined, + networkCapturePath: undefined, launchArgs: undefined, }, }); diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 243ee02451..01f3f859c7 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -40,6 +40,13 @@ export type ApplicationLifecycleExecution = Readonly<{ activity?: string; launchConsole?: string; launchArgs?: readonly string[]; + /** + * Where a launch should write the session's captured network lines (ADR 0037), from + * `open --capture-network`. Its presence is the whole capture request; the owner that injects the + * probe writes the app to this file. An open carrying it is admitted only by an owner that + * declares the capture capability, so no owner can silently ignore the request. + */ + 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..deb906cc98 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -321,6 +321,13 @@ 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. An open carrying it reaches only owners that declare the + * capture capability; the shared dispatch refuses any owner that could not honor 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