Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
bb81506
ios: remove iroh runtime composition from the app target (WIP sweep)
lawrence703 Aug 28, 2026
88aaa12
Merge remote-tracking branch 'origin/feat-relay-input-pipelining' int…
lawrencecchen Aug 28, 2026
dd90b41
ios: remove iroh dialing, discovery, revoke, and UI; relay is the def…
lawrence703 Aug 28, 2026
43e4b93
ios: relay-default test coverage; Mac relay host defaults on
lawrence703 Aug 28, 2026
368ee66
ios: drop CmuxIrohReleaseGateSupport product from the Xcode project
lawrence703 Aug 28, 2026
28a5619
ios: fix ShellUI relay-default fallout (onboarding defaults, migratio…
lawrence703 Aug 28, 2026
eb67795
ios: keep the split settings section helpers inside MobileSettingsVie…
lawrence703 Aug 28, 2026
f478c53
ios: loopback rides alongside the relay method; settings type-check f…
lawrence703 Aug 28, 2026
c7e8d10
ios: relay method dial-set exclusivity in stored reconnects
lawrence703 Aug 29, 2026
52882c3
ios: route-exchange failures land in the copyable debug log
lawrence703 Aug 30, 2026
26910bd
mac: admitted relay sessions get the identity-bearing host status
lawrence703 Aug 30, 2026
5d15982
mobile: zlib-compressed render-grid event frames, negotiated at subsc…
lawrence703 Aug 30, 2026
be3cccb
auth: verify Stack access tokens locally against the project JWKS
lawrence703 Aug 31, 2026
2a48486
ios: startup timing marks around the auth bootstrap and first dial
lawrence703 Aug 31, 2026
2f40c1e
mobile auth: pin JWT issuer in both verifiers; persist the Mac JWKS c…
lawrence703 Aug 31, 2026
44ff121
ios: adaptive liveness cadence detects a dead path in ~3-8s while typing
lawrence703 Aug 31, 2026
1526dfa
Merge remote-tracking branch 'origin/feat-ios-adaptive-liveness' into…
lawrencecchen Aug 31, 2026
aea5752
ios: move MobileStartupConnectionCoordinator into CmuxMobileShellModel
lawrence703 Aug 31, 2026
ef8f3cc
ios: dial the stored Mac before the auth bootstrap finishes
lawrence703 Aug 31, 2026
06ec507
ios: regression tests for the transport request's host-device-id binding
lawrence703 Aug 31, 2026
0431359
ios: bind relay dials to a known host device id; skip relay for anony…
lawrence703 Aug 31, 2026
adaef5f
Merge remote-tracking branch 'origin/feat-ios-dial-before-bootstrap' …
lawrencecchen Aug 31, 2026
40771d0
Merge remote-tracking branch 'origin/feat-attach-url-relay' into feat…
lawrencecchen Aug 31, 2026
4918dd3
mobile-relay: zero-copy data-frame forwarding in the HostRelay DO
lawrence703 Aug 31, 2026
c7c4843
mobile-relay: per-build relay objects via optional x-cmux-instance-tag
lawrence703 Aug 31, 2026
f348c2e
ios: mosh-style local echo prediction core in CmuxMobileShellModel
lawrence703 Aug 31, 2026
c9290f2
ios: echo prediction integration seam behind a local debug toggle
lawrence703 Aug 31, 2026
affc294
Merge remote-tracking branch 'origin/feat-relay-worker-perf' into fea…
lawrence703 Aug 31, 2026
0a69bce
Merge remote-tracking branch 'origin/feat-ios-echo-prediction' into f…
lawrence703 Aug 31, 2026
9d5a408
pairing: attach URLs carry the Mac device id so relay pairing works o…
lawrence703 Aug 31, 2026
b2c32b3
Merge remote-tracking branch 'origin/feat-attach-mint-device-id' into…
lawrencecchen Aug 31, 2026
ae184fe
ios tests: rewrite iroh-era reconnect tests against the relay default
lawrence703 Aug 31, 2026
3087c88
ios tests: pin relay-era pairing deadline copy and anonymous retry ga…
lawrence703 Aug 31, 2026
8acf908
rpc: bounded replacement admission for make-before-break dials
lawrence703 Aug 31, 2026
beb9b78
ios: make-before-break roaming swap for the relay foreground route
lawrence703 Aug 31, 2026
db1f103
ios: roaming swap behavior tests + tagged-pairing focused lookup
lawrence703 Aug 31, 2026
6c74e5b
Merge remote-tracking branch 'origin/feat-ios-make-before-break' into…
lawrence703 Aug 31, 2026
6bda37b
ios: pipeline the connect-time event subscribe onto the workspace-lis…
lawrence703 Aug 31, 2026
1cf639a
ios: cover the pipelined connect subscribe with scripted-transport tests
lawrence703 Aug 31, 2026
25c1eec
ios: pipeline the connect subscribe only from learned capabilities
lawrence703 Aug 31, 2026
12991ef
ios: fix stale never-guess comment on the capability snapshot
lawrence703 Aug 31, 2026
8c43fcc
ios: model the dial path in roaming-swap fakes; pin subscribe-vs-adop…
lawrence703 Aug 31, 2026
ada2921
bench: measure JSON envelope overhead for the reserved raw terminal c…
lawrence703 Aug 31, 2026
30142f6
ios: dial the stored Mac concurrently with the backup refresh
lawrence703 Aug 31, 2026
d66c7fd
ios: persist learned host capabilities so cold launch pipelines the s…
lawrence703 Aug 31, 2026
08a834c
ios: pre-warm the relay TLS session at composition start
lawrence703 Aug 31, 2026
72fac3e
Merge remote-tracking branch 'origin/feat-launch-floor' into feat-ios…
lawrencecchen Aug 31, 2026
126969e
ios: remove the dead iroh independent-event and artifact lanes
lawrence703 Aug 31, 2026
0c11450
ios: transport-neutral names for reconnect backoff and secondary disc…
lawrence703 Sep 1, 2026
56d993d
ios: show stale iroh route rows as Legacy; drop orphaned iroh strings
lawrence703 Sep 1, 2026
c841434
launcher: drop the reader-less iOS iroh release-gate plumbing
lawrence703 Sep 1, 2026
4bf6bbd
launcher: remove the unreachable release-gate readiness branch
lawrence703 Sep 1, 2026
6fd887d
ios: attemptedAutomaticDial replaces the iroh-only backoff marker
lawrence703 Sep 1, 2026
56c424b
ios: record the stored-Mac reconnect preamble timeline and gate claim…
lawrencecchen Sep 1, 2026
82de21a
ios: failing test, cold-launch dial waits for the backup restore insi…
lawrencecchen Sep 1, 2026
fc9df36
ios: serve persisted paired-Mac rows without waiting for the backup r…
lawrencecchen Sep 1, 2026
99ca7e8
ios: publish the backup merge after a cold-launch dial from disk
lawrencecchen Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 4 additions & 94 deletions .github/workflows/iroh-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,14 @@ on:
description: Branch or SHA to verify
required: false
default: ""
mode:
description: Iroh transport mode
required: true
default: all
type: choice
options:
- all
- automatic
- relay-only
- relay-expiry
- direct-only
- private-path

permissions:
contents: read

# The simulator end-to-end gate is gone with the iOS iroh transport: the
# in-app release-gate runner it drove no longer exists in the phone app.
# The Mac still hosts iroh for old phones, so the deterministic Tailscale
# version-skew compatibility job remains.
jobs:
tailscale-version-skew:
name: Tailscale version-skew compatibility
Expand Down Expand Up @@ -53,85 +45,3 @@ jobs:

- name: Run deterministic version-skew gate
run: ./scripts/ci/run-iroh-tailscale-compatibility-gate.sh

simulator-e2e:
env:
# The app gates compile optimized Swift for both endpoints. Xcode 26's
# AArch64 GlobalISel path is not reliable for that exact build shape, so
# use the same supported workaround as tagged cloud reloads and streamed
# validation instead of allowing a compiler failure to masquerade as a
# transport verdict.
CMUX_SWIFT_FRONTEND_WORKAROUND: "1"
strategy:
fail-fast: false
# The three app-backed modes share one staging account. Serial execution
# prevents one mode's zero-touch registration from replacing another
# mode's active Mac route while its authenticated RPC probe is running.
max-parallel: 1
matrix:
mode: ${{ fromJSON(inputs.mode == 'all' && '["automatic","relay-only","relay-expiry","direct-only","private-path"]' || format('["{0}"]', inputs.mode)) }}
runs-on: ${{ vars.MACOS_RUNNER_STREAMED_VALIDATION || 'warp-macos-15-arm64-6x' }}
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
submodules: recursive

- name: Select Xcode
run: ./scripts/select-ci-xcode.sh

- name: Ensure iOS Simulator runtime
if: ${{ matrix.mode != 'private-path' }}
run: |
xcrun simctl list runtimes available | grep -Eq '\biOS\b' || xcodebuild -downloadPlatform iOS

- name: Install app build dependencies
if: ${{ matrix.mode == 'automatic' || matrix.mode == 'relay-only' || matrix.mode == 'relay-expiry' }}
run: |
./scripts/install-zig-ci.sh
./scripts/download-prebuilt-ghosttykit.sh || ./scripts/ensure-ghosttykit.sh

- name: Materialize isolated staging account
if: ${{ matrix.mode == 'automatic' || matrix.mode == 'relay-only' || matrix.mode == 'relay-expiry' }}
env:
CMUX_DOGFOOD_STACK_EMAIL: ${{ secrets.CMUX_DOGFOOD_STACK_EMAIL }}
CMUX_DOGFOOD_STACK_PASSWORD: ${{ secrets.CMUX_DOGFOOD_STACK_PASSWORD }}
run: |
set -euo pipefail
[[ -n "${CMUX_DOGFOOD_STACK_EMAIL:-}" ]] || { echo "::error::missing staging email"; exit 1; }
[[ -n "${CMUX_DOGFOOD_STACK_PASSWORD:-}" ]] || { echo "::error::missing staging password"; exit 1; }
mkdir -p "$HOME/.secrets"
{
printf 'CMUX_DOGFOOD_STACK_EMAIL=%s\n' "$CMUX_DOGFOOD_STACK_EMAIL"
printf 'CMUX_DOGFOOD_STACK_PASSWORD=%s\n' "$CMUX_DOGFOOD_STACK_PASSWORD"
} > "$HOME/.secrets/cmuxterm-dev.env"
chmod 600 "$HOME/.secrets/cmuxterm-dev.env"

- name: Run staging Iroh gate
run: |
set -euo pipefail
case "${{ matrix.mode }}" in
automatic) TAG=irgaut ;;
relay-only) TAG=irgrel ;;
relay-expiry) TAG=irgexp ;;
direct-only) TAG=irgdir ;;
private-path) TAG=irgprv ;;
esac
./scripts/run-iroh-release-gate.sh \
--mode "${{ matrix.mode }}" \
--tag "$TAG" \
--report-output "$RUNNER_TEMP/iroh-release-gate-${{ matrix.mode }}.json"

- name: Upload redacted verdict
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: iroh-release-gate-${{ matrix.mode }}
path: |
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}.json
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-mac.cmuxdiag
if-no-files-found: warn
retention-days: 7
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public struct CmxByteTransportRequest: Equatable, Sendable {
public let route: CmxAttachRoute
/// The authenticated peer device expected on the route, when known.
public let expectedPeerDeviceID: String?
/// The expected peer's app-instance tag (the pairing's Mac build tag),
/// when known. The relay transport dials that build's own relay object
/// with it; nil keeps the untagged (production) object, and non-relay
/// transports ignore it.
public let expectedPeerInstanceTag: String?
/// The authorization evidence permitted on the transport.
public let authorizationMode: CmxTransportAuthorizationMode
/// The local owner whose network path this request represents.
Expand All @@ -36,12 +41,14 @@ public struct CmxByteTransportRequest: Equatable, Sendable {
public init(
route: CmxAttachRoute,
expectedPeerDeviceID: String?,
expectedPeerInstanceTag: String? = nil,
authorizationMode: CmxTransportAuthorizationMode,
sessionPurpose: CmxTransportSessionPurpose = .foregroundControl,
irohDirectOnlyDialCandidates: [CmxIrohDirectDialCandidate]? = nil
) {
self.route = route
self.expectedPeerDeviceID = expectedPeerDeviceID
self.expectedPeerInstanceTag = expectedPeerInstanceTag
self.authorizationMode = authorizationMode
self.sessionPurpose = sessionPurpose
self.irohDirectOnlyDialCandidates = irohDirectOnlyDialCandidates
Expand All @@ -54,6 +61,7 @@ public struct CmxByteTransportRequest: Equatable, Sendable {
Self(
route: route,
expectedPeerDeviceID: expectedPeerDeviceID,
expectedPeerInstanceTag: expectedPeerInstanceTag,
authorizationMode: authorizationMode,
sessionPurpose: sessionPurpose,
irohDirectOnlyDialCandidates: irohDirectOnlyDialCandidates
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import Foundation

/// The minimal pairing-QR grammars for Iroh identity and Tailscale routes.
///
/// Retained Iroh codes carry only the stable EndpointID:
/// `cmux-ios://attach?v=3&i=<endpoint-id>`.
/// Retained Iroh codes carry the stable EndpointID plus the optional Mac
/// device id: `cmux-ios://attach?v=3&i=<endpoint-id>[&d=<mac-device-id>]`.
///
/// The EndpointID is the only value the phone needs before dialing. The
/// signed-in trust broker verifies same-account ownership while minting the
Expand All @@ -14,7 +14,7 @@ import Foundation
///
/// Tailscale compatibility codes keep the v2 grammar so already-released
/// clients can still scan them:
/// `cmux-ios://attach?v=2&ub=<stack-user-id>&pc=<compat>&r=<host>:<port>[&r=<host>:<port>...]`.
/// `cmux-ios://attach?v=2[&d=<mac-device-id>]&ub=<stack-user-id>&pc=<compat>&r=<host>:<port>[&r=<host>:<port>...]`.
///
/// The only metadata a Tailscale code carries is what the phone consults
/// before dialing: `ub`, the opaque Stack user id the account preflight
Expand All @@ -31,10 +31,17 @@ import Foundation
/// code look like a leaked credential.
/// - **No expiry.** Ticket age authorizes nothing, so a code that sat on
/// screen for an hour still pairs.
/// - **No display name, no device id, no build metadata.** All arrive
/// post-handshake from `mobile.host.status`; the decoder leaves
/// `macDeviceID` empty and the shell adopts the host-reported identity
/// once connected.
/// - **No display name, no build metadata.** Those arrive post-handshake
/// from `mobile.host.status`. The one identity field both grammars carry
/// is the optional `d` item, the Mac's opaque device id: the phone's
/// relay method mints its session against one exact host device id, so a
/// ticket without it can never add the relay dial route
/// (`relayMethodDialRoutes` skips relay for anonymous tickets). v2
/// decoders that predate `d` ignore the unknown item; v3 decoders that
/// predate it reject the URL, which is acceptable because v3 URLs travel
/// only between same-build launcher/dev flows while the scannable pairing
/// window emits v2 only. A missing `d` decodes to an empty `macDeviceID`
/// and the shell adopts the host-reported identity once connected.
/// - **No loopback, ever.** v2 routes are Tailscale `host:port` only: the
/// encoder drops a DEBUG Mac's dev loopback route instead of encoding it,
/// the Mac refuses to mint a QR without a Tailscale route (it shows the
Expand Down Expand Up @@ -100,15 +107,22 @@ public struct CmxPairingQRCode: Sendable {
guard let identity = encodableIrohIdentity(of: ticket) else {
return nil
}
items = [
var identityItems = [
"v=\(Self.irohVersion)",
"i=\(identity.endpointID)"
]
if let deviceID = normalizedNonEmpty(ticket.macDeviceID) {
identityItems.append("d=\(percentEncodeQueryValue(deviceID))")
}
items = identityItems
case .legacyPrivateNetworkCompatibility:
guard let routes = encodableTailscaleRoutes(of: ticket) else {
return nil
}
var compatibilityItems: [String] = ["v=\(Self.tailscaleVersion)"]
if let deviceID = normalizedNonEmpty(ticket.macDeviceID) {
compatibilityItems.append("d=\(percentEncodeQueryValue(deviceID))")
}
if let userID = normalizedNonEmpty(ticket.macUserID) {
compatibilityItems.append("ub=\(percentEncodeQueryValue(userID))")
}
Expand Down Expand Up @@ -243,8 +257,11 @@ public struct CmxPairingQRCode: Sendable {

/// Decode a supported plain pairing URL into a validated ticket.
///
/// The ticket comes back unscoped with an empty `macDeviceID`; the shell
/// recovers the Mac's identity post-handshake from `mobile.host.status`.
/// The ticket comes back unscoped. `macDeviceID` is read from the
/// optional `d` item when the minting Mac wrote one (it binds the
/// phone's relay dial to that exact host); URLs from older Macs decode
/// to an empty `macDeviceID` and the shell recovers the Mac's identity
/// post-handshake from `mobile.host.status`.
/// - Parameter components: A parsed v2 or v3 attach URL.
/// - Throws: ``MobileSyncPairingPayloadError/invalidURL`` for malformed
/// input and ``MobileSyncPairingPayloadError/loopbackRouteRejected``
Expand Down Expand Up @@ -289,7 +306,7 @@ private extension CmxPairingQRCode {
let ticket = try CmxAttachTicket(
workspaceID: "",
terminalID: nil,
macDeviceID: "",
macDeviceID: queryValue(named: "d", in: components) ?? "",
macDisplayName: nil,
macUserEmail: queryValue(named: "e", in: components),
macUserID: queryValue(named: "ub", in: components),
Expand All @@ -305,12 +322,17 @@ private extension CmxPairingQRCode {
}

/// Decode the v3 endpoint-only Iroh grammar.
///
/// Exactly one `v`, one `i`, and at most one `d` (the Mac's device id,
/// which binds the phone's relay dial); any other item is a malformed or
/// hostile-bloated code and is rejected, exactly as before `d` existed.
func decodeIroh(_ components: URLComponents) throws -> CmxAttachTicket {
let items = components.queryItems ?? []
guard items.count == 2,
guard items.allSatisfy({ ["v", "i", "d"].contains($0.name) }),
items.filter({ $0.name == "v" }).count == 1,
let endpointID = items.first(where: { $0.name == "i" })?.value,
items.filter({ $0.name == "i" }).count == 1,
items.filter({ $0.name == "d" }).count <= 1,
let identity = try? CmxIrohPeerIdentity(endpointID: endpointID) else {
throw MobileSyncPairingPayloadError.invalidURL
}
Expand All @@ -323,7 +345,7 @@ private extension CmxPairingQRCode {
let ticket = try CmxAttachTicket(
workspaceID: "",
terminalID: nil,
macDeviceID: "",
macDeviceID: queryValue(named: "d", in: components) ?? "",
macDisplayName: nil,
// v3 is intentionally endpoint-only. `nil` means the QR did not
// make a compatibility claim, unlike v2's explicit unknown value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
public import Foundation

/// Optional zlib compression for mobile sync EVENT frames.
///
/// A compressed frame is `[0x01][zlib-compressed JSON envelope]`. The magic
/// byte can never begin a JSON envelope (those start with `{`, 0x7B), so a
/// decoder that checks the first byte handles old and new senders alike. The
/// capability is negotiated at `mobile.events.subscribe` time with
/// `event_compression: "deflate"`; a sender must never emit a compressed
/// frame to a connection that did not ask for one. Only event frames are
/// compressed (that is where the bytes are: render-grid deltas and full
/// frames); requests and responses stay plain.
public enum MobileEventFrameCompression {
/// First byte of a compressed frame payload.
public static let compressedFrameMagic: UInt8 = 0x01
/// The subscribe-parameter value that opts a connection in.
public static let deflateParameterValue = "deflate"
/// Frames smaller than this are sent plain: zlib overhead and the extra
/// copy are not worth it below roughly one MTU of JSON.
public static let minimumCompressibleByteCount = 256

/// `[magic][zlib(envelope)]`, or nil when compression fails or does not
/// shrink the payload (the caller then sends the plain frame).
public static func compressedPayload(for envelope: Data) -> Data? {
guard envelope.count >= minimumCompressibleByteCount else { return nil }
guard let compressed = try? (envelope as NSData).compressed(using: .zlib) as Data,
compressed.count + 1 < envelope.count else {
return nil
}
var out = Data(capacity: compressed.count + 1)
out.append(compressedFrameMagic)
out.append(compressed)
return out
}

/// The plain envelope for `frame`. A frame without the magic byte is
/// returned unchanged. A magic-prefixed frame is inflated with
/// `maximumInflatedByteCount` as the hard output cap (a frame claiming to
/// inflate past the codec's frame limit is hostile or corrupt); nil means
/// the frame was compressed but could not be inflated, and the caller
/// should drop it exactly like an unparseable envelope.
public static func inflatedFrame(
_ frame: Data,
maximumInflatedByteCount: Int
) -> Data? {
guard frame.first == compressedFrameMagic else { return frame }
guard frame.count > 1,
let inflated = try? (frame.dropFirst() as NSData).decompressed(using: .zlib) as Data,
inflated.count <= maximumInflatedByteCount else {
return nil
}
return inflated
}
}
Loading
Loading