Skip to content

iosmux documentation

Status: Phase D.6.6 / Q-D66-15 active research — last-verified 2026-05-02

Every claim on this page links to either a delivered commit or a research artifact with a raw-data backing. ADR-0006 empirical-only is the hard rule.

Live site

Renders from the main branch of the private staticwire/iosmux repo, deployed to Cloudflare Pages at https://iosmux-docs.pages.dev/. Every push to main that touches docs/ or mkdocs.yml triggers a rebuild via .github/workflows/docs.yml.

TL;DR

iosmux makes an iPhone physically connected to a Linux host appear as locally-attached on a macOS VM (havoc), so Xcode + CoreDeviceService in the VM can talk to it. Currently the synthetic device is fully registered, listed by devicectl, GAMBIT-replied for all 15 allow-listed Mercury actions, and visible in Xcode's Devices window. The remaining single gap is the "Connected" / "Disconnected" UI label and a cosmetic "Failed to acquire assertion" error — tracked as Q-D66-15 under active investigation.

Surface State
devicectl list devices iPhone (iosmux) connected (no DDI) — works
devicectl device info details full info returned (with cosmetic warning)
Xcode Devices window — row visible yes, model / serial / id from real iPhone Handshake
Pair button gone (Q-D66-14 RESOLVED via RDSUE broadcast)
Spinner "Xcode has already started…" cleared (Q-D66-14)
Connection label "Connected" OPEN — Q-D66-15 (shows "Disconnected")
Install / DDI / debug / app-launch flow through interpose chain (per ADR-0009)
Pair / Unpair / Acquire / Connect / etc GAMBIT static-success replies

Quick navigation

Tree What lives here
architecture/ How iosmux works right now. Ground-truth.
plans/ Forward-looking work. Stage 2, Q-D66.
research/ Empirical work, every claim backed by raw artifact.
adr/ Architecture Decision Records (immutable, append-only).
patches/ pymobiledevice3 upstream-plus-overlay (temporary).
runbooks/ Operational how-tos.

Hot links (Q-D66-15 active investigation):

Q-D66-15 — current focus

The cosmetic "Disconnected / Failed to acquire assertion" gap blocks only the Devices-window label. Install / DDI / debug pipelines work through the existing interpose chain (ADR-0009 §Consequences). The user reopened this gap 2026-05-01 with a directive to restore full UI functionality (Connected status, no error label) because every devicectl device info* command fans out through com.apple.coredevice.action.acquireusageassertion and surfaces this error in the unified log.

Architectural anchor: aua-side-channel-mechanism.md.

Probe timeline (D30 → D38)

  • D30 — AFM peer keep-alive (retain peer for full CDS lifetime). FAILED: peer lifetime is NOT the gate.
  • D31 — lldb dynamic probe; localised input #3 = peer[1013] race; Run #2 SUCCESS path captured. Race deterministically winnable.
  • D32 — peer[1013] cancel chain analysis. Kernel-driven MACH_NOTIFY_NO_SENDERS; trigger lives in devicectl, not CDS.
  • D33 — DYLD_INSERT_LIBRARIES feasibility into devicectl. FEASIBLE on Tahoe 26.x (no hardened runtime, ad-hoc signing OK).
  • D34 + D35a — lldb capture HIT #18 (cancel) vs HIT #23 (DUA deinit late). Heisenbug discovered: lldb perturbs Path-A → Path-B.
  • D36 — host-side static disasm with Ghidra. Full Path-A/Path-B map; convergence on _completionGuard semaphore.
  • D37-CContinuation.resume(returning:) ABI mapped. Hooks #1/#2/#3 deprecated as denial-masking.
  • D37-D — static localised upstream release to Mercury.SystemXPCListenerConnection.deinit at Mercury+0x4e930. G-1 hook target identified (later FALSIFIED in D38).
  • D37-E — dtrace pid-provider on listener.deinit, 6 runs × ~30k frames. Zero firings observed; Heisenbug caveat (dtrace also suppresses Path-A).
  • D37-F — cross-binary trigger search libxpc / libdispatch. Hypothesis B falsified; G-4 alternative (CD+0x304a0 alloc retain) identified.
  • D37-G — CDS-side counter-instrumentation. Path-A not reproduced (0/5 + 0/24h); §D-1 INDETERMINATE from CDS-side alone.
  • D37-H — post-VM-reboot baseline retest. PATH_B_ONLY confirmed apparatus-state-INVARIANT.
  • D38 G-4 — devicectl-side extra retain on AUA-completion context at CD+0x30791. FALSIFIED: Path-B fires 9.9 ms; lVar17+0x18 not a strong ref.
  • D38 G-1 — devicectl-side NOP listener deinit body at Mercury+0x4e930. FALSIFIED: Path-B fires ~10 ms; D37-D claim wrong.
  • D38 G-1' — devicectl-side NOP listener __deallocating_deinit at Mercury+0x4e960. FALSIFIED: Path-B fires 11.0 ms; entire dealloc skipped, no effect.
  • D39 — Xcode-side hasConnection.getter direct override at DVT+0x19e10. 6-byte patch b8 01 00 00 00 c3 via mach_vm_protect VM_PROT_COPY. Lazy-load fix via _dyld_register_func_for_add_image. Deploy attempt produced "Xcode is damaged" Gatekeeper alert — LaunchServices state poisoning, recovered by reboot. Bundle integrity intact (sha-verified). Future H#2 retest gated on user-driven state-tagged protocol.
  • D40 — state-aware retrospective. Memory rule feedback_state_tagged_testing.md adopted: every probe MUST tag apparatus state. Past 0/N baselines may be state-conditional. Comprehensive backup of all Apple bundles + signatures at recovery-2026-05-02/.

D38 empirical conclusion (2026-05-02)

Three structurally independent devicectl-side cause-side hooks on the listener Swift wrapper lifecycle ALL fail with byte-identical Path-B surface. Listener Swift wrappers at Mercury+0x4e930-0x4ea00 do not hold the mach send right that triggers MACH_NOTIFY_NO_SENDERS. D37-D's static map is incomplete on the critical path of the send-right release.

Per user directive (after a week of cause-side iterations confirmed the approach is structurally infeasible), D39 research-dive direction shift — NOT another Swift-class-lifecycle iteration. Working hypotheses:

  1. Hook AUA wrapper FUNCTION ENTRY at CD+0x31750 (acquireDeviceUsageAssertion(...) async throws). Synthesise a DeviceUsageAssertion success result directly. Bypass the race, not fight it.
  2. Xcode-side direct intervention: hook DVTCoreDeviceCore's _shadowUseAssertion.fulfilled setter directly. Pure UI-gate flip via DYLD_INSERT into Xcode itself.
  3. Real Apple flow capture: D31 captured ONE success run on this apparatus — closer analysis of side-channel peer transcript may reveal what messages we're failing to send/respond-to.
  4. Symptom-side suppression (D33 Option 2 — DEPRIORITISED; user rejected as a poor approach. Fix must address root cause, not consequences).

Architecture decisions

Full ADR index →. Load-bearing decisions today:

  • ADR-0001 — pymobiledevice3 (not go-ios) for the iOS-side protocol
  • ADR-0002 — tunneld runs inside the VM, not on the host
  • ADR-0005 — Phase D backend is Go only, no new production Python
  • ADR-0006 — empirical-only discipline (no guessing)
  • ADR-0009 — iosmux is a bridge that synthesises a device, not a transparent proxy

How to read these docs

This tree is organized so that what we know is separated from what we are planning and from what we are investigating. Every document belongs to exactly one of four categories and carries a status badge.

Status badges (YAML frontmatter)

Status Meaning
verified Empirically confirmed against artifact / source / commit. Strongest claim.
reviewed Logically derived from verified facts; peer-reviewed. Safe to act on.
draft In progress — expect churn.
hypothesis Unconfirmed; names the empirical task that would resolve it.
superseded Replaced; frontmatter has superseded-by: link. Kept for history.
archived Historical, no longer maintained.

Inline admonitions

Custom admonitions for inline evidence calls (in addition to standard MkDocs Material note / warning / info / danger):

  • !!! verified "..." — wraps a specific empirical claim with evidence (green).
  • !!! hypothesis "..." — wraps an unconfirmed assumption (yellow). Must name the research task.
  • !!! gap "..." — wraps a known empirical unknown (red). Halts dependent decisions.
  • !!! superseded "..." — claim replaced, points at replacement (grey).

The hard rule

No guessing. Every claim is either backed by evidence we can point at, or explicitly labeled as a gap. Protocol bisection halts on the first unknown — never reply with bytes we can't source from a capture or a primary reference. Formalised in ADR-0006.

Current state (session 10, post S2.A + S2.B + S2.C — historical)

Stage S1 (A, B, C, D) has landed. devicectl list devices returns the virtual iPhone end-to-end on the very first call after a fresh CoreDeviceService launch. Xcode's Devices window shows the device row with an honest Pair button and the state=connected (no DDI) label CDS produces on its own when we stop lying about device state. The L2 bridge + VM-local pymobiledevice3 tunneld + CDS inject chain is documented in architecture-connection.md.

Stage S2.A removed four DeviceInfo force-writes that had been bypassing the pair flow (commit 18331ca). Only DeviceInfo.state=connected was kept, because it is a truthful link-state value (we hold a live RSDDeviceWrapper with a working tunnel), and removing it caused Xcode to filter the device out.

Stage S2.B ran the Pair-button smoke test and produced a course-correcting finding: CDS cannot natively speak the transport that pymobiledevice3 tunneld exposes. Clicking Pair causes CDS to open nw_connection directly to the tunnel RSD endpoint and receive an immediate TCP RST. This killed Options α/γ from the original Stage 2 plan and moved the project to Option δ: intercept CDS's outgoing connection and route it through a local shim backend that speaks the protocol CDS expects. Full analysis in s2b-pair-attempt-log.md.

Stage S2.C ran a single unified self-experiment on havoc that closed the three blockers from the devil's-advocate pass:

  • X (client-side protocol) — captured and decoded. CDS speaks plain prior-knowledge HTTP/2 cleartext, no TLS, no ALPN. Above HTTP/2 it uses RemoteXPC DATA frames on stream 1 (ROOT_CHANNEL) and stream 3 (REPLY_CHANNEL) with XpcWrapper / XpcPayload framing whose magic bytes match pymobiledevice3/remote/xpc_message.py byte for byte. A Go HTTP/2 server can serve this.
  • Y (which PID) — empirically the same PID our LC_LOAD_DYLIB inject loads into. No helper process, no remotepairingd delegation. The earlier "three CDS PIDs" observation was concurrent launchd instances, not a split of responsibilities.
  • Z (sandbox) — all four Shape B primitives (AF_UNIX socket, socketpair, bind to /tmp/*.sock, sendmsg with SCM_RIGHTS) work inside CoreDeviceService's sandbox with errno=0.

Full hex-level write-up in s2c-self-experiment/FINDINGS.md. Shape B is therefore architecturally viable; Phase D is a Go implementation with pymobiledevice3 staying confined to the existing tunneld subprocess.

Phase D direction is Go-only. The Option δ backend is a new Go component under cmd/iosmux-backend/ that uses golang.org/x/net/http2 for h2c + a hand-written XpcWrapper / XpcPayload codec ported from pymobiledevice3 as reference. No new production Python code is introduced; the existing tunneld Python subprocess is the only Python in steady state, and we talk to it as an HTTP client on loopback as we do today.

Active plans

  • plan-stage2-pair-flow.mdauthoritative forward plan. Phase A (revert DeviceInfo lies), Phase B (smoke test), and Phase C self-experiment have landed. Blockers X / Y / Z are closed. Phase C's self-research queue Q1-Q5 is fully resolved (2026-04-18). Q3 iter-0 produced a byte-exact dispatch table of real iPhone replies; Q3 iter-1 showed CDS rejects the naive upfront replay with a clean GOAWAY PROTOCOL_ERROR "request HEADERS: invalid stream_id"; iter-2 brought the event-driven dispatcher online — HTTP/2 framing now accepted by CDS, full XPC handshake on both channels (recv +21%), REPLY_CHANNEL closed with a RST_STREAM(s3, STREAM_CLOSED) on a dispatch-ordering bug (big Handshake fires on DATA(s1) instead of DATA(s3)); iter-3 relocated the trigger onto DATA(s3) and reproduced the pcap emit order exactly, but CDS still ended with the same RST_STREAM(s3) — the ordering hypothesis is empirically disproven; iter-4 then dropped our reciprocal #9 RST_STREAM(s1) and CDS's teardown signal vanished entirely — CDS reached a quiescent state after our 9-frame reply. Q3 under the replay approach is closed: further s2c progress belongs to Phase D backend work that can respond dynamically to CDS's post-handshake service calls. Phase D is the Go-only Option δ backend (cmd/iosmux-backend/); Phase E covers downstream (DDI / developer mode / install / debug) through the same backend.
  • docs/patches/pymobiledevice3/ — temporary upstream-plus-patches overlay for pymobiledevice3. Every patch is documented with a Go-Rewrite-Note so the Phase D Go backend can drop Python at the first opportunity without losing any behavior. Deleted when Python is removed.
  • plan-stage1-rebuild.md — the session 8-9 roadmap that delivered S1. Kept as historical reference.
  • architecture-connection.md — ground-truth architecture diagram of how iPhone, host, VM, tunneld, and CDS inject fit together. Includes a "historical lesson" section on why host-side tunneld was the wrong architecture the first time, so future sessions do not re-introduce it.

Stages landed so far

Stage 0 — Safety-belt fixes (f529798)

Five defensive fixes from docs/research/code-audit-findings.md (C1, C2, C4, C5, C6). Removed a class of latent memory/ABI bugs in the inject that had been surviving by accident. No behavior change, but subsequent stages could not have landed safely without these.

Stage S1.A — Properties from in-memory handshake (c89dbe1)

The inject used to HTTP-fetch device properties from a Go relay running on port 62078. The fetch was a hard dependency on the relay being up at exactly the right moment; when it was not, the whole CDS device-registration chain cascade-failed. Replaced the HTTP fetch with a direct read of g_rsd_handshake_properties, which is already in process memory at that point. No more relay dependency, no more Go-relay HTTP path in steady state.

Stage S1.B — Passthrough trampoline for Step 19 (8197c8a)

Step 19 was an inline hook installed at CoreDevice + 0xB896 that replaced invoke(anyOf:usingContentsOf:) with mov al, 1; ret. It was added in session 5 to mask a SIGSEGV in PairAction's NULL self handler, but the SIGSEGV's root cause was the SDR read-overflow fixed in session 6, not anything action-dispatch related. With the overflow gone, the mov al, 1; ret stub was silently eating DeviceManagerCheckInRequest by returning "handled" for every message that reached Mercury. Converted to a passthrough trampoline (movabs r10, orig; jmp r10) so the original invoke(anyOf:) runs, check-in reaches ServiceDeviceManager, and Published DeviceManagerCheckInCompleteEvent fires for the first time since session 5.

Stage S1.C — DeviceIdentifier enum tag (7e7a403)

Smoking-gun one-byte bug. The inject was writing dev_id_buf[32] = 0 with a comment labelling that byte as "enum tag = UUID variant". The comment was wrong. CoreDevice.DeviceIdentifier is:

enum DeviceIdentifier {
    case ecid(UInt64)                        // tag 0
    case uuid(Foundation.UUID, Swift.String) // tag 1
}

With tag = 0, CDS was interpreting the first 8 bytes of our UUID buffer as a UInt64 ECID. Downstream, uuidRepresentation.getter fell into an AMDevice keypath synthesis path that produced a completely different UUID, and every identity-keyed lookup inside CDS ended up querying under that synthetic UUID instead of our configured one. Fix: set tag = 1 and populate the .uuid(UUID, Swift.String) payload correctly (16-byte UUID + empty small-string marker 0xE000000000000000 + tag byte). After this one-byte change the whole identity chain became consistent.

The same commit also deleted the dead updateIdentifier(_:forIdentityManagedDevice:) call that was previously attempted as an SDR-registration path. Runtime research had already shown the call was hitting the miss branch with no side effects; it was not part of the real registration path.

Stage S1.D — Hook install(browser:) (54edb58)

The inject constructor used to schedule the main registration work via dispatch_after(3 * NSEC_PER_SEC). That was a day-one guess about "how long until CDS has a live ServiceDeviceManager". On fresh launches it lost the race — the first check-in request arrived and was served from an empty managedServiceDevices dict. Replaced with an inline hook on ServiceDeviceManager.install(browser:) at CoreDevice + 0x27d7d0. The hook captures the SDM self pointer on the first call (Swift class-method ABI puts self in %r13), swift_retains it, and dispatch_asyncs the registration onto a dedicated serial queue. No more wall-clock guessing; the signal comes from CDS's own init chain.

Five parallel risk-research agents validated the approach before any code was written: prologue patchability, thread context, swift_retain from C, deadlock surface, symbol stability. All came back green. Hook target was also revised from __allocating_init to install(browser:) mid-research because the latter gives us the SDM pointer AND the guarantee that SDM is in a fully-constructed state.

Restore script (0eb972f)

scripts/iosmux-restore.sh is the single idempotent post-reboot script. It drives the host bridge (iosmux bridge, sudo for sysfs / ip link), starts a fresh pymobiledevice3 remote tunneld inside the VM via ssh havoc-root (so no sudo inside the VM), and waits until the tunnel API reports a live device. Always tears down any existing tunneld and rebuilds, so the post-run state is deterministic regardless of what was already running.

Stage S2.A — Stop lying about device state (18331ca)

Removed three DeviceInfo force-writes that were bypassing the real pair flow: pairingState = .paired, preparednessState = .all, and areDeveloperDiskImageServicesAvailable = true. The fourth candidate (state = .connected) was temporarily removed and then restored after empirical test showed Xcode filters devices whose DeviceInfo.state defaults to .unavailable — the link physically exists, so .connected is a truthful link-state value, not a lie about pairing. visibilityClass=default was kept as a UI categorization, not a lifecycle field.

Stage S2.B — Pair-button smoke test and course correction

Not a code commit — an empirical finding that changed the Stage 2 plan. With the three lies removed, clicking Pair in Xcode caused CDS to open nw_connection directly to the tunnel RSD endpoint and receive an immediate TCP RST. The tunnel is fully functional via pymobiledevice3 Python clients (rsd-info, mounter list, DVT filesystem listing all verified working). The impedance mismatch is inside the VM, between Apple's native CDS client and pymobiledevice3's tunneld transport. Option α ("let CDS speak to the tunnel naturally") is falsified; Option δ (interpose CDS and redirect to a local pymobiledevice3 backend) is the new direction. Full analysis in research/s2b-pair-attempt-log.md.

Stage S2.C — Self-experiment: three blockers closed (afd8dd6 + this commit)

A single unified empirical run on havoc that resolved the three Stage 2 blockers identified by the devil's advocate:

  • Blocker X (client-side protocol) — CDS speaks plain prior-knowledge HTTP/2 cleartext (h2c), no TLS, no ALPN. Captured with a spike HTTP/2 listener on [::1]:34719 (advertised to CDS via an IOSMUX_SPIKE=1-gated pymobiledevice3 tunneld patch). First DATA frame on stream 1 carries an XpcWrapper / XpcPayload blob whose magic bytes (0x290bb092, 0x42133742) match pymobiledevice3's remote/xpc_message.py byte for byte. Go's golang.org/x/net/http2.Framer can serve this directly.
  • Blocker Y (which PID) — every nw_connection_* call logged by the new iosmux_wire_logger.m DYLD_INTERPOSE bank came from the same PID our LC_LOAD_DYLIB inject loads into. No helper process, no remotepairingd delegation. Current injection is sufficient.
  • Blocker Z (sandbox) — a runtime syscall probe added to the inject constructor ran socket(AF_UNIX), socketpair(), bind() to /tmp/iosmux_probe_*.sock, and sendmsg() with SCM_RIGHTS. All four returned errno=0. Shape B's fd-passing is sandbox-legal.

Full hex breakdown and raw artifacts in research/s2c-self-experiment/FINDINGS.md. Phase D is now unblocked and committed to a Go implementation — cmd/iosmux-backend/ driven by golang.org/x/net/http2, with a hand-ported Go XpcWrapper / XpcPayload codec using pymobiledevice3 as a reference source. No new production Python. Phase D.0 through D.5 have landed as of 2026-04-19: the Go backend's smoke test against real CDS on havoc produces byte-exact server-side output compared with iter-4's Python listener (14313 B, cmp clean). See iter-05-go-backend/findings.md. D.6.0-B added an important correction: CDS does NOT sit quietly after our handshake — it closes the TCP connection with peer EOF ~30 s later. Previous iter-1-5 observation windows were too short (5-13 s pkill) to see this. The byte-level findings stand, but the session is semantically rejected by CDS, not accepted. Four candidate causes ranked in iter-06-pair-trigger/findings.md. D.6.1 landed the fix: patching a fresh RFC 4122 v4 UUID into #8 big Handshake per session (IOSMUX_HANDSHAKE_UUID=random, commit ffe7c29) eliminated the 30 s timeout entirely. CDS now accepts the handshake and holds the connection ESTABLISHED indefinitely (130 s observed, only TCP keepalives). H2/H3/H4 rendered moot. See iter-07-uuid-patched/findings.md. D.6.2 tested H1c (devicectl manage pair vs device info) on 2026-04-23 — falsified: four sessions captured, all 40-line handshake baseline, zero post-handshake frames. Three trigger classes all produce identical silent-hold. The missing variable is on our backend side, not in client trigger choice. D.6.3 widens pcap filter to separate H1b (CDS back-connects to advertised ports) from H2-reply (missing server follow-up frame). See iter-08-pair-trigger/findings.md. D.6.3 (2026-04-24) FALSIFIED H1b decisively — single wide- filter pcap captured exactly one TCP connection (CDS → our backend), zero SYNs to any of the 62 advertised service ports. CDS doesn't back-connect; it waits for a server-initiated follow-up frame on the existing HTTP/2 stream. H2-reply survives. D.6.4a will temporarily swap tunneld out of SPIKE mode and run Q2-methodology capture on utun4 against a real iPhone to observe the authoritative post-handshake bytes. See iter-09-wide-pcap/findings.md. D.6.4a (2026-04-24) FALSIFIED H2-reply + discovered parallel service: temporary SPIKE → stock tunneld swap, tcpdump on utun4 during non-destructive triggers, swap back. Real iPhone greeting-stream output is byte-identical to our SPIKE backend (same 10 frames, same silence). Real discovery: devicectl opens a parallel non-HTTP/2 TCP service on iPhone:50367 (~9.5 KB iPhone→client, likely lockdown-over-TCP). SPIKE has no equivalent listener. CDS hangs trying to reach that parallel service. D.6.5 decodes the 50367 bytes and identifies what to emulate. See iter-10-real-iphone-ref/findings.md. D.6.5 (2026-04-24) decoded the protocol: iPhone:50367 is classic lockdown-over-TCP (plaintext XML plist, 4-byte BE length prefix, 3-verb API: RSDCheckin, QueryType, GetValue). Advertised in our Handshake Services dict as com.apple.mobile.lockdown.remote.trusted. Services census: 62 total, 40 classic-lockdown + 22 RemoteXPC — one Go handler unlocks 40 endpoints. D.6.6 implements the handler. See iter-11-lockdown-decode/findings.md. D.6.5 control (same day) FALSIFIED the lockdown back-connect hypothesis. On SPIKE backend with devicectl device info details trigger, CDS makes zero SYNs to any advertised service port — it bails upstream of Services dict consultation. Revised causal model: CDS evaluates a pair/trust signal after Handshake and refuses to proceed when reading "unpaired / untrusted" (which SPIKE signals). iter-10's :50367 flow was opened by pymobiledevice3 (no trust gate), not by CDS (has trust gate). D.6.6 plan revised to research-a (Handshake Properties dict diff) + research-b (per-field patching) preceding the handler implementation. See iter-12-spike-control/findings.md.

Phase D.6.6-impl sub-task 1 — lockdown handler + dynamic RSD port discovery (06ceda8..281120f)

Sub-task 1 of Phase D.6.6-impl per ADR-0009 §Consequences landed across sessions 12 + 13. Session 12 (06ceda8) added the classic-lockdown 3-verb handler at [::1]:50367 with self-bootstrap via a Go classic-lockdown client, plus the FORCE per-session docs-read pass (4ab1a3c) that hard-blocks non-Read tools until every entry of scripts/iosmux-session-required-reads.txt has been read. Session 13 (2026-04-28) closed two empirical questions and shipped the matching docs sync.

Q-D66-13 — RESOLVED (b2da3f6 + docs 0bcf2b9, 0683d71, 1e96f56). iPhone Services-dict ports rotate per tunneld session. Verified live across two consecutive iosmux-restore.sh cycles: Round 1 lockdown.remote.trusted=54311, Round 2 =54421. The session-12 iPhoneLockdownPort=50367 constant was an iter-10 captured value mis-promoted to a constant — replaced by a native Go RSD-greeting client (internal/lockdown/rsd_discovery.go) that opens an h2c connection to [tunnel-address]:tunnel-port, receives the iPhone's unilateral big-Handshake DATA frame on stream 1, decodes the XpcWrapper payload via internal/xpc/, and returns the Services dict for downstream port lookup. Full backend self-bootstrap chain documented in architecture/connection.md §"Backend self-bootstrap chain". Q-D66-13 Resolution log in plans/d66-research-questions.md; iter-11/findings.md carries an inline !!! warning admonition that "50367 in this doc is iter-10's per-session value, not a stable port".

Q-D66-1 — RESOLVED (0d6b926). Sub-task 1 alone is insufficient to bring CDS to the lockdown handler. Wide-pcap on havoc with SPIKE tunneld + devicectl device info details trigger captured exactly one TCP SYN (to the greeting listener ::1:34719) and zero back-connects to any service port — not :50367 (synthetic listener), not :55493 (fixture-advertised), not the discovered live :54421. CDS bails upstream of consulting the Services dict, mirroring iter-12's prediction precisely with sub-task 1 deployed as control. Pcap held local at ~/backups/iosmux/pcaps/iosmux-d13-q1.pcap (host-side, gitignored).

Implication for the plan. Sub-task 3 (Mercury Codable interceptor for AcquireDeviceUsageAssertion and the static-success action set per ADR-0009 §Consequences) is now empirically the critical path; sub-task 1 stands as load-bearing infrastructure (lockdown surface honest and byte-correct, ADR-0006-compliant) that will receive traffic only once sub-task 3 makes CDS progress past the Mercury action layer. Sub-task 2 (logging-only Mercury interpose to capture the Codable Output bytes that resolve Q-D66-2) is the prerequisite for sub-task 3 — see the Phase D.6.6-impl sub-task 2 section below.

Phase D.6.6-impl sub-task 2 — Mercury XPC capture rig (dc4d004 + dffe662)

Phase 2 of Phase D.6.6-impl per ADR-0009 §Consequences. The capture rig is a logging-only DYLD_INTERPOSE bank in inject/iosmux_mercury_logger.m that runs inside the CoreDeviceService process, filters every libxpc message by the top-level mangledTypeName key, redacts PII in length-preserved form, and dumps each matched event to /tmp/iosmux-mercury-events.jsonl plus /tmp/iosmux-mercury-raw/<seq>-<dir>.{bin,txt} on havoc.

Send side (dc4d004, session 13). Three interposers on the xpc_connection_send_message family captured CDS-as-sender traffic. A session-13 trigger run produced 7 events confirming the Mercury envelope shape — a 2-key xpc_dictionary {mangledTypeName, value} where value is itself a structural xpc_dictionary, NOT an opaque Codable archive. This finding materially simplifies sub-task 3: synthetic Output replies become xpc_dictionary_create plus recursive value-dict construction, no opaque-archive forging required, every byte sourced from real captures (ADR-0006-compliant). Full envelope analysis in docs/research/coredevice-internals/mercury-envelope-empirical.md.

Recv side (dffe662 + b252961, session 14). A session-14 verification run drove the full Pair-button flow against the synthetic device and produced 20 events — all of them CDS→Xcode broadcasts. The priority targets AcquireDeviceUsageAssertionActionDeclaration and PairActionDeclaration were absent because the send-only rig had zero coverage on Xcode→CDS messages (which reach CDS as event-handler callbacks, not as CDS-side send calls). Commit dffe662 adds a fourth DYLD_INTERPOSE on xpc_connection_set_event_handler that wraps the user-supplied event handler block, filters and dumps incoming Mercury dicts with dir="recv", then forwards to the original block. Capture happens before CDS dispatches the message into Codable invoke, so byte shapes are preserved even when downstream dispatch crashes (the session-14 Pair flow produced an EXC_BAD_ACCESS in swift_retain whose stack returns through CDS+0xB89B, the byte after the S1.B passthrough trampoline; that crash is a separate downstream issue, not in scope for the capture rig).

A first deploy of dffe662 showed the wrap fired correctly — DeviceManagerCheckInRequest, ProvisioningProvidersListRequest and RemotePairing.ServiceEvent were caught — but the priority PairActionDeclaration and AcquireDeviceUsageAssertionActionDeclaration events still did not appear in JSONL. Static disasm research (notes/iosmux-mercury-listener-disasm-summary.md, gitignored; distilled findings in mercury-envelope-empirical.md §"Pair action invocation envelope") verified Mercury has exactly two xpc_connection_set_event_handler block-API call sites and both ARE caught by our existing interpose; the gap was a filter mismatch, not a missing interpose target. Apple delivers Mercury action invocations through a different envelope shape (flat dict with CoreDevice.actionIdentifier discriminator) on the same XPC connection, and the pre-existing top-level-mangledTypeName filter rejected them.

Commit b252961 relaxes the recv path filter only (send / reply / named-conn paths stay strict): when a dict arrives on an anonymous listener-accepted peer without a top-level mangledTypeName, log it with mangled=null. Empirically verified: zero loose-recv events on baseline, exactly one loose-recv event during a Pair click — the full Apple Pair action invocation envelope keyed by CoreDevice.actionIdentifier = "com.apple.coredevice.action.pair", with CoreDevice.deviceIdentifier, CoreDevice.invocationIdentifier, CoreDevice.input.endpoint siblings. First empirical evidence that the Mercury action protocol is structurally distinct from the Mercury Codable event protocol on the same connection. Full envelope shape + implications for sub-task 3 in docs/research/coredevice-internals/mercury-envelope-empirical.md §"Pair action invocation envelope (session 14 Round 3, 2026-04-28)". Q-D66-2 status moves to "Pair INPUT captured; OUTPUT and other action IDs still pending"; sub-task 3 GAMBIT can now design against an empirical envelope rather than against the older Mercury-Codable-only assumption.

Background: docs/research/coredevice-internals/mercury-envelope-empirical.md §"Coverage limitation discovered 2026-04-28 (session 14)" + the Q-D66-2 Resolution log in docs/plans/d66-research-questions.md.

Phase D.6.6-impl sub-task 3 design-research — Mercury action interceptor (GAMBIT)

Two rounds of static disasm research on havoc (2026-04-28) produced the implementation-ready design baseline for the Mercury action interceptor. Findings distilled in docs/research/coredevice-internals/mercury-action-interceptor-design.md:

  • Hook target: prologue of CoreDeviceUtilities.invoke(anyOf:usingContentsOf:) — file offset 0xff50 on x86_64 (0xfeb0 on arm64e). Resolution via dlsym(RTLD_DEFAULT, "<mangled>"), NOT hardcoded offset.
  • Mechanism: inline 13-byte prologue patch mirroring the proven S1.B trampoline pattern at inject/iosmux_inject.m:1463–1525. DYLD_INTERPOSE does NOT work — Swift internal calls go through direct callq rel32, not __got indirect.
  • Reply construction: xpc_dictionary_create_reply(request) + echo envelope keys (actionIdentifier, invocationIdentifier, deviceIdentifier, coreDeviceVersion) + empty CoreDevice.output + send via xpc_remote_connection_send_message (or fallback). NO CoreDevice.error key — empirically that key does not exist in CoreDevice / CoreDeviceUtilities.
  • Allow-list: 15 actions per ADR-0009 §Consequences exact (Pair, Unpair, AcquireDeviceUsageAssertion, ListUsageAssertions, Connect, Disconnect, Tags, GetTrainName, DarwinNotificationObserve/Post, Enable/DisableDDIServices, FetchDDIMetadata, Update/RemoveHostDDIs). All 15 have empirically Output == Void. lockstate explicitly excluded — returns non-Void LockState struct, static-success would lie.
  • Failure mode: passthrough (tail-call original via trampoline) on any internal error.
  • Coexistence: GAMBIT's reply-send goes through the existing Mercury logger interposes — free audit trail of synthetic replies in JSONL with dir="send". No re-entrancy.

Three deploy-time empirical bits remain: exact Codable Void encoding for CoreDevice.output (empty dict / absent / null — env-gated try-order on first deploy), whether progress events on the input endpoint are required, whether coreDeviceVersion must echo verbatim. Sub-task 3 implementation lands the hook + 15-action allow-list; first deploy iterates the three plausibilities until Xcode accepts the synthetic reply.

Phase D.6.6-impl sub-task 3 — GAMBIT iter 1-17 schema empirically resolved (b808eae..49c8f1e)

Sub-task 3 implementation went through 17 deploy-and-click iterations on havoc with a live iPhone (2026-04-28). Each iteration peeled exactly one Codable schema element by reading the Apple decoder error out of the unified log (subsystem com.apple.dt.coredevice) and committing one fix. By iter 17 Apple's decoder accepts the entire PairActionDeclaration.Output = CoreDevice.DeviceConnectionChangeResult reply: the unified log shows Pairing attempt completed with error nil, and Apple's CustomString representation reproduces our synthetic data verbatim including pairingState: Paired, the real-iPhone ECID 1224229469044766, hardwareModel: t8110, and the bridged name: "iPhone (iosmux)".

Material falsifications of the design-research starting hypotheses:

  • Output is NOT Void. Apple's decoder named the actual type on iter 3 — CoreDevice.DeviceConnectionChangeResult, a struct with two required fields (outcome, updatedSnapshot). The round-2 disasm conclusion of "Output == Void per absent type descriptor" was incorrect; the descriptor exists at a stripped symbol the initial probe didn't reach.
  • DeviceIdentifier uses CUSTOM Codable with named keys. Not Swift auto-synth's positional _0/_1 for the (UUID, String) associated values — the keys are identifier and domain. Discovered via iter 11 → 12 decoder errors.
  • Apple's xpc-Codable bridge is strict-typed for UUID and Int64. Every UUID slot wants raw xpc_uuid (16 bytes); every Int64 wants xpc_int64 not xpc_uint64. The auto-synth-vs-custom-Codable distinction does NOT loosen this. Falsified via iter 11/14 (UUID) and iter 16 (Int64).
  • Same case-name strings live in DIFFERENT enum types. connected/disconnected literals in strings(1) belong to DeviceState (String rawValue used at DeviceInfo.state) AND to the auto-Codable enum used at DeviceStateSnapshot.state — two separate types with two separate wire forms despite the same case names. Falsified via iter 14 typeMismatch on DeviceInfo.state.

Optional DeviceInfo fields are populated from the live RSD handshake (iosmux_rsd_get_handshake_properties() — exposes the 46-key dict captured at handshake time). Real-iPhone data sourced for productType, osVersion, osBuild, udid, serialNumber, hardwareModel, ecid — never fabricated, per ADR-0006. If the handshake hasn't completed yet (returns NULL), GAMBIT skips ALL optional fields and logs a warning rather than substituting defaults.

Authoritative byte-level schema with every type form, every wire encoding, the iter 1-17 decoder-error trail, and the five schema-quirk insights is in docs/research/coredevice-internals/gambit-pair-action-schema.md — now a project anchor. Reference it when extending GAMBIT to other allow-listed actions whose Output types might be different.

Outstanding gate: the Pair button does NOT disappear after the successful reply. Xcode UI transitioned to a NEW state never observed before — a spinner saying "Xcode has already started pairing… Follow the instructions on iPhone (iosmux) to complete pairing." DVTFoundation's log at the same instant logs skipping implicit preparation for device being ignored with reason: deviceIsUnpaired despite our pairingState: Paired. Xcode UI binds to a separate signal independent of PairAction's Output — likely _shadowUseAssertion.fulfilled (per pair-button-and-cfnetwork.md §"Two gates, not one" admonition) AND/OR a Mercury RemoteDeviceStateUpdatedEvent broadcast that GAMBIT does not yet emit. Tracked as Q-D66-14 in d66-research-questions.md. Next-iter direction: Path B — synthesise a RemoteDeviceStateUpdatedEvent Mercury broadcast from the inject side after the PairAction reply, so KVO observers see a state-change event (not just an action reply containing a snapshot).

Adjacent infrastructure cleanups landed in the same session: 8039029 taught scripts/iosmux-d66-deploy.sh to set IOSMUX_HANDSHAKE_UUID=random (the D.6.1 fix that was previously operator-managed); a47fb52 rewrote the deploy script's ssh launch to a bash -s heredoc to dodge a remote-zsh bracket-glob trap on [::1]: arguments; 281120f extended the same heredoc shape to iosmux-tunneld-mode.sh and added defensive variable-quoting in iosmux-restore.sh so all three ssh-launch sites share one shape.

Phase D.6.6-impl Phase 1 — spinner gate broken + per-action Output dispatch (5de52be..28d7d89)

Session 16 (2026-04-29) closed the post-pair UI spinner gate, the biggest single empirical milestone since devicectl list devices landed. After 17 click iterations of GAMBIT's PairAction reply (session 14) and a separate Path B RDSUE broadcast attempt (commit 5de52be), Phase 1 of the implementation plan (notes/iosmux-implementation-plan.md gitignored, host-side) landed three improvements:

Three research probes seeded the fix. Across session 16, agent-driven probes catalogued the Xcode-side gate machinery:

  • iosmux-uigate-probe.md identified the spinner gate as DVTCoreDevice_Impl.kvoCache_isPaired : Swift.Bool — a private Bool ivar consumed by DVTDeviceOperation._connectAndPrepareImplicitly's "skipping implicit preparation" decision.
  • iosmux-cdstream-probe.md traced the (UUID, DeviceInfo) stream that writes the gate, and identified DeviceStateSnapshot.monotonicIdentifier as the RDSUE-broadcast filter — RemoteDevice.updateState silently drops snapshots whose monotonicIdentifier is ≤ the previously cached value.
  • iosmux-afm-delivery-probe.md established that AcquireDeviceUsageAssertion's success-side payload is delivered as a SEPARATE AssertionFulfilledMessage event over the Input.endpoint mach-port side-channel, not over the action's reply channel. AUA Output is Void.

Three commits landed Phase 1. Commit 648f531 (fix(inject/gambit): seed monotonicIdentifier from mach_absolute_time) seeded GAMBIT's monotonic counter from mach_absolute_time() at install time (nanoseconds-since-boot, ~10^11+ baseline) so our RDSUE broadcasts are strictly newer than Apple's session-local counter. Commit b440a92 introduced per-action Output dispatch replacing the blanket DeviceConnectionChangeResult-for-all-15-actions mode with a per-aid switch using empirically-derived Output types from iosmux-actions-output-probe.md. Commit 1e8ef81 added gambit_emit_afm_via_endpoint which extracts Input.endpoint from the AUA request, calls xpc_connection_create_from_endpoint, and pushes a synthesised CoreDevice.AssertionFulfilledMessage event on that connection post-reply. Commit 28d7d89 deferred the connection cancel via xpc_connection_send_barrier after observing the inline cancel race silently drop AFM messages on first deploy.

Empirical milestone. First click after 648f531 deploy: Xcode spinner cleared. Device row transitioned out of "Xcode has already started pairing…" state for the first time in project history. Device row shows full info — Model, Serial, Identifier, all synthesised from real iPhone Handshake properties. The deviceIsUnpaired log line stopped firing after our PairAction reply lands. Q-D66-14 is RESOLVED — the spinner gate is kvoCache_isPaired and we have full control of it.

Outstanding cosmetic gap (Q-D66-15). Device row label remains "Disconnected" with a "Failed to acquire assertion" error. AFM endpoint emission is verified on our side (GAMBIT: emitted AFM endpoint=... in inject log) but Apple unified log shows zero "Successfully acquired usage assertion" lines and zero decoder errors. The _shadowUseAssertion.fulfilled field stays nil, so hasConnection evaluates false and the UI category bucket reads "Disconnected". Per pair-button-and-cfnetwork.md §"Pair button gating" this gate controls UI category and Pair button render — NOT install/debug/run pipelines (those flow through the existing remote_service_create_connected_socket interpose chain per ADR-0009 §Consequences). Three remaining hypotheses for the AFM acceptance failure are catalogued in Q-D66-15 in d66-research-questions.md; deferred as cosmetic until install/debug pipelines empirically fail because of _shadowUseAssertion=nil.

Session 10 realizations — bypass audit + transport audit

Two course corrections happened in session 10. The full story lives in plan-stage2-pair-flow.md; short versions follow.

Bypass audit (pre-S2.A). S1 finished with devicectl list devices working, but the very next smoke test revealed that several Stage-1 decisions had been made in bypass mode: four DeviceInfo fields were force-written to make CDS believe the device was further along its lifecycle than it really was (pairingState=.paired, preparednessState=.all, areDeveloperDiskImageServicesAvailable=true, and state=.connected at a stage where CDS had not yet observed a transport). Plus an earlier proposal from the same session (intercept acquireUsageAssertion to make the Pair button disappear) was in the same bypass family. All removed from planning. Stage S2.A reverted the three truly-fake fields; state=.connected was kept on second pass because the link physically exists and the value is truthful.

Transport audit (S2.B). With the inject honest, clicking Pair produced an immediate TCP RST on CDS's nw_connection to the tunnel RSD endpoint — not a crash, not a lie CDS was checking, not a missing DeviceInfo field. CDS simply cannot speak pymobiledevice3 tunneld's transport. The earlier assumption that "a real pair flow over the real tunnel will eventually succeed" was itself a bypass of the transport-mismatch reality. The forward direction is no longer "let the real pair flow run naturally"; it is Option δ: interpose CDS's service-open call chain and redirect it to a local pymobiledevice3 backend that already speaks the right transport, translating Python-side results back into the shapes CDS expects. This generalizes the pattern our MDRemoteServiceSupport interposers already use for property queries.

Research index

Session 10

  • s2b-pair-attempt-log.md — Phase B deliverable. Full capture of the Pair-button smoke test: CDS's nw_connection to the tunnel RSD endpoint, the immediate TCP RST, the ground-truth verification that pymobiledevice3 clients work on the same tunnel, and the falsification of Options α/γ in favor of Option δ.
  • s2c-self-experiment/FINDINGS.md — Phase C self-experiment deliverable. Hex-level breakdown of the captured CDS → spike-endpoint bytes (HTTP/2 preface + SETTINGS + empty-headers stream opens + XpcWrapper DATA frames), the per-PID wire-log from the new iosmux_wire_logger.m interposers, and the sandbox syscall probe results. Three blockers (X / Y / Z) resolved, Shape B declared viable, Go pivot committed. Raw artifacts in the results/ sibling directory.
  • s2c-self-experiment/README.md — runbook for the self-experiment (install patched pymobiledevice3, deploy inject, start listener, trigger CDS, collect artifacts). Still useful as the entry point for any future spike run against a different CDS build.

Session 9 (delivered S1)

  • session8-five-questions.md — the Q1..Q5 synthesis from session 8 that seeded the rebuild.
  • s1a-properties-audit.md — properties source compatibility audit for S1.A.
  • s1c-managed-service-devices-registration.md — first-pass research on how SDRs enter managedServiceDevices. Superseded by the disasm doc below after runtime verification.
  • s1c-static-browser-disasm.md — deep disasm of the registration chain, the SDM install browser path, and the Q-A..Q-F clarifying investigations that produced the Q-C enum tag finding. This is the heaviest single research doc; future sessions should grep it before disassembling anything in CoreDevice.

Session 8 (consolidation + preparation)

  • rsd-info-ios2641-reference.json — captured pymobiledevice3 remote rsd-info output. Reference for which property keys actually come off the iPhone vs. which we synthesize.

Earlier sessions (historical)

Other plans