From 75d3e668e2d846d3a74d473bc993f7d97090ced8 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:00:12 -0700 Subject: [PATCH 1/3] test(ios): cover zero-touch sibling builds --- .../IrohZeroTouchDiscoveryTests.swift | 120 +++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift index ecdf8e6658b5..a25286a6779e 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift @@ -101,6 +101,57 @@ struct IrohZeroTouchDiscoveryTests { #expect(rows.first?.macDeviceID == "mac-b") } + @Test + func cleanInstallPersistsEveryAuthenticatedSiblingBuild() async throws { + let nightlyRouteID = "iroh-mac-a-nightly" + let stableRouteID = "iroh-mac-a-default" + let fixture = try await makeFixture( + candidates: [ + try candidate( + deviceID: "mac-a", + endpointByte: "a", + instanceTag: "nightly", + routeID: nightlyRouteID + ), + try candidate( + deviceID: "mac-a", + endpointByte: "b", + instanceTag: "default", + routeID: stableRouteID + ), + ], + reportedHostsByRouteID: [ + nightlyRouteID: ZeroTouchReportedHost( + deviceID: "mac-a", + instanceTag: "nightly" + ), + stableRouteID: ZeroTouchReportedHost( + deviceID: "mac-a", + instanceTag: "default" + ), + ] + ) + defer { fixture.cleanup() } + + #expect(await fixture.shell.reconnectActiveMacIfAvailable(stackUserID: "user-1")) + #expect(try await pollUntil { + let rows = try? await fixture.store.loadAll( + stackUserID: "user-1", + teamID: nil + ) + return Set(rows?.compactMap(\.instanceTag) ?? []) + == Set(["default", "nightly"]) + }) + #expect(Set(fixture.factory.attemptedRouteIDs()) == Set([ + nightlyRouteID, + stableRouteID, + ])) + + await fixture.shell.loadPairedMacs() + #expect(Set(fixture.shell.displayPairedMacs.compactMap(\.instanceTag)) + == Set(["default", "nightly"])) + } + @Test func malformedDuplicateCannotHideLaterAuthenticatedCandidate() async throws { let valid = try candidate(deviceID: "mac-a", endpointByte: "a") @@ -330,10 +381,64 @@ struct IrohZeroTouchDiscoveryTests { ) } + private func makeFixture( + candidates: [MobileDiscoveredIrohMac], + reportedHostsByRouteID: [String: ZeroTouchReportedHost] + ) async throws -> ZeroTouchFixture { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let store = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired-macs.sqlite3") + ) + var routersByRouteID: [String: LivenessHostRouter] = [:] + for (routeID, host) in reportedHostsByRouteID { + let router = LivenessHostRouter() + await router.setHostIdentity( + deviceID: host.deviceID, + instanceTag: host.instanceTag, + displayName: host.displayName + ) + routersByRouteID[routeID] = router + } + let fallbackRouter = LivenessHostRouter() + let factory = ZeroTouchRouteFactory( + router: fallbackRouter, + routersByRouteID: routersByRouteID, + failingRouteIDs: [], + rateLimitedRouteIDs: [] + ) + let shell = MobileShellComposite( + runtime: LivenessTestRuntime( + transportFactory: factory, + now: { Self.fixedNow }, + supportedRouteKinds: [.iroh] + ), + isSignedIn: true, + pairedMacStore: store, + personalIrohDiscovery: ScriptedIrohDiscovery( + snapshots: [candidates] + ), + identityProvider: StaticIdentityProvider(userID: "user-1"), + reachability: AlwaysOnlineReachability(), + pairingHintDefaults: UserDefaults( + suiteName: "iroh-zero-touch-\(UUID().uuidString)" + )! + ) + return ZeroTouchFixture( + shell: shell, + store: store, + factory: factory, + router: fallbackRouter, + directory: directory + ) + } + private func candidate( deviceID: String, endpointByte: Character, instanceTag: String = "stable", + routeID: String? = nil, extraRoutes: [CmxAttachRoute] = [] ) throws -> MobileDiscoveredIrohMac { let endpointID = String(repeating: String(endpointByte), count: 64) @@ -342,7 +447,7 @@ struct IrohZeroTouchDiscoveryTests { displayName: "Test \(deviceID)", instanceTag: instanceTag, routes: [try CmxAttachRoute( - id: "iroh-\(deviceID)", + id: routeID ?? "iroh-\(deviceID)", kind: .iroh, endpoint: .peer( identity: CmxIrohPeerIdentity(endpointID: endpointID), @@ -355,6 +460,12 @@ struct IrohZeroTouchDiscoveryTests { } } +private struct ZeroTouchReportedHost { + let deviceID: String + let instanceTag: String + var displayName = "Test Mac" +} + @MainActor private final class ScriptedIrohDiscovery: MobileIrohMacDiscovering { private var snapshots: [[MobileDiscoveredIrohMac]] @@ -414,6 +525,7 @@ private final class SuspendedIrohDiscovery: MobileIrohMacDiscovering { private final class ZeroTouchRouteFactory: CmxByteTransportFactory, @unchecked Sendable { private let router: LivenessHostRouter + private let routersByRouteID: [String: LivenessHostRouter] private let failingRouteIDs: Set private let rateLimitedRouteIDs: Set private let lock = NSLock() @@ -421,10 +533,12 @@ private final class ZeroTouchRouteFactory: CmxByteTransportFactory, @unchecked S init( router: LivenessHostRouter, + routersByRouteID: [String: LivenessHostRouter] = [:], failingRouteIDs: Set, rateLimitedRouteIDs: Set ) { self.router = router + self.routersByRouteID = routersByRouteID self.failingRouteIDs = failingRouteIDs self.rateLimitedRouteIDs = rateLimitedRouteIDs } @@ -437,7 +551,9 @@ private final class ZeroTouchRouteFactory: CmxByteTransportFactory, @unchecked S if rateLimitedRouteIDs.contains(route.id) { throw ZeroTouchRouteError.rateLimited } - return LivenessTransport(router: router) + return LivenessTransport( + router: routersByRouteID[route.id] ?? router + ) } func attemptedRouteIDs() -> [String] { From faf67ef0cf73d01c9ed2fb7aa9a9dc90b90c86fb Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:09:07 -0700 Subject: [PATCH 2/3] fix(ios): retain zero-touch sibling builds --- ...eShellComposite+PairedMacPersistence.swift | 6 +- .../MobileShellComposite+ZeroTouchIroh.swift | 161 ++++++++++++++++++ .../MobileShellComposite.swift | 21 +++ .../SecondaryClientHandle.swift | 2 + .../IrohZeroTouchDiscoveryTests.swift | 4 +- 5 files changed, 191 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift index 6f49636176ed..b83b9ab74a95 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift @@ -27,6 +27,7 @@ extension MobileShellComposite { instanceTagUpdate: PairedMacInstanceTagUpdate = .preserve, displayNameOverride: String? = nil, userAuthorizedTailscaleRoutes: [CmxAttachRoute] = [], + markActive: Bool = true, ifStillCurrent: (() -> Bool)? = nil ) async -> Bool { guard let pairedMacStore, @@ -102,6 +103,7 @@ extension MobileShellComposite { ticketRoutes: ticket.routes, storedRoutes: storedRoutes ) : ticket.routes + let shouldMarkActive = markActive || existing?.isActive == true do { if case .preserveOnlyIfUnclaimed = instanceTagUpdate { accepted = try await pairedMacStore.upsertRoutesIfAuthorized( @@ -109,7 +111,7 @@ extension MobileShellComposite { displayName: displayName, routes: routes, condition: .unclaimed, - markActive: true, + markActive: shouldMarkActive, stackUserID: stackUserID, teamID: scope?.teamID, now: Date() @@ -121,7 +123,7 @@ extension MobileShellComposite { displayName: displayName, routes: routes, instanceTag: instanceTag, - markActive: true, + markActive: shouldMarkActive, stackUserID: stackUserID, teamID: scope?.teamID, now: Date() diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift index 99dcdeab6dfc..04557e6c6577 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift @@ -1,6 +1,13 @@ import CmuxMobilePairedMac import Foundation +private enum ZeroTouchSecondaryAuthenticationOutcome { + case persisted + case transientFailure + case discarded + case superseded +} + @MainActor extension MobileShellComposite { /// Limits one automatic launch pass so stale live registrations cannot make @@ -54,4 +61,158 @@ extension MobileShellComposite { } return candidates } + + /// Retains every discovered app instance except the foreground winner. + /// Broker data is only a dial hint; each row still has to prove its device + /// and build identity over an authenticated RPC before it is persisted. + func stageZeroTouchIrohCandidatesForSecondaryAuthentication( + _ candidates: [MobilePairedMac], + scope: MobileShellScopeSnapshot + ) { + guard multiMacAggregationEnabled else { return } + if pendingZeroTouchIrohCandidateScope != scope { + pendingZeroTouchIrohCandidatesByPairingID = [:] + pendingZeroTouchIrohCandidateScope = scope + } + let foregroundPairingID = foregroundMacDeviceID.map { + MobilePairedMac.pairingID( + macDeviceID: $0, + instanceTag: activeMacInstanceTag + ) + } + for candidate in candidates where candidate.id != foregroundPairingID { + pendingZeroTouchIrohCandidatesByPairingID[candidate.id] = candidate + } + if pendingZeroTouchIrohCandidatesByPairingID.isEmpty { + pendingZeroTouchIrohCandidateScope = nil + } + } + + /// Authenticates and saves sibling zero-touch candidates without replacing + /// the foreground connection. Successful rows become ordinary secondary + /// aggregation targets on the same pass. + func authenticatePendingZeroTouchIrohCandidates( + scope: MobileShellScopeSnapshot + ) async { + guard let pairedMacStore, + pendingZeroTouchIrohCandidateScope == scope, + !pendingZeroTouchIrohCandidatesByPairingID.isEmpty else { + return + } + let storedPairingIDs = Set( + (try? await pairedMacStore.loadAll( + stackUserID: scope.userID, + teamID: scope.teamID + ))?.map(\.id) ?? [] + ) + guard !Task.isCancelled, await isScopeCurrent(scope) else { return } + + var didPersistCandidate = false + var transientFailureMacIDs: Set = [] + let foregroundPairingID = foregroundMacDeviceID.map { + MobilePairedMac.pairingID( + macDeviceID: $0, + instanceTag: activeMacInstanceTag + ) + } + let candidates = pendingZeroTouchIrohCandidatesByPairingID.values + .sorted { + if $0.lastSeenAt != $1.lastSeenAt { + return $0.lastSeenAt > $1.lastSeenAt + } + return $0.id < $1.id + } + for candidate in candidates { + guard !Task.isCancelled, await isScopeCurrent(scope) else { return } + guard pendingZeroTouchIrohCandidatesByPairingID[candidate.id] + == candidate else { + continue + } + switch await authenticateZeroTouchIrohCandidate( + candidate, + storedPairingIDs: storedPairingIDs, + foregroundPairingID: foregroundPairingID, + scope: scope + ) { + case .persisted: + didPersistCandidate = true + pendingZeroTouchIrohCandidatesByPairingID[candidate.id] = nil + case .discarded: + pendingZeroTouchIrohCandidatesByPairingID[candidate.id] = nil + case .transientFailure: + transientFailureMacIDs.insert( + candidate.macDeviceID + ) + case .superseded: + return + } + } + if pendingZeroTouchIrohCandidatesByPairingID.isEmpty { + pendingZeroTouchIrohCandidateScope = nil + } + if didPersistCandidate { + await loadPairedMacs() + } + if !transientFailureMacIDs.isEmpty { + scheduleSecondaryAggregationRetry( + macDeviceIDs: transientFailureMacIDs + ) + } + } + + private func authenticateZeroTouchIrohCandidate( + _ candidate: MobilePairedMac, + storedPairingIDs: Set, + foregroundPairingID: String?, + scope: MobileShellScopeSnapshot + ) async -> ZeroTouchSecondaryAuthenticationOutcome { + guard !storedPairingIDs.contains(candidate.id), + candidate.id != foregroundPairingID, + await !isHiddenMacDeviceID( + candidate.macDeviceID, + instanceTag: candidate.instanceTag, + scope: scope + ) else { + return .discarded + } + let handle: SecondaryClientHandle + switch await makeSecondaryClient(for: candidate) { + case let .connected(connectedHandle): + handle = connectedHandle + case .transientFailure: + return .transientFailure + case .permanentFailure: + return .discarded + } + let client = handle.client + guard !Task.isCancelled, await isScopeCurrent(scope) else { + await client.disconnect() + return .superseded + } + guard macBuildIsCompatible( + instanceTag: handle.authenticatedInstanceTag + ), + await !isHiddenMacDeviceID( + candidate.macDeviceID, + instanceTag: candidate.instanceTag, + scope: scope + ) else { + await client.disconnect() + return .discarded + } + let accepted = await persistPairedMacFromTicket( + handle.ticket, + instanceTagUpdate: .replace(handle.authenticatedInstanceTag), + displayNameOverride: handle.authenticatedDisplayName, + markActive: false, + ifStillCurrent: { [weak self] in + self?.pendingZeroTouchIrohCandidateScope == scope + } + ) + await client.disconnect() + guard !Task.isCancelled, await isScopeCurrent(scope) else { + return .superseded + } + return accepted ? .persisted : .transientFailure + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 33aa3547c1d5..307604774a97 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1049,6 +1049,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private var secondaryAggregationTask: Task? private var secondaryAggregationTaskGeneration = UUID() private var secondaryAggregationPending = false + /// Live zero-touch rows that still need endpoint authentication before + /// they can enter the paired store. The existing aggregation task owns + /// these dials so sign-out and account changes cancel the whole operation. + @ObservationIgnored + var pendingZeroTouchIrohCandidatesByPairingID: + [String: MobilePairedMac] = [:] + @ObservationIgnored + var pendingZeroTouchIrohCandidateScope: MobileShellScopeSnapshot? /// Incremental presence edges are reconciled only for their affected Macs. /// One coalesced task drains the pending id set without widening each edge /// into a pool-wide workspace refresh. @@ -2610,6 +2618,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { attemptedAutomaticIroh { recordTransientAutomaticReconnectBackoff(accountID: scope.userID) } + if connectionState == .connected { + stageZeroTouchIrohCandidatesForSecondaryAuthentication( + zeroTouchCandidates, + scope: scope + ) + } return connectionState == .connected ? .connected : lastDialOutcome } @@ -4330,6 +4344,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { authenticatedInstanceTag: macInstanceTagAuthority.normalize( status.macInstanceTag ), + authenticatedDisplayName: status.macDisplayName, supportedHostCapabilities: capabilities, actionCapabilities: Self.workspaceActionCapabilities( from: capabilities, @@ -4532,6 +4547,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { await refresher.refreshFromBackup(stackUserID: scope.userID) } guard await isAggregationScopeValid(scope) else { return } + if allowsNewConnections { + await authenticatePendingZeroTouchIrohCandidates(scope: scope) + } + guard await isAggregationScopeValid(scope) else { return } let rawRequestedCanonicalIDs = onlyMacDeviceIDs.map { Set($0.map(cmxCanonicalDeviceID)) } @@ -6296,6 +6315,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { secondaryAggregationTask = nil secondaryAggregationTaskGeneration = UUID() secondaryAggregationPending = false + pendingZeroTouchIrohCandidatesByPairingID = [:] + pendingZeroTouchIrohCandidateScope = nil secondaryPresenceAggregationTask?.cancel() secondaryPresenceAggregationTask = nil secondaryPresenceAggregationTaskGeneration = UUID() diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift index 651429a9202b..c06c17e4f7f7 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift @@ -11,6 +11,8 @@ struct SecondaryClientHandle { let storedInstanceTag: String? /// Instance identity proven by this client's authenticated host status. let authenticatedInstanceTag: String? + /// Display name proven by the same authenticated host status response. + let authenticatedDisplayName: String? let supportedHostCapabilities: Set let actionCapabilities: MobileWorkspaceActionCapabilities } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift index a25286a6779e..c712dce9b9e1 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift @@ -579,7 +579,9 @@ private struct ZeroTouchFixture { let directory: URL func cleanup() { - Task { await shell.remoteClient?.disconnect() } + let remoteClient = shell.remoteClient + shell.signOut() + Task { await remoteClient?.disconnect() } try? FileManager.default.removeItem(at: directory) } } From ce91b32a1ff32b034db18d605f3d8bf9721a1f50 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:35:56 -0700 Subject: [PATCH 3/3] fix(ios): finish zero-touch aggregation cleanup --- .../MobileShellComposite+ZeroTouchIroh.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift index 04557e6c6577..5d9419eb7fee 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift @@ -122,8 +122,10 @@ extension MobileShellComposite { } return $0.id < $1.id } - for candidate in candidates { - guard !Task.isCancelled, await isScopeCurrent(scope) else { return } + candidateLoop: for candidate in candidates { + guard !Task.isCancelled, await isScopeCurrent(scope) else { + break candidateLoop + } guard pendingZeroTouchIrohCandidatesByPairingID[candidate.id] == candidate else { continue @@ -144,7 +146,7 @@ extension MobileShellComposite { candidate.macDeviceID ) case .superseded: - return + break candidateLoop } } if pendingZeroTouchIrohCandidatesByPairingID.isEmpty {