Skip to content

Documentation

Status: verified — session 10, 2026-04-13

Every claim on this page links to either a delivered commit or a research artifact with a raw-data backing. No guesses.

Live site

This site renders from the main branch of the private staticwire/iosmux repository and is 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.

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 these four categories and carries a status badge at the top.

The four trees

  • architecture/ — how iosmux works right now. Ground-truth explanations. Edit only when the code actually changes.
  • plans/ — forward-looking work. Stage plans, queues, acceptance criteria. Superseded plans move to plans/archive/ with a date-prefixed filename.
  • research/ — empirical work, grouped by topic (protocol / coredevice-internals / environment). Every claim is backed by a raw artifact or a direct upstream-source quote. No guessing — anything open is named as a gap and tracked in the relevant plan.
  • adr/ — Architecture Decision Records. Immutable. New decision → new ADR; an old ADR is never edited, only superseded.

Plus patches/ for the pymobiledevice3 upstream-plus-overlay workflow and runbooks/ for operational how-tos.

Status badges (YAML frontmatter)

Every key file starts with a status: frontmatter block. Readers should treat the status as the load-bearing metadata, not the age of the document.

Status Meaning
verified Empirically confirmed against a raw artifact, a primary source, or a landed commit. Strongest claim.
reviewed Derived logically from verified facts. Ran through a review pass. Safe to act on but not itself raw evidence.
draft In progress — expect churn.
hypothesis Unconfirmed, needs an empirical task to become verified or be dropped.
superseded Content replaced by another document. Frontmatter includes superseded-by: pointing at the replacement. Kept for history.
archived Historical. No longer active, no longer maintained, but preserved for future context.

Inline admonitions

On top of the standard MkDocs Material admonitions (note, warning, info, danger, …), four custom admonition types are available for inline evidence calls. They share the status vocabulary above:

  • !!! verified "..." — wraps a specific empirical claim with its evidence. Green. Use when a sentence would otherwise feel unsourced.
  • !!! hypothesis "..." — wraps an unconfirmed assumption. Yellow. Must name the research task that would confirm or refute it.
  • !!! gap "..." — wraps a known empirical unknown. Red. Halts any downstream decision that would require the missing data.
  • !!! superseded "..." — wraps a claim that has been replaced, pointing at the replacement. Grey.

The hard rule

No guessing. Every claim is either backed by evidence we can point at, or explicitly labeled as a gap. When research bisects a protocol one step at a time, we never reply to the other side with a byte sequence we cannot source from a capture or a primary reference — the bisection stops at the first unknown and the unknown is named.

This rule is formalized in ADR-0006 (Phase 4 deliverable) and is why the Stage 2 / Phase C self-research queue explicitly halts on gaps rather than proposing plausible-but-invented replies.

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

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 still has a self-research queue (Q1-Q5) to fully characterize the server side of the CDS ↔ iPhone protocol before implementation begins. 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.

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