Mercury XPC envelope — empirical capture (Phase D.6.6-impl sub-task 2 partial)¶
Status: verified — 2026-04-28, capture rig commits dc4d004 (send-side) + dffe662 (recv-side)
First empirical evidence of the Mercury XPC envelope structure
against a live CoreDeviceService process on havoc. Capture rig
landed in two commits (Phase D.6.6-impl sub-task 2):
dc4d004 added three DYLD_INTERPOSE entries on
xpc_connection_send_message family (CDS-as-sender);
dffe662 added a fourth on xpc_connection_set_event_handler
(CDS-as-receiver) after the send-only rig was empirically shown
to be half-deaf — see "Coverage limitation discovered 2026-04-28"
below. All four interposers share a single filter
(xpc_get_type==XPC_TYPE_DICTIONARY AND top-level
mangledTypeName string present), the same length-preserved
redaction pass for PII, and the same on-disk format
(/tmp/iosmux-mercury-events.jsonl + /tmp/iosmux-mercury-raw/<seq>-<dir>.{bin,txt}).
The byte-shape findings below are what 7 captured events from
xcrun devicectl list devices + xcrun devicectl device info
details produced under the send-side-only rig in session 13;
the session-14 recv-side build is committed but not yet
deployed at the time of this writing.
TL;DR — Mercury XPC carries TWO distinct envelope shapes on the same connection¶
Updated 2026-04-28 session 14 Round 3: previously this section
described a single envelope shape ({mangledTypeName, value}). That
was correct for the events we had captured under the original
send-side filter, but incomplete. After the recv-side filter was
relaxed (commit b252961) we captured a different envelope shape
that is also delivered through Mercury, on the same anonymous
listener-accepted peer connections. So Mercury is a transport with at
least two distinct application-layer envelope conventions:
Envelope A — Mercury Codable event envelope (always seen so far on send side)¶
A 2-key xpc_dictionary keyed by Swift mangled type name:
{
"mangledTypeName": <xpc_string: Swift Codable type name>,
"value": <xpc_dictionary: nested Codable struct fields>
}
Used for events / requests / replies whose body is a single Swift
Codable value. Examples observed: RemotePairing.BrowseRequest,
CoreDevice.ServiceEvent (carrying inner
DeviceManagerCheckInCompleteEvent,
DeviceManagerFullyInitializedEvent,
RemoteDeviceStateUpdatedEvent, DeviceManagerCheckInRequest,
ProvisioningProvidersListRequest, RemotePairing.ServiceEvent).
Filterable by top-level xpc_dictionary_get_string(msg, "mangledTypeName").
Envelope B — Mercury action invocation envelope (Round 3, 2026-04-28)¶
A 6-key flat xpc_dictionary keyed by Apple-reverse-DNS action ID:
{
"CoreDevice.actionIdentifier": <xpc_string: "com.apple.coredevice.action.pair">,
"CoreDevice.invocationIdentifier": <xpc_string: per-call UUID>,
"CoreDevice.deviceIdentifier": <xpc_string: target CoreDevice UUID, 36 chars>,
"CoreDevice.coreDeviceVersion": <xpc_dictionary: {components, stringValue}>,
"CoreDevice.CoreDeviceDDIProtocolVersion": <int64>,
"CoreDevice.input": <xpc_dictionary: action-specific input fields>
}
Used for action declaration invocations from Xcode to CDS. Filterable
by top-level xpc_dictionary_get_string(msg, "CoreDevice.actionIdentifier").
The action's input parameters live under CoreDevice.input. Reply
bytes are presumably symmetric with CoreDevice.output, but no
working reply has been captured yet (CDS crashes during dispatch
before forming one — see "Coverage limitation discovered 2026-04-28
(session 14)" below).
Both envelope types are observed on conn=null (anonymous,
listener-accepted) peer connections in the same CDS process. A
single xpc_connection_set_event_handler interpose with a two-tier
filter (strict mangledTypeName for Envelope A, plus a relaxed
fallback that captures any anonymous-peer dict for Envelope B and
unknown shapes) catches both.
The "Codable archive bytes" framing older project notes hint at —
specifically action-interception-full-picture.md §"From research
C+D" — refers to the nested value dict in Envelope A, which is a
structural xpc tree, not an opaque byte array. Envelope B is
flat (no mangledTypeName, no value wrapper) and the
CoreDevice.input body is itself a structural xpc tree of typed
fields.
action-interception-full-picture.md is partially superseded
The doc claims strings(1) search of CDS / CoreDevice
framework / DVTCoreDeviceCore for
CoreDevice.featureIdentifier|deviceIdentifier|output returns
zero hits, and concludes those keys "don't exist on the
Xcode↔CDS connection." This was wrong: today's empirical
capture (Round 3, seq=17) shows CoreDevice.deviceIdentifier AND
CoreDevice.input AND CoreDevice.actionIdentifier literally
in the wire bytes of an Xcode→CDS Mercury action invocation. The
featureIdentifier key specifically is the one that didn't
appear (it is from the device-side RemoteXPC protocol, not from
Mercury); the rest of that key family DOES live on the Mercury
wire under the action-invocation envelope. See the captured
Round 3 envelope below for byte-level evidence. The
action-interception-full-picture.md doc carries an inline
!!! warning admonition pointing at this section.
Implication for Phase 3 GAMBIT: replying to a captured request shape
is now an xpc_dictionary_create + set_string("mangledTypeName") +
recursive value-dict construction. No opaque-archive forging
required. ADR-0006-compliant: every byte we set is sourced from a
real captured shape.
Capture methodology¶
- Build inject with mercury logger enabled (commit
dc4d004): - Deploy + force CDS respawn (havoc-root):
- Trigger CDS spawn (any devicectl call respawns the on-demand XPC service). Verify banner:
- Run trigger (any of these spawns CDS and exercises the Mercury bus):
xcrun devicectl list devicesxcrun devicectl device info details --device <CoreDevice-UUID>- Inspect captures:
Captured types so far (session 13)¶
mangledTypeName |
Outer/Inner | Connection name | Notes |
|---|---|---|---|
RemotePairing.BrowseRequest |
outer | com.apple.CoreDevice.remotepairingd |
CDS browsing for paired peers via remotepairingd. Single field currentDevicesOnly: false (xpc bool). |
CoreDevice.ServiceEvent |
outer | <null> |
Generic event wrapper. The inner Codable type is announced via a nested request.mangledTypeName. |
CoreDevice.DeviceManagerCheckInCompleteEvent |
inner (nested under ServiceEvent.value.request) |
n/a (wrapped) | Fires when SDM check-in completes. Three fields: checkInRequestIdentifier (xpc UUID), serviceFullyInitialized (xpc bool), initialDeviceSnapshots (xpc array, empty in current state). |
The <null> connection name on ServiceEvent events is
xpc_connection_get_name() returning NULL — common for unnamed
peer-to-peer XPC connections.
Coverage limitation discovered 2026-04-28 (session 14)¶
The session-13 capture rig (commit dc4d004) interposed only
the outgoing libxpc send family inside the CoreDeviceService
process: xpc_connection_send_message,
xpc_connection_send_message_with_reply, and
xpc_connection_send_message_with_reply_sync. This caught
every Mercury envelope where CDS is the sender and missed
every Mercury envelope where CDS is the receiver —
because Xcode→CDS messages reach CDS through the connection's
event-handler block, not by way of CDS calling a send function.
A session-14 verification run drove the full Pair-button flow
(open Devices and Simulators, click iPhone (iosmux), click
Pair, observe failure) and captured 20 events. All 20 were
dir="send" CDS→Xcode broadcasts:
RemotePairing.BrowseRequest ×2 plus
CoreDevice.ServiceEvent wrappers ×18 carrying inner
DeviceManagerFullyInitializedEvent,
DeviceManagerCheckInCompleteEvent, and
RemoteDeviceStateUpdatedEvent. The priority targets
AcquireDeviceUsageAssertionActionDeclaration and
PairActionDeclaration were absent — they are
Xcode-as-sender, CDS-as-receiver, and the send-only rig had
zero coverage on that direction.
Commit dffe662 (Phase D.6.6-impl sub-task 2 follow-up) adds
a fourth DYLD_INTERPOSE entry on
xpc_connection_set_event_handler. When CDS calls
set_event_handler(conn, blk) on any of its connections, the
interposer wraps blk in a transparent shim that filters by
the same mangledTypeName rule, dumps via the shared
iosmux_mercury_dump_event() helper with dir="recv", then
forwards the message to the original block. Block memory
notes (caller block Block_copy'd into wrapper capture; libxpc
Block_copys the wrapper) are documented inline in
inject/iosmux_mercury_logger.m and in
notes/iosmux-mercury-recv-side-summary.md (gitignored).
Capture happens before CDS dispatches the message into
its Codable invoke chain. This matters because the same
session-14 Pair-button flow also produced an
EXC_BAD_ACCESS crash in swift_retain inside
libswiftCore Codable type-metadata initialisation
(report at
/Users/nullweft/Library/Logs/DiagnosticReports/CoreDeviceService-2026-04-28-065314.ips
on havoc; faulting stack returns through CDS+0xB89B, the
byte after the S1.B passthrough trampoline at CDS+0xB896).
Wrapping at the event-handler entry means the recv-side
wrapper logs the raw byte shape before the dispatch chain
runs, so even a message that subsequently crashes CDS leaves
its bytes on disk. The crash itself is a separate downstream
issue and is not addressed by the capture rig.
The recv-side build was deployed on 2026-04-28 (commit dffe662).
The Pair-button re-run captured 8 recv events under the strict
top-level-mangledTypeName filter
(DeviceManagerCheckInRequest ×5, RemotePairing.ServiceEvent ×2,
ProvisioningProvidersListRequest ×1) but zero events for the
priority targets PairActionDeclaration /
AcquireDeviceUsageAssertionActionDeclaration. Apple's own unified log
confirmed Xcode HAD sent PairActionDeclaration to the named CDS
service connection (PID 1650, dying within 3 ms in a swift_retain
Codable type-metadata crash through CDS+0xB89B); the Mercury envelope
shape used for those action invocations was therefore not the
mangledTypeName-keyed shape we filter on. Disasm research
(notes/iosmux-mercury-listener-disasm-summary.md, host-side
gitignored) verified Mercury has exactly TWO
xpc_connection_set_event_handler block-API call sites — at
Mercury+0x4e6ca (inside Mercury.SystemXPCConnection.setEventHandler,
function head 0x4e620) and Mercury+0x4eb62 (inside
Mercury.SystemXPCListenerConnection.<setPeerHandler>, function head
0x4ea60). Both call sites ARE caught by our existing interpose; there
is NO third call site, no NSXPCConnection overlay (verified by nm -u
showing zero NSXPC symbols in either Mercury or CoreDeviceService), no
public function-pointer variant xpc_connection_set_event_handler_f
(dlsym returns NULL on macOS 26.3), and no other libxpc entry
through which Mercury could install a Swift event handler that our
interposer would miss. CDS itself imports only _xpc_main and
_xpc_connection_create_from_endpoint from the libxpc family —
the listener handler is registered through Mercury, which uses the
two call sites above. Together this is the empirical proof that the
recv-side gap was a filter mismatch, not a missing interpose
target. Commit b252961 relaxed the recv-side filter to also accept
any anonymous-peer dict that lacks a top-level mangledTypeName; the
next section documents the envelope shape that relaxed filter
revealed.
Pair action invocation envelope (session 14 Round 3, 2026-04-28)¶
After commit b252961 redeploy + Pair-button-click, exactly one
loose-recv event was captured — seq=17, conn=null,
redactions=["coredevice_uuid"], 664-byte structural dump on havoc
at /tmp/iosmux-mercury-raw/000017-recv.bin. Top-level structure as
emitted by xpc_copy_description:
<dictionary> { count = 6, transaction = 1, voucher = <set>, contents =
"CoreDevice.coreDeviceVersion" => <dictionary> { count = 3, contents =
"originalComponentsCount" => <int64>: 2
"components" => <array> [<uint64>:518, <uint64>:27, <uint64>:0, <uint64>:0, <uint64>:0]
"stringValue" => <string>: "518.27"
}
"CoreDevice.deviceIdentifier" => <string, 36 bytes>: <CoreDevice UUID, redacted in capture as 36 zeros>
"CoreDevice.invocationIdentifier" => <string, 36 bytes>: <per-call UUID v4, e.g. "727220F4-DACE-4B51-87E9-832836A1CC7B">
"CoreDevice.CoreDeviceDDIProtocolVersion" => <int64>: 2
"CoreDevice.actionIdentifier" => <string, 32 bytes>: "com.apple.coredevice.action.pair"
"CoreDevice.input" => <dictionary> { count = 1, contents =
"endpoint" => <xpc_endpoint_t — mach send right; opaque>
}
}
Implications:
- Action discriminator key is
CoreDevice.actionIdentifier— a reverse-DNS string. Apple's pair flow ID iscom.apple.coredevice.action.pair. Other actions presumably use similar reverse-DNS IDs undercom.apple.coredevice.action.*; enumerate by capturing each one with its corresponding UI trigger. CoreDevice.deviceIdentifieris the target CoreDevice UUID. Redaction fired correctly — the value hit disk as 36 zeros, not the live UUID.CoreDevice.invocationIdentifieris a fresh UUID v4 per call. Useful as request↔reply correlation key in JSONL once both sides are captured.CoreDevice.coreDeviceVersioncarries the client'sCFBundleShortVersionStringdecomposed into a 5-element components array plus the joined string. Diagnostic for version skew between Xcode and CDS.CoreDevice.CoreDeviceDDIProtocolVersionsignals which DDI protocol revision the caller speaks. Currently2.CoreDevice.inputis the action's per-action input record. For Pair it contains a singleendpointfield of typexpc_endpoint_t— a mach send right that lets the two sides exchange asynchronous progress / result frames out-of-band of the primary XPC reply. Our structural dumper currently emits it asUNKNOWN type; future iteration could decode the endpoint's name string viaxpc_endpoint_get_name()if useful.- No
CoreDevice.outputkey in the request. The reply is presumably symmetric — same envelope shape withCoreDevice.outputreplacingCoreDevice.input— but no reply has been observed yet. CDS crashes in Codable type-metadata init during dispatch (the sameswift_retain/ CDS+0xB89B crash documented in 5 separate crash reports on 2026-04-28). The crash happens AFTER our recv-side wrapper logs the request; the output shape requires either (a) a working CDS+device pair on a different apparatus (Phase 4 reserve) or (b) sub-task 3 intercepting the request before dispatch and synthesising a reply whose shape is derived from another action's reference capture.
Privacy: CoreDevice UUID and any nested device fields fire the
existing redaction table. The captured .bin and .txt artifacts
on havoc are length-preserved redacted in place. Per project
policy raw captures are NOT committed; the Round 3 Pair-input shape
documented here IS the public, redacted distillation suitable for
sub-task 3 reference.
Concrete byte shapes (from xpc_copy_description)¶
RemotePairing.BrowseRequest:
<dictionary> { count = 2,
"mangledTypeName" => "RemotePairing.BrowseRequest"
"value" => <dictionary> { count = 1,
"currentDevicesOnly" => <bool>: false
}
}
CoreDevice.ServiceEvent carrying DeviceManagerCheckInCompleteEvent:
<dictionary> { count = 2,
"mangledTypeName" => "CoreDevice.ServiceEvent"
"value" => <dictionary> { count = 1,
"request" => <dictionary> { count = 2,
"mangledTypeName" => "CoreDevice.DeviceManagerCheckInCompleteEvent"
"value" => <dictionary> { count = 3,
"checkInRequestIdentifier" => <uuid> ...
"serviceFullyInitialized" => <bool>: false
"initialDeviceSnapshots" => <array>: count = 0
}
}
}
}
The recursive shape (ServiceEvent.value.request.mangledTypeName +
value) confirms the wrapper pattern: ServiceEvent is a generic
event-bus message; the actually-interesting Codable type is one
level deeper in value.request. Sub-task 3 must recognise this
nesting.
What we still do NOT know¶
AcquireDeviceUsageAssertionActionDeclaration.Inputand.Outputbyte shapes. Perpair-button-and-cfnetwork.md§"Pair button gating" this is the priority Codable to capture — a successful syntheticOutputreply is what flips_shadowUseAssertion.fulfilledXcode-side. Verified empirically in session 13: 7 events captured across twodevicectlruns (list devices,device info details), none of themAcquireDeviceUsageAssertion. The action is dispatched only when Xcode UI inspects a device row;devicectlcalls do not trigger it. Capturing it therefore requires Xcode IDE running on havoc with the Devices and Simulators window open and the iPhone (iosmux) row clicked. Capture is deferred until Xcode IDE on havoc is available again (component re-download in progress at session 13 close).- Reply byte shapes for any captured action. The session-13
capture had no reply-direction events. The original analysis
ranked three structural causes (fire-and-forget actions, non-dict
replies skipped by filter, JSONL flush race); session-14 work
established a fourth, dominant cause that subsumes the first
three: see "Coverage limitation discovered 2026-04-28
(session 14)" below. Once the recv-side build is deployed,
reply byte shapes for actions that have replies will appear
alongside their requests in the JSONL with
dir="reply"(the send-with-reply interposers' wrapper) ordir="recv"(when CDS is on the receiving side of a message that carries a Mercury envelope). - Whether the
valuedict ever containsxpc_datablobs. The capture rig handlesxpc_databy emitting raw bytes betweenbytes=...and/DATAmarkers in the structural.bindump, but noxpc_datavalue has been observed in the 7 captured events. May be Apple uses Codable's structural encoding all the way down (no opaque blobs), or these particular actions happen to not carry binary payloads. Check again onceAcquireDeviceUsageAssertion.Inputis captured.
Known issues (capture rig — for follow-up)¶
- Sequence-number collision on CDS respawn. The atomic counter
is per-process; CDS is an on-demand XPC service that exits after
idle and respawns later. Each respawn restarts the sequence at
1, and
/tmp/iosmux-mercury-raw/<seq>-<dir>.{bin,txt}files are opened withO_TRUNC, so the second CDS lifetime overwrites the first lifetime's000001-*.{bin,txt}. The JSONL manifest is append-mode so the metadata survives — only the raw dump files collide. Fix: includegetpid()in the path (/tmp/iosmux-mercury-raw/<pid>/<seq>-<dir>.bin) so each CDS lifetime has its own subdirectory. Tracked as a follow-up commit; not blocking for current empirical work. - Connection name often
<null>.xpc_connection_get_name()returns NULL for many internal connections. Not a capture bug — reflects Apple's API. The named connections we care about arecom.apple.CoreDevice.CoreDeviceService(inbound from Xcode) andcom.apple.CoreDevice.remotepairingd(CDS to remotepairingd); both have appeared correctly when present.
Privacy / storage¶
All captures live local-only on havoc + scp'd to host
~/backups/iosmux/mercury/ for analysis. Per project policy, raw
captures are NOT committed as repo fixtures. Phase 3 GAMBIT will
distill byte SHAPES from these captures into a small set of fixture
templates suitable for committing — those templates will carry
synthetic identifiers, not user PII.
The redaction policy in iosmux_mercury_logger.m already overwrites
the user's UDID, Serial, BT MAC, CoreDevice UUID, and Bonjour-style
32-hex identifiers in-place before any byte hits disk. Verified
firing on session 13 captures (events 3 + 4 + 6 had coredevice_uuid
redaction applied).
What this enables for sub-task 3 (Phase 3 GAMBIT)¶
With the envelope structure now known empirically:
- The Mercury Codable interceptor in the inject can recognise an incoming Input message by:
- reading top-level
mangledTypeName(filter discriminator — same approach as the logger uses). - matching against a small allow-list of action names per ADR-0009 §Consequences static-success set.
- For each recognised action the interceptor builds a synthetic reply:
- top-level
xpc_dictionary_create()with two keys. xpc_dictionary_set_string("mangledTypeName", "<ActionName>.Output").xpc_dictionary_set_value("value", <nested dict>)with fields populated from the action's Codable Output schema (still to be captured for AcquireDeviceUsageAssertion in particular).- The synthetic reply is delivered via the original
xpc_handler_tBlock the caller passed in — same shape Mercury uses for genuine successful replies. No new framework calls.
This is materially less work than the "opaque archive forging" path older project notes feared. Sub-task 3's blocker is now strictly "capture the success-Output for AcquireDeviceUsageAssertion + the rest of the static-success set" — which is Phase 4 (friend's working pair) or post-Xcode-download local capture, depending on which arrives first.
See also¶
- ADR-0009 — iosmux is a bridge, not a proxy
action-interception-full-picture.md— pre-empirical research design doc; this file confirms its Phase 1 capture approach workedpair-button-and-cfnetwork.md§"Pair button gating" — why AcquireDeviceUsageAssertion mattersdocs/plans/d66-research-questions.mdQ-D66-2 — the open empirical question this capture partially resolvesnotes/iosmux-mercury-interpose-summary.md(gitignored) — agent's design notes from the capture rig implementation