iOS: Tailscale connection method opt-in (Settings + onboarding) with QR-authorized pairing - #9247
Conversation
…airing Adds an Auto-Connect vs Tailscale connection-method choice to iOS Settings and the last onboarding page. Choosing Tailscale reorders dialing to put authorized Tailscale routes ahead of the iroh pin (iroh stays as fallback) and routes the user to the Mac's compatibility QR scanner. A scanned/pasted v2 compatibility code becomes the authorization event: a new .userAuthorizedTailscalePairing transport mode dials only the exact host:port the user entered, only while the peer is unidentified, and only from explicit in-app code entry (external URL opens never mint it). After the Mac authenticates, a device-local 'user'-origin grant row persists so reconnects use the existing evidence path. v9 schema adds grant origin; migration-origin grants keep dying on iroh arrival, user-origin grants survive because the user chose Tailscale deliberately. Mac pairing window's legacy toggle is relabeled "Use Tailscale Pairing Code" (EN+JA) to match the iOS copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds user-selected Tailscale pairing to iOS. Exact host-port authorization flows through transport validation, route selection, paired-Mac persistence, reconnect ordering, and onboarding/settings UI with persisted connection-method preferences. ChangesTailscale pairing authorization
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant MobileHostPickerView
participant MobileShellComposite
participant MobileCoreRPCClient
participant CmxTailscaleRouteProof
participant MobilePairedMacStore
MobileHostPickerView->>MobileShellComposite: submit user-entered pairing code
MobileShellComposite->>MobileCoreRPCClient: provide exact host-port authorization
MobileCoreRPCClient->>CmxTailscaleRouteProof: validate identity-free Tailscale route
MobileShellComposite->>MobilePairedMacStore: persist authorized routes
MobileShellComposite->>MobileCoreRPCClient: reconnect using authorized Tailscale route
Possibly related PRs
Suggested reviewers: Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (1 error, 1 warning)
✅ Passed checks (23 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The Mac pairing window's Tailscale code is the tokenless v1 compatibility ticket, which carries a self-reported macDeviceID. Gating the user-entered authorization on an empty ticket identity would reject exactly the code users scan. The claimed identity adds no authority at first dial, so the authorization now anchors on the exact user-entered host:port alone; the in-app entry gate and the interface-bound route proof are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift (1)
65-92: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winConsolidate the triplicated Tailscale authorization-mode switch.
Three files independently re-implement the same "does this
authorizationModeauthorize dialing this exact host/port given this expected peer device ID" decision, and this PR extended all three identically for the newuserAuthorizedTailscalePairingcase. A single shared helper (e.g.CmxTransportAuthorizationMode.authorizesTailscaleDial(host:port:expectedPeerDeviceID:) -> Bool, or an equivalent method placed on the enum in CMUXMobileCore) would let each call site map the result to its own error type while guaranteeing the three checks can never silently diverge.
Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift#L65-L92: replace the inlineswitch request.authorizationMode { case .legacyTailscaleBearer... case .userAuthorizedTailscalePairing... }with a call to the shared helper, throwingtailscaleAuthorizationUnavailableonfalse.Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift#L153-L171: replace the equivalent switch with the same shared helper, throwingauthorizationEvidenceMismatch/unsupportedAuthorizationModeonfalse.Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift#L588-L625: replace the.legacyTailscaleBearer/.userAuthorizedTailscalePairingarms ofcanSendStackBearerwith a call to the same shared helper (still handling.stackBearer/.transportAdmissionlocally since those two arms differ in intent from the route-authorization checks).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift` around lines 65 - 92, Introduce one shared authorization helper on CmxTransportAuthorizationMode (or the equivalent CMUXMobileCore enum) that validates Tailscale host, port, and expected peer device ID for both legacy bearer and user-authorized pairing modes. Replace the duplicated checks in Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift lines 65-92 and Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift lines 153-171, mapping false to each existing local error; update Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift lines 588-625 to use the helper for those two modes while retaining its local stackBearer and transportAdmission handling.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift`:
- Around line 496-511: The authorizeUserTailscaleRoutes implementations must
resolve the actual paired-Mac row scope before forwarding. In
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift:496-511,
mirror setCustomizationUnlocked by checking scopedRows(stackUserID:teamID:) and
selecting scopedTeamID(teamID) or scopedTeamID(nil) for the matching fallback
row. In
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TeamScopedPairedMacStore.swift:320-335,
mirror setCustomization(instanceTag:...) and remove(instanceTag:...) by calling
visibleScope(...) and forwarding scope.stackUserID and scope.teamID.
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 8339-8364: The Tailscale-preference ordering algorithm is
duplicated and uses drifting authorization predicates. In
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift#L8339-L8364,
extract a shared static helper such as orderRoutesPreferringAuthorizedTailscale
that accepts ordered routes, Iroh routes, and an authorization closure, then
call it with the combined legacy and user-pairing authorization predicate. In
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift#L135-L171,
replace the local ordering logic with the same helper using the legacy
authorization predicate via TailscaleRoutePreference.
In `@Resources/Localizable.xcstrings`:
- Around line 132186-132218: Add localization entries for every supported locale
missing from mobile.pairing.codeMode.legacyDetail and
mobile.pairing.codeMode.useLegacy, preserving the existing en and ja entries and
matching the locale set used by Resources/Localizable.xcstrings: ar, bs, da, de,
es, fr, it, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, and zh-Hant.
---
Outside diff comments:
In
`@Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift`:
- Around line 65-92: Introduce one shared authorization helper on
CmxTransportAuthorizationMode (or the equivalent CMUXMobileCore enum) that
validates Tailscale host, port, and expected peer device ID for both legacy
bearer and user-authorized pairing modes. Replace the duplicated checks in
Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift
lines 65-92 and
Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift
lines 153-171, mapping false to each existing local error; update
Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift lines
588-625 to use the helper for those two modes while retaining its local
stackBearer and transportAdmission handling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 37202665-8882-4b2b-8203-ef48f2f8e77e
📒 Files selected for processing (42)
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxByteTransportRequest.swiftPackages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxUserTailscalePairingAuthorization.swiftPackages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxUserTailscalePairingAuthorizationTests.swiftPackages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/CmuxSyncStoreTests.swiftPackages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swiftPackages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoring.swiftPackages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swiftPackages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierPairedMacStore.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swiftPackages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TeamScopedPairedMacStore.swiftPackages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swiftPackages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/GatedUpsertStore.swiftPackages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetWildcardBreadthTests.swiftPackages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTeamRoutingTests.swiftPackages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swiftPackages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swiftPackages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionMethodSection.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingConnectionMethodPicker.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingConnectionView.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swiftPackages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swiftPackages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionPairedMacStore.swiftPackages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swiftPackages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swiftPackages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportFactorySecurityTests.swiftResources/Localizable.xcstringsSources/Mobile/Pairing/MobilePairingView.swiftios/cmux/AppCompositionRoot.swiftios/cmux/Resources/Localizable.xcstringsios/cmux/cmuxApp.swiftios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift
| // The user's explicit Tailscale method relaxes only the Iroh pin's | ||
| // ORDER: authorized Tailscale routes dial first and Iroh remains the | ||
| // fallback. Routes without a grant or a user-entered code stay | ||
| // undialable regardless of the preference. | ||
| if connectionMethodStore?.method == .tailscale { | ||
| let authorizedTailscale = supportedRoutes.filter { route in | ||
| Self.legacyTailscaleAuthorizationEvidence( | ||
| for: route, | ||
| macDeviceID: ticket.macDeviceID, | ||
| persistedRoutes: legacyTailscaleRoutes | ||
| ) != nil | ||
| || Self.userTailscalePairingAuthorization( | ||
| for: route, | ||
| ticket: ticket, | ||
| authorizations: userTailscalePairingAuthorizations | ||
| ) != nil | ||
| } | ||
| if !authorizedTailscale.isEmpty { | ||
| let rest = supportedRoutes.filter { route in | ||
| route.kind != .iroh && route.kind != .tailscale | ||
| } | ||
| return authorizedTailscale + irohRoutes + rest | ||
| } | ||
| } | ||
| return irohRoutes.isEmpty ? supportedRoutes : irohRoutes | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Duplicate Tailscale-preference route-ordering logic across two files. Both sites implement the same "authorized Tailscale routes dial first, Iroh stays as fallback, otherwise keep the exclusive Iroh pin" algorithm independently, with slightly different (overlapping) authorization predicates. This is authorization-critical routing logic; keeping two hand-synchronized copies risks the predicates drifting apart as the grant model evolves.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift#L8339-L8364: extract theif !authorizedTailscale.isEmpty { return authorizedTailscale + irohRoutes + rest } else { return irohRoutes.isEmpty ? supportedRoutes : irohRoutes }template into a shared static helper (e.g. takingordered:,irohRoutes:, and anisAuthorizedTailscale: (CmxAttachRoute) -> Boolclosure) and call it here with a predicate combininglegacyTailscaleAuthorizationEvidenceanduserTailscalePairingAuthorization.Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift#L135-L171: call the same shared helper fromstoredReconnectRoutes, passing a predicate built fromlegacyTailscaleAuthorizationEvidenceonly (viaTailscaleRoutePreference), so both ordering algorithms stay byte-for-byte identical going forward.
♻️ Suggested shared-helper shape
static func orderRoutesPreferringAuthorizedTailscale(
_ ordered: [CmxAttachRoute],
irohRoutes: [CmxAttachRoute],
isAuthorizedTailscale: (CmxAttachRoute) -> Bool
) -> [CmxAttachRoute] {
let authorizedTailscale = ordered.filter(isAuthorizedTailscale)
guard !authorizedTailscale.isEmpty else {
return irohRoutes.isEmpty ? ordered : irohRoutes
}
let rest = ordered.filter { $0.kind != .iroh && $0.kind != .tailscale }
return authorizedTailscale + irohRoutes + rest
}📍 Affects 2 files
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift#L8339-L8364(this comment)Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift#L135-L171
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 8339 - 8364, The Tailscale-preference ordering algorithm is
duplicated and uses drifting authorization predicates. In
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift#L8339-L8364,
extract a shared static helper such as orderRoutesPreferringAuthorizedTailscale
that accepts ordered routes, Iroh routes, and an authorization closure, then
call it with the combined legacy and user-pairing authorization predicate. In
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift#L135-L171,
replace the local ordering logic with the same helper using the legacy
authorization predicate via TailscaleRoutePreference.
| "mobile.pairing.codeMode.legacyDetail": { | ||
| "extractionState": "manual", | ||
| "localizations": { | ||
| "en": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Tailscale code: for the Tailscale connection method and older iPhone apps. The iPhone must be on the same Tailscale network." | ||
| } | ||
| }, | ||
| "ja": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Tailscaleコード: Tailscale接続方法および旧バージョンのiPhoneアプリ用。iPhoneが同じTailscaleネットワークに接続されている必要があります。" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "mobile.pairing.codeMode.useLegacy": { | ||
| "extractionState": "manual", | ||
| "localizations": { | ||
| "en": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Use Tailscale Pairing Code" | ||
| } | ||
| }, | ||
| "ja": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Tailscaleペアリングコードを使用" | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
python3 - <<'PY'
import json
from pathlib import Path
data = json.loads(Path("Resources/Localizable.xcstrings").read_text())
keys = ["mobile.pairing.codeMode.legacyDetail", "mobile.pairing.codeMode.useLegacy"]
for key in keys:
print(key, sorted(data["strings"][key]["localizations"]))
locales = sorted({
locale
for entry in data["strings"].values()
for locale in entry.get("localizations", {})
})
print("catalog locales:", locales)
PYRepository: manaflow-ai/cmux
Length of output: 402
Add localization entries for the supported xcstrings locales.
mobile.pairing.codeMode.legacyDetail and mobile.pairing.codeMode.useLegacy only include en and ja, but Resources/Localizable.xcstrings uses these locales: ar, bs, da, de, en, es, fr, it, ja, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, zh-Hant. Add matching entries for the missing supported locales.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Resources/Localizable.xcstrings` around lines 132186 - 132218, Add
localization entries for every supported locale missing from
mobile.pairing.codeMode.legacyDetail and mobile.pairing.codeMode.useLegacy,
preserving the existing en and ja entries and matching the locale set used by
Resources/Localizable.xcstrings: ar, bs, da, de, es, fr, it, km, ko, nb, pl,
pt-BR, ru, th, tr, uk, zh-Hans, and zh-Hant.
Sources: Path instructions, Learnings
The body and primary button already switch to the Tailscale flow; the title kept claiming automatic connection. Title now reads "Connect over Tailscale" (EN+JA) while the method is selected and the Mac is not yet connected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scoped-store decorators forwarded the selected team verbatim, but the base store's grant write requires an exact existing row and silently no-ops otherwise, so a Mac whose row still lives in the team-less fallback scope would drop the user-entered grant. Mirror the sibling exact-instance writes (visibleScope / setCustomizationUnlocked) in both decorators. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
Users can now opt into Tailscale over iroh on iOS, from two surfaces:
Why this is more than UI
On current builds a brand-new Tailscale pairing is impossible:
.tailscaleroutes only dial with a device-localCmxLegacyTailscaleAuthorizationEvidencegrant that exists solely via the v8 store migration (pre-iroh pairings), and route selection pins exclusively to iroh whenever an iroh route exists. Scanning the Mac's compatibility QR today decodes fine and then fails ashostUnreachable.How it works
CmxTransportAuthorizationMode.userAuthorizedTailscalePairing(CmxUserTailscalePairingAuthorization)lets a user-entered compatibility code (the pairing window's tokenless v1 ticket or the bare-route v2 grammar) dial exactly the numeric tailscale host:port it named. The authorization anchors on the destination alone — a device identity a code claims is self-reported and grants nothing — and it is minted only for codes entered in the in-app pairing UI (scanner or paste). External URL opens (onOpenURL, injected attach) never mint it, so a malicious deeplink cannot direct a bearer to an attacker address. The interface-bound Tailscale route proof (single utun, numeric peer, per-write revalidation, local-device rejection) applies unchanged, and the value never persists or outlives the pairing dial.macDeviceID), the store records the entered destinations inlegacy_tailscale_route_grantswith a neworigin='user'column (v9 migration). Migration-origin grants keep their existing lifecycle (deleted forever once iroh persists); user-origin grants survive iroh arrival because the user chose Tailscale deliberately. Grants stay device-local and never back up; a deliberate re-scan upgrades a migration grant to user origin.MobileConnectionMethodStore(UserDefaults) feeds route selection: with Tailscale selected, granted tailscale routes dial first and iroh remains the fallback instead of being exclusive (storedReconnectRoutes(tailscalePreference:),supportedRoutesinconnect). Flipping the preference without a grant changes nothing — the iroh pin stays.Scope notes
docs/iroh-app-transport-architecture.md) describes Tailscale TCP as migration-frozen; this PR deliberately re-opens explicit user-driven Tailscale pairing as a product decision, using the already-hardened path. Happy to update the doc in this PR or a follow-up.Tests
CMUXMobileCore320 ✓ (new:CmxUserTailscalePairingAuthorizationTests— canonicalization, non-tailnet rejection, substitution refusal)CmuxMobileTransport42 ✓ (new: user-authorized mode builds the interface-proof transport for its exact destination regardless of the claimed identity; host/port substitution fails closed; stack-bearer stays fail-closed)CmuxMobilePairedMac33 ✓ (new: user grant mint, survives iroh publication, requires existing row, re-scan upgrades migration grant)CmuxMobileShellModel186 ✓ (new: preference store round-trip)CmuxMobileShellnew route-ordering tests ✓; full-suite failures reproduced identically on cleanmain(pre-existing terminal-replay/render-grid flakes)CmuxSyncStore49 ✓Localization audit: 12 new iOS keys + 2 macOS keys, EN+JA, in
ios/cmux/Resources/Localizable.xcstrings(app catalog, wins at runtime) andResources/Localizable.xcstrings.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes