Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions apple/network-capture/ADNetworkCapture.m
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <unistd.h>

// 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<id, id> *headers) {
if (headers.count == 0) return @"null";
NSMutableArray<NSString *> *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<NSKeyValueChangeKey, id> *)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);
}
77 changes: 77 additions & 0 deletions docs/adr/0037-ios-simulator-network-capture-probe.md
Original file line number Diff line number Diff line change
@@ -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 <app> --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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading