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 Resolution log — every probe / failure / pivot.
- AUA side-channel mechanism — the architectural anchor for the race we're fighting.
- GAMBIT design — Mercury action interceptor that gets us this far.
- GAMBIT Pair-action schema — full Codable schema iter 1-17 trail.
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
_completionGuardsemaphore. - D37-C —
Continuation.resume(returning:)ABI mapped. Hooks #1/#2/#3 deprecated as denial-masking. - D37-D — static localised upstream release to
Mercury.SystemXPCListenerConnection.deinitat 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_deinitat Mercury+0x4e960. FALSIFIED: Path-B fires 11.0 ms; entire dealloc skipped, no effect. - D39 — Xcode-side
hasConnection.getterdirect override at DVT+0x19e10. 6-byte patchb8 01 00 00 00 c3viamach_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.mdadopted: every probe MUST tag apparatus state. Past 0/N baselines may be state-conditional. Comprehensive backup of all Apple bundles + signatures atrecovery-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:
- Hook AUA wrapper FUNCTION ENTRY at CD+0x31750 (
acquireDeviceUsageAssertion(...) async throws). Synthesise aDeviceUsageAssertionsuccess result directly. Bypass the race, not fight it. - Xcode-side direct intervention: hook DVTCoreDeviceCore's
_shadowUseAssertion.fulfilledsetter directly. Pure UI-gate flip via DYLD_INSERT into Xcode itself. - 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.
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.pybyte for byte. A Go HTTP/2 server can serve this. - Y (which PID) — empirically the same PID our
LC_LOAD_DYLIBinject loads into. No helper process, noremotepairingddelegation. 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.md —
authoritative 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 aRST_STREAM(s3, STREAM_CLOSED)on a dispatch-ordering bug (big Handshake fires onDATA(s1)instead ofDATA(s3)); iter-3 relocated the trigger ontoDATA(s3)and reproduced the pcap emit order exactly, but CDS still ended with the sameRST_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-Noteso 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 anIOSMUX_SPIKE=1-gated pymobiledevice3 tunneld patch). First DATA frame on stream 1 carries an XpcWrapper / XpcPayload blob whose magic bytes (0x290bb092,0x42133742) match pymobiledevice3'sremote/xpc_message.pybyte for byte. Go'sgolang.org/x/net/http2.Framercan serve this directly. - Blocker Y (which PID) — every
nw_connection_*call logged by the newiosmux_wire_logger.mDYLD_INTERPOSE bank came from the same PID ourLC_LOAD_DYLIBinject loads into. No helper process, noremotepairingddelegation. 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, andsendmsg()withSCM_RIGHTS. All four returnederrno=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 offset0xff50on x86_64 (0xfeb0on arm64e). Resolution viadlsym(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 directcallqrel32, not__gotindirect. - Reply construction:
xpc_dictionary_create_reply(request)+ echo envelope keys (actionIdentifier,invocationIdentifier,deviceIdentifier,coreDeviceVersion) + emptyCoreDevice.output+ send viaxpc_remote_connection_send_message(or fallback). NOCoreDevice.errorkey — 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.lockstateexplicitly excluded — returns non-VoidLockStatestruct, 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/_1for the(UUID, String)associated values — the keys areidentifieranddomain. 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 wantsxpc_int64notxpc_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/disconnectedliterals instrings(1)belong toDeviceState(String rawValue used atDeviceInfo.state) AND to the auto-Codable enum used atDeviceStateSnapshot.state— two separate types with two separate wire forms despite the same case names. Falsified via iter 14 typeMismatch onDeviceInfo.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.mdidentified the spinner gate asDVTCoreDevice_Impl.kvoCache_isPaired : Swift.Bool— a private Bool ivar consumed byDVTDeviceOperation._connectAndPrepareImplicitly's "skipping implicit preparation" decision.iosmux-cdstream-probe.mdtraced the(UUID, DeviceInfo)stream that writes the gate, and identifiedDeviceStateSnapshot.monotonicIdentifieras the RDSUE-broadcast filter —RemoteDevice.updateStatesilently drops snapshots whosemonotonicIdentifieris ≤ the previously cached value.iosmux-afm-delivery-probe.mdestablished that AcquireDeviceUsageAssertion's success-side payload is delivered as a SEPARATEAssertionFulfilledMessageevent over theInput.endpointmach-port side-channel, not over the action's reply channel. AUA Output isVoid.
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_connectionto 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.minterposers, and the sandbox syscall probe results. Three blockers (X / Y / Z) resolved, Shape B declared viable, Go pivot committed. Raw artifacts in theresults/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-infooutput. Reference for which property keys actually come off the iPhone vs. which we synthesize.
Earlier sessions (historical)¶
- action-interception-full-picture.md — early action-dispatch research. Flagged in multiple later sessions as containing bypass-oriented conclusions; read with skepticism. Parts of it remain useful for understanding CoreDeviceUtilities internals.
- coredevice-injection.md — general CDS dylib injection mechanics.
- coredevice-representation.md
—
ServiceDeviceRepresentation/ServiceDeviceManagerdata structures as we understood them pre-S1. - coredevice-xpc-protocol.md — Mercury XPC dispatch, action identifiers, device discovery pipeline.
- os-remote-device-api.md — OS_remote_device C API, ivar layout, connection handling.
- rsd-wrapper-init-analysis.md
—
RSDDeviceWrapper.initcall chain analysis. - pair-button-and-cfnetwork.md
— the
_shadowUseAssertion/hasConnectionresearch that seeded theacquireUsageAssertionbypass idea we later rejected in session 10. Useful for the Xcode-side architecture; skip the "make Pair button disappear" prescriptions. - remotexpc-protocol.md — RemoteXPC byte-level protocol.
- l2-bridge.md — L2 bridge architecture.
- code-audit-findings.md — the Stage 0 audit that produced the six safety-belt fixes.
- phase2-wrapper-fix-test-results.md and phase2-behavior-analysis.md — test records from the wrapper read-overflow fix deployment.
Other plans¶
- plan-forward-roadmap.md — superseded
by
plan-stage1-rebuild.mdand thenplan-stage2-pair-flow.md. Kept for history. - plan-full-xcode-integration.md — early architecture overview.
- plan-cds-rsd-inject.md — CDS inject implementation details and open issues from before S1.
- plan-next-steps.md — historical snapshot of workstream progress from sessions 5-6.