Stage S2 — Honest pair flow¶
Status: reviewed — forward plan, active
Phases A, B, and C have landed. Phase C's self-research queue
(Q1-Q5) is fully resolved; Q3's
replay-based investigation closed across iter-0 through iter-4:
reference dispatch table built from pcap (iter-0), upfront
replay rejected with PROTOCOL_ERROR (iter-1), event-driven
dispatch clears HTTP/2 but REPLY_CHANNEL still closes (iter-2),
strict pcap ordering disproves the ordering hypothesis (iter-3),
dropping our reciprocal #9 RST_STREAM(s1) removes CDS's
application-layer teardown and CDS reaches a quiescent state
(iter-4). Pure replay has reached its ceiling. Further s2c
progress belongs to Phase D backend work (dynamic response to
CDS's post-handshake RemoteXPC service calls). Phase D (Go-only
Option δ backend) is specified but not yet implemented.
Date: session 10 (post S2.B landing) Supersedes: the original Option α/γ framing of this document, which Phase B empirically falsified. Philosophy: CDS must be told the truth about device state, and the code it runs to make things happen must either actually work, or be actively shimmed through a backend we know works. No bypass, no lies.
The Phase A realization (unchanged, landed)¶
Stage S1 finished with devicectl list devices returning the virtual
iPhone end-to-end. Opening Xcode's Devices window revealed that four
pieces of DeviceInfo had been force-written by the inject to make
CDS believe the device was further along its lifecycle than it was:
DeviceInfo.pairingState = .pairedDeviceInfo.preparednessState = .allDeviceInfo.areDeveloperDiskImageServicesAvailable = trueDeviceInfo.state = .connected
Phase A removed the first three outright. The fourth (state) was
temporarily removed and then restored when empirical test showed
Xcode filters devices whose DeviceInfo.state defaults to
.unavailable (tag 0) — the link physically exists because an
RSDDeviceWrapper with a live tunnel is in process memory, so
.connected is a truthful link-state value, not a lie about
pairing. See inject/iosmux_inject.m around the visibility_setter
/ state_setter block and the tombstone comment there.
Phase A landed in commit 18331ca as "Stage 2.A: stop lying about
device state".
The Phase B realization (new, course-correcting)¶
With the three lies removed, the Pair-button smoke test was run.
Findings are fully documented in docs/research/s2b-pair-attempt-log.md;
the short version is:
- Xcode correctly shows a Pair button,
state=connected (no DDI),pairingState=pairingInProgress— all the honest pre-pair values. - Clicking Pair causes CDS to invoke
PairActionImplementationthrough our S1.B passthrough trampoline. No crash, no SIGSEGV. - CDS's next step is to open an
nw_connectiononutun4directly to[fdc5:8480:2949::1]:57346(the RSD endpoint address it fetched fromtunneld's HTTP/API) and speak HTTP/2 on it. - The connection is immediately reset by the peer. Retries
identical.
com.apple.remotepairingsubsystem never emits a single event, because CDS never gets past the TCP-level failure. - The tunnel itself is not broken. A pymobiledevice3 Python client using the same tunneld instance and the same RSD endpoint successfully pulls the full handshake, queries DDI mount state, and lists the iPhone's root filesystem via the DVT developer service. User-confirmed: DDI mount, service calls, and filesystem access have all worked repeatedly via pymobiledevice3 in previous sessions.
The pre-Phase-B assumption (Option α: "let the natural CDS pair path
work through the existing tunnel") is now empirically false. CDS and
pymobiledevice3 are two independent client implementations of the
RSD-family transport, and CDS cannot speak to a channel produced by
pymobiledevice3 tunneld. Option γ ("audit our existing DYLD_INTERPOSE
so the pair service is forwarded correctly") is also falsified: the
pair path does not flow through remote_service_create_connected_socket
or any other MobileDevice/RSD C API at all — it goes straight through
Network.framework (nw_connection), bypassing everything we
currently interpose.
Option β (transplant Linux host's pair record) remains rejected. The problem is not pairing, the problem is that CDS does not speak the tunnel's transport; handing it a pair record would not give it a different transport layer.
Option δ — CDS → pymobiledevice3 backend shim¶
This is the new direction. The short statement is:
Stop trying to make CDS talk to the iPhone. Make CDS talk to pymobiledevice3, and let pymobiledevice3 talk to the iPhone.
The precedent already exists in the current inject:
iosmux_md_proxy.minterposesMDRemoteServiceSupport.retrievePropertiesForUUID:andretrieveNameForUUID:and answers them from an in-memory cache built from the pymobiledevice3 handshake. CDS never actually queries the device for properties — the inject serves them from Python-captured data. This works. No crash, no lie — just a translation layer.
Option δ generalizes that pattern to every CDS → iPhone call. When CDS tries to open a service socket, do a pair handshake, mount a DDI, install an app, or attach a debugger, the request is caught by our inject and routed to a local pymobiledevice3 session that does the real work on its existing working tunnel. The return value (socket fd, response bytes, Codable envelope, whatever) is handed back to CDS in the shape CDS expects.
This reframes Stage 2 from "observe the real pair flow" to "build the smallest shim that makes CDS's attempts succeed by delegating them". It is more work than α/γ would have been, but α/γ were physically impossible given the transport mismatch — this is the first plan that is actually compatible with the ground truth.
Phase B — done¶
Deliverable was docs/research/s2b-pair-attempt-log.md. Landed
alongside the earlier rewrite of this plan. Identified three
blockers — X (client-side protocol), Y (which PID runs the call),
Z (sandbox SCM_RIGHTS capability) — that Phase C was tasked with
resolving.
Phase C — self-experiment (done) + remaining research queue¶
What the self-experiment closed¶
Full write-up in
docs/research/s2c-self-experiment/FINDINGS.md.
Short version:
- X (client side) — CDS speaks plain prior-knowledge HTTP/2
cleartext (h2c) to the tunnel endpoint. No TLS, no ALPN. The
24-byte HTTP/2 connection preface arrives first, then a stock
SETTINGS frame whose values (
MAX_CONCURRENT_STREAMS=100,INITIAL_WINDOW_SIZE=1 MiB) match pymobiledevice3's own reverse observations byte for byte. Above HTTP/2 CDS uses RemoteXPC DATA frames on stream 1 (ROOT_CHANNEL) and stream 3 (REPLY_CHANNEL) with XpcWrapper / XpcPayload framing whose magic bytes (0x290bb092,0x42133742) match pymobiledevice3'sremote/xpc_message.pyexactly. The empty-HEADERS stream-open pattern CDS uses is unusual but compatible withgolang.org/x/net/http2at the framer level. - Y — every
nw_connection_*call observed in the experiment came from the same CoreDeviceService PID ourLC_LOAD_DYLIBinject loaded into. No helper process, no delegation toremotepairingd. The earlier "three CoreDeviceService PIDs" observation turned out to be concurrent launchd-managed instances of the same binary (user session / system session / log-stream-ephemeral), and the one handling each request is whichever the caller's XPC connection resolves to. Our current injection reaches it. - Z — CDS's sandbox permits, at runtime, each of the four
Shape B primitives we probed:
socket(AF_UNIX, SOCK_STREAM),socketpair(),bind()to/tmp/*.sock, andsendmsg()with anSCM_RIGHTSancillary block. All returnederrno=0. The devil's advocate attack ranked "sandbox denies SCM_RIGHTS" as a 55% chance of killing Shape B — it is now 0%, empirically verified.
Shape B is therefore architecturally viable. The hook site exists in the right process, the sandbox allows the required primitives, and the client-side transport is standard enough for a Go HTTP/2 server to speak.
Phase C research — all five questions resolved¶
The self-experiment did NOT tell us what the server side of the
conversation should look like — our listener never replied to CDS's
first DATA frame, so the session stalled at that point. Five
research questions (Q1..Q5) were tracked in a dedicated standalone
file: plan-stage2-phase-c-queue.md.
All five are resolved as of 2026-04-18, on havoc, self-contained, no
guessing. Short summary:
- Q1 (verified) — decoded the full 204-byte CDS → spike capture
via
pymobiledevice3.remote.xpc_message.XpcWrapper.parse. Classification: generic RemoteXPC connection init, byte-exact with pymobiledevice3's own_do_handshake. See Q1-decode.md. - Q2 (verified) —
tcpdumpon havoc'sutun4during a livepymobiledevice3 remote rsd-inforound-trip shows plaintext HTTP/2 prior-knowledge (h2c), no TLS. See Q2-utun4-capture.md. - Q3 iter-0 (verified) — reference dispatch table built from
the Q2 pcap. The iPhone unilaterally sends a single 14 KB
MessageType="Handshake"DATA frame on stream 1 (62 services + 46 properties) right after h2c init, then RSTs the stream with NO_ERROR. Stream 3 (REPLY_CHANNEL) stays open. See Q3-iter-00-pcap-dispatch.md. iter-1+ is implementation work — modify the spike listener to replay this Handshake at CDS, observe CDS's next request, iterate. - Q4 (verified) — pymobiledevice3 ships no "pretend to be
iPhone" server fixture, but its
XpcWrapper.build+_build_xpc_*helpers are direction-agnostic and reusable for server-side replies. See Q4-server-side.md. - Q5 (closed without work) — the post-TLS hook was predicated on Q2 showing TLS. Q2 showed plaintext; Q5 has nothing to do. See Q5-post-tls.md.
Every answer is tied to a raw artifact on disk. Any residual
unknown is named explicitly in the per-question doc (the 0x200
unnamed flag bit and the message_id=1 skip are the two minor
gaps still in the books; neither blocks Phase D).
Still open — carried over from earlier¶
C.7 is backlog-priority until we see Phase D behavior
We assume the DeviceIdentifier.uuid(UUID, String) second slot
does not affect pair-flow correctness, only identity-UI
presentation. This is unverified — the assumption would be
promoted to verified by either a disasm note in Phase D's
CoreDevice work or by an empirical test that toggles the string
and observes Xcode's reaction. Until then treat any Phase D bug
that names this slot as potentially related.
C.7 DeviceIdentifier.uuid(UUID, String) second slot — what
does CDS do with the String payload? This is a small identity-UI
question that only matters once the pair flow otherwise works. It
stays in the backlog and gets answered by the same disasm work
that will show up in Phase D if we discover a related behavior
while implementing the backend.
Closed by Phases B + C¶
"iOS 17+ pair service endpoint — find name and port"→ the endpoint is the unified RSD endpoint[tunnel-address]:tunnel-port, not a per-service port."does CDS reach the pair service via→ no, it goes throughremote_service_create_connected_socket"nw_connectiondirectly."what does default unpaired DeviceInfo look like on a real iPhone"→ answered empirically: removing our three lies produces Xcode's "unpaired, connected, no DDI, Pair button visible" UI, which is exactly a fresh iPhone's baseline."does the physical iPhone accept pair from a new host while already paired with Linux"→ moot; Option δ routes through the existing pair record thatpymobiledevice3 tunneldalready holds."CDS's own service-open call chain for PairAction"→ resolved empirically by the wire logger. The path isPairActionImplementation.invoke→ Swift async continuation → RemotePairing.framework → Network.frameworknw_connection_create(_with_connected_socket). All in-process in the same CDS instance."CDS's expected client-side protocol"→ plain prior-knowledge h2c + RemoteXPC DATA frames, perFINDINGS.md§X."Best interposition point"→ the spike override in pymobiledevice3 tunneld is already sufficient to redirect CDS without an inject hook onnw_connection_create_*at all, and the Go backend will own the redirected listener endpoint directly. See Phase D for the concrete shape."Backend architecture: embedded Python vs sibling daemon vs Go"→ Go, not Python, as a first-class principle. Rationale in Phase D."Surface area of pair flow in pymobiledevice3"→ known: the RemoteXPC message types we need to handle are the ones CDS actually sends on streams 1 and 3 of its h2c session. Q1-Q3 will enumerate them one by one."pymobiledevice3 pair record storage and survival"→ already answered in an earlier research pass: Ed25519 pair records live in~/.pymobiledevice3/remote_{udid}.plist, survive tunneld restarts, trigger the physical Trust prompt only on the very first pair. The Go backend does not touch pair records directly — the existing pymobiledevice3 tunneld subprocess keeps owning that state.
Phase D — implementation (shape locked by Phase C findings)¶
Phase D delivers the Option δ shim. Its shape is now fixed by what the self-experiment found.
Principle: new code is Go, Python stays where it already is¶
All new production code for Phase D is Go. The project has been Go-based from the start; introducing a second production language on a component we plan to delete in Phase E (the "remove Python" milestone) would create exactly the inertia we want to avoid. Python involvement is strictly limited to:
pymobiledevice3 remote tunneld— the existing subprocess we already run on havoc. It keeps owning TLS 1.2 PSK handshake to the real iPhone, pair identity management, and the utun tunnel lifecycle. Go talks to it over HTTP on loopback, exactly as it does today. We do not ship any NEW Python production code.- Spike/research tooling in
docs/research/s2c-self-experiment/(the Python HTTP/2 listener, one-shot capture scripts). These are research artifacts, never run in production, deleted with the rest of Stage S2's spike infrastructure when the Go backend ships.
If a future step genuinely needs server-side XPC logic that is
only implemented in pymobiledevice3 today, the contained fallback
is a narrow RPC subprocess (single pymobiledevice3-backed
Python process driven by Go over a Unix socket with a few RPC
methods) — but only as a contained compatibility shim with an
explicit Go porting plan per method. We do not write a full
Python backend and call it "temporary".
Components¶
The Go backend is a new command under
cmd/iosmux-backend/ in this repo. It is a single long-lived
process, launched from iosmux-restore.sh (or a LaunchAgent on
havoc once we productize), that:
- Listens on a loopback TCP endpoint — the one tunneld
advertises via its HTTP API response. During spike/research
this is driven by the
IOSMUX_SPIKEenv-var patch (docs/patches/pymobiledevice3/0001-iosmux-spike-tunneld-endpoint-override.patch); in production the same mechanism can be generalized to an always-onIOSMUX_BACKEND_ADDRoverride, or tunneld can be replaced altogether by a Go component that owns the HTTP API. Either way, CDS learns the shim's address through the sameGET /mechanism it already uses. - Speaks prior-knowledge h2c using
golang.org/x/net/http2. Notnet/http.Server(that wants a full request/response model) but the lower-levelFramerinterface, because RemoteXPC uses persistent streams and DATA frames rather than HTTP semantics. Example prior art: Caddy's h2c handler, gRPC-Go's h2c mode — both production. - Implements RemoteXPC on top of the raw frame layer:
- XpcWrapper / XpcPayload encode+decode (magic bytes, flags,
size, msg_id) — ported to Go from
pymobiledevice3/remote/xpc_message.pywith the decoded bytes inresearch/s2c-self-experiment/FINDINGS.mdas a ground-truth test vector. - bplist16 decoding for XPC object payloads.
howett.net/plist(already ingo.mod) handles bplist00 and may handle bplist16 too; if not, the missing bits are a small extension. - Stream routing — messages on stream 1 = ROOT_CHANNEL, stream 3 = REPLY_CHANNEL, one open per channel, handled as two persistent async loops. Matches the empirically-observed stream topology from the S2.C capture.
- Delegates the actual device work to its internal session, which is held open against tunneld's HTTP API for the lifetime of the backend. Every CDS request is translated into either:
- A native Go implementation of the equivalent RemoteXPC call (for calls we fully understand), or
- A
pymobiledevice3-backed fallback over a narrow subprocess RPC (for calls that are only implemented in pymobiledevice3 today, pending a Go port per call). - Materializes replies as XpcWrapper-framed DATA frames written back to CDS on the correct stream. Every reply must come from real data (Phase A's "never lie about state" rule stays binding here).
Concrete sub-tasks¶
D.0— Checkpoint current work (this commit). ✅ done.D.1— Finish the Phase C research queue (Q1-Q5 in the section above) until either the server-side protocol is fully understood or a concrete empirical gap is named. ✅ done (Q1-Q5 verified, Q3 iter-1 through iter-4 closed under replay — see iter-04/findings.md).D.2— Bootstrapcmd/iosmux-backend/with a skeleton that binds a configurable address, logs every accepted connection, and shuts down cleanly on SIGTERM. No HTTP/2 yet. Verify it fits the existinggo build ./...flow. ✅ done —cmd/iosmux-backend/main.go+internal/backend/{backend.go, backend_test.go}. Default listen[::1]:34719matches the SPIKE tunneld advertisement;go test -race ./internal/backend/passes; SIGTERM shutdown verified under 1s.D.3— Wire the Go HTTP/2 framer to the listener. Replicate the empirically-observed server handshake (SETTINGS{MAX_CONCURRENT_STREAMS=100, INITIAL_WINDOW_SIZE=1 MiB}, WINDOW_UPDATE(983041), auto-SETTINGS-ACK). Reuse the existing spike listener's session logs as regression fixtures. ✅ done —golang.org/x/net v0.53.0added; rawhttp2.Framerused directly (nothttp2.Server{}.ServeConn— keeps CDS'sHEADERS len=0minor RFC deviation working). Byte-exactness with the iter-01 iPhone fixture locked by a dedicated test (TestServerHandshakeExactBytes). Preface mismatch path classified (TLS ClientHello via0x16; HTTP/1.x viaG/H/P).ReadFrameis not ctx-aware, so a watcher goroutine closes the conn on ctx cancellation and the framer loop exits onnet.ErrClosed. All 7 tests pass under-race.D.4— Port XpcWrapper / XpcPayload / bplist16 decoders to Go. The hex blob inFINDINGS.md §Xis a unit test vector: the decoder either produces the same object graph pymobiledevice3's parser produces (Q1 decode) or the decoder is wrong. ✅ done —internal/xpc/from earlier project stages already implements the full codec (wrapper.go, types.go, decode.go, encode.go). Note: the stage2.md "bplist16" language is imprecise — XPC uses its own binary format (magic0x42133742, type codes0x00001000/0x00002000/...), not Apple's bplist00/bplist16, sohowett.net/plistis not involved. Four iter-01 regression fixtures added toxpc_test.go: 44 B empty-dict mirror, 24 B sync0x0201, 24 B INIT_HANDSHAKE, 14124 B big Handshake (62 services, 46 properties,MessageType="Handshake",MessagingProtocolVersion=7). All four round-trip byte-exact throughUnmarshalMessage/MarshalMessage.D.5— Implement the ROOT_CHANNEL state machine: accept the first XPC request, dispatch to a stubbed handler, emit a reply, watch CDS proceed to the next step. Dialog-bisect forward using the Q3 methodology. ✅ done —internal/backend/dispatcher.goimplements the iter-4 dispatch table as a per-session state machine; iter-01 payloads embedded via//go:embedininternal/backend/fixtures/.TestDispatcherReproducesIter04drives the full 8-frame CDS client sequence and asserts the server emits exactly 14313 bytes byte-exact vs. iter-04/results/session-01-send.bin, asserting no 10th frame (i.e. no#9 RST_STREAM(s1)). Cross- compileGOOS=darwin GOARCH=amd64produces a 5.47 MiB binary ready for havoc smoke test. Smoke test executed 2026-04-19 with real CDS: server-side output byte-exact vs iter-4 (cmp-clean 14313 B), quiescent state reached identically. See iter-05-go-backend/findings.md.D.6.0— Research step before service handlers. Original D.6 framing ("per-service handlers land one at a time") was too optimistic: D.6.0-A added verbose XPC logging (commit5d47ae5), D.6.0-B deployed it and let CDS run past the iter-4 observation window. Finding: CDS disconnects with peer EOF ~30 s after our#8 big Handshake. The iter-4 "quiescent" claim was observation-window bias — the byte-level work from iter-1 through iter-4 is correct, but the session is semantically rejected by CDS, not accepted. Before D.6.1 can land any handler, we need to identify and fix whatever CDS disqualifies our handshake on. Four hypotheses ranked in iter-06-pair-trigger/findings.md.D.6.1— H1 confirmed: zero UUID was the gate. D.6.1-A (commitffe7c29) added env-gated UUID patching in the dispatcher (IOSMUX_HANDSHAKE_UUID=randomgenerates a fresh RFC 4122 v4 UUID per session at offset0x371Cof the#8 big Handshakefixture). D.6.1-B deployed it on havoc and let the session run 130 s: zero EOF, TCP ESTABLISHED throughout, only 15 s TCP keepalives. H2/H3/H4 rendered moot. The iter-4 and D.5 wire-level findings were always correct; only the 16-byte placeholder UUID (redacted at source iniphone_replay_bytes.py) blocked CDS's session-validity check. See iter-07-uuid-patched/findings.md. D.6.2 (next) picks up the "CDS now accepts but is silent — what does it expect from us?" question.D.6.2— H1c falsified: trigger-type is not the variable. Testeddevicectl manage pairandunpair+pairfallback against the UUID-patched backend on 2026-04-23. Four sessions captured, every one hit the 40-line handshake baseline with zero post-handshake frames. Mercury 1000 error surface identical to iter-6. Three trigger classes now tested (device info,manage pair,unpair+pair) — all produce identical silent-hold; the missing variable is on our backend side, not in the client trigger choice. D.6.3 widens the tcpdump lens tolo0with no port filter to see whether CDS back-connects to advertised service ports (H1b) or is waiting for a server-side follow-up frame (H2-reply). See iter-08-pair-trigger/findings.md.D.6.3— H1b FALSIFIED decisively. Single wide-filter pcap capture on 2026-04-24 during the samemanage pairtrigger. Exactly one TCP connection observed (CDS to our backend on[::1]:34719), zero SYNs to any other local port. CDS does not attempt to back-connect to any of the 62 service ports we advertise. H2-reply is the surviving hypothesis: CDS is waiting for a server-initiated follow-up frame on the existing HTTP/2 stream that the iter-01 replay corpus doesn't cover (Q2 pcap was a one-shotrsd-info; real pair-flow emits more than 10 frames). D.6.4a will temporarily swap tunneld out of SPIKE mode and run Q2-methodology capture onutun4to observe the authoritative post-handshake bytes from a real iPhone. See iter-09-wide-pcap/findings.md.D.6.4a— H2-reply FALSIFIED; parallel service discovered. Temporary SPIKE → stock tunneld swap on 2026-04-24 +utun4tcpdump during three non-destructive triggers. Apparatus restored to SPIKE cleanly. Real iPhone greeting-stream output is byte-identical to our SPIKE backend (same 10 frames, same silence after#9 RST_STREAM). H2-reply is falsified. Real finding: devicectl opens a parallel non-HTTP/2 TCP service on iPhone:50367 (~9.5 KB iPhone→client, likely lockdown-over-TCP). SPIKE backend has no listener at any equivalent port — tunneld in SPIKE mode advertises onlytunnel-port:34719. CDS hangs trying to reach that parallel service. D.6.5 decodes the 50367 byte stream and identifies the service to emulate. Raw pcap at/home/op/backups/iosmux/pcaps/(contains real UDID, gitignored). See iter-10-real-iphone-ref/findings.md.D.6.5— Protocol identified + service advertised. Pure local decode of the iter-10 held-local pcap on 2026-04-24. Protocol on iPhone:50367 is classic lockdown-over-TCP: plaintext XML plist, 4-byte BE length prefix, 3-verb API (RSDCheckin,QueryType,GetValue). Port 50367 is already advertised in our Handshake Services dict undercom.apple.mobile.lockdown.remote.trusted(UsesRemoteXPC=False, ServiceVersion=1). Full Services census: 62 entries total, 40 classic-lockdown (UsesRemoteXPC=False) + 22 RemoteXPC (UsesRemoteXPC=True). One Go-backend handler unlocks 40 endpoints. D.6.6 implements: bind[::1]:50367, read length-prefixed XML plists, dispatch 3 verbs, serve cached 88-key device dict forGetValue. ~1 day estimated. See iter-11-lockdown-decode/findings.md.D.6.5-control— Lockdown back-connect hypothesis FALSIFIED. Re-ran iter-09 wide-pcap on SPIKE backend withdevicectl device info detailstrigger (the one that iter-10 proved causes lockdown traffic against real iPhone). Result: exactly one SYN (client→our-backend:34719), zero SYNs to any advertised service port, zero RSTs. CDS does NOT back-connect in SPIKE mode regardless of trigger. The "implement lockdown handler → CDS connects" plan is necessary but not sufficient. Revised causal model: CDS evaluates a pair/trust signal after the Handshake and bails upstream of Services dict consultation when it reads "unpaired / untrusted" (which SPIKE apparently signals). iter-10's :50367 flow was opened by pymobiledevice3 (no trust gate), not devicectl (has trust gate) — explains why iter-10 saw :50367 traffic but iter-9 and iter-12 (both SPIKE + devicectl) don't. D.6.6 plan revised: research-a (Properties dict diff between SPIKE and real-iPhone Handshakes) + research-b (env-gated per-field bisection patching) precede the lockdown-handler implementation. See iter-12-spike-control/findings.md.D.6.6-impl— three concrete sub-tasks landed by ADR-0009 + the iter-11/iter-18 evidence chain. The empirical questions that Phase D.6.6-impl will answer (and that the prose below assumes but does not verify) are catalogued ind66-research-questions.md— every prediction in the sub-task descriptions has a stable Q-ID and test plan there. Sub-task 1 deploy resolves Q-D66-1 (lockdown handler reachability); sub-task 2 capture resolves Q-D66-2 (Codable Output bytes); sub-task 3 deploy resolves Q-D66-3 and Q-D66-5.
The three sub-tasks:
1. Lockdown handler at [::1]:50367 in
cmd/iosmux-backend/ (synthetic listener bind address —
chosen for parity with our greeting fixture's advertised
port; whether CDS reaches it is Q-D66-1). Wire format
[BE-u32 length][XML plist]. Three verbs (RSDCheckin,
QueryType, GetValue). The 88-key device-info dict is
sourced by self-bootstrap at backend startup using a
three-step chain (commit b2da3f6):
1. resolve tunnel-address + tunnel-port via tunneld's HTTP
API at `127.0.0.1:49151`
(`internal/lockdown/tunneld.go:ResolveTunnelAddress`);
2. open an h2c RemoteXPC greeting client to
`[tunnel-address]:tunnel-port`, receive the iPhone's
unilateral 14 KB big-Handshake DATA frame on stream 1,
decode the wrapper payload via `internal/xpc/`, return
the 62-entry Services dict
(`internal/lockdown/rsd_discovery.go:FetchServices`);
3. look up `com.apple.mobile.lockdown.remote.trusted` to
get its **per-session** port number, dial that port,
run the iter-11 3-verb dialog as a client, cache the
GetValue Value response, then bind the synthetic
listener
(`internal/lockdown/rsd_discovery.go:FindServicePort` +
existing `internal/lockdown/client.go:FetchDict`).
The lockdown port number is **not** a constant in code —
iOS 17+ allocates Services-dict ports per tunneld session;
see Q-D66-13 in
[`d66-research-questions.md`](d66-research-questions.md)
for the empirical cross-session evidence.
Note: `pymobiledevice3 remote rsd-info` is NOT a possible
dict source — it returns the 46-key Handshake Properties
dict (per
[`s1a-properties-audit.md`](../research/coredevice-internals/s1a-properties-audit.md)
§"What MobileDevice expects"), which is a different dict
and lacks the trust-adjacent fields (`HostAttached`,
`TrustedHostAttached`, `PairRecords`, `ActivationState`,
`PasswordProtected`) that GetValue carries. Wire-protocol
reference:
[iter-11/findings.md §"The 50367 dialog"](../research/protocol/s2c-self-experiment/iter-11-lockdown-decode/findings.md)
— note that the "50367" in iter-11's tables is the iter-10
pcap's per-session value, not a stable port.
2. **Mercury Codable Output capture (BLOCKING work item).**
The byte layout of every `*ActionDeclaration.Output`
Codable struct CDS expects on the reply XPC connection is
currently undocumented. Capture is a Mercury logging
interpose against any working CDS+device pair (logging-only
`xpc_connection_send_message`), redact identity fields,
commit the resulting per-action byte sequences as
fixtures under `internal/backend/fixtures/`. Without
this capture, the Mercury Codable interceptor in the
inject cannot encode replies — ADR-0006 forbids inventing
byte shapes. Highest-priority action to capture is
`AcquireDeviceUsageAssertionActionDeclaration.Output` because
its successful reply is what flips
`_shadowUseAssertion.fulfilled` Xcode-side and hides the
Pair button (per
[pair-button-and-cfnetwork.md](../research/coredevice-internals/pair-button-and-cfnetwork.md)
§"Pair button gating").
3. **Mercury Codable interceptor in inject.** New patch on
`inject/iosmux_inject.m` that recognises a small set of
`mangledTypeName` values and replies with empty-but-typed
Codable Output structs derived from the captures in
sub-task 2. Initial action set: AcquireDeviceUsageAssertion
(priority), then Pair, Unpair, Connect, Disconnect, then
the broader static-success set per ADR-0009 §Consequences.
D.6.6-impl-phase1— sub-task 3 landed across session 14 (iter 1-17 PairAction Codable schema, commitsb808eae…49c8f1e) and session 16 (per-action dispatch + AFM endpoint side-channel, commits5de52be…28d7d89). Phase 1 milestone: spinner gate broken (Q-D66-14 RESOLVED —kvoCache_isPairedBool ivar driven by RDSUE broadcasts filtered onmonotonicIdentifier, fixed by seeding the counter frommach_absolute_time()). Per-action dispatch replaces blanketDeviceConnectionChangeResultwith per-aid Output types empirically derived innotes/iosmux-actions-output-probe.md. AFM endpoint emit (xpc_connection_create_from_endpointonInput.endpoint, send AFM withxpc_connection_send_barrierdeferred cancel) verified on our side but Apple-side acceptance is the remaining cosmetic gap — see Q-D66-15 ind66-research-questions.md.D.6.6-impl-phase2— close theIDEDeviceSymbolsCoordinatornon-emptyosBuildUpdate.nameassertion + any non-display gates uncovered after Phase 1. Plan innotes/iosmux-implementation-plan.md§4. Deferred until Phase 1 cosmetic AFM gap resolution determines whether_shadowUseAssertion=nilactually blocks anything beyond UI category.D.6.6-impl-phase3— structured DeviceInfo display fields (osVersionNumber/osBuildUpdatestructs replacing the bare strings,internalStorageCapacityUInt64) + remaining action Output handlers for actions whose Output is not DeviceConnectionChangeResult. Plan in same notes file §5.D.6— Other per-service handlers (DDI mount, app install, etc.) land one at a time, each gated on real empirical data from either Q2/Q5 captures or dialog bisection.D.7— Remove theIOSMUX_SPIKEtunneld patch once the Go backend owns the address CDS is told to connect to; replace with either a generalized tunneld override or a tunneld replacement owned by iosmux itself.
Non-goals (still binding):
- Faking any reply out of thin air. Every byte the backend sends on the wire must either come from a real data source or be a well-defined framing artifact (header magic, length field, etc.). If a request arrives for a shape we do not know, we halt and research it — we do not invent a reply.
- Setting any
DeviceInfofield to a value we have not observed from a real source. - Hooking any Swift action-dispatch path beyond the existing S1.B passthrough trampoline. The S2.C findings show the inject hook is unnecessary for redirecting the transport — the tunneld address override does that alone — so the inject stays minimal.
Phase E — post-pair (DDI, developer mode, install, debug)¶
Same shape as the old Phase E, except all of it flows through the same Option δ shim. DDI mount, developer-mode query, app install, debug attach — each is its own set of pymobiledevice3 calls exposed to the backend, each is its own pair of CDS-interposition points. We may find additional missing services to proxy as we walk the flow.
Risks and unknowns we are accepting¶
- Server-side protocol gap — now much narrower. The S2.C
capture gave us every byte CDS sends but no byte CDS
expects back from a server. Q1-Q5 closed the structural
gap: Q2's live pcap plus Q3 iter-0's decoded dispatch table are
the reference bytes for the h2c + handshake phase. Unknowns that
remain are scoped to iter-N+1 specific commands: whenever a
Q3 iteration hits a CDS request for which the iter-0 dispatch
table has no match, we re-run the matching
pymobiledevice3 remote ...command on havoc under tcpdump and extract authoritative iPhone bytes for it. This is fully self-contained on havoc — no external party needed. - Go
http2framer edge cases. CDS opens streams withHEADERS len=0, which is a minor RFC deviation.net/httpServer rejects this; the lower-levelFramerAPI accepts it. Phase D.3 must drive the framer directly, not go throughhttp2.Server{}.ServeConnwith a standardHandler. If some other frame-level quirk we have not yet seen exists, the spike listener's capture-and-halt methodology will surface it — no production code runs on unverified assumptions. - Backend lifecycle. The Go backend must survive CDS
restarts, respond quickly enough that CDS does not time out,
and not leak tunnel state across device reconnects. The
simplest answer: one long-lived backend process launched at
the same time as tunneld, per havoc session, managed by the
same
iosmux-restore.shflow that already owns tunneld's lifecycle. - bplist16 coverage in
howett.net/plist. If the existing Go plist library does not handle bplist16 (the XPC variant), D.4 gains a "port bplist16 from pymobiledevice3" sub-task. Bounded, ~200-400 LOC Go at most. - pymobiledevice3 version drift. We pin upstream via
docs/patches/pymobiledevice3/UPSTREAM_VERSIONand treat any upgrade as a deliberate research task. Patches underdocs/patches/pymobiledevice3/are all research-scoped; none of them define production behavior.
Open questions that do not need research but need decisions¶
- Do we keep
tunneldas an independent long-running Python subprocess and make the Go backend talk to it over its HTTP API, or do we eventually bring the tunneld state machine into Go alongside the backend so there is one less moving part? The Python version stays for Stage S2; Go replacement is a separate track. - Where does the Go backend's logging go, and who watches it? A
file under
/tmp/iosmux-backend.logwithlog/slog-structured records is probably enough for the spike runs; productization adds proper rotation later. - At what point do we freeze "Stage 2 is done"? Pair working is the minimum bar; DDI + developer mode + install + debug are incremental over the same backend and can slip into Stage 3 if the pair plumbing takes longer than expected.
What comes AFTER this roadmap¶
After Phase E works end-to-end:
- Promote from PoC to proper config (remove hardcoded UUIDs, service names, paths).
- Productize
iosmux-backendinto a daemon that ships with the project. - Write user-facing docs.
- Binary releases, supported OS matrix, upgrade safety across macOS / iOS versions.
None of that matters until the backend shim proves it can drive the pair flow honestly end to end.