Skip to content

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

  1. Build inject with mercury logger enabled (commit dc4d004):
    cd ~/iosmux/inject
    make iosmux_inject.dylib
    
  2. Deploy + force CDS respawn (havoc-root):
    cp iosmux_inject.dylib /Library/Developer/CoreDevice/iosmux_inject.dylib
    killall CoreDeviceService
    
  3. Trigger CDS spawn (any devicectl call respawns the on-demand XPC service). Verify banner:
    tail /tmp/iosmux_mercury.log
    # mercury logger active — jsonl=… rawdir=… xpc_ready=1
    
  4. Run trigger (any of these spawns CDS and exercises the Mercury bus):
  5. xcrun devicectl list devices
  6. xcrun devicectl device info details --device <CoreDevice-UUID>
  7. Inspect captures:
    cat /tmp/iosmux-mercury-events.jsonl
    cat /tmp/iosmux-mercury-raw/000001-send.txt
    

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 is com.apple.coredevice.action.pair. Other actions presumably use similar reverse-DNS IDs under com.apple.coredevice.action.*; enumerate by capturing each one with its corresponding UI trigger.
  • CoreDevice.deviceIdentifier is the target CoreDevice UUID. Redaction fired correctly — the value hit disk as 36 zeros, not the live UUID.
  • CoreDevice.invocationIdentifier is a fresh UUID v4 per call. Useful as request↔reply correlation key in JSONL once both sides are captured.
  • CoreDevice.coreDeviceVersion carries the client's CFBundleShortVersionString decomposed into a 5-element components array plus the joined string. Diagnostic for version skew between Xcode and CDS.
  • CoreDevice.CoreDeviceDDIProtocolVersion signals which DDI protocol revision the caller speaks. Currently 2.
  • CoreDevice.input is the action's per-action input record. For Pair it contains a single endpoint field of type xpc_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 as UNKNOWN type; future iteration could decode the endpoint's name string via xpc_endpoint_get_name() if useful.
  • No CoreDevice.output key in the request. The reply is presumably symmetric — same envelope shape with CoreDevice.output replacing CoreDevice.input — but no reply has been observed yet. CDS crashes in Codable type-metadata init during dispatch (the same swift_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.Input and .Output byte shapes. Per pair-button-and-cfnetwork.md §"Pair button gating" this is the priority Codable to capture — a successful synthetic Output reply is what flips _shadowUseAssertion.fulfilled Xcode-side. Verified empirically in session 13: 7 events captured across two devicectl runs (list devices, device info details), none of them AcquireDeviceUsageAssertion. The action is dispatched only when Xcode UI inspects a device row; devicectl calls 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) or dir="recv" (when CDS is on the receiving side of a message that carries a Mercury envelope).
  • Whether the value dict ever contains xpc_data blobs. The capture rig handles xpc_data by emitting raw bytes between bytes=... and /DATA markers in the structural .bin dump, but no xpc_data value 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 once AcquireDeviceUsageAssertion.Input is 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 with O_TRUNC, so the second CDS lifetime overwrites the first lifetime's 000001-*.{bin,txt}. The JSONL manifest is append-mode so the metadata survives — only the raw dump files collide. Fix: include getpid() 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 are com.apple.CoreDevice.CoreDeviceService (inbound from Xcode) and com.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:

  1. The Mercury Codable interceptor in the inject can recognise an incoming Input message by:
  2. reading top-level mangledTypeName (filter discriminator — same approach as the logger uses).
  3. matching against a small allow-list of action names per ADR-0009 §Consequences static-success set.
  4. For each recognised action the interceptor builds a synthetic reply:
  5. top-level xpc_dictionary_create() with two keys.
  6. xpc_dictionary_set_string("mangledTypeName", "<ActionName>.Output").
  7. 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).
  8. The synthetic reply is delivered via the original xpc_handler_t Block 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