From a320f775f0f3f058b612da8b1fbc2adaebca097d Mon Sep 17 00:00:00 2001 From: PollyBot13 Date: Sun, 5 Jul 2026 21:31:53 +0200 Subject: [PATCH] fix(ios): defer QR pairing after scanner dismissal (#99572) * fix(ios): defer QR pairing after scanner dismissal * fix(ios): process QR pairing after scanner dismissal * fix(ios): harden QR scanner handoff * fix(ios): give QR scanner dismissal more time * fix(ios): keep onboarding open for QR trust prompt * fix(ios): keep QR trust prompt owned by onboarding * fix(ios): recover operator pairing after QR bootstrap * fix(ios): cancel stale QR scanner handoffs Co-authored-by: PollyBot13 * fix(ios): defer QR setup until onboarding closes * fix(ios): keep QR setup links with visible settings * fix(ios): consume setup links during onboarding * fix(ios): handle setup links during onboarding launch * fix(ios): route setup links through active onboarding * fix(ios): harden QR gateway handoff * fix(ios): cancel superseded gateway attempts * fix(ios): serialize scanner result delivery * fix(ios): prevent stale gateway reconnects * fix(ios): serialize gateway target handoff * fix(ios): disable stale gateway relaunch route * fix(ios): await staged bootstrap reset * test(ios): bound gateway reset handoff * fix(ios): preserve explicit gateway handoff * fix(ios): harden gateway lifecycle ownership * chore(ios): sync native i18n inventory * test(ios): align gateway ownership assertions * refactor(ios): remove superseded gateway helpers * fix(ios): keep gateway auth route scoped * fix(ios): restore gateway target review state * fix(protocol): refresh Swift plugin approval model * test(ios): isolate state directory overrides * fix(ios): preserve watch alerts across gateway switches * fix(ios): bind deferred work to gateway ownership * docs(changelog): credit iOS gateway handoff fix * chore(i18n): sync native app inventory * test(ios): remove unused Watch approval hooks --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + apps/.i18n/native-source.json | 360 +-- .../ShareExtension/ShareViewController.swift | 6 +- .../Chat/IOSGatewayChatTransport.swift | 50 +- apps/ios/Sources/Design/SettingsProTab.swift | 96 +- .../Design/SettingsProTabActions.swift | 281 +- .../Design/SettingsProTabSections.swift | 8 +- .../Gateway/GatewayConnectConfig.swift | 8 +- .../Gateway/GatewayConnectionController.swift | 843 ++++-- .../Gateway/GatewaySettingsStore.swift | 305 +- .../Gateway/GatewayTrustPromptAlert.swift | 7 +- ...odeAppModel+WatchNotifyNormalization.swift | 1 + apps/ios/Sources/Model/NodeAppModel.swift | 2482 +++++++++++++---- .../Onboarding/GatewayOnboardingReset.swift | 83 +- .../Onboarding/OnboardingWizardView.swift | 605 +++- .../Sources/Onboarding/QRScannerView.swift | 133 +- apps/ios/Sources/OpenClawApp.swift | 139 +- .../Push/ExecApprovalNotificationBridge.swift | 81 +- apps/ios/Sources/RootTabs.swift | 44 +- .../Services/NodeServiceProtocols.swift | 3 +- .../Services/WatchConnectivityTransport.swift | 8 +- .../Services/WatchMessagingPayloadCodec.swift | 44 + .../Sources/Terminal/TerminalHubScreen.swift | 20 +- apps/ios/Sources/Voice/TalkModeManager.swift | 40 +- apps/ios/Tests/DeepLinkParserTests.swift | 28 + .../ExecApprovalNotificationBridgeTests.swift | 90 +- .../GatewayConnectionControllerTests.swift | 1482 +++++++++- .../GatewayConnectionSecurityTests.swift | 88 +- .../ios/Tests/GatewaySettingsStoreTests.swift | 282 +- apps/ios/Tests/NodeAppModelInvokeTests.swift | 929 +++++- apps/ios/Tests/OpenClawAppDelegateTests.swift | 55 + .../Tests/QRScannerResultHandoffTests.swift | 141 + apps/ios/Tests/RootTabsSourceGuardTests.swift | 400 ++- apps/ios/Tests/TerminalHubScreenTests.swift | 57 +- .../WatchDeferredPayloadOrderingTests.swift | 29 + .../WatchApp/Sources/OpenClawWatchApp.swift | 6 +- .../Sources/WatchConnectivityReceiver.swift | 40 + .../WatchDeferredPayloadOrdering.swift | 26 + .../WatchApp/Sources/WatchInboxStore.swift | 463 ++- .../ios/WatchApp/Sources/WatchInboxView.swift | 70 +- apps/ios/project.yml | 2 + .../GatewayChannelConnectTests.swift | 2 +- .../Sources/OpenClawKit/DeepLinks.swift | 62 +- .../Sources/OpenClawKit/DeviceAuthStore.swift | 112 +- .../Sources/OpenClawKit/GatewayChannel.swift | 84 +- .../OpenClawKit/GatewayNodeSession.swift | 103 +- .../ShareGatewayRelaySettings.swift | 24 +- .../Sources/OpenClawKit/WatchCommands.swift | 18 + .../OpenClawProtocol/GatewayModels.swift | 4 +- .../DeviceIdentityStoreTests.swift | 150 +- .../GatewayModelsCompatibilityTests.swift | 26 +- .../GatewayNodeSessionTests.swift | 526 +++- .../StateDirectoryIsolationTrait.swift | 50 + scripts/protocol-gen-swift.ts | 3 +- src/gateway/exec-approval-ios-push.test.ts | 25 +- src/gateway/exec-approval-ios-push.ts | 19 +- src/gateway/server-methods/system.ts | 4 +- src/gateway/server-node-events.runtime.ts | 2 +- src/gateway/server-node-events.test.ts | 6 +- src/gateway/server-node-events.ts | 4 +- src/infra/device-identity.test.ts | 3 + src/infra/device-identity.ts | 17 + src/infra/push-apns.relay.ts | 4 +- src/infra/push-apns.test.ts | 6 + src/infra/push-apns.ts | 18 +- 65 files changed, 9360 insertions(+), 1748 deletions(-) create mode 100644 apps/ios/Tests/QRScannerResultHandoffTests.swift create mode 100644 apps/ios/Tests/WatchDeferredPayloadOrderingTests.swift create mode 100644 apps/ios/WatchApp/Sources/WatchDeferredPayloadOrdering.swift create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/StateDirectoryIsolationTrait.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e445375e4f3f..50e18285b601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **iOS QR gateway handoff:** stop VisionKit before delivering scanned setup codes, and keep deferred auth, approval, Watch, and foreground-node work bound to its originating gateway across reconnects. (#99572) Thanks @PollyBot13. - **iOS Voice Wake cleanup:** avoid initializing the microphone audio pipeline while disabling inactive Voice Wake, preventing simulator launch aborts and unnecessary audio setup. - **Cron duration validation:** reject positive durations that truncate below one millisecond instead of silently scheduling a zero-duration interval. (#100311) Thanks @qingminglong. - **Skill workshop proposals:** preserve the terminal newline in generated proposal Markdown while still rejecting blank raw content. (#100293) Thanks @anyech. diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 612ad2b45704..724c5251844b 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -8691,7 +8691,7 @@ }, { "kind": "ui-modifier", - "line": 132, + "line": 139, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Settings", "surface": "apple", @@ -8699,7 +8699,7 @@ }, { "kind": "ui-modifier", - "line": 229, + "line": 243, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Scan QR Code", "surface": "apple", @@ -8707,7 +8707,7 @@ }, { "kind": "ui-modifier", - "line": 250, + "line": 265, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset Onboarding?", "surface": "apple", @@ -8715,7 +8715,7 @@ }, { "kind": "ui-call", - "line": 254, + "line": 269, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset", "surface": "apple", @@ -8723,7 +8723,7 @@ }, { "kind": "ui-call", - "line": 258, + "line": 273, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Cancel", "surface": "apple", @@ -8731,7 +8731,7 @@ }, { "kind": "ui-call", - "line": 262, + "line": 277, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This disconnects, clears saved gateway credentials, and reopens onboarding.", "surface": "apple", @@ -8739,7 +8739,7 @@ }, { "kind": "ui-modifier", - "line": 265, + "line": 280, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -8747,7 +8747,7 @@ }, { "kind": "ui-call", - "line": 272, + "line": 287, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "OK", "surface": "apple", @@ -8755,7 +8755,7 @@ }, { "kind": "ui-call", - "line": 326, + "line": 342, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Enable OpenClaw Hosted Push Relay?", "surface": "apple", @@ -8763,7 +8763,7 @@ }, { "kind": "ui-call", - "line": 340, + "line": 356, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Continue", "surface": "apple", @@ -8771,7 +8771,7 @@ }, { "kind": "ui-call", - "line": 348, + "line": 364, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Not Now", "surface": "apple", @@ -8859,7 +8859,7 @@ }, { "kind": "conditional-branch", - "line": 664, + "line": 867, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Configured", "surface": "apple", @@ -8867,7 +8867,7 @@ }, { "kind": "conditional-branch", - "line": 664, + "line": 867, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not configured", "surface": "apple", @@ -8875,7 +8875,7 @@ }, { "kind": "conditional-branch", - "line": 729, + "line": 932, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connect to the gateway.", "surface": "apple", @@ -8883,7 +8883,7 @@ }, { "kind": "conditional-branch", - "line": 729, + "line": 932, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Gateway requests will appear here.", "surface": "apple", @@ -8891,7 +8891,7 @@ }, { "kind": "conditional-branch", - "line": 776, + "line": 979, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "High", "surface": "apple", @@ -8899,7 +8899,7 @@ }, { "kind": "conditional-branch", - "line": 776, + "line": 979, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Resolving", "surface": "apple", @@ -8907,7 +8907,7 @@ }, { "kind": "conditional-branch", - "line": 781, + "line": 984, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "One-time approval", "surface": "apple", @@ -8915,7 +8915,7 @@ }, { "kind": "conditional-branch", - "line": 781, + "line": 984, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Permission can be saved", "surface": "apple", @@ -8923,7 +8923,7 @@ }, { "kind": "conditional-branch", - "line": 783, + "line": 986, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Medium", "surface": "apple", @@ -8931,7 +8931,7 @@ }, { "kind": "conditional-branch", - "line": 783, + "line": 986, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Review", "surface": "apple", @@ -8939,7 +8939,7 @@ }, { "kind": "conditional-branch", - "line": 804, + "line": 1007, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "\\(diagnosticsIssueCount)", "surface": "apple", @@ -8947,7 +8947,7 @@ }, { "kind": "conditional-branch", - "line": 815, + "line": 1018, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location off", "surface": "apple", @@ -8955,7 +8955,7 @@ }, { "kind": "conditional-branch", - "line": 817, + "line": 1020, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using", "surface": "apple", @@ -8963,7 +8963,7 @@ }, { "kind": "conditional-branch", - "line": 819, + "line": 1022, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using, effective Off", "surface": "apple", @@ -8971,7 +8971,7 @@ }, { "kind": "conditional-branch", - "line": 821, + "line": 1024, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using, effective Always", "surface": "apple", @@ -8979,7 +8979,7 @@ }, { "kind": "conditional-branch", - "line": 823, + "line": 1026, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always", "surface": "apple", @@ -8987,7 +8987,7 @@ }, { "kind": "conditional-branch", - "line": 825, + "line": 1028, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always, effective While Using", "surface": "apple", @@ -8995,7 +8995,7 @@ }, { "kind": "conditional-branch", - "line": 827, + "line": 1030, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always, effective Off", "surface": "apple", @@ -9003,7 +9003,7 @@ }, { "kind": "conditional-branch", - "line": 855, + "line": 1058, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Checking iOS notification permission.", "surface": "apple", @@ -9011,7 +9011,7 @@ }, { "kind": "conditional-branch", - "line": 857, + "line": 1060, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "OpenClaw can show approval prompts and event alerts when the app is not active.", "surface": "apple", @@ -9019,7 +9019,7 @@ }, { "kind": "conditional-branch", - "line": 859, + "line": 1062, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Notifications have been denied. Enable them in iOS Settings.", "surface": "apple", @@ -9027,7 +9027,7 @@ }, { "kind": "conditional-branch", - "line": 861, + "line": 1064, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Enable notifications to receive approval prompts and event alerts outside the app.", "surface": "apple", @@ -9035,7 +9035,7 @@ }, { "kind": "conditional-branch", - "line": 863, + "line": 1066, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "OpenClaw cannot determine the current notification permission state.", "surface": "apple", @@ -10667,7 +10667,7 @@ }, { "kind": "ui-modifier", - "line": 7, + "line": 8, "path": "apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift", "source": "Trust this gateway?", "surface": "apple", @@ -10675,7 +10675,7 @@ }, { "kind": "ui-call", - "line": 21, + "line": 22, "path": "apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift", "source": "Cancel", "surface": "apple", @@ -10683,7 +10683,7 @@ }, { "kind": "ui-call", - "line": 27, + "line": 28, "path": "apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift", "source": "Trust and connect", "surface": "apple", @@ -10995,7 +10995,7 @@ }, { "kind": "conditional-branch", - "line": 2343, + "line": 2545, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -11003,7 +11003,7 @@ }, { "kind": "conditional-branch", - "line": 2343, + "line": 2545, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -11011,7 +11011,7 @@ }, { "kind": "conditional-branch", - "line": 2676, + "line": 3100, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -11019,7 +11019,7 @@ }, { "kind": "conditional-branch", - "line": 2676, + "line": 3100, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -11027,7 +11027,7 @@ }, { "kind": "conditional-branch", - "line": 2680, + "line": 3104, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -11035,7 +11035,7 @@ }, { "kind": "conditional-branch", - "line": 2680, + "line": 3104, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -11043,7 +11043,7 @@ }, { "kind": "conditional-branch", - "line": 2988, + "line": 3392, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -11051,7 +11051,7 @@ }, { "kind": "conditional-branch", - "line": 2988, + "line": 3392, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", @@ -11243,7 +11243,7 @@ }, { "kind": "ui-call", - "line": 148, + "line": 185, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back", "surface": "apple", @@ -11251,7 +11251,7 @@ }, { "kind": "ui-call", - "line": 157, + "line": 194, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Close", "surface": "apple", @@ -11259,7 +11259,7 @@ }, { "kind": "ui-call", - "line": 172, + "line": 209, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Done", "surface": "apple", @@ -11267,7 +11267,7 @@ }, { "kind": "ui-modifier", - "line": 180, + "line": 217, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -11275,7 +11275,7 @@ }, { "kind": "ui-call", - "line": 185, + "line": 222, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "OK", "surface": "apple", @@ -11283,7 +11283,7 @@ }, { "kind": "ui-call", - "line": 214, + "line": 327, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan QR Code", "surface": "apple", @@ -11291,7 +11291,7 @@ }, { "kind": "ui-call", - "line": 221, + "line": 335, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Cancel", "surface": "apple", @@ -11299,7 +11299,7 @@ }, { "kind": "ui-call", - "line": 228, + "line": 342, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Photos", "surface": "apple", @@ -11307,7 +11307,7 @@ }, { "kind": "ui-named-argument", - "line": 353, + "line": 398, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "LAN or Tailscale host", "surface": "apple", @@ -11315,7 +11315,7 @@ }, { "kind": "ui-named-argument", - "line": 361, + "line": 406, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "VPS with domain", "surface": "apple", @@ -11323,7 +11323,7 @@ }, { "kind": "ui-named-argument", - "line": 372, + "line": 417, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "For local iOS app development", "surface": "apple", @@ -11331,7 +11331,7 @@ }, { "kind": "ui-call", - "line": 388, + "line": 433, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Continue", "surface": "apple", @@ -11339,7 +11339,7 @@ }, { "kind": "ui-call", - "line": 397, + "line": 442, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer mode", "surface": "apple", @@ -11347,7 +11347,7 @@ }, { "kind": "conditional-branch", - "line": 425, + "line": 470, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Off", "surface": "apple", @@ -11355,7 +11355,7 @@ }, { "kind": "conditional-branch", - "line": 425, + "line": 470, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "On", "surface": "apple", @@ -11363,7 +11363,7 @@ }, { "kind": "ui-call", - "line": 432, + "line": 477, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Mode", "surface": "apple", @@ -11371,7 +11371,7 @@ }, { "kind": "ui-call", - "line": 433, + "line": 478, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery", "surface": "apple", @@ -11379,7 +11379,7 @@ }, { "kind": "ui-call", - "line": 435, + "line": 480, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Progress", "surface": "apple", @@ -11387,7 +11387,7 @@ }, { "kind": "ui-call", - "line": 437, + "line": 482, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Status", "surface": "apple", @@ -11395,7 +11395,7 @@ }, { "kind": "ui-call", - "line": 456, + "line": 505, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Choose a mode first.", "surface": "apple", @@ -11403,15 +11403,63 @@ }, { "kind": "ui-call", - "line": 461, + "line": 510, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back to Mode Selection", "surface": "apple", "id": "native.apple.378c2c7be159c1f2" }, + { + "kind": "conditional-branch", + "line": 522, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", + "source": "Plaintext (local network)", + "surface": "apple", + "id": "native.apple.4014217851d06190" + }, { "kind": "ui-call", - "line": 473, + "line": 522, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", + "source": "Security", + "surface": "apple", + "id": "native.apple.cf616b515da5bc19" + }, + { + "kind": "ui-call", + "line": 545, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", + "source": "Use Manual Setup", + "surface": "apple", + "id": "native.apple.db7b52a1bbc6fac5" + }, + { + "kind": "ui-call", + "line": 551, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", + "source": "Setup Link", + "surface": "apple", + "id": "native.apple.7dbdf9a439f64f08" + }, + { + "kind": "conditional-branch", + "line": 555, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", + "source": "Plaintext may expose credentials. Continue only if you trust this local network and host.", + "surface": "apple", + "id": "native.apple.6bfb611862fb1687" + }, + { + "kind": "conditional-branch", + "line": 555, + "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", + "source": "Review this endpoint. Credentials are applied only after you tap Connect.", + "surface": "apple", + "id": "native.apple.3329c7f367f10c78" + }, + { + "kind": "ui-call", + "line": 566, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "No gateways found yet.", "surface": "apple", @@ -11419,7 +11467,7 @@ }, { "kind": "ui-call", - "line": 498, + "line": 591, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resolving…", "surface": "apple", @@ -11427,7 +11475,7 @@ }, { "kind": "ui-call", - "line": 514, + "line": 607, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Restart Discovery", "surface": "apple", @@ -11435,7 +11483,7 @@ }, { "kind": "ui-call", - "line": 520, + "line": 613, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovered Gateways", "surface": "apple", @@ -11443,7 +11491,7 @@ }, { "kind": "ui-named-argument", - "line": 524, + "line": 617, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Manual Fallback", "surface": "apple", @@ -11451,7 +11499,7 @@ }, { "kind": "ui-named-argument", - "line": 529, + "line": 622, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Domain Settings", "surface": "apple", @@ -11459,7 +11507,7 @@ }, { "kind": "ui-call", - "line": 544, + "line": 637, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer Local", "surface": "apple", @@ -11467,7 +11515,7 @@ }, { "kind": "ui-call", - "line": 547, + "line": 640, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.", "surface": "apple", @@ -11475,7 +11523,7 @@ }, { "kind": "ui-call", - "line": 571, + "line": 664, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway rejected credentials. Scan a fresh QR code or update token/password.", "surface": "apple", @@ -11483,7 +11531,7 @@ }, { "kind": "ui-call", - "line": 575, + "line": 668, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Auth token looks valid.", "surface": "apple", @@ -11491,7 +11539,7 @@ }, { "kind": "ui-call", - "line": 589, + "line": 682, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resume After Approval", "surface": "apple", @@ -11499,7 +11547,7 @@ }, { "kind": "ui-call", - "line": 595, + "line": 688, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Pairing Approval", "surface": "apple", @@ -11507,7 +11555,7 @@ }, { "kind": "ui-call-concatenated", - "line": 605, + "line": 698, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Approve this device on the gateway.\n1) `\\(commandLine)`\n2) `/pair approve` in your OpenClaw chat\n\\(requestLine)\nOpenClaw will also retry automatically when you return to this app.", "surface": "apple", @@ -11515,7 +11563,7 @@ }, { "kind": "ui-call", - "line": 619, + "line": 712, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan QR Code Again", "surface": "apple", @@ -11523,7 +11571,7 @@ }, { "kind": "ui-call", - "line": 632, + "line": 725, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Retry Connection", "surface": "apple", @@ -11531,7 +11579,7 @@ }, { "kind": "ui-call", - "line": 665, + "line": 758, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Go to Chat", "surface": "apple", @@ -11539,7 +11587,7 @@ }, { "kind": "ui-call", - "line": 679, + "line": 772, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Paste setup code", "surface": "apple", @@ -11547,7 +11595,7 @@ }, { "kind": "ui-call", - "line": 695, + "line": 788, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Applying...", "surface": "apple", @@ -11555,7 +11603,7 @@ }, { "kind": "ui-call", - "line": 699, + "line": 792, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Apply Setup Code", "surface": "apple", @@ -11563,7 +11611,7 @@ }, { "kind": "ui-call", - "line": 714, + "line": 807, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Setup Code", "surface": "apple", @@ -11571,7 +11619,7 @@ }, { "kind": "ui-call", - "line": 717, + "line": 810, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use this if you received a setup code instead of a QR code.", "surface": "apple", @@ -11579,7 +11627,7 @@ }, { "kind": "ui-call", - "line": 724, + "line": 817, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Host", "surface": "apple", @@ -11587,7 +11635,7 @@ }, { "kind": "ui-call", - "line": 728, + "line": 821, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Port", "surface": "apple", @@ -11595,7 +11643,7 @@ }, { "kind": "ui-call", - "line": 731, + "line": 824, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use TLS", "surface": "apple", @@ -11603,7 +11651,7 @@ }, { "kind": "ui-call", - "line": 732, + "line": 825, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery Domain (optional)", "surface": "apple", @@ -11611,7 +11659,7 @@ }, { "kind": "ui-call", - "line": 737, + "line": 830, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -11619,7 +11667,7 @@ }, { "kind": "ui-call", - "line": 740, + "line": 833, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Password", "surface": "apple", @@ -11627,7 +11675,7 @@ }, { "kind": "ui-call", - "line": 784, + "line": 877, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connecting…", "surface": "apple", @@ -11659,7 +11707,7 @@ }, { "kind": "ui-call", - "line": 209, + "line": 212, "path": "apps/ios/Sources/RootTabs.swift", "source": "Settings", "surface": "apple", @@ -11667,7 +11715,7 @@ }, { "kind": "ui-call", - "line": 308, + "line": 311, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw", "surface": "apple", @@ -11675,7 +11723,7 @@ }, { "kind": "ui-modifier", - "line": 338, + "line": 341, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw \\(self.sidebarGatewayStatusTitle)", "surface": "apple", @@ -11683,7 +11731,7 @@ }, { "kind": "conditional-branch", - "line": 343, + "line": 346, "path": "apps/ios/Sources/RootTabs.swift", "source": "Online", "surface": "apple", @@ -11691,7 +11739,7 @@ }, { "kind": "conditional-branch", - "line": 345, + "line": 348, "path": "apps/ios/Sources/RootTabs.swift", "source": "Connecting", "surface": "apple", @@ -11699,7 +11747,7 @@ }, { "kind": "conditional-branch", - "line": 347, + "line": 350, "path": "apps/ios/Sources/RootTabs.swift", "source": "Needs attention", "surface": "apple", @@ -11707,7 +11755,7 @@ }, { "kind": "conditional-branch", - "line": 349, + "line": 352, "path": "apps/ios/Sources/RootTabs.swift", "source": "Offline", "surface": "apple", @@ -11715,7 +11763,7 @@ }, { "kind": "ui-named-argument", - "line": 429, + "line": 432, "path": "apps/ios/Sources/RootTabs.swift", "source": "Chat", "surface": "apple", @@ -11723,7 +11771,7 @@ }, { "kind": "ui-named-argument", - "line": 442, + "line": 445, "path": "apps/ios/Sources/RootTabs.swift", "source": "Overview", "surface": "apple", @@ -11731,7 +11779,7 @@ }, { "kind": "ui-named-argument", - "line": 466, + "line": 469, "path": "apps/ios/Sources/RootTabs.swift", "source": "Agents", "surface": "apple", @@ -11739,7 +11787,7 @@ }, { "kind": "ui-named-argument", - "line": 473, + "line": 476, "path": "apps/ios/Sources/RootTabs.swift", "source": "Instances", "surface": "apple", @@ -11747,7 +11795,7 @@ }, { "kind": "ui-named-argument", - "line": 484, + "line": 487, "path": "apps/ios/Sources/RootTabs.swift", "source": "Dreaming", "surface": "apple", @@ -11755,7 +11803,7 @@ }, { "kind": "ui-named-argument", - "line": 491, + "line": 494, "path": "apps/ios/Sources/RootTabs.swift", "source": "Usage", "surface": "apple", @@ -11763,7 +11811,7 @@ }, { "kind": "ui-named-argument", - "line": 498, + "line": 501, "path": "apps/ios/Sources/RootTabs.swift", "source": "Cron Jobs", "surface": "apple", @@ -11771,7 +11819,7 @@ }, { "kind": "ui-modifier", - "line": 630, + "line": 634, "path": "apps/ios/Sources/RootTabs.swift", "source": "Hide Sidebar", "surface": "apple", @@ -11779,7 +11827,7 @@ }, { "kind": "ui-modifier", - "line": 757, + "line": 761, "path": "apps/ios/Sources/RootTabs.swift", "source": "Close canvas", "surface": "apple", @@ -11787,7 +11835,7 @@ }, { "kind": "conditional-branch", - "line": 1006, + "line": 1010, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway needs attention", "surface": "apple", @@ -11795,7 +11843,7 @@ }, { "kind": "conditional-branch", - "line": 1006, + "line": 1010, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw iOS", "surface": "apple", @@ -11803,7 +11851,7 @@ }, { "kind": "conditional-branch", - "line": 1042, + "line": 1046, "path": "apps/ios/Sources/RootTabs.swift", "source": "Available", "surface": "apple", @@ -11811,7 +11859,7 @@ }, { "kind": "conditional-branch", - "line": 1042, + "line": 1046, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway default", "surface": "apple", @@ -12275,7 +12323,7 @@ }, { "kind": "conditional-branch", - "line": 284, + "line": 285, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Offline", "surface": "apple", @@ -12283,7 +12331,7 @@ }, { "kind": "conditional-branch", - "line": 284, + "line": 285, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Ready", "surface": "apple", @@ -12291,7 +12339,7 @@ }, { "kind": "conditional-branch", - "line": 957, + "line": 959, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Listening", "surface": "apple", @@ -12299,7 +12347,7 @@ }, { "kind": "conditional-branch", - "line": 957, + "line": 959, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Speech error: \\(msg)", "surface": "apple", @@ -12307,7 +12355,7 @@ }, { "kind": "conditional-branch", - "line": 1158, + "line": 1160, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Aborted", "surface": "apple", @@ -12315,7 +12363,7 @@ }, { "kind": "conditional-branch", - "line": 1158, + "line": 1160, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Chat error", "surface": "apple", @@ -12323,7 +12371,7 @@ }, { "kind": "conditional-branch", - "line": 2988, + "line": 3006, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Native", "surface": "apple", @@ -12331,7 +12379,7 @@ }, { "kind": "conditional-branch", - "line": 2988, + "line": 3006, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Native WebRTC", "surface": "apple", @@ -12531,7 +12579,7 @@ }, { "kind": "ui-named-argument", - "line": 275, + "line": 281, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Review again", "surface": "apple", @@ -12539,7 +12587,7 @@ }, { "kind": "ui-named-argument", - "line": 285, + "line": 291, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Open all approvals", "surface": "apple", @@ -12547,7 +12595,7 @@ }, { "kind": "conditional-branch", - "line": 329, + "line": 335, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.chatCount)", "surface": "apple", @@ -12555,7 +12603,7 @@ }, { "kind": "conditional-branch", - "line": 333, + "line": 339, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.approvalCount)", "surface": "apple", @@ -12563,7 +12611,7 @@ }, { "kind": "conditional-branch", - "line": 338, + "line": 344, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "AI agent online", "surface": "apple", @@ -12571,7 +12619,7 @@ }, { "kind": "conditional-branch", - "line": 338, + "line": 344, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Reconnect on iPhone", "surface": "apple", @@ -12579,7 +12627,7 @@ }, { "kind": "conditional-branch", - "line": 345, + "line": 351, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Pairing", "surface": "apple", @@ -12587,7 +12635,7 @@ }, { "kind": "conditional-branch", - "line": 345, + "line": 351, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Running", "surface": "apple", @@ -12595,7 +12643,7 @@ }, { "kind": "conditional-branch", - "line": 365, + "line": 371, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Ready for quick actions", "surface": "apple", @@ -12603,7 +12651,7 @@ }, { "kind": "conditional-branch", - "line": 365, + "line": 371, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Waiting for iPhone sync", "surface": "apple", @@ -12611,7 +12659,7 @@ }, { "kind": "conditional-branch", - "line": 369, + "line": 375, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "1 approval waiting", "surface": "apple", @@ -12619,7 +12667,7 @@ }, { "kind": "conditional-branch", - "line": 369, + "line": 375, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.approvalCount) approvals", "surface": "apple", @@ -12627,7 +12675,7 @@ }, { "kind": "conditional-branch", - "line": 378, + "line": 384, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Decide from watch", "surface": "apple", @@ -12635,7 +12683,7 @@ }, { "kind": "conditional-branch", - "line": 378, + "line": 384, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No approvals", "surface": "apple", @@ -12643,7 +12691,7 @@ }, { "kind": "conditional-branch", - "line": 432, + "line": 438, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "1 recent message", "surface": "apple", @@ -12651,7 +12699,7 @@ }, { "kind": "conditional-branch", - "line": 432, + "line": 438, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.chatCount) recent messages", "surface": "apple", @@ -12659,7 +12707,7 @@ }, { "kind": "conditional-branch", - "line": 434, + "line": 440, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No messages synced", "surface": "apple", @@ -12667,7 +12715,7 @@ }, { "kind": "conditional-branch", - "line": 464, + "line": 470, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Synced", "surface": "apple", @@ -12675,7 +12723,7 @@ }, { "kind": "conditional-branch", - "line": 464, + "line": 470, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Waiting for iPhone", "surface": "apple", @@ -12683,7 +12731,7 @@ }, { "kind": "ui-named-argument", - "line": 730, + "line": 736, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Inbox", "surface": "apple", @@ -12691,7 +12739,7 @@ }, { "kind": "ui-named-argument", - "line": 906, + "line": 912, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "OpenClaw", "surface": "apple", @@ -12699,7 +12747,7 @@ }, { "kind": "conditional-branch", - "line": 995, + "line": 1001, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "You", "surface": "apple", @@ -12707,7 +12755,7 @@ }, { "kind": "conditional-branch", - "line": 997, + "line": 1003, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "System", "surface": "apple", @@ -12715,7 +12763,7 @@ }, { "kind": "ui-named-argument", - "line": 1042, + "line": 1048, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Refresh", "surface": "apple", @@ -12723,7 +12771,7 @@ }, { "kind": "ui-call", - "line": 1137, + "line": 1143, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No chat synced", "surface": "apple", @@ -12731,7 +12779,7 @@ }, { "kind": "ui-call", - "line": 1144, + "line": 1150, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Tap the message pill below to start from your watch.", "surface": "apple", @@ -12739,7 +12787,7 @@ }, { "kind": "ui-call", - "line": 1192, + "line": 1198, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Message OpenClaw", "surface": "apple", @@ -12747,7 +12795,7 @@ }, { "kind": "ui-named-argument", - "line": 1295, + "line": 1301, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approvals", "surface": "apple", @@ -12755,7 +12803,7 @@ }, { "kind": "ui-named-argument", - "line": 1298, + "line": 1304, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Clear", "surface": "apple", @@ -12763,7 +12811,7 @@ }, { "kind": "ui-named-argument", - "line": 1299, + "line": 1305, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No approvals waiting", "surface": "apple", @@ -12771,7 +12819,7 @@ }, { "kind": "ui-named-argument", - "line": 1359, + "line": 1365, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approval", "surface": "apple", @@ -12779,7 +12827,7 @@ }, { "kind": "ui-named-argument", - "line": 1373, + "line": 1379, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Sending decision...", "surface": "apple", @@ -12787,7 +12835,7 @@ }, { "kind": "ui-named-argument", - "line": 1377, + "line": 1383, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approve", "surface": "apple", @@ -12795,7 +12843,7 @@ }, { "kind": "ui-named-argument", - "line": 1383, + "line": 1392, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Deny", "surface": "apple", diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift index 5b84f3b3f89e..eb451ac7f51e 100644 --- a/apps/ios/ShareExtension/ShareViewController.swift +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -164,7 +164,7 @@ final class ShareViewController: UIViewController { } private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws { - guard let config = ShareGatewayRelaySettings.loadConfig() else { + guard let config = ShareGatewayRelaySettings.loadConfigDiscardingUnscopedDeviceAuth() else { throw NSError( domain: "OpenClawShare", code: 10, @@ -200,7 +200,9 @@ final class ShareViewController: UIViewController { clientMode: "node", clientDisplayName: "OpenClaw Share", deviceIdentityProfile: .shareExtension, - includeDeviceIdentity: true) + includeDeviceIdentity: true, + allowStoredDeviceAuth: config.gatewayStableID != nil, + deviceAuthGatewayID: config.gatewayStableID) } do { diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 20d18412cb3a..16e842f24464 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -214,8 +214,19 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { } func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { + try await self.requestHistory(sessionKey: sessionKey, ifCurrentRoute: nil) + } + + func requestHistory( + sessionKey: String, + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatHistoryPayload + { let json = try Self.makeHistoryParamsJSON(sessionKey: sessionKey) - let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15) + let res = try await self.gateway.request( + method: "chat.history", + paramsJSON: json, + timeoutSeconds: 15, + ifCurrentRoute: expectedRoute) return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res) } @@ -236,6 +247,23 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { thinking: String, idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + try await self.sendMessage( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments, + ifCurrentRoute: nil) + } + + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload], + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatSendResponse { let startLogMessage = "chat.send start sessionKey=\(sessionKey) " @@ -250,7 +278,11 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { idempotencyKey: idempotencyKey, attachments: attachments) do { - let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) + let res = try await self.gateway.request( + method: "chat.send", + paramsJSON: json, + timeoutSeconds: 35, + ifCurrentRoute: expectedRoute) let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res) Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)") GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)") @@ -294,6 +326,17 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { } func waitForRunCompletion(runId rawRunId: String, timeoutMs: Int) async -> Bool { + await self.waitForRunCompletion( + runId: rawRunId, + timeoutMs: timeoutMs, + ifCurrentRoute: nil) + } + + func waitForRunCompletion( + runId rawRunId: String, + timeoutMs: Int, + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async -> Bool + { let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines) guard !runId.isEmpty else { return false } @@ -304,7 +347,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport { let res = try await self.gateway.request( method: "agent.wait", paramsJSON: json, - timeoutSeconds: requestTimeoutSeconds) + timeoutSeconds: requestTimeoutSeconds, + ifCurrentRoute: expectedRoute) let completion = try Self.decodeAgentWaitCompletion(res, fallbackRunId: runId) GatewayDiagnostics.log("agent.wait completed runId=\(completion.runId) status=\(completion.status)") if !completion.completed { diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index abd3c9d56204..ca3c0e1277b1 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -44,11 +44,15 @@ struct SettingsProTab: View { @State var selectedAgentPickerId = "" @State var gatewayToken = "" @State var gatewayPassword = "" + @State var gatewayCredentialFieldStableID: String? @State var manualGatewayPortText = "" @State var setupStatusText: String? @State var setupAttemptID: UUID? @State var stagedGatewaySetupLink: GatewayConnectDeepLink? @State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride? + @State var scannerResultHandoff = QRScannerResultHandoff() + @State var scannerScanID: UInt64 = 0 + @State var pendingTargetSuppression = GatewayPendingTargetSuppression() @State var defaultShareInstruction = "" @State var showQRScanner = false @State var scannerError: String? @@ -71,6 +75,7 @@ struct SettingsProTab: View { @State private var navigationPath: [SettingsRoute] = [] let initialRoute: SettingsRoute? let directRoute: SettingsRoute? + let acceptsGatewaySetupRequests: Bool let headerLeadingAction: OpenClawSidebarHeaderAction? let ownsNavigationStack: Bool let navigateToRoute: ((SettingsRoute) -> Void)? @@ -81,6 +86,7 @@ struct SettingsProTab: View { init( initialRoute: SettingsRoute? = nil, directRoute: SettingsRoute? = nil, + acceptsGatewaySetupRequests: Bool = false, headerLeadingAction: OpenClawSidebarHeaderAction? = nil, ownsNavigationStack: Bool = true, navigateToRoute: ((SettingsRoute) -> Void)? = nil, @@ -90,6 +96,7 @@ struct SettingsProTab: View { { self.initialRoute = initialRoute self.directRoute = directRoute + self.acceptsGatewaySetupRequests = acceptsGatewaySetupRequests self.headerLeadingAction = headerLeadingAction self.ownsNavigationStack = ownsNavigationStack self.navigateToRoute = navigateToRoute @@ -155,6 +162,10 @@ struct SettingsProTab: View { self.applyInitialRouteIfNeeded() self.notifyRouteChange() } + .onDisappear { + self.scannerResultHandoff.cancel() + self.pendingTargetSuppression.resumeAutoConnect(controller: self.gatewayController) + } .onChange(of: self.gatewaySetupRequest?.id) { _, _ in self.applyGatewaySetupRequestIfNeeded() } @@ -176,20 +187,18 @@ struct SettingsProTab: View { self.selectedAgentPickerId = newValue } } - .onChange(of: self.gatewayToken) { _, newValue in - self.persistGatewayToken(newValue) - } - .onChange(of: self.gatewayPassword) { _, newValue in - self.persistGatewayPassword(newValue) - } .onChange(of: self.setupCode) { _, newValue in if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.stagedGatewaySetupLink = nil + self.clearStagedGatewaySetupLink() } } .onChange(of: self.defaultShareInstruction) { _, newValue in ShareToAgentSettings.saveDefaultInstruction(newValue) } + .onChange(of: self.acceptsGatewaySetupRequests) { _, acceptsRequests in + guard acceptsRequests else { return } + self.applyGatewaySetupRequestIfNeeded() + } .onChange(of: self.onboardingRequestID) { _, _ in // Root-owned resets leave Settings mounted behind onboarding. // Reload cleared credentials before the view can persist stale state. @@ -202,46 +211,52 @@ struct SettingsProTab: View { } private func settingsModalPresentation(_ content: some View) -> some View { - content + let scanID = self.scannerScanID + return content .sheet(isPresented: self.$showTalkIssueDetails) { if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue { TalkRuntimeIssueDetailsSheet(issue: issue) } } - .sheet(isPresented: self.$showQRScanner) { - NavigationStack { - QRScannerView( - onGatewayLink: { link in - self.handleScannedGatewayLink(link) - }, - onSetupCode: { code in - self.handleScannedSetupCode(code) - }, - onError: { error in - self.showQRScanner = false - self.setupStatusText = "Scanner error: \(error)" - self.scannerError = error - }, - onDismiss: { - self.showQRScanner = false - }) - .ignoresSafeArea() - .navigationTitle("Scan QR Code") - .navigationBarTitleDisplayMode(.inline) - .font(OpenClawType.body) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - self.showQRScanner = false - } label: { - Text("Cancel") - .font(OpenClawType.subheadSemiBold) + .sheet( + isPresented: self.$showQRScanner, + onDismiss: { + self.processQueuedScannerResult() + }, + content: { + NavigationStack { + QRScannerView( + onResult: { result in + self.queueScannedResult(result, scanID: scanID) + }, + onError: { error in + guard self.scannerResultHandoff.isActive(scanID: scanID) else { return } + self.showQRScanner = false + self.setupStatusText = "Scanner error: \(error)" + self.scannerError = error + }, + onDismiss: { + guard self.scannerResultHandoff.isActive(scanID: scanID) else { return } + self.showQRScanner = false + }) + .ignoresSafeArea() + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .font(OpenClawType.body) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + self.scannerResultHandoff.cancel() + self.showQRScanner = false + } label: { + Text("Cancel") + .font(OpenClawType.subheadSemiBold) + } + .font(OpenClawType.subheadSemiBold) } - .font(OpenClawType.subheadSemiBold) } - } - } - } + } + }) .sheet(isPresented: self.$showNotificationRelayDisclosure) { HostedPushRelayDisclosureSheet( message: self.notificationRelayDisclosureMessage, @@ -279,6 +294,7 @@ struct SettingsProTab: View { } private func applyGatewaySetupRequestIfNeeded() { + guard self.acceptsGatewaySetupRequests else { return } guard let gatewaySetupRequest else { return } self.applyGatewaySetupLink(gatewaySetupRequest.link) self.onGatewaySetupRequestHandled?(gatewaySetupRequest.id) diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index b283f91e2421..9d56baebfe68 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -156,8 +156,23 @@ extension SettingsProTab { self.refreshLocationPermissionSummary() let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedInstanceId.isEmpty else { return } - self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" - self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" + guard let stableID = self.currentManualGatewayStableID else { + self.gatewayCredentialFieldStableID = nil + self.gatewayToken = "" + self.gatewayPassword = "" + self.pendingManualAuthOverride = nil + return + } + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: trimmedInstanceId, + gatewayStableID: stableID) + let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth + self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil + self.gatewayToken = credentials.token ?? "" + self.gatewayPassword = credentials.password ?? "" + self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: trimmedInstanceId, + targetStableID: stableID) } func refreshLocationPermissionSummary(desiredMode modeOverride: OpenClawLocationMode? = nil) { @@ -196,12 +211,20 @@ extension SettingsProTab { self.stagedGatewaySetupLink = nil self.pendingManualAuthOverride = nil self.syncSettingsState() + self.pendingTargetSuppression.releaseAutoConnect(controller: self.gatewayController) } func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + let supersededSetupLease = self.takeStagedGatewaySetupSuppression() + defer { + if let supersededSetupLease { + self.gatewayController.resumeAutoConnect(after: supersededSetupLease) + } + } self.connectingGatewayID = gateway.id defer { self.connectingGatewayID = nil } self.manualGatewayEnabled = false + self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false) GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID) GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) if let err = await self.gatewayController.connectWithDiagnostics(gateway) { @@ -211,7 +234,10 @@ extension SettingsProTab { func applySetupCodeAndConnect() async { guard let attemptID = self.beginGatewaySetupAttempt() else { return } - defer { self.finishGatewaySetupAttempt(attemptID) } + defer { + self.finishGatewaySetupAttempt(attemptID) + self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) + } self.setupStatusText = nil guard await self.applySetupCode(attemptID: attemptID) else { return } let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) @@ -225,6 +251,12 @@ extension SettingsProTab { } func applyGatewaySetupLink(_ link: GatewayConnectDeepLink) { + // Only the root-selected Gateway destination may destructively claim a + // setup link; other Settings views can remain mounted behind onboarding. + self.showQRScanner = false + self.scannerResultHandoff.cancel() + let lease = self.gatewayController.cancelPendingConnectionAttempts() + self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease) self.setupCode = "" self.setupStatusText = nil self.stagedGatewaySetupLink = link @@ -246,6 +278,7 @@ extension SettingsProTab { self.setupCode = "" self.setupStatusText = "Apple Review demo mode enabled." self.appModel.enterAppleReviewDemoMode() + self.pendingTargetSuppression.releaseAutoConnect(.setupLink, controller: self.gatewayController) return false } @@ -267,34 +300,58 @@ extension SettingsProTab { self.manualGatewayTLS = link.tls let instanceId = GatewaySettingsStore.currentInstanceID() let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) + self.gatewayCredentialFieldStableID = setupAuth.targetStableID if setupAuth.hasBootstrapToken { - GatewayOnboardingReset.prepareForBootstrapPairing(appModel: self.appModel, instanceId: instanceId) + GatewayOnboardingReset.prepareForBootstrapPairing( + appModel: self.appModel, + instanceId: instanceId, + gatewayStableID: setupAuth.targetStableID) } if !instanceId.isEmpty { - GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: instanceId) - } - if setupAuth.shouldApplyTokenField { - self.gatewayToken = setupAuth.token - if !instanceId.isEmpty { - GatewaySettingsStore.saveGatewayToken(setupAuth.token, instanceId: instanceId) - } - } - if setupAuth.shouldApplyPasswordField { - self.gatewayPassword = setupAuth.password - if !instanceId.isEmpty { - GatewaySettingsStore.saveGatewayPassword(setupAuth.password, instanceId: instanceId) - } + GatewaySettingsStore.saveGatewayCredentials( + token: setupAuth.token, + bootstrapToken: setupAuth.bootstrapToken, + password: setupAuth.password, + gatewayStableID: setupAuth.targetStableID, + suppressStoredDeviceAuth: true, + instanceId: instanceId) } + self.gatewayToken = setupAuth.token + self.gatewayPassword = setupAuth.password self.pendingManualAuthOverride = setupAuth.manualAuthOverride } func openGatewayQRScanner() { - self.appModel.disconnectGateway() self.invalidateGatewaySetupAttempt() + let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true) + self.stagedGatewaySetupLink = nil + self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease) + self.scannerScanID = self.scannerResultHandoff.beginScan() + self.connectingGatewayID = nil self.setupStatusText = "Opening QR scanner..." self.showQRScanner = true } + func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) { + guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return } + self.setupStatusText = "QR loaded. Closing scanner..." + self.showQRScanner = false + } + + func processQueuedScannerResult() { + let delivery = self.scannerResultHandoff.processAfterDismissal { result in + switch result { + case let .gatewayLink(link): + self.handleScannedGatewayLink(link) + case let .setupCode(code): + self.handleScannedSetupCode(code) + } + } + if delivery == nil { + self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController) + } + } + func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) { self.showQRScanner = false guard let attemptID = self.beginGatewaySetupAttempt() else { return } @@ -309,10 +366,25 @@ extension SettingsProTab { self.stagedGatewaySetupLink = nil self.setupStatusText = "Apple Review demo mode enabled." self.appModel.enterAppleReviewDemoMode() + self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController) + } + + func clearStagedGatewaySetupLink() { + guard self.stagedGatewaySetupLink != nil else { return } + self.stagedGatewaySetupLink = nil + self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) + } + + private func takeStagedGatewaySetupSuppression() -> GatewayConnectionController.AutoConnectSuppressionLease? { + self.stagedGatewaySetupLink = nil + return self.pendingTargetSuppression.take(ifOwnedBy: .setupLink) } func connectAfterScannedGatewayLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async { - defer { self.finishGatewaySetupAttempt(attemptID) } + defer { + self.finishGatewaySetupAttempt(attemptID) + self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController) + } let link = await self.gatewayController.selectReachableSetupLink(parsedLink) guard self.setupAttemptID == attemptID else { return } self.applyGatewayLink(link) @@ -332,6 +404,12 @@ extension SettingsProTab { } else { self.invalidateGatewaySetupAttempt() } + let supersededSetupLease = self.takeStagedGatewaySetupSuppression() + defer { + if let supersededSetupLease { + self.gatewayController.resumeAutoConnect(after: supersededSetupLease) + } + } let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty else { self.setupStatusText = "Failed: host required" @@ -341,19 +419,49 @@ extension SettingsProTab { self.setupStatusText = "Failed: invalid port" return } + guard let port = self.resolvedManualPort(host: host) else { + self.setupStatusText = "Failed: invalid port" + return + } self.connectingGatewayID = "manual" self.manualGatewayEnabled = true defer { self.connectingGatewayID = nil } + let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID( + host: host, + port: port) + self.selectGatewayCredentialTarget(stableID, allowManualOverride: true) + if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID, + self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true + { + self.pendingManualAuthOverride = nil + } + let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID + let pendingOverride = self.pendingManualAuthOverride?.targetStableID == stableID + ? self.pendingManualAuthOverride + : nil let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput( - token: self.gatewayToken, - pendingOverride: self.pendingManualAuthOverride, - password: self.gatewayPassword) - self.pendingManualAuthOverride = nil + token: fieldsMatchTarget ? self.gatewayToken : nil, + pendingOverride: pendingOverride, + password: fieldsMatchTarget ? self.gatewayPassword : nil, + targetStableID: stableID) + let instanceId = GatewaySettingsStore.currentInstanceID() + if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil { + GatewaySettingsStore.saveGatewayCredentials( + token: authOverride?.token, + bootstrapToken: authOverride?.bootstrapToken, + password: authOverride?.password, + gatewayStableID: stableID, + suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true, + instanceId: instanceId) + } await self.gatewayController.connectManual( host: host, - port: self.manualGatewayPort, + port: port, useTLS: self.manualGatewayTLS, authOverride: authOverride) + // The controller now owns this attempt's immutable override. A later retry must reload + // durable state so a spent bootstrap token cannot be resurrected from the live view. + self.pendingManualAuthOverride = nil } func preflightGateway(host: String) async -> Bool { @@ -376,6 +484,8 @@ extension SettingsProTab { defer { self.suppressCredentialPersist = false } self.gatewayToken = "" self.gatewayPassword = "" + self.gatewayCredentialFieldStableID = nil + self.pendingManualAuthOverride = nil GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) self.onboardingComplete = false self.hasConnectedOnce = false @@ -501,22 +611,88 @@ extension SettingsProTab { self.notificationStatus = SettingsNotificationStatus(status) } + var currentManualGatewayStableID: String? { + let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil } + return GatewayConnectionController.ManualAuthOverride.manualStableID( + host: host, + port: port) + } + + var gatewayCredentialTargetStableID: String? { + // Auth fields follow the selected route. Otherwise a discovered-gateway retry can save + // credentials under the unrelated manual endpoint and immediately reload an empty bundle. + self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID + } + + var manualGatewayEnabledBinding: Binding { + Binding( + get: { self.manualGatewayEnabled }, + set: { enabled in + self.manualGatewayEnabled = enabled + guard enabled, let stableID = self.currentManualGatewayStableID else { return } + self.selectGatewayCredentialTarget(stableID, allowManualOverride: true) + }) + } + + var gatewayTokenBinding: Binding { + Binding( + get: { self.gatewayToken }, + set: { self.persistGatewayToken($0) }) + } + + var gatewayPasswordBinding: Binding { + Binding( + get: { self.gatewayPassword }, + set: { self.persistGatewayPassword($0) }) + } + + var manualHostBinding: Binding { + Binding( + get: { self.manualGatewayHost }, + set: { value in + let previousStableID = self.currentManualGatewayStableID + self.manualGatewayHost = value + if previousStableID != self.currentManualGatewayStableID { + self.clearManualCredentialFields() + } + }) + } + func persistGatewayToken(_ value: String) { + self.gatewayToken = value guard !self.suppressCredentialPersist else { return } let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !instanceId.isEmpty else { return } - GatewaySettingsStore.saveGatewayToken( - value.trimmingCharacters(in: .whitespacesAndNewlines), + guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return } + self.gatewayCredentialFieldStableID = stableID + let saved = GatewaySettingsStore.updateGatewayCredentials( + token: value, + password: self.gatewayPassword, + gatewayStableID: stableID, instanceId: instanceId) + self.pendingManualAuthOverride = saved + ? GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: instanceId, + targetStableID: stableID) + : nil } func persistGatewayPassword(_ value: String) { + self.gatewayPassword = value guard !self.suppressCredentialPersist else { return } let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !instanceId.isEmpty else { return } - GatewaySettingsStore.saveGatewayPassword( - value.trimmingCharacters(in: .whitespacesAndNewlines), + guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return } + self.gatewayCredentialFieldStableID = stableID + let saved = GatewaySettingsStore.updateGatewayCredentials( + token: self.gatewayToken, + password: value, + gatewayStableID: stableID, instanceId: instanceId) + self.pendingManualAuthOverride = saved + ? GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: instanceId, + targetStableID: stableID) + : nil } func openNotificationSettings() { @@ -543,27 +719,54 @@ extension SettingsProTab { Binding( get: { self.manualGatewayPortText }, set: { newValue in + let previousStableID = self.currentManualGatewayStableID let filtered = newValue.filter(\.isNumber) self.manualGatewayPortText = filtered self.manualGatewayPort = Int(filtered) ?? 0 + if previousStableID != self.currentManualGatewayStableID { + self.clearManualCredentialFields() + } }) } + private func clearManualCredentialFields() { + self.gatewayToken = "" + self.gatewayPassword = "" + self.gatewayCredentialFieldStableID = nil + self.pendingManualAuthOverride = nil + } + + private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) { + let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if self.gatewayCredentialFieldStableID != stableID { + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: stableID) + self.gatewayCredentialFieldStableID = stableID + self.gatewayToken = credentials.token ?? "" + self.gatewayPassword = credentials.password ?? "" + } + guard allowManualOverride else { + self.pendingManualAuthOverride = nil + return + } + // Each attempt consumes the in-memory override. Reload durable bootstrap auth even + // when the endpoint fields did not change so retry never erases a one-time token. + self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: instanceId, + targetStableID: stableID) + } + var manualPortIsValid: Bool { if self.manualGatewayPortText.isEmpty { return true } return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535 } func resolvedManualPort(host: String) -> Int? { - if self.manualGatewayPort > 0 { - return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil - } - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") { - return 443 - } - return 18789 + guard self.manualGatewayPortText.isEmpty || self.manualGatewayPort > 0 else { return nil } + return GatewayConnectionController.resolvedManualPort( + host: host, + port: self.manualGatewayPort) } var setupStatusLine: String? { diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 64206ddc2149..ef44b8831e14 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -784,11 +784,11 @@ extension SettingsProTab { var manualGatewayCard: some View { Section("Manual Gateway") { - Toggle(isOn: self.$manualGatewayEnabled) { + Toggle(isOn: self.manualGatewayEnabledBinding) { Text("Use Manual Gateway") .font(OpenClawType.body) } - TextField("Host", text: self.$manualGatewayHost) + TextField("Host", text: self.manualHostBinding) .font(OpenClawType.body) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -819,8 +819,8 @@ extension SettingsProTab { Text("Auto-connect on launch") .font(OpenClawType.body) } - self.gatewaySecureField("Gateway Auth Token", text: self.$gatewayToken) - self.gatewaySecureField("Gateway Password", text: self.$gatewayPassword) + self.gatewaySecureField("Gateway Auth Token", text: self.gatewayTokenBinding) + self.gatewaySecureField("Gateway Password", text: self.gatewayPasswordBinding) Button(role: .destructive) { self.showResetOnboardingAlert = true } label: { diff --git a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift index bb1c6e672e7a..b864103f6170 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift @@ -7,8 +7,8 @@ import OpenClawKit /// - a `role=node` session for device capabilities (`node.invoke.*`) /// - a `role=operator` session for chat/talk/config (`chat.*`, `talk.*`, etc.) /// -/// Both sessions should derive all connection inputs from this config so we -/// don't accidentally persist gateway-scoped state under different keys. +/// Both sessions derive routing and authentication ownership from the route's +/// `stableID`. TLS certificate pins prove transport trust but are not gateway identity. struct GatewayConnectConfig { let url: URL let stableID: String @@ -18,7 +18,7 @@ struct GatewayConnectConfig { let password: String? let nodeOptions: GatewayConnectOptions - /// Stable, non-empty identifier used for gateway-scoped persistence keys. + /// Stable, non-empty route identifier used for UI/event ownership. /// If the caller doesn't provide a stableID, fall back to URL identity. var effectiveStableID: String { let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines) @@ -64,6 +64,8 @@ struct GatewayConnectConfig { lhs.clientDisplayName == rhs.clientDisplayName && lhs.deviceIdentityProfile == rhs.deviceIdentityProfile && lhs.includeDeviceIdentity == rhs.includeDeviceIdentity && + lhs.allowStoredDeviceAuth == rhs.allowStoredDeviceAuth && + lhs.deviceAuthGatewayID == rhs.deviceAuthGatewayID && lhsScopes == rhsScopes && lhsCaps == rhsCaps && lhsCommands == rhsCommands && diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 9b324bfd219a..794af6a14aea 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -31,6 +31,8 @@ enum GatewayTLSFingerprintProbeResult: Equatable { typealias GatewayTCPReachabilityProbe = @Sendable (String, Int, Double, String) async -> Bool typealias GatewayTLSFingerprintProbeFunction = @Sendable (URL) async -> GatewayTLSFingerprintProbeResult typealias GatewayServiceEndpointResolver = @Sendable (NWEndpoint) async -> (host: String, port: Int)? +typealias GatewayForceReconnectReset = @MainActor (NodeAppModel) async -> Void +typealias GatewayTLSFingerprintPersist = @Sendable (_ fingerprint: String, _ stableID: String) -> Bool private enum GatewayTLSFingerprintProbeBudget { static let tcpConnectTimeoutSeconds = 3.0 @@ -65,91 +67,16 @@ private func defaultGatewayTLSFingerprintProbe(url: URL) async -> GatewayTLSFing @MainActor @Observable final class GatewayConnectionController { - struct ManualAuthOverride: Equatable { - struct SetupAuth { - let token: String - let bootstrapToken: String - let password: String - - var hasBootstrapToken: Bool { - !self.bootstrapToken.isEmpty - } - - var shouldApplyTokenField: Bool { - !self.token.isEmpty || self.hasBootstrapToken - } - - var shouldApplyPasswordField: Bool { - !self.password.isEmpty || self.hasBootstrapToken - } - - var manualAuthOverride: ManualAuthOverride? { - ManualAuthOverride.normalized( - token: self.token, - bootstrapToken: self.bootstrapToken, - password: self.password) - } + static func resolvedManualPort(host: String, port: Int) -> Int? { + if port > 0 { + return port <= 65535 ? port : nil } - - let token: String? - let bootstrapToken: String? - let password: String? - - static func explicit( - token: String?, - bootstrapToken: String?, - password: String?) -> ManualAuthOverride - { - let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return ManualAuthOverride( - token: trimmedToken.isEmpty ? nil : trimmedToken, - bootstrapToken: trimmedBootstrapToken.isEmpty ? nil : trimmedBootstrapToken, - password: trimmedPassword.isEmpty ? nil : trimmedPassword) + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmedHost.isEmpty else { return nil } + if trimmedHost.hasSuffix(".ts.net") || trimmedHost.hasSuffix(".ts.net.") { + return 443 } - - static func normalized( - token: String?, - bootstrapToken: String?, - password: String?) -> ManualAuthOverride? - { - let override = ManualAuthOverride.explicit( - token: token, - bootstrapToken: bootstrapToken, - password: password) - guard override.token != nil || override.bootstrapToken != nil || override.password != nil - else { return nil } - return override - } - - static func currentManualInput( - token: String?, - pendingOverride: ManualAuthOverride?, - password: String?) -> ManualAuthOverride? - { - guard let pendingOverride else { - return ManualAuthOverride.normalized(token: token, bootstrapToken: nil, password: password) - } - return ManualAuthOverride.explicit( - token: token, - bootstrapToken: pendingOverride.bootstrapToken, - password: password) - } - - static func setupAuth(from link: GatewayConnectDeepLink) -> SetupAuth { - SetupAuth( - token: link.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - bootstrapToken: link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - password: link.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") - } - } - - private struct PendingTrustConnect { - let url: URL - let stableID: String - let isManual: Bool - let authOverride: ManualAuthOverride? + return 18789 } struct TrustPrompt: Identifiable, Equatable { @@ -165,6 +92,13 @@ final class GatewayConnectionController { } } + struct AutoConnectSuppressionLease { + fileprivate let generation: UInt64 + fileprivate let previousAutoReconnectEnabled: Bool + fileprivate let restoresAutoReconnect: Bool + fileprivate let suspendedConfig: GatewayConnectConfig? + } + private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = [] private(set) var discoveryStatusText: String = "Idle" private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = [] @@ -177,10 +111,22 @@ final class GatewayConnectionController { private var currentScenePhase: ScenePhase = .inactive private var didAutoConnect = false private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] - private var pendingTrustConnect: PendingTrustConnect? + private var pendingTrustConnect: GatewayPendingTrustConnect? + private var trustProbeGeneration: UInt64 = 0 + private var connectAttemptGeneration: UInt64 = 0 + private var autoConnectSuppressionGeneration: UInt64? + private var autoConnectSuppressionBaseline: ( + autoReconnectEnabled: Bool, + restoresAutoReconnect: Bool, + suspendedConfig: GatewayConnectConfig?)? + @ObservationIgnored private var pendingAutoConnectTask: Task? + @ObservationIgnored private var pendingAutoConnectGeneration: UInt64? + @ObservationIgnored private var pendingAutoConnectSuppressionGeneration: UInt64? private let tcpReachabilityProbe: GatewayTCPReachabilityProbe private let tlsFingerprintProbe: GatewayTLSFingerprintProbeFunction private let serviceEndpointResolver: GatewayServiceEndpointResolver? + private let forceReconnectReset: GatewayForceReconnectReset + private let persistTLSFingerprint: GatewayTLSFingerprintPersist private struct SavedManualEndpoint: Equatable { let host: String @@ -194,7 +140,13 @@ final class GatewayConnectionController { deferDiscoveryUntilLocalNetworkRequest: Bool = false, tcpReachabilityProbe: @escaping GatewayTCPReachabilityProbe = defaultGatewayTCPReachabilityProbe, tlsFingerprintProbe: @escaping GatewayTLSFingerprintProbeFunction = defaultGatewayTLSFingerprintProbe, - serviceEndpointResolver: GatewayServiceEndpointResolver? = nil) + serviceEndpointResolver: GatewayServiceEndpointResolver? = nil, + forceReconnectReset: @escaping GatewayForceReconnectReset = { appModel in + await appModel.resetGatewaySessionsForForcedReconnect() + }, + persistTLSFingerprint: @escaping GatewayTLSFingerprintPersist = { fingerprint, stableID in + GatewayTLSStore.replaceFingerprint(fingerprint, stableID: stableID) + }) { self.discoveryEnabled = startDiscovery self.appModel = appModel @@ -202,8 +154,11 @@ final class GatewayConnectionController { self.tcpReachabilityProbe = tcpReachabilityProbe self.tlsFingerprintProbe = tlsFingerprintProbe self.serviceEndpointResolver = serviceEndpointResolver + self.forceReconnectReset = forceReconnectReset + self.persistTLSFingerprint = persistTLSFingerprint GatewaySettingsStore.bootstrapPersistence() + Self.migrateLegacyDeviceAuth() let defaults = UserDefaults.standard self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs")) @@ -238,10 +193,10 @@ final class GatewayConnectionController { return link } - func requestLocalNetworkAccess(reason: String) { + func requestLocalNetworkAccess(reason: String, allowAutoReconnect: Bool = true) { guard self.discoveryEnabled else { self.discovery.stop() - self.updateFromDiscovery() + self.updateFromDiscovery(allowAutoConnect: allowAutoReconnect) return } @@ -250,7 +205,8 @@ final class GatewayConnectionController { guard self.currentScenePhase != .background else { return } self.discovery.start() - self.updateFromDiscovery() + self.updateFromDiscovery(allowAutoConnect: allowAutoReconnect) + guard allowAutoReconnect else { return } self.attemptAutoReconnectIfNeeded() } @@ -300,27 +256,29 @@ final class GatewayConnectionController { _ gateway: GatewayDiscoveryModel.DiscoveredGateway, forceReconnect: Bool = false) async -> String? { - self.requestLocalNetworkAccess(reason: "connect_discovered_gateway") + let connectAttempt = self.beginConnectAttempt() + defer { self.finishConnectAttempt(connectAttempt.suppressionLease) } + self.requestLocalNetworkAccess(reason: "connect_discovered_gateway", allowAutoReconnect: false) let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if instanceId.isEmpty { return "Missing instanceId (node.instanceId). Try restarting the app." } - let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) - let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) - let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. let target = if let serviceEndpointResolver { await serviceEndpointResolver(gateway.endpoint) } else { await self.resolveServiceEndpoint(gateway.endpoint) } + guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil } guard let target else { return "Failed to resolve the discovered gateway endpoint." } let stableID = gateway.stableID + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: stableID) // Discovery is a LAN operation; refuse unauthenticated plaintext connects. let tlsRequired = true let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) @@ -333,18 +291,23 @@ final class GatewayConnectionController { guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true) else { return "Failed to build TLS URL for trust verification." } self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…") - switch await self.probeTLSFingerprint( + guard let probeResult = await self.probeTLSFingerprint( host: target.host, port: target.port, url: url, queueLabel: "gateway.tls.discovered") - { + else { return nil } + guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil } + switch probeResult { case let .fingerprint(fp): - self.pendingTrustConnect = PendingTrustConnect( + self.pendingTrustConnect = GatewayPendingTrustConnect( url: url, stableID: stableID, isManual: false, - authOverride: nil) + authOverride: nil, + allowStoredDeviceAuth: true, + suppressionLease: connectAttempt.suppressionLease, + gatewayGeneration: connectAttempt.gatewayGeneration) self.pendingTrustPrompt = TrustPrompt( stableID: stableID, gatewayName: gateway.name, @@ -379,10 +342,13 @@ final class GatewayConnectionController { url: url, gatewayStableID: stableID, tls: tlsParams, - token: token, - bootstrapToken: bootstrapToken, - password: password, - forceReconnect: forceReconnect) + token: credentials.token, + bootstrapToken: credentials.bootstrapToken, + password: credentials.password, + allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth, + forceReconnect: forceReconnect, + suppressionGeneration: connectAttempt.suppressionLease.generation, + expectedGeneration: connectAttempt.gatewayGeneration) return nil } @@ -397,38 +363,51 @@ final class GatewayConnectionController { authOverride: ManualAuthOverride? = nil, forceReconnect: Bool = false) async { - self.requestLocalNetworkAccess(reason: "connect_manual") - let instanceId = GatewaySettingsStore.currentInstanceID() - let token = - authOverride.map(\.token) ?? GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) - let bootstrapToken = - authOverride.map(\.bootstrapToken) ?? GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) - let password = - authOverride.map(\.password) ?? GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - let pendingAuthOverride = authOverride ?? ManualAuthOverride.normalized( - token: token, - bootstrapToken: bootstrapToken, - password: password) + let connectAttempt = self.beginConnectAttempt() + defer { self.finishConnectAttempt(connectAttempt.suppressionLease) } + self.requestLocalNetworkAccess(reason: "connect_manual", allowAutoReconnect: false) let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) - guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) + guard let resolvedPort = Self.resolvedManualPort(host: host, port: port) else { return } let stableID = self.manualStableID(host: host, port: resolvedPort) + let instanceId = GatewaySettingsStore.currentInstanceID() + let storedCredentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: stableID) + let token = authOverride.map(\.token) ?? storedCredentials.token + let bootstrapToken = authOverride.map(\.bootstrapToken) ?? storedCredentials.bootstrapToken + let password = authOverride.map(\.password) ?? storedCredentials.password + let suppressStoredDeviceAuth = + authOverride?.suppressStoredDeviceAuth ?? storedCredentials.suppressStoredDeviceAuth + let pendingAuthOverride = authOverride ?? (storedCredentials.hasCredentials + ? ManualAuthOverride.explicit( + token: token, + bootstrapToken: bootstrapToken, + password: password, + targetStableID: stableID, + suppressStoredDeviceAuth: suppressStoredDeviceAuth) + : nil) let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) if resolvedUseTLS, stored == nil { guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return } self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…") - switch await self.probeTLSFingerprint( + guard let probeResult = await self.probeTLSFingerprint( host: host, port: resolvedPort, url: url, queueLabel: "gateway.tls.manual") - { + else { return } + guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return } + switch probeResult { case let .fingerprint(fp): - self.pendingTrustConnect = PendingTrustConnect( + self.pendingTrustConnect = GatewayPendingTrustConnect( url: url, stableID: stableID, isManual: true, - authOverride: pendingAuthOverride) + authOverride: pendingAuthOverride, + allowStoredDeviceAuth: !suppressStoredDeviceAuth, + suppressionLease: connectAttempt.suppressionLease, + gatewayGeneration: connectAttempt.gatewayGeneration) self.pendingTrustPrompt = TrustPrompt( stableID: stableID, gatewayName: "\(host):\(resolvedPort)", @@ -468,11 +447,14 @@ final class GatewayConnectionController { token: token, bootstrapToken: bootstrapToken, password: password, - forceReconnect: forceReconnect) + allowStoredDeviceAuth: !suppressStoredDeviceAuth, + forceReconnect: forceReconnect, + suppressionGeneration: connectAttempt.suppressionLease.generation, + expectedGeneration: connectAttempt.gatewayGeneration) } func connectLastKnown() async { - self.requestLocalNetworkAccess(reason: "connect_last_known") + self.requestLocalNetworkAccess(reason: "connect_last_known", allowAutoReconnect: false) guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return } switch last { case let .manual(host, port, useTLS, _): @@ -498,8 +480,12 @@ final class GatewayConnectionController { guard let appModel else { return } guard let cfg = appModel.activeGatewayConnectConfig else { return } guard appModel.gatewayAutoReconnectEnabled else { return } + let generation = appModel.gatewayConnectGeneration - let nodeOptions = await self.makeConnectOptions(stableID: cfg.stableID) + let nodeOptions = await self.makeConnectOptions( + stableID: cfg.stableID, + deviceAuthGatewayID: cfg.nodeOptions.deviceAuthGatewayID, + allowStoredDeviceAuth: cfg.nodeOptions.allowStoredDeviceAuth) let refreshedConfig = GatewayConnectConfig( url: cfg.url, stableID: cfg.stableID, @@ -508,23 +494,95 @@ final class GatewayConnectionController { bootstrapToken: cfg.bootstrapToken, password: cfg.password, nodeOptions: nodeOptions) - appModel.applyGatewayConnectConfig(refreshedConfig) + appModel.applyGatewayConnectConfig(refreshedConfig, expectedGeneration: generation) } func clearPendingTrustPrompt() { + // Invalidate an in-flight probe so its late result cannot restore a stale prompt. + self.trustProbeGeneration &+= 1 self.pendingTrustPrompt = nil self.pendingTrustConnect = nil } + @discardableResult + func cancelPendingConnectionAttempts( + suspendCurrentGateway: Bool = false) -> AutoConnectSuppressionLease + { + let lease = self.beginAutoConnectSuppression(restoresAutoReconnect: suspendCurrentGateway) + _ = self.reserveGatewayConnectAttempt() + if suspendCurrentGateway { + self.appModel?.suspendGatewayForTargetReview() + } + return lease + } + + private func beginAutoConnectSuppression(restoresAutoReconnect: Bool) -> AutoConnectSuppressionLease { + let baseline = if self.autoConnectSuppressionGeneration != nil, + let baseline = self.autoConnectSuppressionBaseline + { + ( + autoReconnectEnabled: baseline.autoReconnectEnabled, + restoresAutoReconnect: baseline.restoresAutoReconnect || restoresAutoReconnect, + suspendedConfig: baseline.suspendedConfig ?? + (restoresAutoReconnect ? self.appModel?.activeGatewayConnectConfig : nil)) + } else { + ( + autoReconnectEnabled: self.appModel?.gatewayAutoReconnectEnabled ?? false, + restoresAutoReconnect: restoresAutoReconnect, + suspendedConfig: restoresAutoReconnect ? self.appModel?.activeGatewayConnectConfig : nil) + } + self.connectAttemptGeneration &+= 1 + self.autoConnectSuppressionGeneration = self.connectAttemptGeneration + self.autoConnectSuppressionBaseline = baseline + self.clearPendingTrustPrompt() + return AutoConnectSuppressionLease( + generation: self.connectAttemptGeneration, + previousAutoReconnectEnabled: baseline.autoReconnectEnabled, + restoresAutoReconnect: baseline.restoresAutoReconnect, + suspendedConfig: baseline.suspendedConfig) + } + + func resumeAutoConnect(after lease: AutoConnectSuppressionLease) { + // A dismissed older target must not release suppression owned by its replacement. + guard self.autoConnectSuppressionGeneration == lease.generation else { return } + self.clearAutoConnectSuppression(generation: lease.generation) + if lease.restoresAutoReconnect { + let currentPreference = UserDefaults.standard.bool(forKey: "gateway.autoconnect") + if lease.previousAutoReconnectEnabled, + currentPreference, + let suspendedConfig = lease.suspendedConfig + { + self.appModel?.resumeGatewayAfterTargetReview(suspendedConfig) + return + } + self.appModel?.gatewayAutoReconnectEnabled = lease.previousAutoReconnectEnabled && currentPreference + } + self.attemptAutoReconnectIfNeeded() + } + + func releaseAutoConnectSuppression(after lease: AutoConnectSuppressionLease) { + self.clearAutoConnectSuppression(generation: lease.generation) + } + + private func clearAutoConnectSuppression(generation: UInt64) { + guard self.autoConnectSuppressionGeneration == generation else { return } + self.autoConnectSuppressionGeneration = nil + self.autoConnectSuppressionBaseline = nil + } + func acceptPendingTrustPrompt() async { guard let pending = self.pendingTrustConnect, let prompt = self.pendingTrustPrompt, pending.stableID == prompt.stableID else { return } - GatewayTLSStore.saveFingerprint(prompt.fingerprintSha256, stableID: pending.stableID) - self.clearPendingTrustPrompt() + guard self.persistTLSFingerprint(prompt.fingerprintSha256, pending.stableID) else { + self.appModel?.gatewayStatusText = "Could not save gateway certificate" + return + } + let instanceId = GatewaySettingsStore.currentInstanceID() + self.clearPendingTrustPrompt() if pending.isManual { GatewaySettingsStore.saveLastGatewayConnectionManual( host: prompt.host, @@ -534,15 +592,14 @@ final class GatewayConnectionController { } else { GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: pending.stableID, useTLS: true) } - - let instanceId = GatewaySettingsStore.currentInstanceID() - let token = - pending.authOverride.map(\.token) ?? GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) - let bootstrapToken = - pending.authOverride.map(\.bootstrapToken) ?? GatewaySettingsStore.loadGatewayBootstrapToken( - instanceId: instanceId) - let password = - pending.authOverride.map(\.password) ?? GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) + let storedCredentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: pending.stableID) + let token = pending.authOverride.map(\.token) ?? storedCredentials.token + let bootstrapToken = pending.authOverride.map(\.bootstrapToken) ?? storedCredentials.bootstrapToken + let password = pending.authOverride.map(\.password) ?? storedCredentials.password + let suppressStoredDeviceAuth = + pending.authOverride?.suppressStoredDeviceAuth ?? storedCredentials.suppressStoredDeviceAuth let tlsParams = GatewayTLSParams( required: true, expectedFingerprint: prompt.fingerprintSha256, @@ -550,18 +607,28 @@ final class GatewayConnectionController { storeKey: pending.stableID) self.didAutoConnect = true - self.startAutoConnect( + let didStart = self.startAutoConnect( url: pending.url, gatewayStableID: pending.stableID, tls: tlsParams, token: token, bootstrapToken: bootstrapToken, - password: password) + password: password, + allowStoredDeviceAuth: pending.allowStoredDeviceAuth && !suppressStoredDeviceAuth, + suppressionGeneration: pending.suppressionLease.generation, + expectedGeneration: pending.gatewayGeneration) + if !didStart { + self.clearAutoConnectSuppression(generation: pending.suppressionLease.generation) + } } func declinePendingTrustPrompt() { + let lease = self.pendingTrustConnect?.suppressionLease self.clearPendingTrustPrompt() self.appModel?.gatewayStatusText = "Offline" + if let lease { + self.resumeAutoConnect(after: lease) + } } @discardableResult @@ -574,7 +641,7 @@ final class GatewayConnectionController { return false } - guard GatewayTLSStore.replaceFingerprint(fingerprint, stableID: stableID) else { + guard self.persistTLSFingerprint(fingerprint, stableID) else { self.appModel?.gatewayStatusText = "Could not update gateway certificate" return false } @@ -605,13 +672,15 @@ final class GatewayConnectionController { return true } - private func updateFromDiscovery() { + private func updateFromDiscovery(allowAutoConnect: Bool = true) { let newGateways = self.discovery.gateways self.gateways = newGateways self.discoveryStatusText = self.discovery.statusText self.discoveryDebugLog = self.discovery.debugLog self.updateLastDiscoveredGateway(from: newGateways) - self.maybeAutoConnect() + if allowAutoConnect { + self.maybeAutoConnect() + } } private func observeDiscovery() { @@ -629,6 +698,7 @@ final class GatewayConnectionController { } private func maybeAutoConnect() { + guard self.autoConnectSuppressionGeneration == nil else { return } guard !self.didAutoConnect else { return } guard let appModel = self.appModel else { return } guard appModel.gatewayServerName == nil else { return } @@ -641,52 +711,13 @@ final class GatewayConnectionController { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !instanceId.isEmpty else { return } - let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) - let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) - let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - if manualEnabled { - let manualHost = defaults.string(forKey: "gateway.manual.host")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !manualHost.isEmpty else { return } - - let manualPort = defaults.integer(forKey: "gateway.manual.port") - let manualTLS = defaults.bool(forKey: "gateway.manual.tls") - let resolvedUseTLS = self.resolveManualUseTLS(host: manualHost, useTLS: manualTLS) - guard let resolvedPort = self.resolveManualPort( - host: manualHost, - port: manualPort, - useTLS: resolvedUseTLS) - else { return } - - let stableID = self.manualStableID(host: manualHost, port: resolvedPort) - let tlsParams = self.resolveManualTLSParams( - stableID: stableID, - tlsEnabled: resolvedUseTLS) - - guard let url = self.buildGatewayURL( - host: manualHost, - port: resolvedPort, - useTLS: tlsParams?.required == true) - else { return } - - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: stableID, - tls: tlsParams, - token: token, - bootstrapToken: bootstrapToken, - password: password) + self.startConfiguredManualAutoConnect(defaults: defaults, instanceId: instanceId) return } if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(), - self.startLastKnownAutoConnect( - lastKnown, - token: token, - bootstrapToken: bootstrapToken, - password: password) + self.startLastKnownAutoConnect(lastKnown, instanceId: instanceId) { return } @@ -723,15 +754,42 @@ final class GatewayConnectionController { } return } + } - _ = self.startSavedManualEndpointFallback() + private func startConfiguredManualAutoConnect(defaults: UserDefaults, instanceId: String) { + let host = defaults.string(forKey: "gateway.manual.host")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !host.isEmpty else { return } + + let configuredPort = defaults.integer(forKey: "gateway.manual.port") + let configuredTLS = defaults.bool(forKey: "gateway.manual.tls") + let useTLS = self.resolveManualUseTLS(host: host, useTLS: configuredTLS) + guard let port = Self.resolvedManualPort(host: host, port: configuredPort) else { return } + + let stableID = self.manualStableID(host: host, port: port) + let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS) + // Manual TLS auto-connect cannot present first-use trust UI, so it requires an existing pin. + guard !useTLS || tlsParams?.expectedFingerprint != nil else { return } + guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) + else { return } + + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: stableID) + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: stableID, + tls: tlsParams, + token: credentials.token, + bootstrapToken: credentials.bootstrapToken, + password: credentials.password, + allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth) } private func startLastKnownAutoConnect( _ lastKnown: GatewaySettingsStore.LastGatewayConnection, - token: String?, - bootstrapToken: String?, - password: String?) -> Bool + instanceId: String) -> Bool { switch lastKnown { case let .manual(host, port, useTLS, stableID): @@ -749,14 +807,18 @@ final class GatewayConnectionController { // Security: autoconnect only to previously trusted gateways (stored TLS pin). guard tlsParams != nil else { return false } + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: stableID) self.didAutoConnect = true self.startAutoConnect( url: url, gatewayStableID: stableID, tls: tlsParams, - token: token, - bootstrapToken: bootstrapToken, - password: password) + token: credentials.token, + bootstrapToken: credentials.bootstrapToken, + password: credentials.password, + allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth) return true case .discovered: return false @@ -766,6 +828,7 @@ final class GatewayConnectionController { private func attemptAutoReconnectIfNeeded() { guard let appModel = self.appModel else { return } guard appModel.gatewayAutoReconnectEnabled else { return } + guard self.autoConnectSuppressionGeneration == nil else { return } // Avoid starting duplicate connect loops while a prior config is active. guard appModel.activeGatewayConnectConfig == nil else { return } guard UserDefaults.standard.bool(forKey: "gateway.autoconnect") else { return } @@ -783,27 +846,12 @@ final class GatewayConnectionController { let configuredPort = defaults.integer(forKey: "gateway.manual.port") let configuredUseTLS = defaults.bool(forKey: "gateway.manual.tls") let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: configuredUseTLS) - guard let resolvedPort = self.resolveManualPort( - host: host, - port: configuredPort, - useTLS: resolvedUseTLS) + guard let resolvedPort = Self.resolvedManualPort(host: host, port: configuredPort) else { return nil } return SavedManualEndpoint(host: host, port: resolvedPort, useTLS: resolvedUseTLS) } - private func startSavedManualEndpointFallback() -> Bool { - guard let endpoint = self.savedManualEndpointFallback() else { return false } - self.didAutoConnect = true - Task { [weak self] in - await self?.connectManual( - host: endpoint.host, - port: endpoint.port, - useTLS: endpoint.useTLS) - } - return true - } - private func connectSavedManualEndpointFallback() async -> Bool { guard let endpoint = self.savedManualEndpointFallback() else { return false } await self.connectManual( @@ -828,6 +876,7 @@ final class GatewayConnectionController { GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID) } + @discardableResult private func startAutoConnect( url: URL, gatewayStableID: String, @@ -835,16 +884,54 @@ final class GatewayConnectionController { token: String?, bootstrapToken: String?, password: String?, - forceReconnect: Bool = false) + allowStoredDeviceAuth: Bool = true, + forceReconnect: Bool = false, + suppressionGeneration: UInt64? = nil, + expectedGeneration: UInt64? = nil) -> Bool { - guard let appModel else { return } + guard let appModel else { return false } + if let expectedGeneration { + guard expectedGeneration == appModel.gatewayConnectGeneration else { return false } + } + let previousTask = self.pendingAutoConnectTask + previousTask?.cancel() + // Advancing again at handoff rejects work that captured the reservation generation while + // endpoint resolution or trust verification was suspended. + let generation = appModel.beginGatewayConnectAttempt() + self.pendingAutoConnectGeneration = generation + // An explicit target owns suppression until its queued handoff exits. Otherwise a + // foreground reconnect can replace it while reset or permission work is suspended. + self.pendingAutoConnectSuppressionGeneration = suppressionGeneration appModel.gatewayStatusText = "Connecting…" - Task { [weak self, weak appModel] in + let task = Task { [weak self, weak appModel] in guard let self, let appModel else { return } - if forceReconnect { - await appModel.resetGatewaySessionsForForcedReconnect() + defer { + if self.pendingAutoConnectGeneration == generation { + self.pendingAutoConnectTask = nil + self.pendingAutoConnectGeneration = nil + self.pendingAutoConnectSuppressionGeneration = nil + } + if let suppressionGeneration, + self.autoConnectSuppressionGeneration == suppressionGeneration + { + self.clearAutoConnectSuppression(generation: suppressionGeneration) + } } - let nodeOptions = await self.makeConnectOptions(stableID: gatewayStableID) + await previousTask?.value + await appModel.waitForGatewaySessionResetIfNeeded() + guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return } + if forceReconnect { + await self.forceReconnectReset(appModel) + guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return } + } + let nodeOptions = await self.makeConnectOptions( + stableID: gatewayStableID, + deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayStableID), + allowStoredDeviceAuth: allowStoredDeviceAuth) + // Permission reads above can suspend long enough for a model-owned reconnect reset + // to start, so close the reset barrier again immediately before the synchronous apply. + await appModel.waitForGatewaySessionResetIfNeeded() + guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return } let cfg = GatewayConnectConfig( url: url, stableID: gatewayStableID, @@ -853,8 +940,13 @@ final class GatewayConnectionController { bootstrapToken: bootstrapToken, password: password, nodeOptions: nodeOptions) - appModel.applyGatewayConnectConfig(cfg, forceReconnect: forceReconnect) + appModel.applyGatewayConnectConfig( + cfg, + forceReconnect: forceReconnect, + expectedGeneration: generation) } + self.pendingAutoConnectTask = task + return true } private func resolveDiscoveredTLSParams( @@ -903,8 +995,10 @@ final class GatewayConnectionController { host: String, port: Int, url: URL, - queueLabel: String) async -> GatewayTLSFingerprintProbeResult + queueLabel: String) async -> GatewayTLSFingerprintProbeResult? { + self.trustProbeGeneration &+= 1 + let generation = self.trustProbeGeneration self.pendingTrustConnect = nil self.pendingTrustPrompt = nil let reachable = await self.tcpReachabilityProbe( @@ -912,10 +1006,73 @@ final class GatewayConnectionController { port, GatewayTLSFingerprintProbeBudget.tcpConnectTimeoutSeconds, queueLabel) + guard self.trustProbeGeneration == generation else { return nil } guard reachable else { return .failure(.endpointUnreachable) } - return await self.tlsFingerprintProbe(url) + let result = await self.tlsFingerprintProbe(url) + guard self.trustProbeGeneration == generation else { return nil } + return result + } + + private func beginConnectAttempt() + -> (suppressionLease: AutoConnectSuppressionLease, gatewayGeneration: UInt64?) + { + let suppressionLease = self.beginAutoConnectSuppression(restoresAutoReconnect: false) + // Allocate both tokens before any resolution or trust work. A new explicit target must + // invalidate queued config construction from the previous target immediately. + let gatewayGeneration = self.reserveGatewayConnectAttempt() + return (suppressionLease, gatewayGeneration) + } + + private func reserveGatewayConnectAttempt() -> UInt64? { + let previousTask = self.pendingAutoConnectTask + previousTask?.cancel() + guard let appModel else { return nil } + let generation = appModel.beginGatewayConnectAttempt() + let activeConfig = appModel.activeGatewayConnectConfig + let shouldRestoreActiveConfig = appModel.gatewayAutoReconnectEnabled && + !appModel.gatewayPairingPaused && + appModel.lastGatewayProblem?.pauseReconnect != true && + (previousTask != nil || appModel.hasGatewaySessionResetInFlight) + self.pendingAutoConnectSuppressionGeneration = nil + self.pendingAutoConnectGeneration = generation + // The barrier owns any superseded teardown until it finishes. If the replacement never + // reaches handoff, restore the still-current route after that teardown completes. + let barrier = Task { [weak self, weak appModel] in + guard let self, let appModel else { return } + defer { + if self.pendingAutoConnectGeneration == generation { + self.pendingAutoConnectTask = nil + self.pendingAutoConnectGeneration = nil + } + } + await previousTask?.value + await appModel.waitForGatewaySessionResetIfNeeded() + guard !Task.isCancelled, + generation == appModel.gatewayConnectGeneration, + shouldRestoreActiveConfig, + let activeConfig, + appModel.gatewayAutoReconnectEnabled, + !appModel.gatewayPairingPaused, + appModel.lastGatewayProblem?.pauseReconnect != true, + appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: activeConfig) == true + else { return } + appModel.applyGatewayConnectConfig(activeConfig, expectedGeneration: generation) + } + self.pendingAutoConnectTask = barrier + return generation + } + + private func finishConnectAttempt(_ lease: AutoConnectSuppressionLease) { + guard self.connectAttemptGeneration == lease.generation else { return } + guard self.pendingTrustPrompt == nil else { return } + guard self.pendingAutoConnectSuppressionGeneration != lease.generation else { return } + if lease.restoresAutoReconnect { + self.resumeAutoConnect(after: lease) + } else { + self.releaseAutoConnectSuppression(after: lease) + } } private func tlsProbeFailureMessage( @@ -952,6 +1109,228 @@ final class GatewayConnectionController { } } +extension GatewayConnectionController { + private static func migrateLegacyDeviceAuth() { + let migrationGatewayID = self.legacyDeviceAuthMigrationGatewayID() + let relay = ShareGatewayRelaySettings.loadConfig() + let instanceID = GatewaySettingsStore.currentInstanceID() + if let migrationGatewayID, let relay { + _ = GatewaySettingsStore.migrateProvenRelayCredentials( + instanceId: instanceID, + gatewayStableID: migrationGatewayID, + token: relay.token, + password: relay.password) + } else { + GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID) + } + let primaryIdentity = DeviceIdentityStore.loadOrCreate() + let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension) + // The extension connects independently, so the host's last route cannot prove who + // issued its legacy token. Require one extension re-pair instead of guessing an owner. + DeviceAuthStore.discardUnscopedTokens( + deviceId: shareIdentity.deviceId, + profile: .shareExtension) + guard let migrationGatewayID else { + // No cross-gateway fallback: ambiguous legacy tokens require one explicit re-pair. + DeviceAuthStore.discardUnscopedTokens(deviceId: primaryIdentity.deviceId) + return + } + + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: migrationGatewayID) + let hasProvenOperatorCredentials = credentials.token != nil || credentials.password != nil + if hasProvenOperatorCredentials { + // Shared credentials recover the independently authenticated operator session. + // Without them, migrate neither role so reconnect enters the normal re-pair flow. + DeviceAuthStore.migrateUnscopedToken( + deviceId: primaryIdentity.deviceId, + role: "node", + toGatewayID: migrationGatewayID) + } + DeviceAuthStore.discardUnscopedTokens(deviceId: primaryIdentity.deviceId) + guard let relay else { return } + let relayStableID = relay.gatewayStableID? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard relayStableID.isEmpty else { return } + ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig( + gatewayURLString: relay.gatewayURLString, + gatewayStableID: migrationGatewayID, + token: relay.token, + password: relay.password, + sessionKey: relay.sessionKey, + deliveryChannel: relay.deliveryChannel, + deliveryTo: relay.deliveryTo)) + } + + private static func legacyDeviceAuthMigrationGatewayID() -> String? { + guard let relay = ShareGatewayRelaySettings.loadConfig() else { return nil } + if let stableID = relay.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines), + !stableID.isEmpty + { + return stableID + } + guard case let .manual(host, port, useTLS, stableID) = GatewaySettingsStore.loadLastGatewayConnection(), + let relayURL = URL(string: relay.gatewayURLString), + relayURL.host?.caseInsensitiveCompare(host) == .orderedSame + else { return nil } + let relayPort = relayURL.port ?? (relayURL.scheme?.lowercased() == "wss" ? 443 : 80) + let relayUsesTLS = relayURL.scheme?.lowercased() == "wss" + guard relayPort == port, relayUsesTLS == useTLS else { return nil } + return stableID + } + + struct ManualAuthOverride: Equatable { + struct SetupAuth { + let token: String + let bootstrapToken: String + let password: String + let targetStableID: String + + var hasBootstrapToken: Bool { + !self.bootstrapToken.isEmpty + } + + var manualAuthOverride: ManualAuthOverride { + // Setup-link credentials are endpoint-scoped. An explicit empty override prevents + // a new host from falling back to credentials stored for the previous gateway. + ManualAuthOverride.explicit( + token: self.token, + bootstrapToken: self.bootstrapToken, + password: self.password, + targetStableID: self.targetStableID, + suppressStoredDeviceAuth: true) + } + } + + let token: String? + let bootstrapToken: String? + let password: String? + let targetStableID: String? + let suppressStoredDeviceAuth: Bool + + static func explicit( + token: String?, + bootstrapToken: String?, + password: String?, + targetStableID: String? = nil, + suppressStoredDeviceAuth: Bool) -> ManualAuthOverride + { + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ManualAuthOverride( + token: trimmedToken.isEmpty ? nil : trimmedToken, + bootstrapToken: trimmedBootstrapToken.isEmpty ? nil : trimmedBootstrapToken, + password: trimmedPassword.isEmpty ? nil : trimmedPassword, + targetStableID: targetStableID, + suppressStoredDeviceAuth: suppressStoredDeviceAuth) + } + + static func normalized( + token: String?, + bootstrapToken: String?, + password: String?) -> ManualAuthOverride? + { + let override = ManualAuthOverride.explicit( + token: token, + bootstrapToken: bootstrapToken, + password: password, + suppressStoredDeviceAuth: false) + guard override.token != nil || override.bootstrapToken != nil || override.password != nil + else { return nil } + return override + } + + static func persisted(instanceId: String) -> ManualAuthOverride? { + guard let metadata = GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceId) else { + return nil + } + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: metadata.gatewayStableID) + return ManualAuthOverride.explicit( + token: credentials.token, + bootstrapToken: credentials.bootstrapToken, + password: credentials.password, + targetStableID: metadata.gatewayStableID, + suppressStoredDeviceAuth: metadata.suppressStoredDeviceAuth) + } + + static func persisted(instanceId: String, targetStableID: String) -> ManualAuthOverride? { + let authenticationOwnerID = GatewaySettingsStore.authenticationOwnerID( + routeStableID: targetStableID) + guard let metadata = GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceId), + metadata.gatewayStableID == authenticationOwnerID + else { return nil } + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: targetStableID) + return ManualAuthOverride.explicit( + token: credentials.token, + bootstrapToken: credentials.bootstrapToken, + password: credentials.password, + targetStableID: targetStableID, + suppressStoredDeviceAuth: metadata.suppressStoredDeviceAuth) + } + + static func currentManualInput( + token: String?, + pendingOverride: ManualAuthOverride?, + password: String?, + targetStableID: String? = nil) -> ManualAuthOverride? + { + guard let pendingOverride else { + return ManualAuthOverride.normalized(token: token, bootstrapToken: nil, password: password) + } + if let pendingTarget = pendingOverride.targetStableID, pendingTarget != targetStableID { + let normalizedInput = ManualAuthOverride.explicit( + token: token, + bootstrapToken: nil, + password: password, + targetStableID: targetStableID, + suppressStoredDeviceAuth: true) + // Setup-link fields retain their source provenance. When the endpoint changes, + // carry only values the user replaced instead of forwarding source credentials. + return ManualAuthOverride.explicit( + token: normalizedInput.token == pendingOverride.token ? nil : normalizedInput.token, + bootstrapToken: nil, + password: normalizedInput.password == pendingOverride.password ? nil : normalizedInput.password, + targetStableID: targetStableID, + suppressStoredDeviceAuth: true) + } + return ManualAuthOverride.explicit( + token: token, + bootstrapToken: pendingOverride.bootstrapToken, + password: password, + targetStableID: pendingOverride.targetStableID, + suppressStoredDeviceAuth: pendingOverride.suppressStoredDeviceAuth) + } + + static func manualStableID(host: String, port: Int) -> String { + "manual|\(host.lowercased())|\(port)" + } + + static func setupAuth(from link: GatewayConnectDeepLink) -> SetupAuth { + SetupAuth( + token: link.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + bootstrapToken: link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + password: link.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + targetStableID: self.manualStableID(host: link.host, port: link.port)) + } + } +} + +private struct GatewayPendingTrustConnect { + let url: URL + let stableID: String + let isManual: Bool + let authOverride: GatewayConnectionController.ManualAuthOverride? + let allowStoredDeviceAuth: Bool + let suppressionLease: GatewayConnectionController.AutoConnectSuppressionLease + let gatewayGeneration: UInt64? +} + extension GatewayConnectionController { private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { let scheme = useTLS ? "wss" : "ws" @@ -970,17 +1349,15 @@ extension GatewayConnectionController { !LoopbackHost.isLocalNetworkHost(host) } - private func shouldForceTLS(host: String) -> Bool { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed.isEmpty { return false } - return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") - } - private func manualStableID(host: String, port: Int) -> String { - "manual|\(host.lowercased())|\(port)" + ManualAuthOverride.manualStableID(host: host, port: port) } - private func makeConnectOptions(stableID: String?) async -> GatewayConnectOptions { + private func makeConnectOptions( + stableID: String?, + deviceAuthGatewayID: String?, + allowStoredDeviceAuth: Bool = true) async -> GatewayConnectOptions + { let defaults = UserDefaults.standard let displayName = self.resolvedDisplayName(defaults: defaults) let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID) @@ -994,7 +1371,9 @@ extension GatewayConnectionController { permissions: permissions, clientId: resolvedClientId, clientMode: "node", - clientDisplayName: displayName) + clientDisplayName: displayName, + allowStoredDeviceAuth: allowStoredDeviceAuth, + deviceAuthGatewayID: deviceAuthGatewayID) } private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String { @@ -1011,18 +1390,6 @@ extension GatewayConnectionController { return "openclaw-ios" } - private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? { - if port > 0 { - return port <= 65535 ? port : nil - } - let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedHost.isEmpty else { return nil } - if useTLS, self.shouldForceTLS(host: trimmedHost) { - return 443 - } - return 18789 - } - private func resolvedDisplayName(defaults: UserDefaults) -> String { let key = "node.displayName" let existingRaw = defaults.string(forKey: key) @@ -1218,10 +1585,18 @@ extension GatewayConnectionController { self.maybeAutoConnect() } + func _test_triggerAutoReconnect() { + self.attemptAutoReconnectIfNeeded() + } + func _test_didAutoConnect() -> Bool { self.didAutoConnect } + func _test_isAutoConnectSuppressed() -> Bool { + self.autoConnectSuppressionGeneration != nil + } + func _test_resolveDiscoveredTLSParams( gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? { @@ -1232,8 +1607,8 @@ extension GatewayConnectionController { self.resolveManualUseTLS(host: host, useTLS: useTLS) } - func _test_resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? { - self.resolveManualPort(host: host, port: port, useTLS: useTLS) + func _test_resolveManualPort(host: String, port: Int, useTLS _: Bool) -> Int? { + Self.resolvedManualPort(host: host, port: port) } func _test_savedManualEndpointFallback( diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 74957881e9fe..e14a0473ee0a 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawKit import os enum GatewaySettingsStore { @@ -23,6 +24,38 @@ enum GatewaySettingsStore { private static let lastGatewayConnectionAccount = "lastConnection" private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret + struct GatewayCredentialMetadata: Codable, Equatable { + let gatewayStableID: String + let suppressStoredDeviceAuth: Bool + } + + /// Credential ownership and secrets must move together. Separate Keychain + /// entries can survive a partial update and bind one gateway's secret to another. + private struct GatewayCredentialBundle: Codable { + let gatewayStableID: String + let suppressStoredDeviceAuth: Bool + let token: String? + let bootstrapToken: String? + let password: String? + } + + struct GatewayCredentials: Equatable { + let token: String? + let bootstrapToken: String? + let password: String? + let suppressStoredDeviceAuth: Bool + + static let empty = GatewayCredentials( + token: nil, + bootstrapToken: nil, + password: nil, + suppressStoredDeviceAuth: false) + + var hasCredentials: Bool { + self.token != nil || self.bootstrapToken != nil || self.password != nil + } + } + static func bootstrapPersistence() { self.ensureStableInstanceID() self.ensurePreferredGatewayStableID() @@ -107,75 +140,172 @@ enum GatewaySettingsStore { defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) } - static func loadGatewayToken(instanceId: String) -> String? { - let account = self.gatewayTokenAccount(instanceId: instanceId) - let token = KeychainStore.loadString(service: self.gatewayService, account: account)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if token?.isEmpty == false { return token } - return nil + static func loadGatewayCredentialMetadata(instanceId: String) -> GatewayCredentialMetadata? { + guard let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId) else { return nil } + return GatewayCredentialMetadata( + gatewayStableID: bundle.gatewayStableID, + suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth) } - static func saveGatewayToken(_ token: String, instanceId: String) { - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - _ = KeychainStore.delete( - service: self.gatewayService, - account: self.gatewayTokenAccount(instanceId: instanceId)) - return + static func loadGatewayCredentials(instanceId: String, gatewayStableID: String) -> GatewayCredentials { + let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) + guard !stableID.isEmpty, + let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId), + bundle.gatewayStableID == stableID + else { return .empty } + return GatewayCredentials( + token: bundle.token, + bootstrapToken: bundle.bootstrapToken, + password: bundle.password, + suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth) + } + + @discardableResult + static func saveGatewayCredentials( + token: String?, + bootstrapToken: String?, + password: String?, + gatewayStableID: String, + suppressStoredDeviceAuth: Bool, + instanceId: String) -> Bool + { + let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) + let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !stableID.isEmpty, !trimmedInstanceID.isEmpty else { return false } + let bundle = GatewayCredentialBundle( + gatewayStableID: stableID, + suppressStoredDeviceAuth: suppressStoredDeviceAuth, + token: self.normalizedCredential(token), + bootstrapToken: self.normalizedCredential(bootstrapToken), + password: self.normalizedCredential(password)) + let account = self.gatewayCredentialBundleAccount(instanceId: trimmedInstanceID) + let hasCredentials = bundle.token != nil || bundle.bootstrapToken != nil || bundle.password != nil + guard hasCredentials || suppressStoredDeviceAuth else { + let deleted = KeychainStore.delete(service: self.gatewayService, account: account) + self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID) + return deleted || KeychainStore.loadString(service: self.gatewayService, account: account) == nil } + guard let data = try? JSONEncoder().encode(bundle), + let json = String(data: data, encoding: .utf8) + else { + _ = KeychainStore.delete(service: self.gatewayService, account: account) + return false + } + guard KeychainStore.saveString( + json, + service: self.gatewayService, + account: account) + else { + // The Keychain helper restores the prior item when replacement fails. Keep that + // known-good bundle; callers already treat this attempted update as uncommitted. + return false + } + self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID) + return true + } + + @discardableResult + static func updateGatewayCredentials( + token: String?, + password: String?, + gatewayStableID: String, + instanceId: String) -> Bool + { + let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) + let existing = self.loadGatewayCredentialBundle(instanceId: instanceId) + let sameOwner = existing?.gatewayStableID == stableID + return self.saveGatewayCredentials( + token: token, + bootstrapToken: sameOwner ? existing?.bootstrapToken : nil, + password: password, + gatewayStableID: stableID, + suppressStoredDeviceAuth: sameOwner && existing?.suppressStoredDeviceAuth == true, + instanceId: instanceId) + } + + @discardableResult + static func completeGatewayCredentialHandoff(instanceId: String, gatewayStableID: String) -> Bool { + let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) + guard let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId), + bundle.gatewayStableID == stableID, + bundle.suppressStoredDeviceAuth + else { return false } + // Device-token issuance and bootstrap consumption are one durable handoff. A relaunch + // must never observe a spent bootstrap token while stored device auth remains disabled. + return self.saveGatewayCredentials( + token: bundle.token, + bootstrapToken: nil, + password: bundle.password, + gatewayStableID: stableID, + suppressStoredDeviceAuth: false, + instanceId: instanceId) + } + + static func discardUnscopedGatewayCredentials(instanceId: String) { + // The legacy UI saved fields before a successful connection, so the last route + // cannot prove who owns these secrets. Re-entry is safer than cross-gateway reuse. + self.deleteLegacyGatewayCredentials(instanceId: instanceId) + } + + /// Certificate pins prove transport trust for one route; they are not gateway identities. + /// Wildcard certificates and reverse proxies may legitimately reuse a leaf certificate. + static func authenticationOwnerID(routeStableID: String) -> String { + routeStableID.trimmingCharacters(in: .whitespacesAndNewlines) + } + + @discardableResult + static func migrateProvenRelayCredentials( + instanceId: String, + gatewayStableID: String, + token: String?, + password: String?) -> Bool + { + let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInstanceID.isEmpty, !stableID.isEmpty else { return false } + let legacyAccounts = [ + self.gatewayTokenAccount(instanceId: trimmedInstanceID), + self.gatewayBootstrapTokenAccount(instanceId: trimmedInstanceID), + self.gatewayPasswordAccount(instanceId: trimmedInstanceID), + ] + let hasLegacyCredentials = legacyAccounts.contains { account in + self.normalizedCredential(KeychainStore.loadString( + service: self.gatewayService, + account: account)) != nil + } + guard hasLegacyCredentials else { return true } + + // A canonical bundle already owns the fields atomically. Never replace it with + // older relay data merely because legacy per-field entries still exist. + if self.loadGatewayCredentialBundle(instanceId: trimmedInstanceID) != nil { + self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID) + return true + } + + let relayToken = self.normalizedCredential(token) + let relayPassword = self.normalizedCredential(password) + guard relayToken != nil || relayPassword != nil else { + self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID) + return true + } + // Relay config is written only after a successful connection and therefore proves + // both the credential values and their gateway owner. Preserve it before cleanup. + return self.saveGatewayCredentials( + token: relayToken, + bootstrapToken: nil, + password: relayPassword, + gatewayStableID: stableID, + suppressStoredDeviceAuth: false, + instanceId: trimmedInstanceID) + } + + static func saveLegacyGatewayTokenForMigrationTest(_ token: String, instanceId: String) { _ = KeychainStore.saveString( - trimmed, + token, service: self.gatewayService, account: self.gatewayTokenAccount(instanceId: instanceId)) } - static func loadGatewayBootstrapToken(instanceId: String) -> String? { - let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId) - let token = KeychainStore.loadString(service: self.gatewayService, account: account)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if token?.isEmpty == false { return token } - return nil - } - - static func saveGatewayBootstrapToken(_ token: String, instanceId: String) { - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - self.clearGatewayBootstrapToken(instanceId: instanceId) - return - } - _ = KeychainStore.saveString( - trimmed, - service: self.gatewayService, - account: self.gatewayBootstrapTokenAccount(instanceId: instanceId)) - } - - static func clearGatewayBootstrapToken(instanceId: String) { - _ = KeychainStore.delete( - service: self.gatewayService, - account: self.gatewayBootstrapTokenAccount(instanceId: instanceId)) - } - - static func loadGatewayPassword(instanceId: String) -> String? { - KeychainStore.loadString( - service: self.gatewayService, - account: self.gatewayPasswordAccount(instanceId: instanceId))? - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - static func saveGatewayPassword(_ password: String, instanceId: String) { - let trimmed = password.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - _ = KeychainStore.delete( - service: self.gatewayService, - account: self.gatewayPasswordAccount(instanceId: instanceId)) - return - } - _ = KeychainStore.saveString( - trimmed, - service: self.gatewayService, - account: self.gatewayPasswordAccount(instanceId: instanceId)) - } - enum LastGatewayConnection: Equatable { case manual(host: String, port: Int, useTLS: Bool, stableID: String) case discovered(stableID: String, useTLS: Bool) @@ -308,13 +438,8 @@ enum GatewaySettingsStore { guard !trimmed.isEmpty else { return } _ = KeychainStore.delete( service: self.gatewayService, - account: self.gatewayTokenAccount(instanceId: trimmed)) - _ = KeychainStore.delete( - service: self.gatewayService, - account: self.gatewayBootstrapTokenAccount(instanceId: trimmed)) - _ = KeychainStore.delete( - service: self.gatewayService, - account: self.gatewayPasswordAccount(instanceId: trimmed)) + account: self.gatewayCredentialBundleAccount(instanceId: trimmed)) + self.deleteLegacyGatewayCredentials(instanceId: trimmed) } static func loadGatewayClientIdOverride(stableID: String) -> String? { @@ -373,6 +498,47 @@ enum GatewaySettingsStore { "gateway-password.\(instanceId)" } + private static func gatewayCredentialBundleAccount(instanceId: String) -> String { + "gateway-credentials.\(instanceId)" + } + + private static func loadGatewayCredentialBundle(instanceId: String) -> GatewayCredentialBundle? { + guard let json = KeychainStore.loadString( + service: self.gatewayService, + account: self.gatewayCredentialBundleAccount(instanceId: instanceId)), + let data = json.data(using: .utf8), + let decoded = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data) + else { return nil } + let stableID = decoded.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !stableID.isEmpty else { return nil } + return GatewayCredentialBundle( + gatewayStableID: stableID, + suppressStoredDeviceAuth: decoded.suppressStoredDeviceAuth, + token: self.normalizedCredential(decoded.token), + bootstrapToken: self.normalizedCredential(decoded.bootstrapToken), + password: self.normalizedCredential(decoded.password)) + } + + private static func normalizedCredential(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func deleteLegacyGatewayCredentials(instanceId: String) { + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayTokenAccount(instanceId: instanceId)) + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayBootstrapTokenAccount(instanceId: instanceId)) + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayPasswordAccount(instanceId: instanceId)) + _ = KeychainStore.delete( + service: self.gatewayService, + account: "gateway-credential-metadata.\(instanceId)") + } + private static func talkProviderApiKeyAccount(providerId: String) -> String { self.talkProviderApiKeyAccountPrefix + providerId } @@ -460,9 +626,10 @@ enum GatewayDiagnostics { func failed(_ stage: String, error: Error) { let nsError = error as NSError + let errorType = String(reflecting: type(of: error)) self .stage( - "\(stage) failed errorType=\(String(reflecting: type(of: error))) domain=\(nsError.domain) code=\(nsError.code)") + "\(stage) failed errorType=\(errorType) domain=\(nsError.domain) code=\(nsError.code)") } } diff --git a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift index 89df93ff84f7..b5ff1cd24f59 100644 --- a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift +++ b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift @@ -2,12 +2,13 @@ import SwiftUI struct GatewayTrustPromptAlert: ViewModifier { @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController + let isEnabled: Bool func body(content: Content) -> some View { content.alert( "Trust this gateway?", isPresented: Binding( - get: { self.gatewayController.pendingTrustPrompt != nil }, + get: { self.isEnabled && self.gatewayController.pendingTrustPrompt != nil }, set: { _ in // Keep pending trust state until explicit user action. // SwiftUI may set presentation bindings during dismissal; clearing here can @@ -39,7 +40,7 @@ struct GatewayTrustPromptAlert: ViewModifier { } extension View { - func gatewayTrustPromptAlert() -> some View { - self.modifier(GatewayTrustPromptAlert()) + func gatewayTrustPromptAlert(isEnabled: Bool = true) -> some View { + self.modifier(GatewayTrustPromptAlert(isEnabled: isEnabled)) } } diff --git a/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift b/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift index 08ef81e0cced..988f0d5fa020 100644 --- a/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift +++ b/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift @@ -8,6 +8,7 @@ extension NodeAppModel { normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) normalized.promptId = self.trimmedOrNil(params.promptId) normalized.sessionKey = self.trimmedOrNil(params.sessionKey) + normalized.gatewayStableID = self.trimmedOrNil(params.gatewayStableID) normalized.kind = self.trimmedOrNil(params.kind) normalized.details = self.trimmedOrNil(params.details) normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 7ee993c3da75..ff190f679e92 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -103,6 +103,7 @@ final class NodeAppModel { struct ExecApprovalPrompt: Identifiable, Equatable, Codable { let id: String + let gatewayStableID: String let commandText: String let commandPreview: String? let allowedDecisions: [String] @@ -128,6 +129,18 @@ final class NodeAppModel { case failed(message: String) } + private struct GatewaySessionRouteContext { + let route: GatewayNodeSessionRoute + let gatewayStableID: String + let routeGeneration: UInt64 + } + + private enum ExecApprovalPushRouteValidation { + case validated(GatewaySessionRouteContext) + case unavailable + case mismatchedOwner + } + private enum WatchMessageSendOutcome { case sent case retry @@ -136,7 +149,9 @@ final class NodeAppModel { private struct PersistedWatchExecApprovalBridgeState: Codable { var approvals: [ExecApprovalPrompt] - var pendingApprovalIDs: [String]? + var pendingApprovalPushes: [ExecApprovalNotificationPrompt]? + var pendingResolvedPushes: [ExecApprovalNotificationPrompt]? + var pendingResolutions: [WatchExecApprovalResolveEvent]? } private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink") @@ -220,6 +235,11 @@ final class NodeAppModel { private let operatorGateway = GatewayNodeSession() private var nodeGatewayTask: Task? private var operatorGatewayTask: Task? + @ObservationIgnored private var gatewaySessionResetTask: Task? + @ObservationIgnored private var gatewaySessionResetGeneration: UInt64 = 0 + @ObservationIgnored private var gatewayRouteGeneration: UInt64 = 0 + @ObservationIgnored private var credentialHandoffFailureGeneration: UInt64? + @ObservationIgnored private(set) var gatewayConnectGeneration: UInt64 = 0 private var forceOperatorTalkPermissionUpgradeRequest = false private var lastTalkPermissionReconnectAttemptAt: Date? private var voiceWakeSyncTask: Task? @@ -260,8 +280,12 @@ final class NodeAppModel { @ObservationIgnored private var watchMessageRetryTask: Task? @ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport() private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:] - private var pendingWatchExecApprovalRecoveryIDs: [String] = [] + private var pendingWatchExecApprovalRecoveryPushes: [ExecApprovalNotificationPrompt] = [] + private var pendingExecApprovalResolvedPushes: [ExecApprovalNotificationPrompt] = [] + private var pendingWatchExecApprovalResolutions: [WatchExecApprovalResolveEvent] = [] private var pendingForegroundActionDrainInFlight = false + private var pendingForegroundActionDrainRequested = false + private var completedPendingForegroundActionIDsByGateway: [String: Set] = [:] private var gatewayConnected = false private var operatorConnected = false @@ -348,7 +372,7 @@ final class NodeAppModel { self.watchMessagingService = watchMessagingService self.talkMode = talkMode self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey) - self.restorePersistedWatchExecApprovalBridgeState() + restorePersistedWatchExecApprovalBridgeState() GatewayDiagnostics.bootstrap() GatewayDiagnostics.log("node app model: init start") self.watchMessagingService.setStatusHandler { [weak self] status in @@ -367,7 +391,7 @@ final class NodeAppModel { } self.watchMessagingService.setExecApprovalResolveHandler { [weak self] event in Task { @MainActor in - await self?.handleWatchExecApprovalResolve(event) + _ = await self?.handleWatchExecApprovalResolve(event) } } self.watchMessagingService.setExecApprovalSnapshotRequestHandler { [weak self] event in @@ -411,8 +435,8 @@ final class NodeAppModel { let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") self.voiceWake.setEnabled(enabled) self.talkMode.attachGateway(self.operatorGateway) - self.refreshOperatorAdminScopeFromStore() - self.refreshLastShareEventFromRelay() + refreshOperatorAdminScopeFromStore() + refreshLastShareEventFromRelay() let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") self.setTalkEnabled(talkEnabled) self.locationService.setAuthorizationChangeHandler { [weak self] status in @@ -476,7 +500,7 @@ final class NodeAppModel { interfaceIdiom: UIDevice.current.userInterfaceIdiom) let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"]) - let sessionKey = self.mainSessionKey + let sessionKey = mainSessionKey let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext( actionName: name, @@ -487,12 +511,12 @@ final class NodeAppModel { let ok: Bool var errorText: String? - if await !self.isGatewayConnected() { + if await !isGatewayConnected() { ok = false errorText = "gateway not connected" } else { do { - try await self.sendAgentRequest(link: AgentDeepLink( + try await sendAgentRequest(link: AgentDeepLink( message: message, sessionKey: sessionKey, thinking: "low", @@ -637,7 +661,7 @@ final class NodeAppModel { guard self.isBackgrounded else { return } let leaseSeconds = max(5, seconds) let leaseUntil = Date().addingTimeInterval(leaseSeconds) - if let existing = self.backgroundReconnectLeaseUntil, existing > leaseUntil { + if let existing = backgroundReconnectLeaseUntil, existing > leaseUntil { // Keep the longer lease if one is already active. } else { self.backgroundReconnectLeaseUntil = leaseUntil @@ -743,7 +767,7 @@ final class NodeAppModel { } func requestTalkPermissionUpgrade() { - guard let config = self.activeGatewayConnectConfig else { + guard let config = activeGatewayConnectConfig else { self.talkMode.gatewayTalkPermissionState = .requestFailed("Gateway is not connected") self.talkMode.statusText = "Gateway not connected" return @@ -783,7 +807,7 @@ final class NodeAppModel { return } - guard let cfg = self.activeGatewayConnectConfig else { + guard let cfg = activeGatewayConnectConfig else { self.talkMode.gatewayTalkPermissionState = .requestFailed("Gateway is not connected") self.talkMode.statusText = "Gateway not connected" return @@ -795,17 +819,17 @@ final class NodeAppModel { { return } - self.lastTalkPermissionReconnectAttemptAt = now + lastTalkPermissionReconnectAttemptAt = now GatewayDiagnostics.log("talk permission approval poll reconnect") self.gatewayAutoReconnectEnabled = true self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil - self.ensureOperatorReconnectLoopIfNeeded() + ensureOperatorReconnectLoopIfNeeded() if self.operatorGatewayTask == nil { let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } - self.startOperatorGatewayLoop( + startOperatorGatewayLoop( url: cfg.url, stableID: cfg.effectiveStableID, token: cfg.token, @@ -815,7 +839,7 @@ final class NodeAppModel { sessionBox: sessionBox) } - guard await self.waitForOperatorConnection(timeoutMs: 2500, pollMs: 250) else { + guard await waitForOperatorConnection(timeoutMs: 2500, pollMs: 250) else { return } await self.talkMode.reloadConfig() @@ -834,7 +858,7 @@ final class NodeAppModel { authorizationStatus: self.locationService.authorizationStatus()) return true } - let status = await self.locationService.ensureAuthorization(mode: mode) + let status = await locationService.ensureAuthorization(mode: mode) switch status { case .authorizedAlways: self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status) @@ -870,13 +894,14 @@ final class NodeAppModel { private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() - private func refreshBrandingFromGateway() async { + private func refreshBrandingFromGateway(shouldApply: () -> Bool = { true }) async { do { - let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) + let res = try await operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } let session = config["session"] as? [String: Any] let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) + guard shouldApply() else { return } await MainActor.run { self.mainSessionBaseKey = mainKey self.talkMode.updateMainSessionKey(self.mainSessionKey) @@ -893,10 +918,11 @@ final class NodeAppModel { } } - private func refreshAgentsFromGateway() async { + private func refreshAgentsFromGateway(shouldApply: () -> Bool = { true }) async { do { - let res = try await self.operatorGateway.request(method: "agents.list", paramsJSON: "{}", timeoutSeconds: 8) + let res = try await operatorGateway.request(method: "agents.list", paramsJSON: "{}", timeoutSeconds: 8) let decoded = try JSONDecoder().decode(AgentsListResult.self, from: res) + guard shouldApply() else { return } await MainActor.run { self.gatewayDefaultAgentId = decoded.defaultid self.gatewayAgents = decoded.agents @@ -916,12 +942,12 @@ final class NodeAppModel { } func refreshGatewayOverviewIfConnected() async { - guard await self.isOperatorConnected() else { return } + guard await isOperatorConnected() else { return } if self.foregroundGatewayResumeCheckInFlight { GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check") try? await Task.sleep( nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000) - guard await self.isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return } + guard await isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return } } await self.refreshBrandingFromGateway() await self.refreshAgentsFromGateway() @@ -932,7 +958,7 @@ final class NodeAppModel { let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines) let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId - let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let stableID = (connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if stableID.isEmpty { self.selectedAgentId = nextSelectedAgentId } else { @@ -942,15 +968,16 @@ final class NodeAppModel { if selectedAgentChanged { self.focusedChatSessionKey = nil } - self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.talkMode.updateMainSessionKey(mainSessionKey) self.homeCanvasRevision &+= 1 if let relay = ShareGatewayRelaySettings.loadConfig() { ShareGatewayRelaySettings.saveConfig( ShareGatewayRelayConfig( gatewayURLString: relay.gatewayURLString, + gatewayStableID: relay.gatewayStableID, token: relay.token, password: relay.password, - sessionKey: self.mainSessionKey, + sessionKey: mainSessionKey, deliveryChannel: self.shareDeliveryChannel, deliveryTo: self.shareDeliveryTo)) } @@ -974,26 +1001,36 @@ final class NodeAppModel { } } - private func startVoiceWakeSync() async { + private func startVoiceWakeSync(shouldContinue: @escaping @MainActor @Sendable () -> Bool = { true }) async { + guard shouldContinue() else { return } self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = Task { [weak self] in guard let self else { return } if !self.isGatewayHealthMonitorDisabled() { - await self.refreshWakeWordsFromGateway() + await self.refreshWakeWordsFromGateway(shouldApply: shouldContinue) } + guard shouldContinue() else { return } + guard let operatorRoute = await self.operatorGateway.currentRoute(), shouldContinue() else { return } let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200) for await evt in stream { - if Task.isCancelled { return } - guard let payload = evt.payload else { continue } - await self.handleOperatorGatewayServerEvent(evt) + if Task.isCancelled || !shouldContinue() { return } + guard evt.payload != nil else { continue } + await self.handleOperatorGatewayServerEvent( + evt, + expectedOperatorRoute: operatorRoute, + shouldContinue: shouldContinue) } } } - private func handleOperatorGatewayServerEvent(_ evt: EventFrame) async { - guard let payload = evt.payload else { return } + private func handleOperatorGatewayServerEvent( + _ evt: EventFrame, + expectedOperatorRoute: GatewayNodeSessionRoute? = nil, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldContinue(), let payload = evt.payload else { return } switch evt.event { case "voicewake.changed": struct Payload: Decodable { var triggers: [String] } @@ -1009,12 +1046,19 @@ final class NodeAppModel { self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase) case ExecApprovalNotificationBridge.requestedKind: guard let approvalId = Self.execApprovalEventID(from: payload) else { return } - await self.presentNotificationPermissionGuidanceForExecApprovalIfNeeded(approvalId: approvalId) - await self.presentExecApprovalNotificationPrompt( - ExecApprovalNotificationPrompt(approvalId: approvalId)) + await self.presentNotificationPermissionGuidanceForExecApprovalIfNeeded( + approvalId: approvalId, + shouldApply: shouldContinue) + guard shouldContinue() else { return } + await presentExecApprovalGatewayEventPrompt( + approvalId: approvalId, + expectedOperatorRoute: expectedOperatorRoute, + shouldContinue: shouldContinue) case ExecApprovalNotificationBridge.resolvedKind: guard let approvalId = Self.execApprovalEventID(from: payload) else { return } - await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId) + await handleExecApprovalResolvedForCurrentGateway( + approvalId: approvalId, + shouldContinue: shouldContinue) default: return } @@ -1038,7 +1082,7 @@ final class NodeAppModel { } private func pushTalkModeToGateway(enabled: Bool, phase: String?) async { - guard await self.isOperatorConnected() else { return } + guard await isOperatorConnected() else { return } struct TalkModePayload: Encodable { var enabled: Bool var phase: String? @@ -1097,7 +1141,10 @@ final class NodeAppModel { self.gatewayHealthMonitor.stop() } - private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { + private func handleInvoke( + _ req: BridgeInvokeRequest, + gatewayStableID: String? = nil) async -> BridgeInvokeResponse + { let command = req.command if self.isBackgrounded, self.isBackgroundRestricted(command) { @@ -1109,7 +1156,7 @@ final class NodeAppModel { message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground")) } - if command.hasPrefix("camera."), !self.isCameraEnabled() { + if command.hasPrefix("camera."), !isCameraEnabled() { return BridgeInvokeResponse( id: req.id, ok: false, @@ -1119,7 +1166,8 @@ final class NodeAppModel { } do { - return try await self.capabilityRouter.handle(req) + return try await self.capabilityRouter.handle( + Self.scopedWatchNotificationRequest(req, gatewayStableID: gatewayStableID)) } catch let error as NodeCapabilityRouter.RouterError { switch error { case .unknownCommand: @@ -1136,7 +1184,7 @@ final class NodeAppModel { } catch { if command.hasPrefix("camera.") { let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - self.showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2) + showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2) } return BridgeInvokeResponse( id: req.id, @@ -1145,13 +1193,31 @@ final class NodeAppModel { } } + private static func scopedWatchNotificationRequest( + _ req: BridgeInvokeRequest, + gatewayStableID: String?) -> BridgeInvokeRequest + { + guard req.command == OpenClawWatchCommand.notify.rawValue, + var params = try? decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON) + else { return req } + // Gateway identity comes from the installed node route, never the request payload. + params.gatewayStableID = trimmedOrNil(gatewayStableID) + guard let paramsJSON = try? encodePayload(params) else { return req } + return BridgeInvokeRequest( + type: req.type, + id: req.id, + command: req.command, + paramsJSON: paramsJSON, + nodeId: req.nodeId) + } + private func isBackgroundRestricted(_ command: String) -> Bool { command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") || command.hasPrefix("talk.") } private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let mode = self.locationMode() + let mode = locationMode() guard mode != .off else { return BridgeInvokeResponse( id: req.id, @@ -1171,7 +1237,7 @@ final class NodeAppModel { let params = (try? Self.decodeParams(OpenClawLocationGetParams.self, from: req.paramsJSON)) ?? OpenClawLocationGetParams() let desired = params.desiredAccuracy ?? - (self.isLocationPreciseEnabled() ? .precise : .balanced) + (isLocationPreciseEnabled() ? .precise : .balanced) let status = self.locationService.authorizationStatus() if status != .authorizedAlways, status != .authorizedWhenInUse { return BridgeInvokeResponse( @@ -1189,7 +1255,7 @@ final class NodeAppModel { code: .unavailable, message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access")) } - let location = try await self.locationService.currentLocation( + let location = try await locationService.currentLocation( params: params, desiredAccuracy: desired, maxAgeMs: params.maxAgeMs, @@ -1232,7 +1298,7 @@ final class NodeAppModel { return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.evalJS.rawValue: let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON) - let result = try await self.screen.eval(javaScript: params.javaScript) + let result = try await screen.eval(javaScript: params.javaScript) let payload = try Self.encodePayload(["result": result]) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) case OpenClawCanvasCommand.snapshot.rawValue: @@ -1247,7 +1313,7 @@ final class NodeAppModel { case .jpeg: 1600 } }() - let base64 = try await self.screen.snapshotBase64( + let base64 = try await screen.snapshotBase64( maxWidth: maxWidth, format: format, quality: params?.quality) @@ -1268,7 +1334,7 @@ final class NodeAppModel { let command = req.command switch command { case OpenClawCanvasA2UICommand.reset.rawValue: - switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) { + switch await ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) { case .ready: break case .hostUnavailable: @@ -1279,7 +1345,7 @@ final class NodeAppModel { code: .unavailable, message: "A2UI_HOST_UNAVAILABLE: bundled A2UI host not reachable")) } - let json = try await self.screen.eval(javaScript: """ + let json = try await screen.eval(javaScript: """ (() => { const host = globalThis.openclawA2UI; if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); @@ -1304,7 +1370,7 @@ final class NodeAppModel { } } - switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) { + switch await ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) { case .ready: break case .hostUnavailable: @@ -1329,7 +1395,7 @@ final class NodeAppModel { } })() """ - let resultJSON = try await self.screen.eval(javaScript: js) + let resultJSON = try await screen.eval(javaScript: js) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) default: @@ -1343,18 +1409,18 @@ final class NodeAppModel { private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { switch req.command { case OpenClawCameraCommand.list.rawValue: - let devices = await self.camera.listDevices() + let devices = await camera.listDevices() struct Payload: Codable { var devices: [CameraController.CameraDeviceInfo] } let payload = try Self.encodePayload(Payload(devices: devices)) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) case OpenClawCameraCommand.snap.rawValue: - self.showCameraHUD(text: "Taking photo…", kind: .photo) - self.triggerCameraFlash() + showCameraHUD(text: "Taking photo…", kind: .photo) + triggerCameraFlash() let params = (try? Self.decodeParams(OpenClawCameraSnapParams.self, from: req.paramsJSON)) ?? OpenClawCameraSnapParams() - let res = try await self.camera.snap(params: params) + let res = try await camera.snap(params: params) struct Payload: Codable { var format: String @@ -1367,7 +1433,7 @@ final class NodeAppModel { base64: res.base64, width: res.width, height: res.height)) - self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6) + showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) case OpenClawCameraCommand.clip.rawValue: let params = (try? Self.decodeParams(OpenClawCameraClipParams.self, from: req.paramsJSON)) ?? @@ -1376,8 +1442,8 @@ final class NodeAppModel { let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) } - self.showCameraHUD(text: "Recording…", kind: .recording) - let res = try await self.camera.clip(params: params) + showCameraHUD(text: "Recording…", kind: .recording) + let res = try await camera.clip(params: params) struct Payload: Codable { var format: String @@ -1390,7 +1456,7 @@ final class NodeAppModel { base64: res.base64, durationMs: res.durationMs, hasAudio: res.hasAudio)) - self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8) + showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) default: return BridgeInvokeResponse( @@ -1411,7 +1477,7 @@ final class NodeAppModel { // Status pill mirrors screen recording state so it stays visible without overlay stacking. self.screenRecordActive = true defer { self.screenRecordActive = false } - let path = try await self.screenRecorder.record( + let path = try await screenRecorder.record( screenIndex: params.screenIndex, durationMs: params.durationMs, fps: params.fps, @@ -1448,7 +1514,7 @@ final class NodeAppModel { error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification")) } - let status = await self.notificationAuthorizationStatus() + let status = await notificationAuthorizationStatus() guard Self.isNotificationAuthorizationAllowed(status) else { return BridgeInvokeResponse( id: req.id, @@ -1456,7 +1522,7 @@ final class NodeAppModel { error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications")) } - let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + let addResult = await runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in let content = UNMutableNotificationContent() content.title = title content.body = body @@ -1502,7 +1568,7 @@ final class NodeAppModel { } let shouldSpeak = params.speak ?? true - let status = await self.notificationAuthorizationStatus() + let status = await notificationAuthorizationStatus() let notificationsAllowed = Self.isNotificationAuthorizationAllowed(status) if !notificationsAllowed, !shouldSpeak { return BridgeInvokeResponse( @@ -1513,7 +1579,7 @@ final class NodeAppModel { let messageId = UUID().uuidString if notificationsAllowed { - let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + let addResult = await runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in let content = UNMutableNotificationContent() content.title = "OpenClaw" content.body = text @@ -1546,7 +1612,7 @@ final class NodeAppModel { } private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus { - let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in + let result = await runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in await notificationCenter.authorizationStatus() } switch result { @@ -1568,10 +1634,13 @@ final class NodeAppModel { } } - private func presentNotificationPermissionGuidanceForExecApprovalIfNeeded(approvalId: String) async { - guard !self.execApprovalNotificationGuidanceSuppressed else { return } - let status = await self.notificationAuthorizationStatus() - guard !Self.isNotificationAuthorizationAllowed(status) else { return } + private func presentNotificationPermissionGuidanceForExecApprovalIfNeeded( + approvalId: String, + shouldApply: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldApply(), !self.execApprovalNotificationGuidanceSuppressed else { return } + let status = await notificationAuthorizationStatus() + guard shouldApply(), !Self.isNotificationAuthorizationAllowed(status) else { return } self.pendingNotificationPermissionGuidancePrompt = NotificationPermissionGuidancePrompt(approvalId: approvalId) } @@ -1625,7 +1694,7 @@ final class NodeAppModel { private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { switch req.command { case OpenClawDeviceCommand.status.rawValue: - let payload = try await self.deviceStatusService.status() + let payload = try await deviceStatusService.status() let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawDeviceCommand.info.rawValue: @@ -1643,7 +1712,7 @@ final class NodeAppModel { private func handlePhotosInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ?? OpenClawPhotosLatestParams() - let payload = try await self.photosService.latest(params: params) + let payload = try await photosService.latest(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) } @@ -1653,12 +1722,12 @@ final class NodeAppModel { case OpenClawContactsCommand.search.rawValue: let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ?? OpenClawContactsSearchParams() - let payload = try await self.contactsService.search(params: params) + let payload = try await contactsService.search(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawContactsCommand.add.rawValue: let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON) - let payload = try await self.contactsService.add(params: params) + let payload = try await contactsService.add(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) default: @@ -1674,12 +1743,12 @@ final class NodeAppModel { case OpenClawCalendarCommand.events.rawValue: let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ?? OpenClawCalendarEventsParams() - let payload = try await self.calendarService.events(params: params) + let payload = try await calendarService.events(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawCalendarCommand.add.rawValue: let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON) - let payload = try await self.calendarService.add(params: params) + let payload = try await calendarService.add(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) default: @@ -1695,12 +1764,12 @@ final class NodeAppModel { case OpenClawRemindersCommand.list.rawValue: let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ?? OpenClawRemindersListParams() - let payload = try await self.remindersService.list(params: params) + let payload = try await remindersService.list(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawRemindersCommand.add.rawValue: let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON) - let payload = try await self.remindersService.add(params: params) + let payload = try await remindersService.add(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) default: @@ -1716,13 +1785,13 @@ final class NodeAppModel { case OpenClawMotionCommand.activity.rawValue: let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ?? OpenClawMotionActivityParams() - let payload = try await self.motionService.activities(params: params) + let payload = try await motionService.activities(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawMotionCommand.pedometer.rawValue: let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ?? OpenClawPedometerParams() - let payload = try await self.motionService.pedometer(params: params) + let payload = try await motionService.pedometer(params: params) let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) default: @@ -1737,17 +1806,17 @@ final class NodeAppModel { switch req.command { case OpenClawTalkCommand.pttStart.rawValue: self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - let payload = try await self.talkMode.beginPushToTalk() + let payload = try await talkMode.beginPushToTalk() let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawTalkCommand.pttStop.rawValue: - let payload = await self.talkMode.endPushToTalk() + let payload = await talkMode.endPushToTalk() self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) self.pttVoiceWakeSuspended = false let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawTalkCommand.pttCancel.rawValue: - let payload = await self.talkMode.cancelPushToTalk() + let payload = await talkMode.cancelPushToTalk() self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) self.pttVoiceWakeSuspended = false let json = try Self.encodePayload(payload) @@ -1758,7 +1827,7 @@ final class NodeAppModel { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) self.pttVoiceWakeSuspended = false } - let payload = try await self.talkMode.runPushToTalkOnce() + let payload = try await talkMode.runPushToTalkOnce() let json = try Self.encodePayload(payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) default: @@ -1899,7 +1968,7 @@ extension NodeAppModel { private func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { switch req.command { case OpenClawWatchCommand.status.rawValue: - let status = await self.watchMessagingService.status() + let status = await watchMessagingService.status() let payload = OpenClawWatchStatusPayload( supported: status.supported, paired: status.paired, @@ -1922,11 +1991,11 @@ extension NodeAppModel { message: "INVALID_REQUEST: empty watch notification")) } do { - let gatewayStableID = self.currentWatchChatGatewayStableID() + let gatewayStableID = currentWatchChatGatewayStableID() self.watchMessageOutbox.recordPromptRoute( promptID: normalizedParams.promptId, gatewayStableID: gatewayStableID) - let result = try await self.watchMessagingService.sendNotification( + let result = try await watchMessagingService.sendNotification( id: req.id, params: normalizedParams, gatewayStableID: gatewayStableID) @@ -2024,14 +2093,14 @@ extension NodeAppModel { extension NodeAppModel { var mainSessionKey: String { let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey) - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let agentId = (selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let defaultId = (gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) } var chatSessionKey: String { - if let focused = self.focusedChatSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), + if let focused = focusedChatSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !focused.isEmpty { return focused @@ -2057,7 +2126,7 @@ extension NodeAppModel { } var chatAgentId: String { - if let sessionAgentId = SessionKey.agentId(from: self.chatSessionKey) { + if let sessionAgentId = SessionKey.agentId(from: chatSessionKey) { return sessionAgentId } return self.selectedOrDefaultAgentId @@ -2080,15 +2149,15 @@ extension NodeAppModel { } private var selectedOrDefaultAgentId: String { - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let agentId = (selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let defaultId = (gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return agentId.isEmpty ? defaultId : agentId } private func agentDisplayName(for agentId: String, fallback: String) -> String { let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines) if resolvedId.isEmpty { return fallback } - if let match = self.gatewayAgents.first(where: { $0.id == resolvedId }) { + if let match = gatewayAgents.first(where: { $0.id == resolvedId }) { let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? match.id : name } @@ -2098,7 +2167,7 @@ extension NodeAppModel { private func agentIdentityValue(for agentId: String, key: String) -> String? { let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines) guard !resolvedId.isEmpty, - let match = self.gatewayAgents.first(where: { $0.id == resolvedId }), + let match = gatewayAgents.first(where: { $0.id == resolvedId }), let rawValue = match.identity?[key]?.value as? String else { return nil @@ -2128,25 +2197,40 @@ extension NodeAppModel { bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions) - let operatorLoopRequired = self.shouldStartOperatorGatewayLoop( + let previousGatewayStableID = self.activeGatewayConnectConfig?.effectiveStableID + ?? self.connectedGatewayID + let targetChanged = previousGatewayStableID.map { + !$0.isEmpty && $0 != effectiveStableID + } ?? false + let hasForeignCachedApproval = self.watchExecApprovalPromptsByID.values.contains { + $0.gatewayStableID != effectiveStableID + } + if hasForeignCachedApproval || targetChanged { + // Approval IDs are gateway-local authorization handles. A target switch must remove + // every cached surface so stale prompts cannot authorize work on the replacement. + invalidateExecApprovalSurfacesForGatewayChange() + } + let operatorLoopRequired = shouldStartOperatorGatewayLoop( token: token, bootstrapToken: bootstrapToken, password: password, - stableID: effectiveStableID) - if let activeConfig = self.activeGatewayConnectConfig, + deviceAuthGatewayID: connectOptions.deviceAuthGatewayID ?? effectiveStableID, + allowStoredDeviceAuth: connectOptions.allowStoredDeviceAuth) + if let activeConfig = activeGatewayConnectConfig, activeConfig.hasSameConnectionInputs(as: nextConfig), - self.nodeGatewayTask != nil, - self.operatorGatewayTask != nil || !operatorLoopRequired, + nodeGatewayTask != nil, + operatorGatewayTask != nil || !operatorLoopRequired, !forceReconnect { self.gatewayAutoReconnectEnabled = true return } + self.gatewayRouteGeneration &+= 1 self.activeGatewayConnectConfig = nextConfig - self.prepareForGatewayConnect(stableID: effectiveStableID) + prepareForGatewayConnect(stableID: effectiveStableID) if operatorLoopRequired { - self.startOperatorGatewayLoop( + startOperatorGatewayLoop( url: url, stableID: effectiveStableID, token: token, @@ -2158,7 +2242,7 @@ extension NodeAppModel { self.operatorGatewayTask = nil Task { await self.operatorGateway.disconnect() } } - self.startNodeGatewayLoop( + startNodeGatewayLoop( url: url, stableID: effectiveStableID, token: token, @@ -2169,7 +2253,24 @@ extension NodeAppModel { } /// Preferred entry-point: apply a single config object and start both sessions. - func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) { + func applyGatewayConnectConfig( + _ cfg: GatewayConnectConfig, + forceReconnect: Bool = false) + { + let generation = self.beginGatewayConnectAttempt() + self.applyGatewayConnectConfig( + cfg, + forceReconnect: forceReconnect, + expectedGeneration: generation) + } + + /// Applies queued work only while its originating gateway attempt is still current. + func applyGatewayConnectConfig( + _ cfg: GatewayConnectConfig, + forceReconnect: Bool = false, + expectedGeneration: UInt64) + { + guard expectedGeneration == self.gatewayConnectGeneration else { return } self.isAppleReviewDemoModeEnabled = false self.isScreenshotFixtureModeEnabled = false self.connectToGateway( @@ -2185,72 +2286,171 @@ extension NodeAppModel { forceReconnect: forceReconnect) } - func resetGatewaySessionsForForcedReconnect() async { + func beginGatewayConnectAttempt() -> UInt64 { + self.gatewayConnectGeneration &+= 1 + return self.gatewayConnectGeneration + } + + private func invalidateGatewayConnectAttempts() { + self.gatewayConnectGeneration &+= 1 + } + + var hasGatewaySessionResetInFlight: Bool { + self.gatewaySessionResetTask != nil + } + + func waitForGatewaySessionResetIfNeeded() async { + while let gatewaySessionResetTask { + await gatewaySessionResetTask.value + } + } + + @discardableResult + private func beginGatewaySessionReset(chainingAfterExisting: Bool = false) -> Task { + let previousResetTask = self.gatewaySessionResetTask + if let previousResetTask, !chainingAfterExisting { + return previousResetTask + } let nodeGatewayTask = self.nodeGatewayTask let operatorGatewayTask = self.operatorGatewayTask + self.talkMode.updateGatewayConnected(false) + self.gatewayRouteGeneration &+= 1 nodeGatewayTask?.cancel() self.nodeGatewayTask = nil operatorGatewayTask?.cancel() self.operatorGatewayTask = nil - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - // Foreground recovery reuses the same config immediately after reset. - // Wait for canceled loops so their shutdown cleanup cannot clobber the new reconnect state. - if let operatorGatewayTask { - await operatorGatewayTask.value - } - if let nodeGatewayTask { - await nodeGatewayTask.value + let operatorGateway = self.operatorGateway + let nodeGateway = self.nodeGateway + self.gatewaySessionResetGeneration &+= 1 + let resetGeneration = self.gatewaySessionResetGeneration + // Disconnect first so canceled receive loops can unwind, then keep the barrier until their + // cleanup exits. A stale loop may otherwise disconnect a replacement session after reset. + let gatewaySessionResetTask = Task { + await previousResetTask?.value + await operatorGateway.disconnect() + await nodeGateway.disconnect() + await operatorGatewayTask?.value + await nodeGatewayTask?.value + if self.gatewaySessionResetGeneration == resetGeneration { + self.gatewaySessionResetTask = nil + } } + self.gatewaySessionResetTask = gatewaySessionResetTask + return gatewaySessionResetTask + } + + func resetGatewaySessionsForForcedReconnect() async { + await self.beginGatewaySessionReset().value + } + + func resetGatewaySessionsForTargetSwitch() async { + // A target awaiting TLS trust must not retain a reconnect route to the previous gateway. + invalidateExecApprovalSurfacesForGatewayChange() + self.invalidateGatewayConnectAttempts() + self.disableGatewayAutoReconnect() + self.activeGatewayConnectConfig = nil + ShareGatewayRelaySettings.clearConfig() + await self.resetGatewaySessionsForForcedReconnect() + guard !self.gatewayAutoReconnectEnabled, self.activeGatewayConnectConfig == nil else { return } + // A canceled loop may have persisted its reconnect flag and relay config while teardown was in flight. + self.disableGatewayAutoReconnect() + ShareGatewayRelaySettings.clearConfig() + self.gatewayHealthMonitor.stop() + self.gatewayStatusText = "Offline" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.connectedGatewayID = nil + self.gatewayConnected = false + setOperatorConnected(false) + self.talkMode.updateGatewayConnected(false) } private func restartGatewaySessionsAfterForegroundStaleConnection() async { + guard self.gatewayAutoReconnectEnabled, let cfg = activeGatewayConnectConfig else { return } + let generation = self.gatewayConnectGeneration await self.resetGatewaySessionsForForcedReconnect() + guard generation == self.gatewayConnectGeneration, + self.gatewayAutoReconnectEnabled, + self.activeGatewayConnectConfig?.hasSameConnectionInputs(as: cfg) == true, + self.nodeGatewayTask == nil, + self.operatorGatewayTask == nil + else { return } guard !self.isLocalGatewayFixtureEnabled else { return } - self.setOperatorConnected(false) + setOperatorConnected(false) self.gatewayConnected = false self.gatewayStatusText = "Reconnecting…" self.talkMode.updateGatewayConnected(false) - guard let cfg = self.activeGatewayConnectConfig else { return } - self.applyGatewayConnectConfig(cfg, forceReconnect: true) + self.applyGatewayConnectConfig( + cfg, + forceReconnect: true, + expectedGeneration: generation) } func disconnectGateway() { + self.disconnectGateway(disablePersistedAutoConnect: true) + } + + func suspendGatewayForTargetReview() { + // Target review pauses live reconnects without changing the user's launch preference. + self.disconnectGateway(disablePersistedAutoConnect: false) + } + + private func disconnectGateway(disablePersistedAutoConnect: Bool) { + invalidateExecApprovalSurfacesForGatewayChange() + self.invalidateGatewayConnectAttempts() self.isAppleReviewDemoModeEnabled = false self.isScreenshotFixtureModeEnabled = false - self.gatewayAutoReconnectEnabled = false + if disablePersistedAutoConnect { + self.disableGatewayAutoReconnect() + } else { + self.gatewayAutoReconnectEnabled = false + } self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil self.lastGatewayProblem = nil self.operatorGatewayProblem = nil - self.nodeGatewayTask?.cancel() - self.nodeGatewayTask = nil - self.operatorGatewayTask?.cancel() - self.operatorGatewayTask = nil + // Publish teardown through the shared barrier before returning. A replacement connect + // must await old loop cleanup instead of racing this synchronous UI action. + _ = self.beginGatewaySessionReset(chainingAfterExisting: true) self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil LiveActivityManager.shared.endActivity(reason: "manual_disconnect") self.gatewayHealthMonitor.stop() - Task { - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - } self.gatewayStatusText = "Offline" self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.connectedGatewayID = nil self.activeGatewayConnectConfig = nil self.gatewayConnected = false - self.setOperatorConnected(false) + setOperatorConnected(false) self.talkMode.updateGatewayConnected(false) self.mainSessionBaseKey = "main" self.talkMode.updateMainSessionKey(self.mainSessionKey) ShareGatewayRelaySettings.clearConfig() - self.showLocalCanvasOnDisconnect() + showLocalCanvasOnDisconnect() + } + + private func disableGatewayAutoReconnect() { + // Runtime teardown and persisted startup routing must move together. Otherwise a relaunch + // during target review silently reconnects the gateway the user just left. + self.gatewayAutoReconnectEnabled = false + UserDefaults.standard.set(false, forKey: "gateway.autoconnect") } } extension NodeAppModel { + func resumeGatewayAfterTargetReview(_ config: GatewayConnectConfig) { + let generation = self.beginGatewayConnectAttempt() + self.gatewayStatusText = "Connecting…" + // Reapply the exact suspended route only after teardown; a newer target invalidates the generation. + Task { [weak self] in + guard let self else { return } + await self.waitForGatewaySessionResetIfNeeded() + guard generation == self.gatewayConnectGeneration else { return } + self.applyGatewayConnectConfig(config, expectedGeneration: generation) + } + } + private func prepareForGatewayConnect(stableID: String) { self.isAppleReviewDemoModeEnabled = false self.isScreenshotFixtureModeEnabled = false @@ -2259,6 +2459,7 @@ extension NodeAppModel { self.gatewayPairingRequestId = nil self.lastGatewayProblem = nil self.operatorGatewayProblem = nil + self.credentialHandoffFailureGeneration = nil self.nodeGatewayTask?.cancel() self.operatorGatewayTask?.cancel() self.gatewayHealthMonitor.stop() @@ -2267,6 +2468,7 @@ extension NodeAppModel { self.connectedGatewayID = stableID self.gatewayConnected = false self.setOperatorConnected(false) + self.talkMode.updateGatewayConnected(false) self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil LiveActivityManager.shared.endActivity(reason: "new_gateway_connect") @@ -2310,7 +2512,7 @@ extension NodeAppModel { self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.gatewayConnected = false - self.showLocalCanvasOnDisconnect() + showLocalCanvasOnDisconnect() if problem.pauseReconnect { self.gatewayAutoReconnectEnabled = false } @@ -2372,18 +2574,24 @@ extension NodeAppModel { token: String?, bootstrapToken: String?, password: String?, - stableID _: String) -> Bool + deviceAuthGatewayID: String, + allowStoredDeviceAuth: Bool = true) -> Bool { Self.shouldStartOperatorGatewayLoop( token: token, bootstrapToken: bootstrapToken, password: password, - hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator")) + hasStoredOperatorToken: allowStoredDeviceAuth && self.hasStoredGatewayRoleToken( + "operator", + gatewayID: deviceAuthGatewayID)) } - private func hasStoredGatewayRoleToken(_ role: String) -> Bool { + private func hasStoredGatewayRoleToken(_ role: String, gatewayID: String) -> Bool { let identity = DeviceIdentityStore.loadOrCreate() - return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil + return DeviceAuthStore.loadToken( + deviceId: identity.deviceId, + role: role, + gatewayID: gatewayID) != nil } fileprivate nonisolated static func shouldStartOperatorGatewayLoop( @@ -2407,82 +2615,157 @@ extension NodeAppModel { return hasStoredOperatorToken } - fileprivate nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) - -> GatewayConnectConfig? { - guard let config else { return nil } - let trimmedBootstrapToken = config.bootstrapToken? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmedBootstrapToken.isEmpty else { return config } - return GatewayConnectConfig( + private func currentGatewayReconnectAuth( + fallbackToken: String?, + fallbackBootstrapToken: String?, + fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?) + { + if let cfg = activeGatewayConnectConfig { + return (cfg.token, cfg.bootstrapToken, cfg.password) + } + return (fallbackToken, fallbackBootstrapToken, fallbackPassword) + } + + private func currentGatewayReconnectOptions( + stableID: String, + fallback: GatewayConnectOptions) -> GatewayConnectOptions + { + guard let config = activeGatewayConnectConfig, + config.effectiveStableID == stableID + else { return fallback } + return config.nodeOptions + } + + private nonisolated static func usesBootstrapCredential( + token: String?, + bootstrapToken: String?, + password: String?) -> Bool + { + token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false && + password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false && + bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + private func completeSuccessfulGatewayAuthHandoff( + stableID: String, + routeGeneration: UInt64, + issuedRoles: Set, + nodeOptions: GatewayConnectOptions) -> GatewayConnectOptions? + { + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return nil } + + // Bootstrap authentication is single-use. Do not keep a consumed bootstrap + // route alive unless both replacement sessions can authenticate from secure storage. + guard issuedRoles.isSuperset(of: ["node", "operator"]) else { + return nodeOptions.allowStoredDeviceAuth ? nodeOptions : nil + } + guard let config = activeGatewayConnectConfig, + config.effectiveStableID == stableID + else { return nil } + let instanceID = GatewaySettingsStore.currentInstanceID() + let deviceAuthGatewayID = nodeOptions.deviceAuthGatewayID ?? stableID + if let metadata = GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID), + metadata.gatewayStableID == deviceAuthGatewayID, + metadata.suppressStoredDeviceAuth, + !GatewaySettingsStore.completeGatewayCredentialHandoff( + instanceId: instanceID, + gatewayStableID: deviceAuthGatewayID) + { + return nil + } + var reconnectOptions = nodeOptions + reconnectOptions.allowStoredDeviceAuth = true + self.activeGatewayConnectConfig = GatewayConnectConfig( url: config.url, stableID: config.stableID, tls: config.tls, token: config.token, bootstrapToken: nil, password: config.password, - nodeOptions: config.nodeOptions) - } + nodeOptions: reconnectOptions) - private func currentGatewayReconnectAuth( - fallbackToken: String?, - fallbackBootstrapToken: String?, - fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?) - { - if let cfg = self.activeGatewayConnectConfig { - return (cfg.token, cfg.bootstrapToken, cfg.password) - } - return (fallbackToken, fallbackBootstrapToken, fallbackPassword) - } - - private func clearPersistedGatewayBootstrapTokenIfNeeded() { - // Always drop the in-memory bootstrap token after the first successful - // bootstrap connect so reconnect loops cannot reuse a spent token. - self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig) - - let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmedInstanceId.isEmpty else { return } - guard - GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: trimmedInstanceId) != nil - else { return } - - GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId) - } - - private func handleSuccessfulBootstrapGatewayOnboarding( - url: URL, - stableID: String, - token: String?, - password: String?, - nodeOptions: GatewayConnectOptions, - sessionBox: WebSocketSessionBox?) async - { - self.clearPersistedGatewayBootstrapTokenIfNeeded() - self.operatorGatewayTask?.cancel() - self.operatorGatewayTask = nil - await self.operatorGateway.disconnect() - - if self.shouldStartOperatorGatewayLoop( - token: token, - bootstrapToken: nil, - password: password, - stableID: stableID) + if self.operatorGatewayTask == nil, + self.shouldStartOperatorGatewayLoop( + token: config.token, + bootstrapToken: nil, + password: config.password, + deviceAuthGatewayID: deviceAuthGatewayID, + allowStoredDeviceAuth: true) { + let sessionBox = config.tls.map { + WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) + } self.startOperatorGatewayLoop( - url: url, + url: config.url, stableID: stableID, - token: token, + token: config.token, bootstrapToken: nil, - password: password, - nodeOptions: nodeOptions, + password: config.password, + nodeOptions: reconnectOptions, sessionBox: sessionBox) } + return reconnectOptions + } + + private func gatewayOptionsAfterSuccessfulConnection( + _ nodeOptions: GatewayConnectOptions, + stableID: String, + routeGeneration: UInt64, + auth: (token: String?, bootstrapToken: String?, password: String?)) async -> GatewayConnectOptions? + { + guard !nodeOptions.allowStoredDeviceAuth else { return nodeOptions } + guard Self.usesBootstrapCredential( + token: auth.token, + bootstrapToken: auth.bootstrapToken, + password: auth.password) + else { + return nodeOptions + } + let issuedRoles = await nodeGateway.currentIssuedDeviceAuthRoles() + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return nil } + guard let reconnectOptions = completeSuccessfulGatewayAuthHandoff( + stableID: stableID, + routeGeneration: routeGeneration, + issuedRoles: issuedRoles, + nodeOptions: nodeOptions) + else { + await self.handleGatewayCredentialHandoffPersistenceFailure( + stableID: stableID, + routeGeneration: routeGeneration) + return nil + } + return reconnectOptions + } + + private func handleGatewayCredentialHandoffPersistenceFailure( + stableID: String, + routeGeneration: UInt64) async + { + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } + guard self.credentialHandoffFailureGeneration != routeGeneration else { return } + self.credentialHandoffFailureGeneration = routeGeneration + self.disableGatewayAutoReconnect() + self.nodeGatewayTask?.cancel() + self.nodeGatewayTask = nil + self.operatorGatewayTask?.cancel() + self.operatorGatewayTask = nil + await self.nodeGateway.disconnect() + await self.operatorGateway.disconnect() + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } + self.applyGatewayConnectionProblem(GatewayConnectionProblem( + kind: .unknown, + owner: .iphone, + title: "Credential save failed", + message: "OpenClaw disconnected because it could not securely save the new gateway credential.", + retryable: true, + pauseReconnect: true, + technicalDetails: "Gateway credential handoff persistence failed.")) } private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) { guard self.isBackgrounded else { return } guard !self.backgroundReconnectSuppressed else { return } - guard let leaseUntil = self.backgroundReconnectLeaseUntil else { + guard let leaseUntil = backgroundReconnectLeaseUntil else { self.suppressBackgroundReconnect(reason: "\(source):no_lease", disconnectIfNeeded: true) return } @@ -2496,6 +2779,138 @@ extension NodeAppModel { return self.isBackgrounded && self.backgroundReconnectSuppressed } + private func gatewayReconnectLoopDelay(source: String) -> UInt64? { + if !self.gatewayAutoReconnectEnabled || self.gatewayPairingPaused { + return 1_000_000_000 + } + return self.shouldPauseReconnectLoopInBackground(source: source) + ? 2_000_000_000 + : nil + } + + private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool { + generation == self.gatewayRouteGeneration && + self.activeGatewayConnectConfig?.effectiveStableID == stableID + } + + private func gatewayRouteCheck( + generation: UInt64, + stableID: String) -> @MainActor @Sendable () -> Bool + { + { [weak self] in + self?.isCurrentGatewayRoute(generation: generation, stableID: stableID) == true + } + } + + private func handleOperatorGatewayConnected( + url: URL, + stableID: String, + routeGeneration: UInt64) async + { + guard !self.isLocalGatewayFixtureEnabled, + self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) + else { return } + self.setOperatorConnected(true) + self.clearOperatorGatewayConnectionProblemIfCurrent() + self.forceOperatorTalkPermissionUpgradeRequest = false + self.talkMode.updateGatewayConnected(true) + GatewayDiagnostics.log( + "operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") + + let shouldContinue = self.gatewayRouteCheck( + generation: routeGeneration, + stableID: stableID) + await flushPendingWatchExecApprovalResolutions(shouldContinue: shouldContinue) + guard shouldContinue() else { return } + await self.talkMode.reloadConfig(shouldApply: shouldContinue) + guard shouldContinue() else { return } + await self.talkMode.prefetchRealtimeSessionIfReady( + reason: "operator_connected", + shouldApply: shouldContinue) + guard shouldContinue() else { return } + await self.refreshBrandingFromGateway(shouldApply: shouldContinue) + guard shouldContinue() else { return } + await self.refreshAgentsFromGateway(shouldApply: shouldContinue) + guard shouldContinue() else { return } + await refreshShareRouteFromGateway(shouldApply: shouldContinue) + guard shouldContinue() else { return } + await registerAPNsTokenIfNeeded(shouldContinue: shouldContinue) + guard shouldContinue() else { return } + await self.startVoiceWakeSync(shouldContinue: shouldContinue) + guard shouldContinue() else { return } + self.startGatewayHealthMonitor() + } + + private func handleNodeGatewayConnected( + url: URL, + stableID: String, + routeGeneration: UInt64, + nodeOptions: GatewayConnectOptions, + auth: (token: String?, bootstrapToken: String?, password: String?)) async + { + guard !self.isLocalGatewayFixtureEnabled, + self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) + else { return } + let usedBootstrapToken = Self.usesBootstrapCredential( + token: auth.token, + bootstrapToken: auth.bootstrapToken, + password: auth.password) + if usedBootstrapToken { + let issuedRoles = await nodeGateway.currentIssuedDeviceAuthRoles() + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } + guard self.completeSuccessfulGatewayAuthHandoff( + stableID: stableID, + routeGeneration: routeGeneration, + issuedRoles: issuedRoles, + nodeOptions: nodeOptions) != nil + else { + await self.handleGatewayCredentialHandoffPersistenceFailure( + stableID: stableID, + routeGeneration: routeGeneration) + return + } + } + + self.clearGatewayConnectionProblem() + self.gatewayStatusText = "Connected" + self.gatewayServerName = url.host ?? "gateway" + self.gatewayConnected = true + self.screen.errorText = nil + UserDefaults.standard.set(true, forKey: "gateway.autoconnect") + LiveActivityManager.shared.handleReconnect() + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } + ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig( + gatewayURLString: url.absoluteString, + gatewayStableID: nodeOptions.deviceAuthGatewayID, + token: auth.token, + password: auth.password, + sessionKey: self.mainSessionKey, + deliveryChannel: self.shareDeliveryChannel, + deliveryTo: self.shareDeliveryTo)) + GatewayDiagnostics.log( + "gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") + + if let address = await nodeGateway.currentRemoteAddress() { + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } + self.gatewayRemoteAddress = address + } + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } + await showA2UIOnConnectIfNeeded() + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } + let shouldContinue = self.gatewayRouteCheck( + generation: routeGeneration, + stableID: stableID) + await onNodeGatewayConnected(shouldContinue: shouldContinue) + guard shouldContinue() else { return } + SignificantLocationMonitor.startIfNeeded( + locationService: self.locationService, + locationMode: self.locationMode(), + gateway: self.nodeGateway, + beforeSend: { [weak self] in + await self?.handleSignificantLocationWakeIfNeeded() + }) + } + private func startOperatorGatewayLoop( url: URL, stableID: String, @@ -2505,22 +2920,20 @@ extension NodeAppModel { nodeOptions: GatewayConnectOptions, sessionBox: WebSocketSessionBox?) { + let routeGeneration = self.gatewayRouteGeneration + // Async reconnect helpers can resume after Disconnect or a target switch. Only the + // current route may install a new loop after those suspension points. + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } // Operator session reconnects independently (chat/talk/config/voicewake), but we tie its // lifecycle to the current gateway config so it doesn't keep running across Disconnect. self.operatorGatewayTask = Task { [weak self] in guard let self else { return } var attempt = 0 - while !Task.isCancelled { - if self.gatewayPairingPaused { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - if !self.gatewayAutoReconnectEnabled { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") { - try? await Task.sleep(nanoseconds: 2_000_000_000) + while !Task.isCancelled, + self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) + { + if let delay = self.gatewayReconnectLoopDelay(source: "operator_loop") { + try? await Task.sleep(nanoseconds: delay) continue } if await self.isOperatorConnected() { @@ -2532,21 +2945,31 @@ extension NodeAppModel { fallbackToken: token, fallbackBootstrapToken: bootstrapToken, fallbackPassword: password) + // Bootstrap handoff enables stored auth in the active config. Reconnects must + // consume that current ownership state instead of the loop's one-shot bootstrap options. + let reconnectOptions = self.currentGatewayReconnectOptions( + stableID: stableID, + fallback: nodeOptions) let effectiveClientId = - GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? nodeOptions.clientId + GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? reconnectOptions.clientId let talkPermissionUpgradeRequest = self.forceOperatorTalkPermissionUpgradeRequest + let deviceAuthGatewayID = reconnectOptions.deviceAuthGatewayID ?? stableID let operatorOptions = self.makeOperatorConnectOptions( clientId: effectiveClientId, - displayName: nodeOptions.clientDisplayName, + displayName: reconnectOptions.clientDisplayName, + deviceAuthGatewayID: deviceAuthGatewayID, includeAdminScope: self.shouldRequestOperatorAdminScope( + gatewayID: deviceAuthGatewayID, token: reconnectAuth.token, password: reconnectAuth.password, forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest), includeApprovalScope: self.shouldRequestOperatorApprovalScope( + gatewayID: deviceAuthGatewayID, token: reconnectAuth.token, password: reconnectAuth.password, forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest), - forceExplicitScopes: talkPermissionUpgradeRequest) + forceExplicitScopes: talkPermissionUpgradeRequest, + allowStoredDeviceAuth: reconnectOptions.allowStoredDeviceAuth) do { try await self.operatorGateway.connect( @@ -2557,37 +2980,31 @@ extension NodeAppModel { connectOptions: operatorOptions, sessionBox: sessionBox, onConnected: { [weak self] in - guard let self else { return } - let shouldUseConnection = await MainActor.run { - guard !self.isLocalGatewayFixtureEnabled else { return false } - self.setOperatorConnected(true) - self.clearOperatorGatewayConnectionProblemIfCurrent() - self.forceOperatorTalkPermissionUpgradeRequest = false - self.talkMode.updateGatewayConnected(true) - return true - } - guard shouldUseConnection else { return } - GatewayDiagnostics.log( - "operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") - await self.talkMode.reloadConfig() - await self.talkMode.prefetchRealtimeSessionIfReady(reason: "operator_connected") - await self.refreshBrandingFromGateway() - await self.refreshAgentsFromGateway() - await self.refreshShareRouteFromGateway() - await self.registerAPNsTokenIfNeeded() - await self.startVoiceWakeSync() - await MainActor.run { self.startGatewayHealthMonitor() } + await self?.handleOperatorGatewayConnected( + url: url, + stableID: stableID, + routeGeneration: routeGeneration) }, onDisconnected: { [weak self] reason in guard let self else { return } await MainActor.run { - guard !self.isLocalGatewayFixtureEnabled else { return } + guard !self.isLocalGatewayFixtureEnabled, + self.isCurrentGatewayRoute( + generation: routeGeneration, + stableID: stableID) + else { return } self.setOperatorConnected(false) self.talkMode.updateGatewayConnected(false) LiveActivityManager.shared.endActivity(reason: "operator_disconnected") } GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)") - await MainActor.run { self.stopGatewayHealthMonitor() } + await MainActor.run { + guard self.isCurrentGatewayRoute( + generation: routeGeneration, + stableID: stableID) + else { return } + self.stopGatewayHealthMonitor() + } }, onInvoke: { req in // Operator session should not handle node.invoke requests. @@ -2602,11 +3019,18 @@ extension NodeAppModel { attempt = 0 try? await Task.sleep(nanoseconds: 1_000_000_000) } catch { + guard self.isCurrentGatewayRoute( + generation: routeGeneration, + stableID: stableID) else { break } attempt += 1 GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)") let problem: GatewayConnectionProblem? = await MainActor.run { let nextProblem = GatewayConnectionProblemMapper.map(error: error) - guard !self.isLocalGatewayFixtureEnabled else { return nil } + guard !self.isLocalGatewayFixtureEnabled, + self.isCurrentGatewayRoute( + generation: routeGeneration, + stableID: stableID) + else { return nil } if let nextProblem { if nextProblem.needsPairingApproval || nextProblem.pauseReconnect { self.applyOperatorGatewayConnectionProblem(nextProblem) @@ -2647,6 +3071,8 @@ extension NodeAppModel { nodeOptions: GatewayConnectOptions, sessionBox: WebSocketSessionBox?) { + let routeGeneration = self.gatewayRouteGeneration + guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return } self.nodeGatewayTask = Task { [weak self] in guard let self else { return } var attempt = 0 @@ -2654,17 +3080,11 @@ extension NodeAppModel { var didFallbackClientId = false var pausedForPairingApproval = false - while !Task.isCancelled { - if self.gatewayPairingPaused { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - if !self.gatewayAutoReconnectEnabled { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - if self.shouldPauseReconnectLoopInBackground(source: "node_loop") { - try? await Task.sleep(nanoseconds: 2_000_000_000) + while !Task.isCancelled, + self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) + { + if let delay = self.gatewayReconnectLoopDelay(source: "node_loop") { + try? await Task.sleep(nanoseconds: delay) continue } if await self.isGatewayConnected() { @@ -2672,7 +3092,11 @@ extension NodeAppModel { continue } await MainActor.run { - guard !self.isLocalGatewayFixtureEnabled else { return } + guard !self.isLocalGatewayFixtureEnabled, + self.isCurrentGatewayRoute( + generation: routeGeneration, + stableID: stableID) + else { return } self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…" self.gatewayServerName = nil self.gatewayRemoteAddress = nil @@ -2698,68 +3122,21 @@ extension NodeAppModel { connectOptions: connectedOptions, sessionBox: sessionBox, onConnected: { [weak self] in - guard let self else { return } - let shouldUseConnection = await MainActor.run { - guard !self.isLocalGatewayFixtureEnabled else { return false } - self.clearGatewayConnectionProblem() - self.gatewayStatusText = "Connected" - self.gatewayServerName = url.host ?? "gateway" - self.gatewayConnected = true - self.screen.errorText = nil - UserDefaults.standard.set(true, forKey: "gateway.autoconnect") - LiveActivityManager.shared.handleReconnect() - return true - } - guard shouldUseConnection else { return } - let usedBootstrapToken = - reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false && - reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty == false - if usedBootstrapToken { - await self.handleSuccessfulBootstrapGatewayOnboarding( - url: url, - stableID: stableID, - token: reconnectAuth.token, - password: reconnectAuth.password, - nodeOptions: connectedOptions, - sessionBox: sessionBox) - } - let relayData = await MainActor.run { - ( - sessionKey: self.mainSessionKey, - deliveryChannel: self.shareDeliveryChannel, - deliveryTo: self.shareDeliveryTo) - } - ShareGatewayRelaySettings.saveConfig( - ShareGatewayRelayConfig( - gatewayURLString: url.absoluteString, - token: reconnectAuth.token, - password: reconnectAuth.password, - sessionKey: relayData.sessionKey, - deliveryChannel: relayData.deliveryChannel, - deliveryTo: relayData.deliveryTo)) - GatewayDiagnostics.log( - "gateway connected host=\(url.host ?? "?") " - + "scheme=\(url.scheme ?? "?")") - if let addr = await self.nodeGateway.currentRemoteAddress() { - await MainActor.run { self.gatewayRemoteAddress = addr } - } - await self.showA2UIOnConnectIfNeeded() - await self.onNodeGatewayConnected() - await MainActor.run { - SignificantLocationMonitor.startIfNeeded( - locationService: self.locationService, - locationMode: self.locationMode(), - gateway: self.nodeGateway, - beforeSend: { [weak self] in - await self?.handleSignificantLocationWakeIfNeeded() - }) - } + await self?.handleNodeGatewayConnected( + url: url, + stableID: stableID, + routeGeneration: routeGeneration, + nodeOptions: connectedOptions, + auth: reconnectAuth) }, onDisconnected: { [weak self] reason in guard let self else { return } await MainActor.run { - guard !self.isLocalGatewayFixtureEnabled else { return } + guard !self.isLocalGatewayFixtureEnabled, + self.isCurrentGatewayRoute( + generation: routeGeneration, + stableID: stableID) + else { return } if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason), let lastGatewayProblem = self.lastGatewayProblem { @@ -2783,13 +3160,25 @@ extension NodeAppModel { code: .unavailable, message: "UNAVAILABLE: node not ready")) } - return await self.handleInvoke(req) + return await self.handleInvoke(req, gatewayStableID: stableID) }) + guard let reconnectOptions = await self.gatewayOptionsAfterSuccessfulConnection( + currentOptions, + stableID: stableID, + routeGeneration: routeGeneration, + auth: reconnectAuth) + else { break } + currentOptions = reconnectOptions + attempt = 0 try? await Task.sleep(nanoseconds: 1_000_000_000) } catch { - if Task.isCancelled { break } + if Task.isCancelled || + !self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) + { + break + } if !didFallbackClientId, let fallbackClientId = self.legacyClientIdFallback( currentClientId: currentOptions.clientId, @@ -2809,7 +3198,11 @@ extension NodeAppModel { let nextProblem = GatewayConnectionProblemMapper.map( error: error, preserving: self.lastGatewayProblem) - guard !self.isLocalGatewayFixtureEnabled else { return nil } + guard !self.isLocalGatewayFixtureEnabled, + self.isCurrentGatewayRoute( + generation: routeGeneration, + stableID: stableID) + else { return nil } if let nextProblem { self.applyGatewayConnectionProblem(nextProblem) } else { @@ -2848,9 +3241,14 @@ extension NodeAppModel { // Leave the status text + request id intact so onboarding can guide the user. return } + if self.credentialHandoffFailureGeneration == routeGeneration { + return + } await MainActor.run { - guard !self.isLocalGatewayFixtureEnabled else { return } + guard !self.isLocalGatewayFixtureEnabled, + self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) + else { return } self.lastGatewayProblem = nil self.gatewayStatusText = "Offline" LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped") @@ -2868,13 +3266,14 @@ extension NodeAppModel { } private func shouldRequestOperatorApprovalScope( + gatewayID: String, token: String?, password: String?, forceTalkPermissionUpgradeRequest: Bool = false) -> Bool { let identity = DeviceIdentityStore.loadOrCreate() let storedOperatorScopes = DeviceAuthStore - .loadToken(deviceId: identity.deviceId, role: "operator")? + .loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)? .scopes ?? [] return Self.shouldRequestOperatorApprovalScope( token: token, @@ -2904,13 +3303,14 @@ extension NodeAppModel { } private func shouldRequestOperatorAdminScope( + gatewayID: String, token: String?, password: String?, forceTalkPermissionUpgradeRequest: Bool = false) -> Bool { let identity = DeviceIdentityStore.loadOrCreate() let storedOperatorScopes = DeviceAuthStore - .loadToken(deviceId: identity.deviceId, role: "operator")? + .loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)? .scopes ?? [] return Self.shouldRequestOperatorAdminScope( token: token, @@ -2942,9 +3342,11 @@ extension NodeAppModel { private func makeOperatorConnectOptions( clientId: String, displayName: String?, + deviceAuthGatewayID: String? = nil, includeAdminScope: Bool = false, includeApprovalScope: Bool, - forceExplicitScopes: Bool = false) -> GatewayConnectOptions + forceExplicitScopes: Bool = false, + allowStoredDeviceAuth: Bool = true) -> GatewayConnectOptions { var scopes = ["operator.read", "operator.write", "operator.talk.secrets"] if includeAdminScope { @@ -2965,7 +3367,9 @@ extension NodeAppModel { clientId: clientId, clientMode: "ui", clientDisplayName: displayName, - includeDeviceIdentity: true) + includeDeviceIdentity: true, + allowStoredDeviceAuth: allowStoredDeviceAuth, + deviceAuthGatewayID: deviceAuthGatewayID) } private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? { @@ -2994,7 +3398,13 @@ extension NodeAppModel { } return } + if changed { + // Immediate retries are bounded per connection. A real reconnect grants queued + // messages a fresh budget so one exhausted head cannot strand the durable outbox. + self.watchMessageRetryAttempts.removeAll() + } Task { [weak self] in + await self?.flushPendingExecApprovalResolvedPushes() await self?.flushQueuedWatchMessagesIfAvailable() guard changed else { return } await self?.syncWatchAppSnapshot(reason: "operator_online") @@ -3002,9 +3412,14 @@ extension NodeAppModel { } private func refreshOperatorAdminScopeFromStore() { + guard let config = activeGatewayConnectConfig else { + self.hasOperatorAdminScope = false + return + } + let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID let identity = DeviceIdentityStore.loadOrCreate() self.hasOperatorAdminScope = DeviceAuthStore - .loadToken(deviceId: identity.deviceId, role: "operator")? + .loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)? .scopes .contains("operator.admin") == true } @@ -3012,6 +3427,7 @@ extension NodeAppModel { extension NodeAppModel { func enterAppleReviewDemoMode() { + self.invalidateGatewayConnectAttempts() self.isAppleReviewDemoModeEnabled = true self.isScreenshotFixtureModeEnabled = false self.gatewayAutoReconnectEnabled = false @@ -3019,6 +3435,7 @@ extension NodeAppModel { self.gatewayPairingRequestId = nil self.lastGatewayProblem = nil self.operatorGatewayProblem = nil + self.credentialHandoffFailureGeneration = nil self.nodeGatewayTask?.cancel() self.nodeGatewayTask = nil self.operatorGatewayTask?.cancel() @@ -3056,6 +3473,7 @@ extension NodeAppModel { } func enterScreenshotFixtureMode() { + self.invalidateGatewayConnectAttempts() self.isAppleReviewDemoModeEnabled = false self.isScreenshotFixtureModeEnabled = true self.gatewayAutoReconnectEnabled = false @@ -3114,7 +3532,7 @@ extension NodeAppModel { var ids: [String] } - private func refreshShareRouteFromGateway() async { + private func refreshShareRouteFromGateway(shouldApply: () -> Bool = { true }) async { struct Params: Codable { var includeGlobal: Bool var includeUnknown: Bool @@ -3139,7 +3557,7 @@ extension NodeAppModel { let data = try JSONEncoder().encode( Params(includeGlobal: true, includeUnknown: false, limit: 80)) guard let json = String(data: data, encoding: .utf8) else { return } - let response = try await self.operatorGateway.request( + let response = try await operatorGateway.request( method: "sessions.list", paramsJSON: json, timeoutSeconds: 10) @@ -3153,6 +3571,7 @@ extension NodeAppModel { let channel = normalize(selected?.lastChannel) let to = normalize(selected?.lastTo) + guard shouldApply() else { return } await MainActor.run { self.shareDeliveryChannel = channel self.shareDeliveryTo = to @@ -3160,6 +3579,7 @@ extension NodeAppModel { ShareGatewayRelaySettings.saveConfig( ShareGatewayRelayConfig( gatewayURLString: relay.gatewayURLString, + gatewayStableID: relay.gatewayStableID, token: relay.token, password: relay.password, sessionKey: self.mainSessionKey, @@ -3187,7 +3607,7 @@ extension NodeAppModel { return } - await self.handleDeepLink(url: deepLink) + await handleDeepLink(url: deepLink) } func refreshLastShareEventFromRelay() { @@ -3202,34 +3622,88 @@ extension NodeAppModel { } /// Back-compat hook retained for older gateway-connect flows. - func onNodeGatewayConnected() async { - await self.registerAPNsTokenIfNeeded() - await self.syncWatchAppSnapshot(reason: "node_connected", includeChat: true) - await self.syncWatchExecApprovalSnapshot(reason: "node_connected") - await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected") + func onNodeGatewayConnected( + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldContinue() else { return } + await self.registerAPNsTokenIfNeeded(shouldContinue: shouldContinue) + guard shouldContinue() else { return } + await self.syncWatchAppSnapshot( + reason: "node_connected", + includeChat: true, + shouldContinue: shouldContinue) + guard shouldContinue() else { return } + await self.syncWatchExecApprovalSnapshot( + reason: "node_connected", + shouldContinue: shouldContinue) + guard shouldContinue() else { return } + await self.resumePendingForegroundNodeActionsIfNeeded( + trigger: "node_connected", + shouldContinue: shouldContinue) } - private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async { + private func resumePendingForegroundNodeActionsIfNeeded( + trigger: String, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldContinue() else { return } guard !self.isBackgrounded else { return } - guard await self.isGatewayConnected() else { return } - guard !self.pendingForegroundActionDrainInFlight else { return } + guard await isGatewayConnected() else { return } + guard !self.pendingForegroundActionDrainInFlight else { + self.pendingForegroundActionDrainRequested = true + return + } self.pendingForegroundActionDrainInFlight = true - defer { self.pendingForegroundActionDrainInFlight = false } + defer { + self.pendingForegroundActionDrainInFlight = false + if self.pendingForegroundActionDrainRequested { + self.pendingForegroundActionDrainRequested = false + // Serialize non-idempotent action execution, then retry against whichever + // exact route is current after the suspended drain has unwound. + Task { @MainActor [weak self] in + await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "coalesced") + } + } + } + + let routeGeneration = self.gatewayRouteGeneration + guard let gatewayStableID = self.connectedGatewayID, + let nodeRoute = await nodeGateway.currentRoute(), + shouldContinue(), + self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID) + else { return } do { - let payload = try await self.nodeGateway.request( + let routeContext = GatewaySessionRouteContext( + route: nodeRoute, + gatewayStableID: gatewayStableID, + routeGeneration: routeGeneration) + let payload = try await nodeGateway.request( method: "node.pending.pull", paramsJSON: "{}", - timeoutSeconds: 6) + timeoutSeconds: 6, + ifCurrentRoute: nodeRoute) let decoded = try JSONDecoder().decode( PendingForegroundNodeActionsResponse.self, from: payload) + guard await self.isCurrentGatewaySessionRoute( + routeContext, + session: self.nodeGateway, + shouldContinue: shouldContinue) + else { return } + self.retainCompletedPendingForegroundActionIDs( + presentIn: decoded.actions, + gatewayStableID: gatewayStableID) guard !decoded.actions.isEmpty else { return } self.pendingActionLogger .info("pending actions trigger=\(trigger, privacy: .public)") self.pendingActionLogger.info("pending actions count=\(decoded.actions.count, privacy: .public)") - await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger) + await self.applyPendingForegroundNodeActions( + decoded.actions, + trigger: trigger, + routeContext: routeContext, + shouldContinue: shouldContinue) } catch { // Best-effort only. } @@ -3237,9 +3711,19 @@ extension NodeAppModel { private func applyPendingForegroundNodeActions( _ actions: [PendingForegroundNodeAction], - trigger: String) async + trigger: String, + routeContext: GatewaySessionRouteContext? = nil, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async { for action in actions { + guard shouldContinue() else { return } + if let routeContext { + guard await self.isCurrentGatewaySessionRoute( + routeContext, + session: self.nodeGateway, + shouldContinue: shouldContinue) + else { return } + } guard !self.isBackgrounded else { self.pendingActionLogger.info( "Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded") @@ -3249,32 +3733,107 @@ extension NodeAppModel { id: action.id, command: action.command, paramsJSON: action.paramsJSON) - let result = await self.handleInvoke(req) - self.pendingActionLogger - .info("pending replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public)") - self.pendingActionLogger.info("pending replay ok=\(result.ok, privacy: .public)") - self.pendingActionLogger.info("pending replay command=\(action.command, privacy: .public)") - guard result.ok else { return } - let acked = await self.ackPendingForegroundNodeAction( + let gatewayStableID = routeContext?.gatewayStableID + let alreadyCompleted = gatewayStableID.map { + self.completedPendingForegroundActionIDsByGateway[$0]?.contains(action.id) == true + } ?? false + if !alreadyCompleted { + let result = await handleInvoke( + req, + gatewayStableID: gatewayStableID ?? self.connectedGatewayID) + self.pendingActionLogger + .info("pending replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public)") + self.pendingActionLogger.info("pending replay ok=\(result.ok, privacy: .public)") + self.pendingActionLogger.info("pending replay command=\(action.command, privacy: .public)") + guard result.ok else { return } + if let gatewayStableID { + // The gateway queue is connection-independent. Remember successful local + // execution until its source gateway accepts the ACK so reconnects cannot replay it. + self.completedPendingForegroundActionIDsByGateway[gatewayStableID, default: []] + .insert(action.id) + } + guard shouldContinue() else { return } + } + let acked = await ackPendingForegroundNodeAction( id: action.id, trigger: trigger, - command: action.command) + command: action.command, + routeContext: routeContext) guard acked else { return } + if let gatewayStableID { + self.removeCompletedPendingForegroundActionID( + action.id, + gatewayStableID: gatewayStableID) + } } } + private func retainCompletedPendingForegroundActionIDs( + presentIn actions: [PendingForegroundNodeAction], + gatewayStableID: String) + { + guard let completed = self.completedPendingForegroundActionIDsByGateway[gatewayStableID] else { + return + } + let retained = completed.intersection(actions.map(\.id)) + if retained.isEmpty { + self.completedPendingForegroundActionIDsByGateway.removeValue(forKey: gatewayStableID) + } else { + self.completedPendingForegroundActionIDsByGateway[gatewayStableID] = retained + } + } + + private func removeCompletedPendingForegroundActionID( + _ id: String, + gatewayStableID: String) + { + self.completedPendingForegroundActionIDsByGateway[gatewayStableID]?.remove(id) + if self.completedPendingForegroundActionIDsByGateway[gatewayStableID]?.isEmpty == true { + self.completedPendingForegroundActionIDsByGateway.removeValue(forKey: gatewayStableID) + } + } + + private func isCurrentGatewaySessionRoute( + _ context: GatewaySessionRouteContext, + session: GatewayNodeSession, + shouldContinue: @MainActor @Sendable () -> Bool) async -> Bool + { + guard shouldContinue(), + self.isCurrentGatewayRoute( + generation: context.routeGeneration, + stableID: context.gatewayStableID) + else { return false } + guard await session.currentRoute() == context.route else { return false } + return shouldContinue() && + self.isCurrentGatewayRoute( + generation: context.routeGeneration, + stableID: context.gatewayStableID) + } + private func ackPendingForegroundNodeAction( id: String, trigger: String, - command: String) async -> Bool + command: String, + routeContext: GatewaySessionRouteContext?) async -> Bool { do { + let expectedRoute: GatewayNodeSessionRoute? + if let routeContext { + guard self.activeGatewayConnectConfig?.effectiveStableID == routeContext.gatewayStableID, + let currentRoute = await self.nodeGateway.currentRoute(), + self.activeGatewayConnectConfig?.effectiveStableID == routeContext.gatewayStableID + else { return false } + expectedRoute = currentRoute + } else { + expectedRoute = nil + } let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id])) let paramsJSON = String(bytes: payload, encoding: .utf8) ?? "{}" _ = try await self.nodeGateway.request( method: "node.pending.ack", paramsJSON: paramsJSON, - timeoutSeconds: 6) + timeoutSeconds: 6, + ifCurrentRoute: expectedRoute) return true } catch { self.pendingActionLogger @@ -3295,20 +3854,12 @@ extension NodeAppModel { let payloadGatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let currentGatewayID = self.currentWatchChatGatewayStableID() let routedGatewayID = self.watchMessageOutbox.gatewayStableID(forPromptID: event.promptId) ?? "" - let sourceGatewayID: String - if !payloadGatewayID.isEmpty { - sourceGatewayID = payloadGatewayID + let sourceGatewayID: String = if !payloadGatewayID.isEmpty { + payloadGatewayID } else if !routedGatewayID.isEmpty { - sourceGatewayID = routedGatewayID - } else if let currentGatewayID { - // Shipped prompts predate gateway routing metadata and cannot be migrated after delivery. - // Bind that prompt once; explicit or persisted owners never use this fallback. - sourceGatewayID = currentGatewayID - self.watchMessageOutbox.recordPromptRoute( - promptID: event.promptId, - gatewayStableID: currentGatewayID) + routedGatewayID } else { - sourceGatewayID = "" + "" } if !sourceGatewayID.isEmpty, let currentGatewayID, currentGatewayID != sourceGatewayID { self.watchReplyLogger.info("watch reply dropped: stale gateway target") @@ -3333,7 +3884,7 @@ extension NodeAppModel { await self.handleWatchMessage(message) guard needsReconnect else { return } - let connected = await self.ensureOperatorApprovalConnectionForWatchReview( + let connected = await ensureOperatorApprovalConnectionForWatchReview( timeoutMs: 12000, reason: "watch_reply") guard connected, self.currentWatchChatGatewayStableID() == gatewayStableID else { @@ -3373,13 +3924,75 @@ extension NodeAppModel { } self.watchExecApprovalPromptsByID = Dictionary( uniqueKeysWithValues: state.approvals.map { ($0.id, $0) }) - self.pendingWatchExecApprovalRecoveryIDs = (state.pendingApprovalIDs ?? []) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .sorted() + var restoredPushes = Set() + self.pendingWatchExecApprovalRecoveryPushes = (state.pendingApprovalPushes ?? []) + .filter { push in + !push.approvalId.isEmpty && + push.gatewayDeviceId?.isEmpty != true && + restoredPushes.insert(push).inserted + } + .sorted { lhs, rhs in + (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) + } + var restoredResolvedPushes = Set() + self.pendingExecApprovalResolvedPushes = (state.pendingResolvedPushes ?? []) + .filter { push in + !push.approvalId.isEmpty && + push.gatewayDeviceId?.isEmpty != true && + restoredResolvedPushes.insert(push).inserted + } + .sorted { lhs, rhs in + (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) + } + var restoredReplyIDs = Set() + self.pendingWatchExecApprovalResolutions = Array((state.pendingResolutions ?? []).filter { event in + let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) + let approvalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !replyID.isEmpty && + !approvalID.isEmpty && + !gatewayID.isEmpty && + restoredReplyIDs.insert(replyID).inserted + }.suffix(32)) self.pruneExpiredWatchExecApprovalPrompts() } + private func currentExecApprovalGatewayStableID() -> String? { + let stableID = self.activeGatewayConnectConfig?.effectiveStableID + ?? self.connectedGatewayID + ?? "" + let normalizedStableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) + return normalizedStableID.isEmpty ? nil : normalizedStableID + } + + private func isExecApprovalPromptCurrent(_ prompt: ExecApprovalPrompt) -> Bool { + self.currentExecApprovalGatewayStableID() == prompt.gatewayStableID + } + + private func invalidateExecApprovalSurfacesForGatewayChange() { + self.pendingExecApprovalPromptRequestGeneration &+= 1 + self.dismissPendingExecApprovalPrompt() + self.pendingNotificationPermissionGuidancePrompt = nil + self.watchExecApprovalPromptsByID.removeAll() + let requestedPushes = self.pendingWatchExecApprovalRecoveryPushes + self.pendingWatchExecApprovalRecoveryPushes.removeAll() + let resolvedPushes = self.pendingExecApprovalResolvedPushes + self.pendingExecApprovalResolvedPushes.removeAll() + self.persistWatchExecApprovalBridgeState() + Task { @MainActor [weak self] in + if let self { + // Keep notification pushes until terminal state so route invalidation can remove + // only alerts owned by the old gateway, never a newly delivered replacement. + for push in Set(requestedPushes + resolvedPushes) { + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + } + } + await self?.syncWatchExecApprovalSnapshot(reason: "gateway_changed") + } + } + private func persistWatchExecApprovalBridgeState() { self.pruneExpiredWatchExecApprovalPrompts() let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in @@ -3390,14 +4003,18 @@ extension NodeAppModel { } return lhs.id < rhs.id } - let pendingApprovalIDs = self.pendingWatchExecApprovalRecoveryIDs - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .sorted() + let pendingApprovalPushes = self.pendingWatchExecApprovalRecoveryPushes.sorted { lhs, rhs in + (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) + } + let pendingResolvedPushes = self.pendingExecApprovalResolvedPushes.sorted { lhs, rhs in + (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) + } guard let data = try? JSONEncoder().encode( PersistedWatchExecApprovalBridgeState( approvals: approvals, - pendingApprovalIDs: pendingApprovalIDs)) + pendingApprovalPushes: pendingApprovalPushes, + pendingResolvedPushes: pendingResolvedPushes, + pendingResolutions: pendingWatchExecApprovalResolutions)) else { return } @@ -3425,33 +4042,52 @@ extension NodeAppModel { await self.syncWatchExecApprovalSnapshot(reason: reason) } - private func appendPendingWatchExecApprovalRecoveryID(_ approvalId: String) { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return } - guard !self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID) else { return } - self.pendingWatchExecApprovalRecoveryIDs.append(normalizedApprovalID) - self.pendingWatchExecApprovalRecoveryIDs.sort() + private func appendPendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) { + guard !self.pendingWatchExecApprovalRecoveryPushes.contains(push) else { return } + self.pendingWatchExecApprovalRecoveryPushes.append(push) + self.pendingWatchExecApprovalRecoveryPushes.sort { lhs, rhs in + (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) + } GatewayDiagnostics.log( "watch exec approval: queued recovery " - + "id=\(normalizedApprovalID) pendingCount=\(self.pendingWatchExecApprovalRecoveryIDs.count)") + + "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)") self.persistWatchExecApprovalBridgeState() } - private func removePendingWatchExecApprovalRecoveryID(_ approvalId: String) { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return } - let originalCount = self.pendingWatchExecApprovalRecoveryIDs.count - self.pendingWatchExecApprovalRecoveryIDs.removeAll { $0 == normalizedApprovalID } - guard self.pendingWatchExecApprovalRecoveryIDs.count != originalCount else { return } + private func removePendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) { + let originalCount = self.pendingWatchExecApprovalRecoveryPushes.count + self.pendingWatchExecApprovalRecoveryPushes.removeAll { $0 == push } + guard self.pendingWatchExecApprovalRecoveryPushes.count != originalCount else { return } GatewayDiagnostics.log( "watch exec approval: cleared recovery " - + "id=\(normalizedApprovalID) pendingCount=\(self.pendingWatchExecApprovalRecoveryIDs.count)") + + "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)") + self.persistWatchExecApprovalBridgeState() + } + + private func appendPendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) { + guard !self.pendingExecApprovalResolvedPushes.contains(push) else { return } + // A silent resolution push is not replayed by the gateway. Keep it until the + // authenticated owner route returns so its matching notification cannot linger. + self.pendingExecApprovalResolvedPushes.append(push) + if self.pendingExecApprovalResolvedPushes.count > 32 { + self.pendingExecApprovalResolvedPushes.removeFirst() + } + self.pendingExecApprovalResolvedPushes.sort { lhs, rhs in + (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) + } + self.persistWatchExecApprovalBridgeState() + } + + private func removePendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) { + let originalCount = self.pendingExecApprovalResolvedPushes.count + self.pendingExecApprovalResolvedPushes.removeAll { $0 == push } + guard self.pendingExecApprovalResolvedPushes.count != originalCount else { return } self.persistWatchExecApprovalBridgeState() } private func upsertWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt) { + guard self.isExecApprovalPromptCurrent(prompt) else { return } self.watchExecApprovalPromptsByID[prompt.id] = prompt - self.removePendingWatchExecApprovalRecoveryID(prompt.id) self.persistWatchExecApprovalBridgeState() } @@ -3459,7 +4095,6 @@ extension NodeAppModel { let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedApprovalID.isEmpty else { return } self.watchExecApprovalPromptsByID.removeValue(forKey: normalizedApprovalID) - self.removePendingWatchExecApprovalRecoveryID(normalizedApprovalID) self.persistWatchExecApprovalBridgeState() } @@ -3471,6 +4106,7 @@ extension NodeAppModel { let preview = Self.trimmedOrNil(prompt.commandPreview) ?? Self.trimmedOrNil(prompt.commandText) return OpenClawWatchExecApprovalItem( id: prompt.id, + gatewayStableID: prompt.gatewayStableID, commandText: prompt.commandText, commandPreview: preview, host: Self.trimmedOrNil(prompt.host), @@ -3490,6 +4126,8 @@ extension NodeAppModel { } private func publishWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt, reason: String) async { + guard self.isExecApprovalPromptCurrent(prompt) else { return } + let deliveryGeneration = self.gatewayConnectGeneration let message = OpenClawWatchExecApprovalPromptMessage( approval: Self.makeWatchExecApprovalItem(from: prompt), sentAtMs: Int(Date().timeIntervalSince1970 * 1000), @@ -3506,20 +4144,31 @@ extension NodeAppModel { self.watchExecApprovalLogger.error( "watch approval prompt error=\(error.localizedDescription, privacy: .public)") } + if deliveryGeneration != self.gatewayConnectGeneration { + // WatchConnectivity may finish by durably queueing the old payload after a route + // switch. Publish the replacement owner snapshots after that send completes. + await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair") + await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_route_repair") + return + } await self.syncWatchAppSnapshot(reason: "\(reason)_app") await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot") } private func publishWatchExecApprovalResolved( approvalId: String, + gatewayStableID: String, decision: OpenClawWatchExecApprovalDecision?, source: String) async { let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedApprovalID.isEmpty else { return } - self.removeWatchExecApprovalPrompt(normalizedApprovalID) + if self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == gatewayStableID { + self.removeWatchExecApprovalPrompt(normalizedApprovalID) + } let message = OpenClawWatchExecApprovalResolvedMessage( approvalId: normalizedApprovalID, + gatewayStableID: gatewayStableID, decision: decision, resolvedAtMs: Int(Date().timeIntervalSince1970 * 1000), source: source) @@ -3538,13 +4187,17 @@ extension NodeAppModel { private func publishWatchExecApprovalExpired( approvalId: String, + gatewayStableID: String, reason: OpenClawWatchExecApprovalCloseReason) async { let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedApprovalID.isEmpty else { return } - self.removeWatchExecApprovalPrompt(normalizedApprovalID) + if self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == gatewayStableID { + self.removeWatchExecApprovalPrompt(normalizedApprovalID) + } let message = OpenClawWatchExecApprovalExpiredMessage( approvalId: normalizedApprovalID, + gatewayStableID: gatewayStableID, reason: reason, expiredAtMs: Int(Date().timeIntervalSince1970 * 1000)) do { @@ -3560,13 +4213,19 @@ extension NodeAppModel { await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)") } - private func syncWatchExecApprovalSnapshot(reason: String) async { + private func syncWatchExecApprovalSnapshot( + reason: String, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldContinue() else { return } + let deliveryGeneration = self.gatewayConnectGeneration self.pruneExpiredWatchExecApprovalPrompts() GatewayDiagnostics.log( "watch exec approval: sync snapshot start " + "reason=\(reason) cacheCount=\(self.watchExecApprovalPromptsByID.count) " + "backgrounded=\(self.isBackgrounded)") let approvals = self.watchExecApprovalPromptsByID.values + .filter(self.isExecApprovalPromptCurrent) .sorted { lhs, rhs in let lhsExpires = lhs.expiresAtMs ?? Int.max let rhsExpires = rhs.expiresAtMs ?? Int.max @@ -3578,9 +4237,11 @@ extension NodeAppModel { .map(Self.makeWatchExecApprovalItem) let message = OpenClawWatchExecApprovalSnapshotMessage( approvals: approvals, + gatewayStableID: currentExecApprovalGatewayStableID(), sentAtMs: Int(Date().timeIntervalSince1970 * 1000), snapshotId: UUID().uuidString) do { + guard shouldContinue() else { return } _ = try await self.watchMessagingService.syncExecApprovalSnapshot(message) GatewayDiagnostics.log( "watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)") @@ -3588,6 +4249,9 @@ extension NodeAppModel { .debug("watch approval snapshot reason=\(reason, privacy: .public)") self.watchExecApprovalLogger.debug( "watch approval snapshot count=\(approvals.count, privacy: .public)") + if deliveryGeneration != self.gatewayConnectGeneration { + await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_route_repair") + } } catch { GatewayDiagnostics.log( "watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)") @@ -3672,7 +4336,7 @@ extension NodeAppModel { { guard let data = try? JSONEncoder().encode(raw), let message = try? JSONDecoder().decode(OpenClawChatMessage.self, from: data), - let text = self.nonEmptyWatchChatText(self.watchChatText(from: message)) + let text = nonEmptyWatchChatText(watchChatText(from: message)) else { return nil } @@ -3793,6 +4457,13 @@ extension NodeAppModel { private func handleWatchAppCommand(_ event: WatchAppCommandEvent) async { GatewayDiagnostics.log( "watch app command: handle id=\(event.commandId) command=\(event.command.rawValue)") + if event.command != .sendChat, + !self.watchAppCommandTargetsCurrentGatewayIfTagged(event) + { + GatewayDiagnostics.log("watch app command skipped: stale gateway target") + await self.syncWatchAppSnapshot(reason: "watch_command_stale_gateway", includeChat: true) + return + } switch event.command { case .refresh: break @@ -3814,6 +4485,12 @@ extension NodeAppModel { } private func handleWatchChatCommand(_ event: WatchAppCommandEvent) async { + if self.currentWatchChatGatewayStableID() == nil { + // Startup may deliver a route-tagged Watch action before restoring that route. + // Queue it without publishing an ownerless snapshot that would erase Watch routing. + await self.handleWatchMessage(event) + return + } guard self.watchMessageTargetsCurrentGateway(event) else { GatewayDiagnostics.log("watch chat send skipped: stale gateway target") await self.syncWatchAppSnapshot(reason: "watch_chat_stale_gateway", includeChat: true) @@ -3842,7 +4519,9 @@ extension NodeAppModel { GatewayDiagnostics.log("watch message send deduped id=\(messageID)") case let .queue(messageID): GatewayDiagnostics.log("watch message send queued id=\(messageID)") - if self.watchMessageKind(event) == .chat { + if self.watchMessageKind(event) == .chat, + self.currentWatchChatGatewayStableID() != nil + { await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true) } case .forward: @@ -3862,10 +4541,10 @@ extension NodeAppModel { guard !self.watchMessageFlushInFlight else { return } self.watchMessageFlushInFlight = true defer { self.watchMessageFlushInFlight = false } - guard let gatewayStableID = self.currentWatchChatGatewayStableID() else { return } + guard let gatewayStableID = currentWatchChatGatewayStableID() else { return } while self.currentWatchChatGatewayStableID() == gatewayStableID { - guard let event = self.watchMessageOutbox.nextQueuedMessage( - isAvailable: self.isWatchMessageSendAvailable(), + guard let event = watchMessageOutbox.nextQueuedMessage( + isAvailable: isWatchMessageSendAvailable(), gatewayStableID: gatewayStableID) else { return } guard self.watchMessageTargetsCurrentGateway(event) else { return } @@ -3884,7 +4563,7 @@ extension NodeAppModel { private func scheduleWatchMessageRetry(messageID: String) { guard self.isWatchMessageSendAvailable(), self.watchMessageRetryTask == nil else { return } - let attempt = (self.watchMessageRetryAttempts[messageID] ?? 0) + 1 + let attempt = (watchMessageRetryAttempts[messageID] ?? 0) + 1 guard attempt <= Self.watchMessageMaxImmediateRetryAttempts else { GatewayDiagnostics.log("watch message retry deferred until reconnect id=\(messageID)") return @@ -3919,6 +4598,14 @@ extension NodeAppModel { return eventGatewayID == currentGatewayID } + private func watchAppCommandTargetsCurrentGatewayIfTagged(_ event: WatchAppCommandEvent) -> Bool { + guard let eventGatewayID = normalizedWatchMessageGatewayStableID(event) else { + // Ownerless commands predate route tagging and remain valid for compatibility. + return true + } + return eventGatewayID == self.currentWatchChatGatewayStableID() + } + private func watchMessageKind(_ event: WatchAppCommandEvent) -> WatchMessageKind { event.messageKind ?? .chat } @@ -3950,7 +4637,7 @@ extension NodeAppModel { do { let submittedAtMs = Int(Date().timeIntervalSince1970 * 1000) if self.isAppleReviewDemoModeEnabled { - let response = try await self.appleReviewDemoChatTransport.sendMessage( + let response = try await appleReviewDemoChatTransport.sendMessage( sessionKey: sessionKey, message: text, thinking: thinking, @@ -3960,7 +4647,7 @@ extension NodeAppModel { await self.finishForwardedWatchMessage(event) return .sent } - let history = try await self.appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey) + let history = try await appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey) if let replyText = Self.watchChatReplyText( from: history.messages ?? [], runId: response.runId, @@ -3982,8 +4669,16 @@ extension NodeAppModel { } return .retry } + guard self.watchMessageTargetsCurrentGateway(event), + let operatorRoute = await operatorSession.currentRoute(), + isOperatorGatewayConnected, + watchMessageTargetsCurrentGateway(event) + else { + GatewayDiagnostics.log("watch chat send skipped: gateway route changed before dispatch") + return .retry + } - let transport = IOSGatewayChatTransport(gateway: self.operatorSession) + let transport = IOSGatewayChatTransport(gateway: operatorSession) let completionDeadline = Date().addingTimeInterval( Double(Self.watchChatCompletionWaitMs) / 1000) let response = try await transport.sendMessage( @@ -3991,7 +4686,8 @@ extension NodeAppModel { message: text, thinking: thinking, idempotencyKey: event.commandId, - attachments: []) + attachments: [], + ifCurrentRoute: operatorRoute) if messageKind == .quickReply { await self.finishForwardedWatchMessage(event) return .sent @@ -3999,19 +4695,42 @@ extension NodeAppModel { await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true) _ = await transport.waitForRunCompletion( runId: response.runId, - timeoutMs: Self.watchChatRunWaitSliceMs) - if let replyText = await self.waitForWatchChatReply( + timeoutMs: Self.watchChatRunWaitSliceMs, + ifCurrentRoute: operatorRoute) + if let replyText = await waitForWatchChatReply( transport: transport, sessionKey: sessionKey, runId: response.runId, submittedText: text, submittedAtMs: submittedAtMs, - deadline: completionDeadline) + deadline: completionDeadline, + expectedRoute: operatorRoute) { + guard self.watchMessageTargetsCurrentGateway(event), + await self.operatorSession.currentRoute() == operatorRoute + else { + GatewayDiagnostics.log("watch chat completion skipped: gateway route changed") + return .discard + } await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText) } - await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true) + await self.syncWatchAppSnapshot( + reason: "watch_chat_completed", + includeChat: true, + shouldContinue: { self.watchMessageTargetsCurrentGateway(event) }) return .sent + } catch is CancellationError { + if !self.watchMessageTargetsCurrentGateway(event) { + GatewayDiagnostics.log("watch chat send canceled: gateway target changed") + return .discard + } + GatewayDiagnostics.log("watch chat send canceled before dispatch") + if requeueOnFailure { + self.watchMessageOutbox.requeueFront( + event, + gatewayStableID: self.normalizedWatchMessageGatewayStableID(event)) + } + return .retry } catch { GatewayDiagnostics.log("watch chat send failed error=\(error.localizedDescription)") if Self.shouldDiscardFailedWatchMessage(error) { @@ -4033,15 +4752,19 @@ extension NodeAppModel { runId: String, submittedText: String, submittedAtMs: Int, - deadline: Date) async -> String? + deadline: Date, + expectedRoute: GatewayNodeSessionRoute) async -> String? { repeat { - if let payload = try? await transport.requestHistory(sessionKey: sessionKey), - let replyText = Self.watchChatReplyText( - from: payload.messages ?? [], - runId: runId, - submittedText: submittedText, - submittedAtMs: submittedAtMs) + guard await self.operatorSession.currentRoute() == expectedRoute else { return nil } + if let payload = try? await transport.requestHistory( + sessionKey: sessionKey, + ifCurrentRoute: expectedRoute), + let replyText = Self.watchChatReplyText( + from: payload.messages ?? [], + runId: runId, + submittedText: submittedText, + submittedAtMs: submittedAtMs) { return replyText } @@ -4080,15 +4803,30 @@ extension NodeAppModel { self.openChatRequestID &+= 1 } - private func syncWatchAppSnapshot(reason: String, includeChat: Bool = false) async { - let chatPreview = includeChat ? await self.makeWatchChatPreview() : nil + private func syncWatchAppSnapshot( + reason: String, + includeChat: Bool = false, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldContinue() else { return } + let deliveryGeneration = self.gatewayConnectGeneration + let chatPreview = includeChat ? await makeWatchChatPreview() : nil + guard shouldContinue() else { return } + guard deliveryGeneration == self.gatewayConnectGeneration else { + await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair") + return + } let message = self.makeWatchAppSnapshot(chatPreview: chatPreview) do { + guard shouldContinue() else { return } _ = try await self.watchMessagingService.syncAppSnapshot(message) GatewayDiagnostics.log( "watch app snapshot: sent reason=\(reason) " + "connected=\(message.gatewayConnected) approvals=\(message.pendingApprovalCount) " + "chatItems=\(message.chatItems?.count ?? -1)") + if deliveryGeneration != self.gatewayConnectGeneration { + await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair") + } } catch { GatewayDiagnostics.log( "watch app snapshot: failed reason=\(reason) error=\(error.localizedDescription)") @@ -4126,14 +4864,14 @@ extension NodeAppModel { private func hydrateWatchExecApprovalCacheIfNeeded(reason: String) async { self.pruneExpiredWatchExecApprovalPrompts() - let approvalIDs = await self.pendingExecApprovalIDsForWatchRecovery() - let missingApprovalIDs = Self.watchExecApprovalIDsNeedingFetch( - candidateIDs: approvalIDs, - cachedApprovalIDs: Array(self.watchExecApprovalPromptsByID.keys)) + let approvalPushes = await pendingExecApprovalPushesForWatchRecovery() + let missingApprovalIDs = Set(Self.watchExecApprovalIDsNeedingFetch( + candidateIDs: approvalPushes.map(\.approvalId), + cachedApprovalIDs: Array(self.watchExecApprovalPromptsByID.keys))) GatewayDiagnostics.log( "watch exec approval: hydrate candidates " - + "reason=\(reason) ids=\(approvalIDs.joined(separator: ",")) " - + "missing=\(missingApprovalIDs.joined(separator: ",")) " + + "reason=\(reason) ids=\(approvalPushes.map(\.approvalId).joined(separator: ",")) " + + "missing=\(missingApprovalIDs.sorted().joined(separator: ",")) " + "cached=\(self.watchExecApprovalPromptsByID.count)") guard !missingApprovalIDs.isEmpty else { self.watchExecApprovalLogger.debug( @@ -4141,21 +4879,36 @@ extension NodeAppModel { return } - for approvalId in missingApprovalIDs { + for push in approvalPushes where missingApprovalIDs.contains(push.approvalId) { + let approvalId = push.approvalId GatewayDiagnostics.log( "watch exec approval: hydrate fetch start id=\(approvalId) reason=\(reason)") - let outcome = await self.fetchExecApprovalPrompt( + let operatorRoute: GatewayNodeSessionRoute + switch await self.validateExecApprovalPushRoute(push, sourceReason: reason) { + case let .validated(context): + operatorRoute = context.route + case .unavailable: + continue + case .mismatchedOwner: + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(push) + continue + } + let outcome = await fetchExecApprovalPrompt( approvalId: approvalId, - sourceReason: reason) + sourceReason: reason, + expectedOperatorRoute: operatorRoute) switch outcome { case let .loaded(prompt): GatewayDiagnostics.log("watch exec approval: hydrate fetch loaded id=\(approvalId)") self.upsertWatchExecApprovalPrompt(prompt) case .stale: GatewayDiagnostics.log("watch exec approval: hydrate fetch stale id=\(approvalId)") - self.removePendingWatchExecApprovalRecoveryID(approvalId) + self.removePendingWatchExecApprovalRecoveryPush(push) await ExecApprovalNotificationBridge.removeNotifications( - forApprovalID: approvalId, + for: push, notificationCenter: self.notificationCenter) case let .failed(message): self.watchExecApprovalLogger @@ -4166,67 +4919,165 @@ extension NodeAppModel { } } - private func pendingExecApprovalIDsForWatchRecovery() async -> [String] { - var ids: [String] = [] - var seen = Set() + private func pendingExecApprovalPushesForWatchRecovery() async -> [ExecApprovalNotificationPrompt] { + var pushes = self.pendingWatchExecApprovalRecoveryPushes + var seen = Set(pushes) - func append(_ rawID: String?) { - let approvalId = rawID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !approvalId.isEmpty, seen.insert(approvalId).inserted else { return } - ids.append(approvalId) - } - - append(self.pendingExecApprovalPrompt?.id) - for approvalId in self.pendingWatchExecApprovalRecoveryIDs { - append(approvalId) - } - for approvalId in self.watchExecApprovalPromptsByID.keys.sorted() { - append(approvalId) - } - - let delivered = await self.notificationCenter.deliveredNotifications() + let delivered = await notificationCenter.deliveredNotifications() GatewayDiagnostics.log("watch exec approval: delivered notifications count=\(delivered.count)") for snapshot in delivered { - guard ExecApprovalNotificationBridge.payloadKind(userInfo: snapshot.userInfo) - == ExecApprovalNotificationBridge.requestedKind - else { - continue - } - append(ExecApprovalNotificationBridge.approvalID(from: snapshot.userInfo)) + guard let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: snapshot.userInfo), + seen.insert(push).inserted + else { continue } + pushes.append(push) + // Notification Center may be the only surviving source after relaunch. + // Persist its owner tag so later route invalidation can remove only this alert. + self.appendPendingWatchExecApprovalRecoveryPush(push) } - return ids + return pushes } - private func handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async { + @discardableResult + private func handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async -> Bool { let normalizedApprovalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return } + guard !normalizedApprovalID.isEmpty else { return true } + guard let routedEvent = ownerScopedWatchExecApprovalEvent( + event, + approvalID: normalizedApprovalID) + else { + await self.syncWatchExecApprovalSnapshot(reason: "legacy_watch_reply_rejected") + return true + } + guard let currentGatewayStableID = currentExecApprovalGatewayStableID() else { + self.enqueuePendingWatchExecApprovalResolution(routedEvent) + return false + } + guard Self.trimmedOrNil(routedEvent.gatewayStableID) == currentGatewayStableID else { + // Watch replies can arrive after a gateway switch. Reassert the current + // snapshot instead of allowing an old same-ID prompt to target the new gateway. + await self.syncWatchExecApprovalSnapshot(reason: "stale_gateway_reply") + return true + } + guard let prompt = watchExecApprovalPromptsByID[normalizedApprovalID], + prompt.gatewayStableID == currentGatewayStableID, + isExecApprovalPromptCurrent(prompt) + else { + await self.publishWatchExecApprovalExpired( + approvalId: normalizedApprovalID, + gatewayStableID: currentGatewayStableID, + reason: .unavailable) + return true + } if self.pendingExecApprovalPrompt?.id == normalizedApprovalID { self.pendingExecApprovalPromptResolving = true self.pendingExecApprovalPromptErrorText = nil } - let outcome = await self.resolveExecApprovalNotificationDecision( + let outcome = await resolveExecApprovalNotificationDecision( approvalId: normalizedApprovalID, - decision: event.decision.rawValue, + decision: routedEvent.decision.rawValue, + expectedGatewayStableID: prompt.gatewayStableID, sourceReason: "watch_resolve") if case let .failed(message) = outcome { if self.pendingExecApprovalPrompt?.id == normalizedApprovalID { self.pendingExecApprovalPromptResolving = false self.pendingExecApprovalPromptErrorText = message } - if let prompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] { + if let prompt = watchExecApprovalPromptsByID[normalizedApprovalID] { await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry") } + return false + } + return true + } + + private func ownerScopedWatchExecApprovalEvent( + _ event: WatchExecApprovalResolveEvent, + approvalID: String) -> WatchExecApprovalResolveEvent? + { + if Self.trimmedOrNil(event.gatewayStableID) != nil { + return event + } + guard let prompt = watchExecApprovalPromptsByID[approvalID] else { return nil } + // A shipped Watch binary can omit the owner field. Bind only to the prompt that + // originally supplied this approval ID; never infer ownership from a later route. + var routedEvent = event + routedEvent.gatewayStableID = prompt.gatewayStableID + return routedEvent + } + + private func enqueuePendingWatchExecApprovalResolution(_ event: WatchExecApprovalResolveEvent) { + let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !replyID.isEmpty, + !self.pendingWatchExecApprovalResolutions.contains(where: { $0.replyId == replyID }) + else { return } + // transferUserInfo is durable only until delivery. Retain the delivered action until + // startup restores a route, while bounding malformed or replayed Watch traffic. + self.pendingWatchExecApprovalResolutions.append(event) + if self.pendingWatchExecApprovalResolutions.count > 32 { + self.pendingWatchExecApprovalResolutions.removeFirst() + } + self.persistWatchExecApprovalBridgeState() + } + + private func removePendingWatchExecApprovalResolution(replyID: String) { + let originalCount = self.pendingWatchExecApprovalResolutions.count + self.pendingWatchExecApprovalResolutions.removeAll { $0.replyId == replyID } + guard self.pendingWatchExecApprovalResolutions.count != originalCount else { return } + self.persistWatchExecApprovalBridgeState() + } + + private func flushPendingWatchExecApprovalResolutions( + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldContinue(), !self.pendingWatchExecApprovalResolutions.isEmpty else { return } + await self.hydrateWatchExecApprovalCacheIfNeeded(reason: "queued_watch_resolve") + guard shouldContinue(), let currentGatewayStableID = currentExecApprovalGatewayStableID() else { return } + let pending = self.pendingWatchExecApprovalResolutions + var discardedMismatchedOwner = false + for event in pending { + guard shouldContinue() else { return } + let owner = Self.trimmedOrNil(event.gatewayStableID) + guard owner == currentGatewayStableID else { + discardedMismatchedOwner = true + self.removePendingWatchExecApprovalResolution(replyID: event.replyId) + continue + } + let completed = await handleWatchExecApprovalResolve(event) + if completed { + self.removePendingWatchExecApprovalResolution(replyID: event.replyId) + } + } + if discardedMismatchedOwner, shouldContinue() { + await self.syncWatchExecApprovalSnapshot(reason: "queued_stale_gateway_reply") } } - func handleExecApprovalRequestedRemotePush(approvalId: String) async -> Bool { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) + func handleExecApprovalRequestedRemotePush(_ push: ExecApprovalNotificationPrompt) async -> Bool { + let normalizedApprovalID = push.approvalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedApprovalID.isEmpty else { return false } - self.appendPendingWatchExecApprovalRecoveryID(normalizedApprovalID) - let fetchedPrompt = await self.fetchExecApprovalPrompt( + let operatorRoute: GatewayNodeSessionRoute + switch await self.validateExecApprovalPushRoute(push, sourceReason: "push_request") { + case let .validated(context): + operatorRoute = context.route + case .unavailable: + // APNs delivery is one-shot. Retain the owner-tagged request until its route + // returns so Watch recovery cannot lose an approval during a reconnect. + self.appendPendingWatchExecApprovalRecoveryPush(push) + return true + case .mismatchedOwner: + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(push) + return true + } + self.appendPendingWatchExecApprovalRecoveryPush(push) + guard let gatewayStableID = currentExecApprovalGatewayStableID() else { return true } + let fetchedPrompt = await fetchExecApprovalPrompt( approvalId: normalizedApprovalID, - sourceReason: "push_request") + sourceReason: "push_request", + expectedOperatorRoute: operatorRoute) switch fetchedPrompt { case let .loaded(prompt): self.upsertWatchExecApprovalPrompt(prompt) @@ -4234,11 +5085,13 @@ extension NodeAppModel { return true case .stale: await ExecApprovalNotificationBridge.removeNotifications( - forApprovalID: normalizedApprovalID, + for: push, notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(push) self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) await self.publishWatchExecApprovalExpired( approvalId: normalizedApprovalID, + gatewayStableID: gatewayStableID, reason: .notFound) return true case let .failed(message): @@ -4250,23 +5103,183 @@ extension NodeAppModel { } } - func handleExecApprovalResolvedRemotePush(approvalId: String) async { + @discardableResult + private func handleExecApprovalResolvedForCurrentGateway( + approvalId: String, + recoveryPushGatewayDeviceID: String? = nil, + routeContext: GatewaySessionRouteContext? = nil, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + -> Bool + { let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return } + guard !normalizedApprovalID.isEmpty, + await self.canApplyExecApprovalResolvedState( + routeContext: routeContext, + shouldContinue: shouldContinue) + else { return false } - let hadWatchPrompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] != nil - let hadPendingPrompt = self.pendingExecApprovalPrompt?.id == normalizedApprovalID - let hadPendingRecoveryID = self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID) + let currentGatewayStableID = self.currentExecApprovalGatewayStableID() + let hadWatchPrompt = if let currentGatewayStableID { + self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == currentGatewayStableID + } else { + false + } + let hadPendingPrompt = if let currentGatewayStableID { + self.pendingExecApprovalPrompt?.id == normalizedApprovalID && + self.pendingExecApprovalPrompt?.gatewayStableID == currentGatewayStableID + } else { + false + } + let recoveryPushes: [ExecApprovalNotificationPrompt] = if let recoveryPushGatewayDeviceID = Self + .trimmedOrNil(recoveryPushGatewayDeviceID) + { + self.pendingWatchExecApprovalRecoveryPushes.filter { push in + push.approvalId == normalizedApprovalID && + Self.trimmedOrNil(push.gatewayDeviceId) == recoveryPushGatewayDeviceID + } + } else { + [] + } + let hadPendingRecoveryID = !recoveryPushes.isEmpty let hadGuidancePrompt = self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID let hadApprovalSurface = hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID guard hadApprovalSurface || hadGuidancePrompt else { - return + return true } - if hadApprovalSurface { - await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved) + if hadApprovalSurface, let currentGatewayStableID { + await self.publishWatchExecApprovalExpired( + approvalId: normalizedApprovalID, + gatewayStableID: currentGatewayStableID, + reason: .resolved) + guard await self.canApplyExecApprovalResolvedState( + routeContext: routeContext, + shouldContinue: shouldContinue) + else { return false } } + for push in recoveryPushes { + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + guard await self.canApplyExecApprovalResolvedState( + routeContext: routeContext, + shouldContinue: shouldContinue) + else { return false } + self.removePendingWatchExecApprovalRecoveryPush(push) + } + guard await self.canApplyExecApprovalResolvedState( + routeContext: routeContext, + shouldContinue: shouldContinue) + else { return false } self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) + return true + } + + private func canApplyExecApprovalResolvedState( + routeContext: GatewaySessionRouteContext?, + shouldContinue: @MainActor @Sendable () -> Bool) async -> Bool + { + guard shouldContinue() else { return false } + guard let routeContext else { return true } + return await self.isCurrentGatewaySessionRoute( + routeContext, + session: self.operatorGateway, + shouldContinue: shouldContinue) + } + + func handleExecApprovalResolvedRemotePush(_ push: ExecApprovalNotificationPrompt) async -> Bool { + switch await self.validateExecApprovalPushRoute(push, sourceReason: "push_resolved") { + case let .validated(context): + let applied = await self.applyValidatedExecApprovalResolvedPush(push, context: context) + if !applied { + self.appendPendingExecApprovalResolvedPush(push) + } + case .unavailable: + self.appendPendingExecApprovalResolvedPush(push) + if Self.trimmedOrNil(push.gatewayDeviceId) != nil { + // The terminal push already identifies its notification owner. Remove that + // exact alert now while retaining durable state for route-bound Watch cleanup. + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + } + case .mismatchedOwner: + // The payload names another gateway. Exact owner matching makes cleanup safe, + // but it must not mutate approval state for the active gateway. + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(push) + self.removePendingExecApprovalResolvedPush(push) + } + return true + } + + @discardableResult + private func applyValidatedExecApprovalResolvedPush( + _ push: ExecApprovalNotificationPrompt, + context: GatewaySessionRouteContext) async -> Bool + { + let routeIsCurrent: @MainActor @Sendable () -> Bool = { [weak self] in + self?.isCurrentGatewayRoute( + generation: context.routeGeneration, + stableID: context.gatewayStableID) == true + } + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: routeIsCurrent) + else { return false } + guard await self.handleExecApprovalResolvedForCurrentGateway( + approvalId: push.approvalId, + recoveryPushGatewayDeviceID: push.gatewayDeviceId, + routeContext: context, + shouldContinue: routeIsCurrent) + else { return false } + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: routeIsCurrent) + else { return false } + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter, + includingLegacyOwnerless: true) + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: routeIsCurrent) + else { return false } + self.removePendingWatchExecApprovalRecoveryPush(push) + self.removePendingExecApprovalResolvedPush(push) + return true + } + + private func flushPendingExecApprovalResolvedPushes( + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldContinue(), !self.pendingExecApprovalResolvedPushes.isEmpty else { return } + for push in self.pendingExecApprovalResolvedPushes { + guard shouldContinue() else { return } + switch await self.validateExecApprovalPushRoute( + push, + sourceReason: "push_resolved", + shouldContinue: shouldContinue) + { + case let .validated(context): + guard await self.applyValidatedExecApprovalResolvedPush(push, context: context) else { + return + } + case .unavailable: + return + case .mismatchedOwner: + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(push) + self.removePendingExecApprovalResolvedPush(push) + } + } } func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool { @@ -4279,37 +5292,33 @@ extension NodeAppModel { let receivedMessage = "Silent push received wakeId=\(wakeId) " + "kind=\(pushKind) " - + "backgrounded=\(self.isBackgrounded) " - + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" + + "backgrounded=\(isBackgrounded) " + + "autoReconnect=\(gatewayAutoReconnectEnabled)" self.pushWakeLogger.info("\(receivedMessage, privacy: .public)") - if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded( - userInfo: userInfo, - notificationCenter: self.notificationCenter) - { - if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) { - await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId) - } + if let push = ExecApprovalNotificationBridge.parseResolvedPush(userInfo: userInfo) { + let handled = await handleExecApprovalResolvedRemotePush(push) + let cleanupMessage = + "Handled exec approval cleanup push wakeId=\(wakeId) " + + "handled=\(handled)" self.execApprovalNotificationLogger.info( - "Handled exec approval cleanup push wakeId=\(wakeId, privacy: .public)") - return true + "\(cleanupMessage, privacy: .public)") + return handled } - let execApprovalPushKind = ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo) - let isExecApprovalRequestPush = execApprovalPushKind == ExecApprovalNotificationBridge.requestedKind - if isExecApprovalRequestPush, - let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) - { - let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId) + if let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) { + let handled = await handleExecApprovalRequestedRemotePush(push) if handled { + let handledMessage = + "handled approval push wakeId=\(wakeId) " + + "id=\(push.approvalId)" self.execApprovalNotificationLogger - .info( - "handled approval push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)") + .info("\(handledMessage, privacy: .public)") } return handled } - let result = await self.performBackgroundAliveBeaconIfNeeded( + let result = await performBackgroundAliveBeaconIfNeeded( wakeId: wakeId, trigger: .silentPush) let outcomeMessage = @@ -4331,7 +5340,7 @@ extension NodeAppModel { + "backgrounded=\(self.isBackgrounded) " + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" self.pushWakeLogger.info("\(receivedMessage, privacy: .public)") - let result = await self.performBackgroundAliveBeaconIfNeeded( + let result = await performBackgroundAliveBeaconIfNeeded( wakeId: wakeId, trigger: normalizedTrigger) let outcomeMessage = @@ -4349,12 +5358,12 @@ extension NodeAppModel { let now = Date() let throttleWindowSeconds: TimeInterval = 180 - if await self.isGatewayConnected() { + if await isGatewayConnected() { self.locationWakeLogger.info( "Location wake no-op wakeId=\(wakeId, privacy: .public): already connected") return } - if let last = self.lastSignificantLocationWakeAt, + if let last = lastSignificantLocationWakeAt, now.timeIntervalSince(last) < throttleWindowSeconds { let throttledMessage = @@ -4367,10 +5376,10 @@ extension NodeAppModel { let beginMessage = "Location wake begin wakeId=\(wakeId) " - + "backgrounded=\(self.isBackgrounded) " - + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" + + "backgrounded=\(isBackgrounded) " + + "autoReconnect=\(gatewayAutoReconnectEnabled)" self.locationWakeLogger.info("\(beginMessage, privacy: .public)") - let result = await self.performBackgroundAliveBeaconIfNeeded( + let result = await performBackgroundAliveBeaconIfNeeded( wakeId: wakeId, trigger: .significantLocation) let triggerMessage = @@ -4382,7 +5391,7 @@ extension NodeAppModel { self.locationWakeLogger.info("\(triggerMessage, privacy: .public)") guard result.applied else { return } - let connected = await self.waitForGatewayConnection(timeoutMs: 5000, pollMs: 250) + let connected = await waitForGatewayConnection(timeoutMs: 5000, pollMs: 250) self.locationWakeLogger.info( "Location wake post-check wakeId=\(wakeId, privacy: .public) connected=\(connected, privacy: .public)") } @@ -4398,18 +5407,24 @@ extension NodeAppModel { } } - private func registerAPNsTokenIfNeeded() async { - let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport + private func registerAPNsTokenIfNeeded( + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + guard shouldContinue() else { return } + let usesRelayTransport = await pushRegistrationManager.usesRelayTransport + guard shouldContinue() else { return } guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else { return } + guard shouldContinue() else { return } guard self.gatewayConnected else { if usesRelayTransport { GatewayDiagnostics.pushRelay.skipped("gateway_offline") } return } - guard let token = self.apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines), + guard let nodeRoute = await nodeGateway.currentRoute(), shouldContinue() else { return } + guard let token = apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { if usesRelayTransport { @@ -4438,6 +5453,7 @@ extension NodeAppModel { } GatewayDiagnostics.pushRelay.stage("gateway identity request start") gatewayIdentity = try await self.fetchPushRelayGatewayIdentity() + guard shouldContinue() else { return } GatewayDiagnostics.pushRelay.stage("gateway identity request complete") } else { gatewayIdentity = nil @@ -4445,11 +5461,16 @@ extension NodeAppModel { if usesRelayTransport { GatewayDiagnostics.pushRelay.stage("gateway registration payload start") } - let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload( + let payloadJSON = try await pushRegistrationManager.makeGatewayRegistrationPayload( apnsTokenHex: token, topic: topic, gatewayIdentity: gatewayIdentity) - await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON) + guard shouldContinue() else { return } + let published = await nodeGateway.sendEvent( + event: "push.apns.register", + payloadJSON: payloadJSON, + ifCurrentRoute: nodeRoute) + guard published, shouldContinue() else { return } self.apnsLastRegisteredTokenHex = token if usesRelayTransport { GatewayDiagnostics.pushRelay.stage("gateway registration event published") @@ -4470,7 +5491,7 @@ extension NodeAppModel { } return false } - let status = await self.notificationAuthorizationStatus() + let status = await notificationAuthorizationStatus() guard Self.isNotificationAuthorizationAllowed(status) else { if usesRelayTransport { GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized") @@ -4480,11 +5501,14 @@ extension NodeAppModel { return true } - private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity { - let response = try await self.operatorGateway.request( + private func fetchPushRelayGatewayIdentity( + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async throws -> PushRelayGatewayIdentity + { + let response = try await operatorGateway.request( method: "gateway.identity.get", paramsJSON: "{}", - timeoutSeconds: 8) + timeoutSeconds: 8, + ifCurrentRoute: expectedRoute) let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response) let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines) let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines) @@ -4556,17 +5580,72 @@ extension NodeAppModel { var expiresAtMs: Int? } - func presentExecApprovalNotificationPrompt(_ prompt: ExecApprovalNotificationPrompt) async { + func presentExecApprovalNotificationPrompt( + _ prompt: ExecApprovalNotificationPrompt, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { let approvalId = prompt.approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !approvalId.isEmpty else { return } + guard shouldContinue(), !approvalId.isEmpty else { return } + let operatorRoute: GatewayNodeSessionRoute + switch await self.validateExecApprovalPushRoute( + prompt, + sourceReason: "notification_action", + shouldContinue: shouldContinue) + { + case let .validated(context): + operatorRoute = context.route + case .unavailable: + guard shouldContinue() else { return } + self.appendPendingWatchExecApprovalRecoveryPush(prompt) + return + case .mismatchedOwner: + await ExecApprovalNotificationBridge.removeNotifications( + for: prompt, + notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(prompt) + return + } + self.appendPendingWatchExecApprovalRecoveryPush(prompt) + await self.presentExecApprovalPrompt( + approvalId: approvalId, + notificationPush: prompt, + expectedOperatorRoute: operatorRoute, + shouldContinue: shouldContinue) + } + + private func presentExecApprovalGatewayEventPrompt( + approvalId: String, + expectedOperatorRoute: GatewayNodeSessionRoute? = nil, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async + { + await self.presentExecApprovalPrompt( + approvalId: approvalId, + notificationPush: nil, + expectedOperatorRoute: expectedOperatorRoute, + shouldContinue: shouldContinue) + } + + private func presentExecApprovalPrompt( + approvalId: String, + notificationPush: ExecApprovalNotificationPrompt?, + expectedOperatorRoute: GatewayNodeSessionRoute?, + shouldContinue: @MainActor @Sendable () -> Bool) async + { + guard shouldContinue(), !approvalId.isEmpty else { return } self.pendingExecApprovalPromptRequestGeneration &+= 1 let requestGeneration = self.pendingExecApprovalPromptRequestGeneration self.pendingExecApprovalPromptResolving = true self.pendingExecApprovalPromptErrorText = nil - let fetchedPrompt = await self.fetchExecApprovalPrompt(approvalId: approvalId) - guard self.pendingExecApprovalPromptRequestGeneration == requestGeneration else { + let fetchedPrompt = await fetchExecApprovalPrompt( + approvalId: approvalId, + expectedOperatorRoute: expectedOperatorRoute, + shouldContinue: shouldContinue) + guard shouldContinue(), self.pendingExecApprovalPromptRequestGeneration == requestGeneration else { + if self.pendingExecApprovalPromptRequestGeneration == requestGeneration { + self.pendingExecApprovalPromptResolving = false + } return } self.pendingExecApprovalPromptResolving = false @@ -4574,11 +5653,19 @@ extension NodeAppModel { case let .loaded(fetchedPrompt): self.presentFetchedExecApprovalPrompt(fetchedPrompt) case .stale: - await ExecApprovalNotificationBridge.removeNotifications( - forApprovalID: approvalId, - notificationCenter: self.notificationCenter) + if let notificationPush { + await ExecApprovalNotificationBridge.removeNotifications( + for: notificationPush, + notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(notificationPush) + } self.clearPendingExecApprovalPromptIfMatches(approvalId) - await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound) + if let gatewayStableID = currentExecApprovalGatewayStableID() { + await self.publishWatchExecApprovalExpired( + approvalId: approvalId, + gatewayStableID: gatewayStableID, + reason: .notFound) + } case let .failed(message): self.execApprovalNotificationLogger .error("approval prompt fetch failed id=\(approvalId, privacy: .public)") @@ -4593,6 +5680,7 @@ extension NodeAppModel { } private func presentFetchedExecApprovalPrompt(_ prompt: ExecApprovalPrompt) { + guard self.isExecApprovalPromptCurrent(prompt) else { return } self.pendingExecApprovalPrompt = prompt self.pendingExecApprovalPromptResolving = false self.pendingExecApprovalPromptErrorText = nil @@ -4602,12 +5690,17 @@ extension NodeAppModel { } } - private static func makeExecApprovalPrompt(from details: ExecApprovalGetResponse) -> ExecApprovalPrompt? { + private static func makeExecApprovalPrompt( + from details: ExecApprovalGetResponse, + gatewayStableID: String) -> ExecApprovalPrompt? + { let approvalId = details.id.trimmingCharacters(in: .whitespacesAndNewlines) let commandText = details.commandText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !approvalId.isEmpty, !commandText.isEmpty else { return nil } + let normalizedGatewayStableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !approvalId.isEmpty, !commandText.isEmpty, !normalizedGatewayStableID.isEmpty else { return nil } return ExecApprovalPrompt( id: approvalId, + gatewayStableID: normalizedGatewayStableID, commandText: commandText, commandPreview: details.commandPreview?.trimmingCharacters(in: .whitespacesAndNewlines), allowedDecisions: details.allowedDecisions.compactMap { decision in @@ -4626,16 +5719,110 @@ extension NodeAppModel { { guard isBackgrounded else { return false } switch sourceReason { - case "watch_request", "push_request", "watch_resolve", "notification_action": + case "watch_request", "push_request", "push_resolved", "watch_resolve", "notification_action": return true default: return false } } + private func operatorRouteForExecApproval( + sourceReason: String, + expectedOperatorRoute: GatewayNodeSessionRoute? = nil, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> GatewaySessionRouteContext? + { + guard shouldContinue(), let gatewayStableID = currentExecApprovalGatewayStableID() else { + return nil + } + let routeGeneration = self.gatewayRouteGeneration + let connected: Bool = if expectedOperatorRoute != nil { + self.operatorConnected + } else if Self.shouldUseBackgroundAwareExecApprovalReconnect( + sourceReason: sourceReason, + isBackgrounded: self.isBackgrounded) + { + await self.ensureOperatorApprovalConnectionForWatchReview( + timeoutMs: 12000, + reason: sourceReason) + } else { + await self.ensureOperatorApprovalConnection(timeoutMs: 12000) + } + guard shouldContinue(), connected, + self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID) + else { + return nil + } + let route: GatewayNodeSessionRoute? = if let expectedOperatorRoute { + expectedOperatorRoute + } else { + await self.operatorGateway.currentRoute() + } + guard let route, + shouldContinue(), + self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID) + else { + return nil + } + return GatewaySessionRouteContext( + route: route, + gatewayStableID: gatewayStableID, + routeGeneration: routeGeneration) + } + + private func validatedExecApprovalPushRoute( + _ push: ExecApprovalNotificationPrompt, + sourceReason: String, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> GatewayNodeSessionRoute? + { + guard case let .validated(context) = await validateExecApprovalPushRoute( + push, + sourceReason: sourceReason, + shouldContinue: shouldContinue) + else { + return nil + } + return context.route + } + + private func validateExecApprovalPushRoute( + _ push: ExecApprovalNotificationPrompt, + sourceReason: String, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> ExecApprovalPushRouteValidation + { + guard let context = await operatorRouteForExecApproval( + sourceReason: sourceReason, + shouldContinue: shouldContinue) + else { + return .unavailable + } + // Gateways shipped before owner-tagged APNs payloads are still safe when the + // approval is resolved only through the currently authenticated operator route. + guard let expectedGatewayDeviceID = push.gatewayDeviceId else { + return .validated(context) + } + do { + let identity = try await fetchPushRelayGatewayIdentity(ifCurrentRoute: context.route) + guard shouldContinue(), + self.isCurrentGatewayRoute( + generation: context.routeGeneration, + stableID: context.gatewayStableID) + else { + return .unavailable + } + guard identity.deviceId == expectedGatewayDeviceID else { + return .mismatchedOwner + } + return .validated(context) + } catch { + return .unavailable + } + } + private func fetchExecApprovalPrompt( approvalId: String, - sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome + sourceReason: String? = nil, + expectedOperatorRoute: GatewayNodeSessionRoute? = nil, + shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> ExecApprovalPromptFetchOutcome { let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines) let fetchReason: String = if let normalizedSourceReason, !normalizedSourceReason.isEmpty { @@ -4645,17 +5832,11 @@ extension NodeAppModel { } GatewayDiagnostics.log( "watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)") - let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect( + guard let context = await operatorRouteForExecApproval( sourceReason: fetchReason, - isBackgrounded: self.isBackgrounded) - { - await self.ensureOperatorApprovalConnectionForWatchReview( - timeoutMs: 12000, - reason: fetchReason) - } else { - await self.ensureOperatorApprovalConnection(timeoutMs: 12000) - } - guard connected else { + expectedOperatorRoute: expectedOperatorRoute, + shouldContinue: shouldContinue) + else { GatewayDiagnostics.log( "watch exec approval: fetch prompt operator not connected id=\(approvalId) reason=\(fetchReason)") return .failed(message: "operator_not_connected") @@ -4663,12 +5844,19 @@ extension NodeAppModel { do { let payloadJSON = try Self.encodePayload(ExecApprovalGetRequest(id: approvalId)) - let response = try await self.operatorGateway.request( + let response = try await operatorGateway.request( method: "exec.approval.get", paramsJSON: payloadJSON, - timeoutSeconds: 12) + timeoutSeconds: 12, + ifCurrentRoute: context.route) + guard shouldContinue(), self.currentExecApprovalGatewayStableID() == context.gatewayStableID else { + return .failed(message: "gateway_changed") + } let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response) - guard let prompt = Self.makeExecApprovalPrompt(from: details) else { + guard let prompt = Self.makeExecApprovalPrompt( + from: details, + gatewayStableID: context.gatewayStableID) + else { GatewayDiagnostics.log( "watch exec approval: fetch prompt invalid payload id=\(approvalId) reason=\(fetchReason)") return .failed(message: "invalid_prompt_payload") @@ -4676,7 +5864,12 @@ extension NodeAppModel { GatewayDiagnostics.log( "watch exec approval: fetch prompt loaded id=\(approvalId) reason=\(fetchReason)") return .loaded(prompt) + } catch is CancellationError { + return .failed(message: "route_changed") } catch { + guard self.currentExecApprovalGatewayStableID() == context.gatewayStableID else { + return .failed(message: "gateway_changed") + } if Self.isApprovalNotificationStaleError(error) { GatewayDiagnostics.log( "watch exec approval: fetch prompt stale id=\(approvalId) reason=\(fetchReason)") @@ -4701,15 +5894,20 @@ extension NodeAppModel { } func resolvePendingExecApprovalPrompt(decision: String) async { - guard let prompt = self.pendingExecApprovalPrompt else { return } + guard let prompt = pendingExecApprovalPrompt else { return } + guard self.isExecApprovalPromptCurrent(prompt) else { + self.dismissPendingExecApprovalPrompt() + return + } let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedDecision.isEmpty else { return } self.pendingExecApprovalPromptResolving = true self.pendingExecApprovalPromptErrorText = nil - let outcome = await self.resolveExecApprovalNotificationDecision( + let outcome = await resolveExecApprovalNotificationDecision( approvalId: prompt.id, - decision: normalizedDecision) + decision: normalizedDecision, + expectedGatewayStableID: prompt.gatewayStableID) switch outcome { case .resolved, .stale, .unavailable: break @@ -4722,6 +5920,7 @@ extension NodeAppModel { private func resolveExecApprovalNotificationDecision( approvalId: String, decision: String, + expectedGatewayStableID: String, sourceReason: String? = nil) async -> ExecApprovalResolutionOutcome { let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -4731,6 +5930,9 @@ extension NodeAppModel { guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else { return .failed(message: "Invalid approval request.") } + guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else { + return .failed(message: "This approval belongs to a different gateway.") + } let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect( sourceReason: resolutionReason, @@ -4742,7 +5944,10 @@ extension NodeAppModel { } else { await self.ensureOperatorApprovalConnection(timeoutMs: 12000) } - guard connected else { + guard connected, + self.currentExecApprovalGatewayStableID() == expectedGatewayStableID, + let operatorRoute = await operatorGateway.currentRoute() + else { self.execApprovalNotificationLogger.error( "Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected") return .failed(message: "OpenClaw couldn't connect to the gateway operator session.") @@ -4754,31 +5959,42 @@ extension NodeAppModel { _ = try await self.operatorGateway.request( method: "exec.approval.resolve", paramsJSON: payloadJSON, - timeoutSeconds: 12) - await ExecApprovalNotificationBridge.removeNotifications( - forApprovalID: normalizedApprovalID, - notificationCenter: self.notificationCenter) + timeoutSeconds: 12, + ifCurrentRoute: operatorRoute) + guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else { + return .resolved + } + await self.removeCurrentGatewayExecApprovalNotifications( + approvalId: normalizedApprovalID) self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) await self.publishWatchExecApprovalResolved( approvalId: normalizedApprovalID, + gatewayStableID: expectedGatewayStableID, decision: OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision), source: "iphone") return .resolved } catch { + guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else { + return .failed(message: "This approval belongs to a different gateway.") + } if Self.isApprovalNotificationStaleError(error) { - await ExecApprovalNotificationBridge.removeNotifications( - forApprovalID: normalizedApprovalID, - notificationCenter: self.notificationCenter) + await self.removeCurrentGatewayExecApprovalNotifications( + approvalId: normalizedApprovalID) self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) - await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .notFound) + await self.publishWatchExecApprovalExpired( + approvalId: normalizedApprovalID, + gatewayStableID: expectedGatewayStableID, + reason: .notFound) return .stale } if Self.isApprovalNotificationUnavailableError(error) { - await ExecApprovalNotificationBridge.removeNotifications( - forApprovalID: normalizedApprovalID, - notificationCenter: self.notificationCenter) + await self.removeCurrentGatewayExecApprovalNotifications( + approvalId: normalizedApprovalID) self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) - await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .unavailable) + await self.publishWatchExecApprovalExpired( + approvalId: normalizedApprovalID, + gatewayStableID: expectedGatewayStableID, + reason: .unavailable) return .unavailable } let logMessage = @@ -4796,6 +6012,25 @@ extension NodeAppModel { self.dismissPendingExecApprovalPrompt() } + private func removeCurrentGatewayExecApprovalNotifications(approvalId: String) async { + let delivered = await notificationCenter.deliveredNotifications() + var seen = Set() + for snapshot in delivered { + guard let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: snapshot.userInfo), + push.approvalId == approvalId, + seen.insert(push).inserted, + await validatedExecApprovalPushRoute( + push, + sourceReason: "notification_action") != nil + else { + continue + } + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + } + } + private func clearNotificationPermissionGuidancePromptIfMatches(_ approvalId: String) { let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) guard self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID else { return } @@ -4839,7 +6074,7 @@ extension NodeAppModel { if Task.isCancelled { return false } - if await self.isGatewayConnected() { + if await isGatewayConnected() { return true } do { @@ -4848,7 +6083,7 @@ extension NodeAppModel { return false } } - return await self.isGatewayConnected() + return await isGatewayConnected() } private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool { @@ -4872,7 +6107,7 @@ extension NodeAppModel { } private func ensureOperatorReconnectLoopIfNeeded() { - guard let cfg = self.activeGatewayConnectConfig else { + guard let cfg = activeGatewayConnectConfig else { return } guard self.operatorGatewayTask == nil else { @@ -4905,7 +6140,7 @@ extension NodeAppModel { GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_begin " + "reason=\(reconnectReason) backgrounded=false strategy=default") - let connected = await self.ensureOperatorApprovalConnection(timeoutMs: timeoutMs) + let connected = await ensureOperatorApprovalConnection(timeoutMs: timeoutMs) GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") " + "reason=\(reconnectReason) phase=foreground_delegate") @@ -4919,7 +6154,7 @@ extension NodeAppModel { return false } - guard let cfg = self.activeGatewayConnectConfig else { + guard let cfg = activeGatewayConnectConfig else { GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_timeout " + "reason=\(reconnectReason) phase=no_active_gateway_config") @@ -4939,7 +6174,8 @@ extension NodeAppModel { token: cfg.token, bootstrapToken: cfg.bootstrapToken, password: cfg.password, - stableID: cfg.effectiveStableID) + deviceAuthGatewayID: cfg.nodeOptions.deviceAuthGatewayID ?? cfg.effectiveStableID, + allowStoredDeviceAuth: cfg.nodeOptions.allowStoredDeviceAuth) guard canStartReconnectLoop else { GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_timeout " @@ -4986,7 +6222,7 @@ extension NodeAppModel { GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_wait " + "reason=\(reconnectReason) phase=restart timeoutMs=\(remainingWaitMs)") - let connected = await self.waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200) + let connected = await waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200) GatewayDiagnostics.log( "watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") " + "reason=\(reconnectReason) phase=restart") @@ -5024,31 +6260,40 @@ extension NodeAppModel { return makeResult(false, false, "auto_reconnect_disabled") } let now = Date() - let gatewayConnected = await self.isGatewayConnected() + let gatewayConnected = await isGatewayConnected() var appliedReconnect = false if !gatewayConnected { - guard let cfg = self.activeGatewayConnectConfig else { + guard let cfg = activeGatewayConnectConfig else { self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config") return makeResult(false, false, "no_active_gateway_config") } + let generation = self.gatewayConnectGeneration self.pushWakeLogger.info( "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)") self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() + await self.resetGatewaySessionsForForcedReconnect() + guard generation == self.gatewayConnectGeneration, + self.gatewayAutoReconnectEnabled, + self.activeGatewayConnectConfig?.hasSameConnectionInputs(as: cfg) == true + else { + return makeResult(false, false, "reconnect_superseded") + } self.setOperatorConnected(false) self.gatewayConnected = false self.gatewayStatusText = "Reconnecting…" self.talkMode.updateGatewayConnected(false) - self.applyGatewayConnectConfig(cfg) + self.applyGatewayConnectConfig(cfg, expectedGeneration: generation) appliedReconnect = true self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") - let connected = await self.waitForGatewayConnection(timeoutMs: 12000, pollMs: 250) + let connected = await waitForGatewayConnection(timeoutMs: 12000, pollMs: 250) guard connected else { return makeResult(appliedReconnect, false, "connect_timeout") } + guard generation == self.gatewayConnectGeneration else { + return makeResult(appliedReconnect, false, "reconnect_superseded") + } } else if BackgroundAliveBeacon.shouldSkipRecentSuccess( isGatewayConnected: true, now: now, @@ -5057,7 +6302,7 @@ extension NodeAppModel { return makeResult(false, true, "recent_success") } - let beacon = await self.publishBackgroundAliveBeacon(trigger: trigger) + let beacon = await publishBackgroundAliveBeacon(trigger: trigger) if beacon.handled { let successAtMs = Date().timeIntervalSince1970 * 1000 UserDefaults.standard.set(successAtMs, forKey: Self.backgroundAliveLastSuccessAtMsKey) @@ -5071,7 +6316,7 @@ extension NodeAppModel { trigger: BackgroundAliveBeacon.Trigger) async -> (handled: Bool, reason: String) { do { - let pushTransport = await self.pushRegistrationManager.usesRelayTransport ? "relay" : "direct" + let pushTransport = await pushRegistrationManager.usesRelayTransport ? "relay" : "direct" let displayName = NodeDisplayName.resolve( existing: UserDefaults.standard.string(forKey: "node.displayName"), deviceName: UIDevice.current.name, @@ -5081,7 +6326,7 @@ extension NodeAppModel { displayName: displayName, pushTransport: pushTransport) let paramsJSON = try BackgroundAliveBeacon.makeNodeEventRequestPayloadJSON(payload: payload) - let response = try await self.nodeGateway.request( + let response = try await nodeGateway.request( method: "node.event", paramsJSON: paramsJSON, timeoutSeconds: 8) @@ -5099,15 +6344,19 @@ extension NodeAppModel { } extension NodeAppModel { - private func refreshWakeWordsFromGateway() async { + private func refreshWakeWordsFromGateway( + shouldApply: @escaping @MainActor @Sendable () -> Bool = { true }) async + { do { - let data = try await self.operatorGateway.request( + let data = try await operatorGateway.request( method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } + guard shouldApply() else { return } VoiceWakePreferences.saveTriggerWords(triggers) } catch { + guard shouldApply() else { return } if let gatewayError = error as? GatewayResponseError { let lower = gatewayError.message.lowercased() if lower.contains("unauthorized role") || lower.contains("missing scope") { @@ -5233,7 +6482,10 @@ extension NodeAppModel { await self.submitAgentDeepLink(link, messageCharCount: message.count) } - private func sendAgentRequest(link: AgentDeepLink) async throws { + private func sendAgentRequest( + link: AgentDeepLink, + expectedNodeRoute: GatewayNodeSessionRoute? = nil) async throws + { if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { throw NSError(domain: "DeepLink", code: 1, userInfo: [ NSLocalizedDescriptionKey: "invalid agent message", @@ -5262,7 +6514,8 @@ extension NodeAppModel { _ = try await self.nodeGateway.request( method: "node.event", paramsJSON: requestJSON, - timeoutSeconds: Self.agentRequestNodeEventTimeoutSeconds) + timeoutSeconds: Self.agentRequestNodeEventTimeoutSeconds, + ifCurrentRoute: expectedNodeRoute) } private func isGatewayConnected() async -> Bool { @@ -5279,7 +6532,7 @@ extension NodeAppModel { } func approvePendingAgentDeepLinkPrompt() async { - guard let prompt = self.pendingAgentDeepLinkPrompt else { return } + guard let prompt = pendingAgentDeepLinkPrompt else { return } self.pendingAgentDeepLinkPrompt = nil guard await self.isGatewayConnected() else { self.screen.errorText = "Gateway not connected (cannot forward deep link)." @@ -5324,7 +6577,7 @@ extension NodeAppModel { private func deliverQueuedAgentDeepLinkPrompt() async { defer { self.queuedAgentDeepLinkPromptTask = nil } let promptIntervalSeconds = 5.0 - while let prompt = self.queuedAgentDeepLinkPrompt { + while let prompt = queuedAgentDeepLinkPrompt { if self.pendingAgentDeepLinkPrompt != nil { do { try await Task.sleep(nanoseconds: 200_000_000) @@ -5387,7 +6640,7 @@ extension NodeAppModel { private static func expectedDeepLinkKey() -> String { let defaults = UserDefaults.standard - if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty { + if let key = defaults.string(forKey: deepLinkKeyUserDefaultsKey), !key.isEmpty { return key } let key = self.generateDeepLinkKey() @@ -5415,8 +6668,11 @@ extension NodeAppModel { #if DEBUG extension NodeAppModel { - func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { - await self.handleInvoke(req) + func _test_handleInvoke( + _ req: BridgeInvokeRequest, + gatewayStableID: String? = nil) async -> BridgeInvokeResponse + { + await self.handleInvoke(req, gatewayStableID: gatewayStableID) } static func _test_decodeParams(_ type: T.Type, from json: String?) throws -> T { @@ -5435,6 +6691,14 @@ extension NodeAppModel { self.watchMessageOutbox.queuedCount(kind: .quickReply) } + func _test_setWatchMessageRetryAttempts(_ attempts: Int, messageID: String) { + self.watchMessageRetryAttempts[messageID] = attempts + } + + func _test_watchMessageRetryAttempts(messageID: String) -> Int? { + self.watchMessageRetryAttempts[messageID] + } + func _test_queuedWatchChatCommandCount() -> Int { self.watchMessageOutbox.queuedCount(kind: .chat) } @@ -5531,6 +6795,9 @@ extension NodeAppModel { } func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) { + if self.currentExecApprovalGatewayStableID() == nil { + self.connectedGatewayID = prompt.gatewayStableID + } self.presentFetchedExecApprovalPrompt(prompt) } @@ -5568,16 +6835,38 @@ extension NodeAppModel { self.resetExecApprovalNotificationGuidanceSuppression() } - func _test_recordPendingWatchExecApprovalRecoveryID(_ approvalId: String) { - self.appendPendingWatchExecApprovalRecoveryID(approvalId) + func _test_recordPendingWatchExecApprovalRecoveryID( + _ approvalId: String, + gatewayDeviceId: String = "test-gateway-device") + { + self.appendPendingWatchExecApprovalRecoveryPush(ExecApprovalNotificationPrompt( + approvalId: approvalId, + gatewayDeviceId: gatewayDeviceId)) } func _test_pendingWatchExecApprovalRecoveryIDs() -> [String] { - self.pendingWatchExecApprovalRecoveryIDs + self.pendingWatchExecApprovalRecoveryPushes.map(\.approvalId) + } + + func _test_pendingWatchExecApprovalRecoveryPushes() -> [ExecApprovalNotificationPrompt] { + self.pendingWatchExecApprovalRecoveryPushes + } + + func _test_handleExecApprovalResolvedForCurrentGateway( + approvalId: String, + recoveryPushGatewayDeviceID: String?) async + { + await self.handleExecApprovalResolvedForCurrentGateway( + approvalId: approvalId, + recoveryPushGatewayDeviceID: recoveryPushGatewayDeviceID) + } + + func _test_pendingExecApprovalResolvedPushes() -> [ExecApprovalNotificationPrompt] { + self.pendingExecApprovalResolvedPushes } func _test_pendingExecApprovalIDsForWatchRecovery() async -> [String] { - await self.pendingExecApprovalIDsForWatchRecovery() + await self.pendingExecApprovalPushesForWatchRecovery().map(\.approvalId) } nonisolated static func _test_isApprovalNotificationStaleError(_ error: Error) -> Bool { @@ -5605,6 +6894,13 @@ extension NodeAppModel { await self.handleOperatorGatewayServerEvent(event) } + func _test_handleOperatorGatewayServerEvent( + _ event: EventFrame, + shouldContinue: @escaping @MainActor @Sendable () -> Bool) async + { + await self.handleOperatorGatewayServerEvent(event, shouldContinue: shouldContinue) + } + nonisolated static func _test_watchExecApprovalIDsNeedingFetch( candidateIDs: [String], cachedApprovalIDs: [String]) -> [String] @@ -5622,6 +6918,7 @@ extension NodeAppModel { static func _test_makeExecApprovalPrompt( id: String, + gatewayStableID: String = "test-gateway", commandText: String, allowedDecisions: [String], host: String?, @@ -5638,7 +6935,8 @@ extension NodeAppModel { host: host, nodeId: nodeId, agentId: agentId, - expiresAtMs: expiresAtMs)) + expiresAtMs: expiresAtMs), + gatewayStableID: gatewayStableID) } static func _test_currentDeepLinkKey() -> String { @@ -5670,6 +6968,17 @@ extension NodeAppModel { hasStoredOperatorToken: hasStoredOperatorToken) } + nonisolated static func _test_usesBootstrapCredential( + token: String?, + bootstrapToken: String?, + password: String?) -> Bool + { + self.usesBootstrapCredential( + token: token, + bootstrapToken: bootstrapToken, + password: password) + } + nonisolated static func _test_shouldRequestOperatorApprovalScope( token: String?, password: String?, @@ -5683,6 +6992,17 @@ extension NodeAppModel { forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest) } + func _test_shouldRequestStoredOperatorApprovalScope( + gatewayID: String, + forceTalkPermissionUpgradeRequest: Bool = false) -> Bool + { + self.shouldRequestOperatorApprovalScope( + gatewayID: gatewayID, + token: nil, + password: nil, + forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest) + } + nonisolated static func _test_shouldRequestOperatorAdminScope( token: String?, password: String?, @@ -5696,16 +7016,56 @@ extension NodeAppModel { forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest) } - nonisolated static func _test_clearingBootstrapToken( - in config: GatewayConnectConfig?) -> GatewayConnectConfig? + func _test_shouldRequestStoredOperatorAdminScope(gatewayID: String) -> Bool { + self.shouldRequestOperatorAdminScope(gatewayID: gatewayID, token: nil, password: nil) + } + + func _test_completeSuccessfulGatewayAuthHandoff( + issuedRoles: Set, + nodeOptions: GatewayConnectOptions) -> GatewayConnectOptions? { - self.clearingBootstrapToken(in: config) + guard let stableID = activeGatewayConnectConfig?.effectiveStableID else { return nil } + return self.completeSuccessfulGatewayAuthHandoff( + stableID: stableID, + routeGeneration: self.gatewayRouteGeneration, + issuedRoles: issuedRoles, + nodeOptions: nodeOptions) + } + + func _test_currentGatewayReconnectOptions( + stableID: String, + fallback: GatewayConnectOptions) -> GatewayConnectOptions + { + self.currentGatewayReconnectOptions(stableID: stableID, fallback: fallback) } func _test_hasGatewayLoopTasks() -> (node: Bool, operator: Bool) { (self.nodeGatewayTask != nil, self.operatorGatewayTask != nil) } + func _test_setGatewayLoopTasks( + node: Task?, + operator: Task? = nil) + { + self.nodeGatewayTask = node + self.operatorGatewayTask = `operator` + } + + func _test_setGatewaySessionResetTask(_ task: Task?) { + self.gatewaySessionResetGeneration &+= 1 + let resetGeneration = self.gatewaySessionResetGeneration + guard let task else { + self.gatewaySessionResetTask = nil + return + } + self.gatewaySessionResetTask = Task { + await task.value + if self.gatewaySessionResetGeneration == resetGeneration { + self.gatewaySessionResetTask = nil + } + } + } + func _test_restartGatewaySessionsAfterForegroundStaleConnection() async { await self.restartGatewaySessionsAfterForegroundStaleConnection() } diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift index e9c66c372d9a..cf86460db709 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift @@ -6,25 +6,16 @@ enum GatewayOnboardingReset { static func prepareForBootstrapPairing( appModel: NodeAppModel, instanceId: String, + gatewayStableID: String, + disconnectGateway: Bool = true, defaults: UserDefaults = .standard) { - appModel.disconnectGateway() - - let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedInstanceId.isEmpty { - GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) - } - - let deviceId = DeviceIdentityStore.loadOrCreate().deviceId - DeviceAuthStore.clearToken(deviceId: deviceId, role: "node") - DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator") - DeviceAuthStore.clearAll(profile: .shareExtension) - - GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults) - GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults) - GatewaySettingsStore.clearLastDiscoveredGatewayStableID(defaults: defaults) - GatewayTLSStore.clearAllFingerprints() - defaults.set(false, forKey: "gateway.autoconnect") + self.prepare( + appModel: appModel, + instanceId: instanceId, + gatewayStableID: gatewayStableID, + disconnectGateway: disconnectGateway, + defaults: defaults) } @MainActor @@ -33,7 +24,12 @@ enum GatewayOnboardingReset { instanceId: String, defaults: UserDefaults = .standard) { - self.prepareForBootstrapPairing(appModel: appModel, instanceId: instanceId, defaults: defaults) + self.prepare( + appModel: appModel, + instanceId: instanceId, + gatewayStableID: nil, + disconnectGateway: true, + defaults: defaults) OnboardingStateStore.reset(defaults: defaults) defaults.set(false, forKey: "gateway.onboardingComplete") @@ -43,4 +39,55 @@ enum GatewayOnboardingReset { defaults.set("", forKey: "gateway.setupCode") defaults.set(defaults.integer(forKey: "onboarding.requestID") + 1, forKey: "onboarding.requestID") } + + @MainActor + private static func prepare( + appModel: NodeAppModel, + instanceId: String, + gatewayStableID: String?, + disconnectGateway: Bool, + defaults: UserDefaults) + { + if disconnectGateway { + appModel.disconnectGateway() + } + + let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) + } + + let deviceId = DeviceIdentityStore.loadOrCreate().deviceId + if let gatewayStableID { + let authenticationOwnerID = GatewaySettingsStore.authenticationOwnerID( + routeStableID: gatewayStableID) + let shareDeviceId = DeviceIdentityStore.loadOrCreate(profile: .shareExtension).deviceId + // Bootstrap replacement invalidates only the target. Other paired gateways remain + // usable when the user switches back after reviewing or completing this setup. + DeviceAuthStore.clearToken(deviceId: deviceId, role: "node", gatewayID: authenticationOwnerID) + DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator", gatewayID: authenticationOwnerID) + DeviceAuthStore.clearToken( + deviceId: shareDeviceId, + role: "node", + gatewayID: authenticationOwnerID, + profile: .shareExtension) + DeviceAuthStore.clearToken( + deviceId: shareDeviceId, + role: "operator", + gatewayID: authenticationOwnerID, + profile: .shareExtension) + GatewayTLSStore.clearFingerprint(stableID: gatewayStableID) + } else { + // Full onboarding reset is the only path that intentionally forgets every gateway. + DeviceAuthStore.clearToken(deviceId: deviceId, role: "node") + DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator") + DeviceAuthStore.clearAll(profile: .shareExtension) + GatewayTLSStore.clearAllFingerprints() + } + + GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults) + GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults) + GatewaySettingsStore.clearLastDiscoveredGatewayStableID(defaults: defaults) + defaults.set(false, forKey: "gateway.autoconnect") + } } diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index d29d5bb41bac..f04c768ca47d 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -40,6 +40,26 @@ private enum OnboardingStep: Int, CaseIterable { } } +struct GatewaySetupLinkStaging { + private(set) var link: GatewayConnectDeepLink? + + mutating func stage(_ link: GatewayConnectDeepLink) { + self.link = link + } + + mutating func take() -> GatewayConnectDeepLink? { + defer { self.link = nil } + return self.link + } + + @discardableResult + mutating func cancel() -> Bool { + guard self.link != nil else { return false } + self.link = nil + return true + } +} + struct OnboardingWizardView: View { @Environment(NodeAppModel.self) private var appModel: NodeAppModel @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController @@ -55,6 +75,7 @@ struct OnboardingWizardView: View { @State private var manualTLS: Bool = true @State private var gatewayToken: String = "" @State private var gatewayPassword: String = "" + @State private var gatewayCredentialFieldStableID: String? @State private var connectMessage: String? @State private var statusLine: String = "" @State private var connectingGatewayID: String? @@ -64,10 +85,14 @@ struct OnboardingWizardView: View { @State private var discoveryRestartTask: Task? @State private var showQRScanner: Bool = false @State private var scannerError: String? + @State private var scannerResultHandoff = QRScannerResultHandoff() + @State private var scannerScanID: UInt64 = 0 + @State private var pendingTargetSuppression = GatewayPendingTargetSuppression() @State private var selectedPhoto: PhotosPickerItem? @State private var showGatewayProblemDetails: Bool = false @State private var lastPairingAutoResumeAttemptAt: Date? @State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride? + @State private var setupLinkStaging = GatewaySetupLinkStaging() @State private var setupCode: String = "" @State private var setupCodeStatus: String? @State private var setupAttemptID: UUID? @@ -101,6 +126,18 @@ struct OnboardingWizardView: View { } var body: some View { + self.lifecycleContent + .onChange(of: self.scenePhase) { _, newValue in + guard newValue == ScenePhase.active else { return } + self.applyPendingGatewaySetupLinkIfNeeded() + self.attemptAutomaticPairingResumeIfNeeded() + } + .onReceive(Self.pairingAutoResumeTicker) { _ in + self.attemptAutomaticPairingResumeIfNeeded() + } + } + + private var lifecycleContent: some View { NavigationStack { Group { switch self.step { @@ -189,73 +226,14 @@ struct OnboardingWizardView: View { Text(self.scannerError ?? "") .font(OpenClawType.subhead) } - .sheet(isPresented: self.$showQRScanner) { - NavigationStack { - QRScannerView( - onGatewayLink: { link in - self.handleScannedLink(link) - }, - onSetupCode: { code in - self.handleScannedSetupCode(code) - }, - onError: { error in - self.showQRScanner = false - self.statusLine = "Scanner error: \(error)" - self.scannerError = error - }, - onDismiss: { - self.showQRScanner = false - }) - .ignoresSafeArea() - .navigationTitle("Scan QR Code") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - Text("Scan QR Code") - .font(OpenClawType.headline) - } - ToolbarItem(placement: .topBarLeading) { - Button { - self.showQRScanner = false - } label: { - Text("Cancel") - .font(OpenClawType.subheadSemiBold) - } - .font(OpenClawType.subheadSemiBold) - } - ToolbarItem(placement: .topBarTrailing) { - PhotosPicker(selection: self.$selectedPhoto, matching: .images) { - Label("Photos", systemImage: "photo") - .font(OpenClawType.subheadSemiBold) - } - .font(OpenClawType.subheadSemiBold) - } - } - } - .onChange(of: self.selectedPhoto) { _, newValue in - guard let item = newValue else { return } - self.selectedPhoto = nil - Task { - guard let data = try? await item.loadTransferable(type: Data.self) else { - self.showQRScanner = false - self.scannerError = "Could not load the selected image." - return - } - if let message = self.detectQRCode(from: data) { - if let link = GatewayConnectDeepLink.fromSetupInput(message) { - self.handleScannedLink(link) - return - } - if AppleReviewDemoMode.isSetupCode(message) { - self.handleScannedSetupCode(message) - return - } - } - self.showQRScanner = false - self.scannerError = "No valid QR code found in the selected image." - } - } - } + .sheet( + isPresented: self.$showQRScanner, + onDismiss: { + self.processQueuedScannerResult() + }, + content: { + self.qrScannerSheet + }) .sheet(isPresented: self.$showGatewayProblemDetails) { if let currentProblem = self.currentProblem { GatewayProblemDetailsSheet( @@ -268,12 +246,15 @@ struct OnboardingWizardView: View { } .onAppear { self.initializeState() + self.applyPendingGatewaySetupLinkIfNeeded() self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear") } .onDisappear { self.invalidateSetupAttempt() self.discoveryRestartTask?.cancel() self.discoveryRestartTask = nil + self.scannerResultHandoff.cancel() + self.pendingTargetSuppression.resumeAutoConnect(controller: self.gatewayController) } .onChange(of: self.discoveryDomain) { _, _ in self.scheduleDiscoveryRestart() @@ -296,11 +277,9 @@ struct OnboardingWizardView: View { self.manualPortText = normalized } } - .onChange(of: self.gatewayToken) { _, newValue in - self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword) - } - .onChange(of: self.gatewayPassword) { _, newValue in - self.saveGatewayCredentials(token: self.gatewayToken, password: newValue) + .onChange(of: self.setupCode) { _, newValue in + guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + self.clearStagedGatewaySetupLink() } .onChange(of: self.appModel.lastGatewayProblem) { _, newValue in self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText) @@ -308,8 +287,11 @@ struct OnboardingWizardView: View { .onChange(of: self.appModel.gatewayStatusText) { _, newValue in self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue) } + .onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in + self.applyPendingGatewaySetupLinkIfNeeded() + } .onChange(of: self.appModel.gatewayServerName) { _, newValue in - guard newValue != nil else { return } + guard newValue != nil, self.setupLinkStaging.link == nil else { return } self.showQRScanner = false self.statusLine = "Connected." if !self.didMarkCompleted, let selectedMode { @@ -318,13 +300,76 @@ struct OnboardingWizardView: View { } self.step = .success } - .onChange(of: self.scenePhase) { _, newValue in - guard newValue == .active else { return } - self.attemptAutomaticPairingResumeIfNeeded() - } - .onReceive(Self.pairingAutoResumeTicker) { _ in - self.attemptAutomaticPairingResumeIfNeeded() + } + + private var qrScannerSheet: some View { + let scanID = self.scannerScanID + return NavigationStack { + QRScannerView( + onResult: { result in + self.queueScannedResult(result, scanID: scanID) + }, + onError: { error in + guard self.scannerResultHandoff.isActive(scanID: scanID) else { return } + self.showQRScanner = false + self.statusLine = "Scanner error: \(error)" + self.scannerError = error + }, + onDismiss: { + guard self.scannerResultHandoff.isActive(scanID: scanID) else { return } + self.showQRScanner = false + }) + .ignoresSafeArea() + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Scan QR Code") + .font(OpenClawType.headline) + } + ToolbarItem(placement: .topBarLeading) { + Button { + self.scannerResultHandoff.cancel() + self.showQRScanner = false + } label: { + Text("Cancel") + .font(OpenClawType.subheadSemiBold) + } + .font(OpenClawType.subheadSemiBold) + } + ToolbarItem(placement: .topBarTrailing) { + PhotosPicker(selection: self.$selectedPhoto, matching: .images) { + Label("Photos", systemImage: "photo") + .font(OpenClawType.subheadSemiBold) + } + } + } + } + .onChange(of: self.selectedPhoto) { _, newValue in + guard let item = newValue else { return } + self.selectedPhoto = nil + Task { + guard let data = try? await item.loadTransferable(type: Data.self) else { + guard self.scannerResultHandoff.isActive(scanID: scanID) else { return } + self.showQRScanner = false + self.scannerError = "Could not load the selected image." + return + } + guard self.scannerResultHandoff.isActive(scanID: scanID) else { return } + if let message = self.detectQRCode(from: data) { + if let link = GatewayConnectDeepLink.fromSetupInput(message) { + self.queueScannedResult(.gatewayLink(link), scanID: scanID) + return + } + if AppleReviewDemoMode.isSetupCode(message) { + self.queueScannedResult(.setupCode(message), scanID: scanID) + return + } + } + self.showQRScanner = false + self.scannerError = "No valid QR code found in the selected image." } + } } private var introStep: some View { @@ -443,13 +488,17 @@ struct OnboardingWizardView: View { } } - switch selectedMode { - case .homeNetwork: - self.homeNetworkConnectSection - case .remoteDomain: - self.remoteDomainConnectSection - case .developerLocal: - self.developerConnectSection + if let stagedLink = self.setupLinkStaging.link { + self.stagedGatewaySetupSection(stagedLink) + } else { + switch selectedMode { + case .homeNetwork: + self.homeNetworkConnectSection + case .remoteDomain: + self.remoteDomainConnectSection + case .developerLocal: + self.developerConnectSection + } } } else { Section { @@ -466,6 +515,50 @@ struct OnboardingWizardView: View { } } + private func stagedGatewaySetupSection(_ link: GatewayConnectDeepLink) -> some View { + Section { + self.onboardingLabeledContent("Host", value: link.host) + self.onboardingLabeledContent("Port", value: String(link.port)) + self.onboardingLabeledContent("Security", value: link.tls ? "TLS" : "Plaintext (local network)") + + Button { + Task { await self.connectStagedGatewaySetupLink() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + .font(OpenClawType.subheadSemiBold) + } + } else { + Text("Connect") + .font(OpenClawType.subheadSemiBold) + } + } + .font(OpenClawType.subheadSemiBold) + .disabled(self.connectingGatewayID != nil) + + Button { + self.clearStagedGatewaySetupLink() + } label: { + Text("Use Manual Setup") + .font(OpenClawType.subheadSemiBold) + } + .font(OpenClawType.subheadSemiBold) + .disabled(self.connectingGatewayID != nil) + } header: { + Text("Setup Link") + .font(OpenClawType.captionSemiBold) + } footer: { + Text(link.tls + ? "Review this endpoint. Credentials are applied only after you tap Connect." + : + "Plaintext may expose credentials. Continue only if you trust this local network and host.") + .font(OpenClawType.caption) + } + } + private var homeNetworkConnectSection: some View { Group { Section { @@ -531,11 +624,11 @@ struct OnboardingWizardView: View { private var developerConnectSection: some View { Section { - TextField("Host", text: self.$manualHost) + TextField("Host", text: self.manualHostBinding) .textInputAutocapitalization(.never) .autocorrectionDisabled() .font(OpenClawType.subhead) - TextField("Port", text: self.$manualPortText) + TextField("Port", text: self.manualPortTextBinding) .keyboardType(.numberPad) .font(OpenClawType.subhead) self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS) @@ -552,10 +645,10 @@ struct OnboardingWizardView: View { private var authStep: some View { Group { Section { - self.onboardingSecureField("Gateway Auth Token", text: self.$gatewayToken) + self.onboardingSecureField("Gateway Auth Token", text: self.gatewayTokenBinding) .textInputAutocapitalization(.never) .autocorrectionDisabled() - self.onboardingSecureField("Gateway Password", text: self.$gatewayPassword) + self.onboardingSecureField("Gateway Password", text: self.gatewayPasswordBinding) if let problem = self.currentProblem { GatewayProblemBanner( @@ -721,11 +814,11 @@ extension OnboardingWizardView { private func manualConnectionFieldsSection(title: String) -> some View { Section { - TextField("Host", text: self.$manualHost) + TextField("Host", text: self.manualHostBinding) .textInputAutocapitalization(.never) .autocorrectionDisabled() .font(OpenClawType.subhead) - TextField("Port", text: self.$manualPortText) + TextField("Port", text: self.manualPortTextBinding) .keyboardType(.numberPad) .font(OpenClawType.subhead) self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS) @@ -734,10 +827,10 @@ extension OnboardingWizardView { .autocorrectionDisabled() .font(OpenClawType.subhead) if self.selectedMode == .remoteDomain { - self.onboardingSecureField("Gateway Auth Token", text: self.$gatewayToken) + self.onboardingSecureField("Gateway Auth Token", text: self.gatewayTokenBinding) .textInputAutocapitalization(.never) .autocorrectionDisabled() - self.onboardingSecureField("Gateway Password", text: self.$gatewayPassword) + self.onboardingSecureField("Gateway Password", text: self.gatewayPasswordBinding) } self.manualConnectButton } header: { @@ -800,6 +893,7 @@ extension OnboardingWizardView { self.setupCodeStatus = "Paste a setup code to continue." return } + self.clearStagedGatewaySetupLink() if AppleReviewDemoMode.isSetupCode(raw) { self.setupCode = "" @@ -827,6 +921,26 @@ extension OnboardingWizardView { await self.connectManual(setupAttemptID: attemptID) } + private func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) { + guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return } + self.statusLine = "QR loaded. Closing scanner..." + self.showQRScanner = false + } + + private func processQueuedScannerResult() { + let delivery = self.scannerResultHandoff.processAfterDismissal { result in + switch result { + case let .gatewayLink(link): + self.handleScannedLink(link) + case let .setupCode(code): + self.handleScannedSetupCode(code) + } + } + if delivery == nil { + self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController) + } + } + private func handleScannedLink(_ link: GatewayConnectDeepLink) { self.showQRScanner = false guard let attemptID = self.beginSetupAttempt() else { return } @@ -835,7 +949,10 @@ extension OnboardingWizardView { } private func connectScannedLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async { - defer { self.finishSetupAttempt(attemptID) } + defer { + self.finishSetupAttempt(attemptID) + self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController) + } let link = await self.gatewayController.selectReachableSetupLink(parsedLink) guard self.setupAttemptID == attemptID else { return } self.applyGatewayLink(link) @@ -845,26 +962,86 @@ extension OnboardingWizardView { await self.connectManual(setupAttemptID: attemptID) } - private func applyGatewayLink(_ link: GatewayConnectDeepLink) { + private func applyPendingGatewaySetupLinkIfNeeded() { + guard let link = self.appModel.consumePendingGatewaySetupLink() else { return } + self.showQRScanner = false + self.scannerResultHandoff.cancel() + self.showGatewayProblemDetails = false + let lease = self.gatewayController.cancelPendingConnectionAttempts() + self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease) + if self.selectedMode == nil { + self.selectedMode = link.tls ? .remoteDomain : .homeNetwork + } + self.setupLinkStaging.stage(link) + self.setupCodeStatus = "Setup link loaded for \(link.host):\(link.port). Tap Connect to apply." + self.connectMessage = nil + self.statusLine = self.setupCodeStatus ?? "" + self.step = .connect + } + + private func connectStagedGatewaySetupLink() async { + guard self.connectingGatewayID == nil else { return } + guard let link = self.setupLinkStaging.link else { return } + guard link.isValidEndpoint else { + let message = "Setup link has an invalid gateway endpoint." + self.setupCodeStatus = message + self.statusLine = message + return + } + self.connectingGatewayID = "manual" + defer { self.connectingGatewayID = nil } + let lease = self.gatewayController.cancelPendingConnectionAttempts() + self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease) + defer { self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) } + await self.appModel.resetGatewaySessionsForTargetSwitch() + guard self.setupLinkStaging.link == link else { return } + _ = self.setupLinkStaging.take() + self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false) + self.setupCodeStatus = "Setup link applied. Connecting..." + self.issue = .none + self.connectMessage = "Connecting to \(link.host)…" + self.statusLine = "Connecting to \(link.host):\(link.port)…" + await self.connectCurrentManualGateway(host: link.host, port: link.port, forceReconnect: false) + } + + private func clearStagedGatewaySetupLink() { + guard self.setupLinkStaging.cancel() else { return } + self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) + let message = "Setup link cleared." + self.setupCodeStatus = message + self.statusLine = message + } + + private func applyGatewayLink( + _ link: GatewayConnectDeepLink, + disconnectExistingGatewayForBootstrap: Bool = true) + { self.manualHost = link.host self.manualPort = link.port self.manualPortText = String(link.port) self.manualTLS = link.tls let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) + self.gatewayCredentialFieldStableID = setupAuth.targetStableID if setupAuth.hasBootstrapToken { GatewayOnboardingReset.prepareForBootstrapPairing( appModel: self.appModel, - instanceId: GatewaySettingsStore.currentInstanceID()) - } - self.saveGatewayBootstrapToken(setupAuth.bootstrapToken) - if setupAuth.shouldApplyTokenField { - self.gatewayToken = setupAuth.token - } - if setupAuth.shouldApplyPasswordField { - self.gatewayPassword = setupAuth.password + instanceId: GatewaySettingsStore.currentInstanceID(), + gatewayStableID: setupAuth.targetStableID, + disconnectGateway: disconnectExistingGatewayForBootstrap) } + self.gatewayToken = setupAuth.token + self.gatewayPassword = setupAuth.password self.pendingManualAuthOverride = setupAuth.manualAuthOverride - self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) + let instanceId = GatewaySettingsStore.currentInstanceID() + if !instanceId.isEmpty { + GatewaySettingsStore.saveGatewayCredentials( + token: setupAuth.token, + bootstrapToken: setupAuth.bootstrapToken, + password: setupAuth.password, + gatewayStableID: setupAuth.targetStableID, + suppressStoredDeviceAuth: true, + instanceId: instanceId) + } if self.selectedMode == nil { self.selectedMode = link.tls ? .remoteDomain : .homeNetwork } @@ -878,16 +1055,21 @@ extension OnboardingWizardView { self.statusLine = "Apple Review demo mode enabled." self.selectedMode = .homeNetwork self.appModel.enterAppleReviewDemoMode() + self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController) } - private func openQRScannerFromOnboarding() { + private func openQRScannerFromOnboarding(status: String = "Opening QR scanner…") { // Stop active reconnect loops before scanning new credentials. - self.appModel.disconnectGateway() self.invalidateSetupAttempt() + let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true) + _ = self.setupLinkStaging.cancel() + self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease) + self.scannerScanID = self.scannerResultHandoff.beginScan() + self.connectingGatewayID = nil self.connectMessage = nil self.issue = .none self.pairingRequestId = nil - self.statusLine = "Opening QR scanner…" + self.statusLine = status self.showQRScanner = true } @@ -1024,7 +1206,7 @@ extension OnboardingWizardView { private var canConnectManual: Bool { let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) - return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535 + return !host.isEmpty && self.resolvedManualPort(host: host) != nil } private var successEndpoint: String { @@ -1065,9 +1247,19 @@ extension OnboardingWizardView { } let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedInstanceId.isEmpty { - self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" - self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" + if !trimmedInstanceId.isEmpty, + let stableID = self.currentManualGatewayStableID + { + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: trimmedInstanceId, + gatewayStableID: stableID) + let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth + self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil + self.gatewayToken = credentials.token ?? "" + self.gatewayPassword = credentials.password ?? "" + self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: trimmedInstanceId, + targetStableID: stableID) } let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil @@ -1087,23 +1279,129 @@ extension OnboardingWizardView { } } - private func saveGatewayCredentials(token: String, password: String) { - let trimmedInstanceId = GatewaySettingsStore.currentInstanceID() - guard !trimmedInstanceId.isEmpty else { return } - let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) - GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId) - let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) - GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) + private var currentManualGatewayStableID: String? { + let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil } + return GatewayConnectionController.ManualAuthOverride.manualStableID( + host: host, + port: port) } - private func saveGatewayBootstrapToken(_ token: String?) { - let trimmedInstanceId = GatewaySettingsStore.currentInstanceID() - guard !trimmedInstanceId.isEmpty else { return } - let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - GatewaySettingsStore.saveGatewayBootstrapToken(trimmedToken, instanceId: trimmedInstanceId) + private var gatewayCredentialTargetStableID: String? { + // Auth fields follow the selected route. Otherwise a discovered-gateway retry can save + // credentials under the unrelated manual endpoint and immediately reload an empty bundle. + self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID + } + + private func resolvedManualPort(host: String) -> Int? { + guard self.manualPortText.isEmpty || self.manualPort > 0 else { return nil } + return GatewayConnectionController.resolvedManualPort( + host: host, + port: self.manualPort) + } + + private var gatewayTokenBinding: Binding { + Binding( + get: { self.gatewayToken }, + set: { self.persistGatewayToken($0) }) + } + + private var gatewayPasswordBinding: Binding { + Binding( + get: { self.gatewayPassword }, + set: { self.persistGatewayPassword($0) }) + } + + private var manualHostBinding: Binding { + Binding( + get: { self.manualHost }, + set: { value in + let previousStableID = self.currentManualGatewayStableID + self.manualHost = value + if previousStableID != self.currentManualGatewayStableID { + self.clearManualCredentialFields() + } + }) + } + + private var manualPortTextBinding: Binding { + Binding( + get: { self.manualPortText }, + set: { value in + let previousStableID = self.currentManualGatewayStableID + let digits = value.filter(\.isNumber) + self.manualPortText = digits + self.manualPort = min(Int(digits) ?? 0, 65535) + if previousStableID != self.currentManualGatewayStableID { + self.clearManualCredentialFields() + } + }) + } + + private func persistGatewayToken(_ value: String) { + self.gatewayToken = value + let instanceId = GatewaySettingsStore.currentInstanceID() + guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return } + self.gatewayCredentialFieldStableID = stableID + let saved = GatewaySettingsStore.updateGatewayCredentials( + token: value, + password: self.gatewayPassword, + gatewayStableID: stableID, + instanceId: instanceId) + self.pendingManualAuthOverride = saved + ? GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: instanceId, + targetStableID: stableID) + : nil + } + + private func persistGatewayPassword(_ value: String) { + self.gatewayPassword = value + let instanceId = GatewaySettingsStore.currentInstanceID() + guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return } + self.gatewayCredentialFieldStableID = stableID + let saved = GatewaySettingsStore.updateGatewayCredentials( + token: self.gatewayToken, + password: value, + gatewayStableID: stableID, + instanceId: instanceId) + self.pendingManualAuthOverride = saved + ? GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: instanceId, + targetStableID: stableID) + : nil + } + + private func clearManualCredentialFields() { + self.gatewayToken = "" + self.gatewayPassword = "" + self.gatewayCredentialFieldStableID = nil + self.pendingManualAuthOverride = nil + } + + private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) { + let instanceId = GatewaySettingsStore.currentInstanceID() + if self.gatewayCredentialFieldStableID != stableID { + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceId, + gatewayStableID: stableID) + self.gatewayCredentialFieldStableID = stableID + self.gatewayToken = credentials.token ?? "" + self.gatewayPassword = credentials.password ?? "" + } + guard allowManualOverride else { + self.pendingManualAuthOverride = nil + return + } + // Each attempt consumes the in-memory override. Reload durable bootstrap auth even + // when the endpoint fields did not change so retry never erases a one-time token. + self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: instanceId, + targetStableID: stableID) } private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false) self.connectingGatewayID = gateway.id self.issue = .none self.connectMessage = "Connecting to \(gateway.name)…" @@ -1118,6 +1416,12 @@ extension OnboardingWizardView { } private func applyModeDefaults(_ mode: OnboardingConnectionMode) { + let previousStableID = self.currentManualGatewayStableID + defer { + if previousStableID != self.currentManualGatewayStableID { + self.clearManualCredentialFields() + } + } let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost" @@ -1151,27 +1455,53 @@ extension OnboardingWizardView { self.invalidateSetupAttempt() } let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return } + guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return } self.connectingGatewayID = "manual" self.issue = .none self.connectMessage = "Connecting to \(host)…" - self.statusLine = "Connecting to \(host):\(self.manualPort)…" + self.statusLine = "Connecting to \(host):\(port)…" defer { self.connectingGatewayID = nil } - await self.connectCurrentManualGateway(host: host, forceReconnect: false) + await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: false) } - private func connectCurrentManualGateway(host: String, forceReconnect: Bool) async { + private func connectCurrentManualGateway(host: String, port: Int, forceReconnect: Bool) async { + let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID( + host: host, + port: port) + self.selectGatewayCredentialTarget(stableID, allowManualOverride: true) + if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID, + self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true + { + self.pendingManualAuthOverride = nil + } + let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID + let pendingOverride = self.pendingManualAuthOverride?.targetStableID == stableID + ? self.pendingManualAuthOverride + : nil let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput( - token: self.gatewayToken, - pendingOverride: self.pendingManualAuthOverride, - password: self.gatewayPassword) - self.pendingManualAuthOverride = nil + token: fieldsMatchTarget ? self.gatewayToken : nil, + pendingOverride: pendingOverride, + password: fieldsMatchTarget ? self.gatewayPassword : nil, + targetStableID: stableID) + let instanceId = GatewaySettingsStore.currentInstanceID() + if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil { + GatewaySettingsStore.saveGatewayCredentials( + token: authOverride?.token, + bootstrapToken: authOverride?.bootstrapToken, + password: authOverride?.password, + gatewayStableID: stableID, + suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true, + instanceId: instanceId) + } await self.gatewayController.connectManual( host: host, - port: self.manualPort, + port: port, useTLS: self.manualTLS, authOverride: authOverride, forceReconnect: forceReconnect) + // The controller now owns this attempt's immutable override. A later retry must reload + // durable state so a spent bootstrap token cannot be resurrected from the live view. + self.pendingManualAuthOverride = nil } private func retryLastAttempt(silent: Bool = false) async { @@ -1192,8 +1522,8 @@ extension OnboardingWizardView { // a missing stored connection would silently do nothing. Manual // retries must dial the current form input instead. let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) - if !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 { - await self.connectCurrentManualGateway(host: host, forceReconnect: true) + if !host.isEmpty, let port = self.resolvedManualPort(host: host) { + await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: true) return } if !silent { @@ -1215,13 +1545,14 @@ extension OnboardingWizardView { GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) self.gatewayToken = "" self.gatewayPassword = "" + self.gatewayCredentialFieldStableID = nil + self.pendingManualAuthOverride = nil self.connectingGatewayID = nil self.connectMessage = nil self.issue = .none self.pairingRequestId = nil - self.statusLine = "Scan a fresh setup QR code from this gateway." self.step = .connect - self.showQRScanner = true + self.openQRScannerFromOnboarding(status: "Scan a fresh setup QR code from this gateway.") return } if problem.canTrustRotatedCertificate { diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift index 249a5cf1c701..024532908a90 100644 --- a/apps/ios/Sources/Onboarding/QRScannerView.swift +++ b/apps/ios/Sources/Onboarding/QRScannerView.swift @@ -2,9 +2,112 @@ import OpenClawKit import SwiftUI import VisionKit +enum QRScannerResult: Equatable { + case gatewayLink(GatewayConnectDeepLink) + case setupCode(String) +} + +@MainActor +struct GatewayPendingTargetSuppression { + enum Owner: Equatable { + case qrScanner + case setupLink + } + + private var value: (owner: Owner, lease: GatewayConnectionController.AutoConnectSuppressionLease)? + + mutating func replace( + owner: Owner, + lease: GatewayConnectionController.AutoConnectSuppressionLease) + { + self.value = (owner, lease) + } + + mutating func take(ifOwnedBy owner: Owner? = nil) -> GatewayConnectionController.AutoConnectSuppressionLease? { + guard let value = self.value else { return nil } + if let owner, value.owner != owner { return nil } + self.value = nil + return value.lease + } + + mutating func resumeAutoConnect(_ owner: Owner? = nil, controller: GatewayConnectionController) { + guard let lease = self.take(ifOwnedBy: owner) else { return } + controller.resumeAutoConnect(after: lease) + } + + mutating func releaseAutoConnect(_ owner: Owner? = nil, controller: GatewayConnectionController) { + guard let lease = self.take(ifOwnedBy: owner) else { return } + controller.releaseAutoConnectSuppression(after: lease) + } +} + +@MainActor +final class QRScannerResultHandoff { + /// SwiftUI's onDismiss can precede VisionKit's AV capture teardown. Delay + /// pairing UI briefly so it cannot race the scanner's camera shutdown. + static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000 + + private let settlingNanoseconds: UInt64 + private var pendingResult: QRScannerResult? + private var deliveryTask: Task? + private var activeScanID: UInt64 = 0 + + init(settlingNanoseconds: UInt64 = QRScannerResultHandoff.defaultSettlingNanoseconds) { + self.settlingNanoseconds = settlingNanoseconds + } + + @discardableResult + func beginScan() -> UInt64 { + self.cancel() + return self.activeScanID + } + + func isActive(scanID: UInt64) -> Bool { + scanID == self.activeScanID && self.pendingResult == nil + } + + @discardableResult + func queue(_ result: QRScannerResult, scanID: UInt64) -> Bool { + // Camera and Photos can finish together; the first valid result owns this scan. + guard self.isActive(scanID: scanID) else { return false } + self.pendingResult = result + return true + } + + @discardableResult + func processAfterDismissal( + _ process: @escaping @MainActor (QRScannerResult) -> Void) -> Task? + { + guard let result = self.pendingResult else { + self.cancel() + return nil + } + self.pendingResult = nil + self.activeScanID &+= 1 + self.deliveryTask?.cancel() + let settlingNanoseconds = self.settlingNanoseconds + let task = Task { @MainActor in + do { + try await Task.sleep(nanoseconds: settlingNanoseconds) + } catch { + return + } + process(result) + } + self.deliveryTask = task + return task + } + + func cancel() { + self.activeScanID &+= 1 + self.deliveryTask?.cancel() + self.deliveryTask = nil + self.pendingResult = nil + } +} + struct QRScannerView: UIViewControllerRepresentable { - let onGatewayLink: (GatewayConnectDeepLink) -> Void - let onSetupCode: (String) -> Void + let onResult: (QRScannerResult) -> Void let onError: (String) -> Void let onDismiss: () -> Void @@ -59,7 +162,11 @@ struct QRScannerView: UIViewControllerRepresentable { } } - func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) { + func dataScanner( + _ scanner: DataScannerViewController, + didAdd items: [RecognizedItem], + allItems _: [RecognizedItem]) + { guard !self.handled else { return } for item in items { guard case let .barcode(barcode) = item, @@ -67,22 +174,26 @@ struct QRScannerView: UIViewControllerRepresentable { else { continue } if let link = GatewayConnectDeepLink.fromSetupInput(payload) { - self.handled = true - Task { @MainActor in - self.parent.onGatewayLink(link) - } + self.deliver(.gatewayLink(link), scanner: scanner) return } if AppleReviewDemoMode.isSetupCode(payload) { - self.handled = true - Task { @MainActor in - self.parent.onSetupCode(payload) - } + self.deliver(.setupCode(payload), scanner: scanner) return } } } + private func deliver(_ result: QRScannerResult, scanner: DataScannerViewController) { + self.handled = true + // DataScannerViewController has no teardown-completion callback. Stop capture + // before owners dismiss the sheet and later present pairing UI. + scanner.stopScanning() + Task { @MainActor in + self.parent.onResult(result) + } + } + func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {} func dataScanner( diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 41c7974323d3..9d3613bde6c5 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -43,13 +43,14 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc private var pendingAPNsDeviceToken: Data? private var pendingWatchPromptActions: [PendingWatchPromptAction] = [] private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = [] - private var pendingExecApprovalRequestedPushIDs: [String] = [] - private var pendingExecApprovalResolvedPushIDs: [String] = [] + private var pendingExecApprovalRequestedPushes: [ExecApprovalNotificationPrompt] = [] + private var pendingExecApprovalResolvedPushes: [ExecApprovalNotificationPrompt] = [] + private var pendingOpenURLs: [URL] = [] weak var appModel: NodeAppModel? { didSet { - guard let model = self.resolvedAppModel() else { return } - if let token = self.pendingAPNsDeviceToken { + guard let model = resolvedAppModel() else { return } + if let token = pendingAPNsDeviceToken { self.pendingAPNsDeviceToken = nil Task { @MainActor in model.updateAPNsDeviceToken(token) @@ -78,21 +79,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc } } } - if !self.pendingExecApprovalRequestedPushIDs.isEmpty { - let pending = self.pendingExecApprovalRequestedPushIDs - self.pendingExecApprovalRequestedPushIDs.removeAll() + if !self.pendingExecApprovalRequestedPushes.isEmpty { + let pending = self.pendingExecApprovalRequestedPushes + self.pendingExecApprovalRequestedPushes.removeAll() Task { @MainActor in - for approvalId in pending { - _ = await model.handleExecApprovalRequestedRemotePush(approvalId: approvalId) + for push in pending { + _ = await model.handleExecApprovalRequestedRemotePush(push) } } } - if !self.pendingExecApprovalResolvedPushIDs.isEmpty { - let pending = self.pendingExecApprovalResolvedPushIDs - self.pendingExecApprovalResolvedPushIDs.removeAll() + if !self.pendingExecApprovalResolvedPushes.isEmpty { + let pending = self.pendingExecApprovalResolvedPushes + self.pendingExecApprovalResolvedPushes.removeAll() Task { @MainActor in - for approvalId in pending { - await model.handleExecApprovalResolvedRemotePush(approvalId: approvalId) + for push in pending { + _ = await model.handleExecApprovalResolvedRemotePush(push) + } + } + } + if !self.pendingOpenURLs.isEmpty { + let pending = self.pendingOpenURLs + self.pendingOpenURLs.removeAll() + Task { @MainActor in + for url in pending { + await self.handleOpenURL(url, model: model) } } } @@ -115,7 +125,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc func application( _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { GatewayDiagnostics.log("app delegate: didFinishLaunching") if self.appModel == nil { @@ -131,6 +141,33 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc return true } + func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool + { + guard DeepLinkParser.parse(url) != nil else { return false } + guard let model = resolvedAppModel() else { + self.pendingOpenURLs.append(url) + return true + } + Task { @MainActor in + await self.handleOpenURL(url, model: model) + } + return true + } + + func handleOpenURL(_ url: URL, model: NodeAppModel) async { + guard let route = DeepLinkParser.parse(url) else { return } + + switch route { + case .agent, .dashboard: + await model.handleDeepLink(url: url) + case let .gateway(link): + model.stageGatewaySetupLink(link) + } + } + private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async { guard PushEnrollmentConsent.disclosureAccepted else { return } guard await Self.isNotificationAuthorizationAllowed() else { return } @@ -149,8 +186,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc } } - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - if let appModel = self.resolvedAppModel() { + func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + if let appModel = resolvedAppModel() { Task { @MainActor in appModel.updateAPNsDeviceToken(deviceToken) } @@ -160,38 +197,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc self.pendingAPNsDeviceToken = deviceToken } - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)") } func application( - _ application: UIApplication, + _: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)") Task { @MainActor in - let notificationCenter = LiveNotificationCenter() - if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded( - userInfo: userInfo, - notificationCenter: notificationCenter) - { - if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) { - if let appModel = self.resolvedAppModel() { - await appModel.handleExecApprovalResolvedRemotePush(approvalId: approvalId) - } else { - self.pendingExecApprovalResolvedPushIDs.append(approvalId) - } + if let push = ExecApprovalNotificationBridge.parseResolvedPush(userInfo: userInfo) { + if let appModel = self.resolvedAppModel() { + let handled = await appModel.handleExecApprovalResolvedRemotePush(push) + completionHandler(handled ? .newData : .noData) + } else { + self.pendingExecApprovalResolvedPushes.append(push) + completionHandler(.newData) } - completionHandler(.newData) return } guard let appModel = self.resolvedAppModel() else { - if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo) - == ExecApprovalNotificationBridge.requestedKind, - let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) - { - self.pendingExecApprovalRequestedPushIDs.append(approvalId) + if let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) { + self.pendingExecApprovalRequestedPushes.append(push) } self.logger.info("APNs wake skipped: appModel unavailable") self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model") @@ -340,7 +369,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc } private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async { - guard let appModel = self.resolvedAppModel() else { + guard let appModel = resolvedAppModel() else { self.pendingWatchPromptActions.append(action) return } @@ -354,7 +383,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc } private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) { - guard let appModel = self.resolvedAppModel() else { + guard let appModel = resolvedAppModel() else { self.pendingExecApprovalPrompts.append(prompt) return } @@ -364,7 +393,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc } func userNotificationCenter( - _ center: UNUserNotificationCenter, + _: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { @@ -379,7 +408,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc } func userNotificationCenter( - _ center: UNUserNotificationCenter, + _: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { @@ -451,18 +480,18 @@ enum WatchPromptNotificationBridge { let center = UNUserNotificationCenter.current() var categoryIdentifier = "" if !displayedActions.isEmpty { - let categoryID = "\(self.categoryPrefix)\(invokeID)" + let categoryID = "\(categoryPrefix)\(invokeID)" let category = UNNotificationCategory( identifier: categoryID, - actions: self.categoryActions(displayedActions), + actions: categoryActions(displayedActions), intentIdentifiers: [], options: []) - await self.upsertNotificationCategory(category, center: center) + await upsertNotificationCategory(category, center: center) categoryIdentifier = categoryID } var userInfo: [AnyHashable: Any] = [ - self.typeKey: self.typeValue, + typeKey: typeValue, ] if let promptId = params.promptId?.trimmingCharacters(in: .whitespacesAndNewlines), !promptId.isEmpty { userInfo[self.promptIDKey] = promptId @@ -510,7 +539,7 @@ enum WatchPromptNotificationBridge { identifier: "watch.prompt.\(invokeID)", content: content, trigger: nil) - try? await self.addNotificationRequest(request, center: center) + try? await addNotificationRequest(request, center: center) } static func actionIDKey(index: Int) -> String { @@ -552,7 +581,7 @@ enum WatchPromptNotificationBridge { private static func isNotificationAuthorizationAllowed() async -> Bool { let center = UNUserNotificationCenter.current() - let status = await self.notificationAuthorizationStatus(center: center) + let status = await notificationAuthorizationStatus(center: center) return self.isAuthorizationStatusAllowed(status) } @@ -629,7 +658,7 @@ extension NodeAppModel { note: "source=ios.notification", sentAtMs: Int(Date().timeIntervalSince1970 * 1000), transport: "ios.notification") - await self._bridgeConsumeMirroredWatchReply(event) + await _bridgeConsumeMirroredWatchReply(event) } } @@ -693,7 +722,9 @@ struct OpenClawApp: App { OpenClawType.refreshUIKitAppearance(in: Self.connectedWindows()) }) .onOpenURL { url in - Task { await self.handleOpenURL(url) } + // SwiftUI owns normal scene delivery; the delegate also queues URLs + // that arrive before the scene has installed its model. + Task { await self.appDelegate.handleOpenURL(url, model: self.appModel) } } .onChange(of: self.scenePhase) { _, newValue in self.appModel.setScenePhase(newValue) @@ -736,18 +767,6 @@ struct OpenClawApp: App { } extension OpenClawApp { - @MainActor - private func handleOpenURL(_ url: URL) async { - guard let route = DeepLinkParser.parse(url) else { return } - - switch route { - case .agent, .dashboard: - await self.appModel.handleDeepLink(url: url) - case let .gateway(link): - self.appModel.stageGatewaySetupLink(link) - } - } - private static func installUncaughtExceptionLogger() { NSLog("OpenClaw: installing uncaught exception handler") NSSetUncaughtExceptionHandler { exception in diff --git a/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift b/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift index 3a89c4b082be..1ec7d6399f45 100644 --- a/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift +++ b/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift @@ -1,8 +1,9 @@ import Foundation @preconcurrency import UserNotifications -struct ExecApprovalNotificationPrompt: Equatable { +struct ExecApprovalNotificationPrompt: Codable, Equatable, Hashable { let approvalId: String + let gatewayDeviceId: String? } enum ExecApprovalNotificationBridge { @@ -15,10 +16,10 @@ enum ExecApprovalNotificationBridge { static func registerCategory(center: UNUserNotificationCenter = .current()) { let category = UNNotificationCategory( - identifier: self.categoryIdentifier, + identifier: categoryIdentifier, actions: [ UNNotificationAction( - identifier: self.reviewActionIdentifier, + identifier: reviewActionIdentifier, title: "Review", options: [.foreground]), ], @@ -33,7 +34,7 @@ enum ExecApprovalNotificationBridge { } static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool { - self.payloadKind(userInfo: userInfo) == self.requestedKind + self.parsePush(userInfo: userInfo, expectedKind: self.requestedKind) != nil } static func parsePrompt( @@ -45,40 +46,43 @@ enum ExecApprovalNotificationBridge { else { return nil } - guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil } - guard let approvalId = self.approvalID(from: userInfo) else { return nil } - return ExecApprovalNotificationPrompt(approvalId: approvalId) + return self.parseRequestedPush(userInfo: userInfo) } - @MainActor - static func handleResolvedPushIfNeeded( - userInfo: [AnyHashable: Any], - notificationCenter: NotificationCentering) async -> Bool - { - guard self.payloadKind(userInfo: userInfo) == self.resolvedKind, - let approvalId = self.approvalID(from: userInfo) - else { - return false - } + static func parseRequestedPush(userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt? { + self.parsePush(userInfo: userInfo, expectedKind: self.requestedKind) + } - await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter) - return true + static func parseResolvedPush(userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt? { + self.parsePush(userInfo: userInfo, expectedKind: self.resolvedKind) } @MainActor static func removeNotifications( - forApprovalID approvalId: String, - notificationCenter: NotificationCentering) async + for push: ExecApprovalNotificationPrompt, + notificationCenter: NotificationCentering, + includingLegacyOwnerless: Bool = false) async { - let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedID.isEmpty else { return } - + var pendingIdentifiers = [self.localRequestIdentifier(for: push)] + if includingLegacyOwnerless { + pendingIdentifiers.append("\(self.localRequestPrefix)\(push.approvalId)") + pendingIdentifiers.append(self.localRequestIdentifier(for: ExecApprovalNotificationPrompt( + approvalId: push.approvalId, + gatewayDeviceId: nil))) + } + var seenPendingIdentifiers = Set() + pendingIdentifiers = pendingIdentifiers.filter { seenPendingIdentifiers.insert($0).inserted } await notificationCenter.removePendingNotificationRequests( - withIdentifiers: [self.localRequestIdentifier(for: normalizedID)]) + withIdentifiers: pendingIdentifiers) let delivered = await notificationCenter.deliveredNotifications() let identifiers = delivered.compactMap { snapshot -> String? in - guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil } + guard let requestedPush = self.parseRequestedPush(userInfo: snapshot.userInfo) else { return nil } + let matchesCurrentOwner = requestedPush == push + let matchesLegacyOwnerless = includingLegacyOwnerless && + requestedPush.approvalId == push.approvalId && + requestedPush.gatewayDeviceId == nil + guard matchesCurrentOwner || matchesLegacyOwnerless else { return nil } return snapshot.identifier } await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) @@ -90,8 +94,29 @@ enum ExecApprovalNotificationBridge { return trimmed.isEmpty ? nil : trimmed } - private static func localRequestIdentifier(for approvalId: String) -> String { - "\(self.localRequestPrefix)\(approvalId)" + private static func gatewayDeviceID(from userInfo: [AnyHashable: Any]) -> String? { + let raw = self.openClawPayload(userInfo: userInfo)?["gatewayDeviceId"] as? String + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func parsePush( + userInfo: [AnyHashable: Any], + expectedKind: String) -> ExecApprovalNotificationPrompt? + { + guard self.payloadKind(userInfo: userInfo) == expectedKind, + let approvalId = approvalID(from: userInfo) + else { + return nil + } + return ExecApprovalNotificationPrompt( + approvalId: approvalId, + gatewayDeviceId: self.gatewayDeviceID(from: userInfo)) + } + + private static func localRequestIdentifier(for push: ExecApprovalNotificationPrompt) -> String { + let owner = push.gatewayDeviceId ?? "legacy" + return "\(self.localRequestPrefix)\(owner).\(push.approvalId)" } static func payloadKind(userInfo: [AnyHashable: Any]) -> String { diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index f2132cb53a0b..d6d4ddcd9235 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -202,6 +202,9 @@ struct RootTabs: View { SettingsProTab( initialRoute: self.selectedSettingsRoute, + acceptsGatewaySetupRequests: !self.showOnboarding && + self.selectedTab == .settings && + self.selectedSettingsRoute == .gateway, onRouteChange: self.handleSettingsRouteChange, gatewaySetupRequest: self.gatewaySetupRequest, onGatewaySetupRequestHandled: self.handleGatewaySetupRequest) @@ -512,28 +515,29 @@ struct RootTabs: View { directRoute: selectedSettingsRoute, headerLeadingAction: self.sidebarHeaderLeadingAction, ownsNavigationStack: false, - navigateToRoute: self.pushSidebarSettingsRoute, - onRouteChange: self.handleSettingsRouteChange, + navigateToRoute: pushSidebarSettingsRoute, + onRouteChange: handleSettingsRouteChange, gatewaySetupRequest: self.gatewaySetupRequest, - onGatewaySetupRequestHandled: self.handleGatewaySetupRequest) + onGatewaySetupRequestHandled: handleGatewaySetupRequest) } else { SettingsProTab( headerLeadingAction: self.sidebarHeaderLeadingAction, ownsNavigationStack: false, - navigateToRoute: self.pushSidebarSettingsRoute, - onRouteChange: self.handleSettingsRouteChange, + navigateToRoute: pushSidebarSettingsRoute, + onRouteChange: handleSettingsRouteChange, gatewaySetupRequest: self.gatewaySetupRequest, - onGatewaySetupRequestHandled: self.handleGatewaySetupRequest) + onGatewaySetupRequestHandled: handleGatewaySetupRequest) } case .gateway: SettingsProTab( directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway, + acceptsGatewaySetupRequests: !self.showOnboarding, headerLeadingAction: self.sidebarHeaderLeadingAction, ownsNavigationStack: false, - navigateToRoute: self.pushSidebarSettingsRoute, - onRouteChange: self.handleSettingsRouteChange, + navigateToRoute: pushSidebarSettingsRoute, + onRouteChange: handleSettingsRouteChange, gatewaySetupRequest: self.gatewaySetupRequest, - onGatewaySetupRequestHandled: self.handleGatewaySetupRequest) + onGatewaySetupRequestHandled: handleGatewaySetupRequest) } } @@ -677,7 +681,7 @@ struct RootTabs: View { private var activeGatewayProblemToast: GatewayConnectionProblem? { // Operator-scope auth/pairing failures can coexist with a connected node. // The problem itself, not aggregate gateway status, owns toast visibility. - guard let problem = self.appModel.lastGatewayProblem, + guard let problem = appModel.lastGatewayProblem, !self.isGatewayToastSwipeDismissed else { return nil } return problem @@ -690,7 +694,7 @@ struct RootTabs: View { private func gatewayProblemToast(_ problem: GatewayConnectionProblem) -> some View { GatewayProblemBanner( problem: problem, - primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem), + primaryActionTitle: gatewayProblemPrimaryActionTitle(problem), onPrimaryAction: { self.handleGatewayProblemPrimaryAction(problem) }, @@ -921,7 +925,7 @@ struct RootTabs: View { .environment(self.voiceWake) .environment(self.gatewayController) } - .gatewayTrustPromptAlert() + .gatewayTrustPromptAlert(isEnabled: !self.showOnboarding) .deepLinkAgentPromptAlert() .execApprovalPromptDialog( suppressedApprovalID: self.activeExecApprovalPromptSuppressionID) @@ -965,8 +969,8 @@ struct RootTabs: View { } private func makeHomeCanvasPayload() -> RootTabsHomeCanvasPayload { - let gatewayName = self.normalized(self.appModel.gatewayServerName) - let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress) + let gatewayName = normalized(appModel.gatewayServerName) + let gatewayAddress = normalized(appModel.gatewayRemoteAddress) let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway" let activeAgentID = self.resolveActiveAgentID() let agents = self.homeCanvasAgents(activeAgentID: activeAgentID) @@ -1019,7 +1023,7 @@ struct RootTabs: View { } private func resolveActiveAgentID() -> String { - let selected = self.normalized(self.appModel.selectedAgentId) ?? "" + let selected = normalized(appModel.selectedAgentId) ?? "" if !selected.isEmpty { return selected } @@ -1027,7 +1031,7 @@ struct RootTabs: View { } private func resolveDefaultAgentID() -> String { - self.normalized(self.appModel.gatewayDefaultAgentId) ?? "" + normalized(self.appModel.gatewayDefaultAgentId) ?? "" } private func homeCanvasAgents(activeAgentID: String) -> [RootTabsHomeCanvasAgentCard] { @@ -1052,7 +1056,7 @@ struct RootTabs: View { } private func homeCanvasName(for agent: AgentSummary) -> String { - self.normalized(agent.name) ?? agent.id + normalized(agent.name) ?? agent.id } } @@ -1123,7 +1127,7 @@ extension RootTabs { } private func requestPhoneControlNavigation(_ target: PhoneControlNavigationRequest.Target) { - let requestID = (self.phoneControlNavigationRequest?.id ?? 0) &+ 1 + let requestID = (phoneControlNavigationRequest?.id ?? 0) &+ 1 self.phoneControlNavigationRequest = PhoneControlNavigationRequest(id: requestID, target: target) } @@ -1300,7 +1304,9 @@ extension RootTabs { private func maybeOpenSettingsForGatewaySetup() { let requestID = self.appModel.gatewaySetupRequestID guard requestID != 0, requestID != self.gatewaySetupRequest?.id else { return } - guard let link = self.appModel.consumePendingGatewaySetupLink() else { return } + // The presented onboarding flow owns setup-link staging until it dismisses. + guard !self.showOnboarding else { return } + guard let link = appModel.consumePendingGatewaySetupLink() else { return } self.showOnboarding = false self.presentedSheet = nil self.didAutoOpenSettings = true diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index 98aad9c4e5dc..215e8f50aac8 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -93,9 +93,10 @@ enum WatchMessageKind: String, Codable, Equatable { case quickReply } -struct WatchExecApprovalResolveEvent: Equatable { +struct WatchExecApprovalResolveEvent: Codable, Equatable { var replyId: String var approvalId: String + var gatewayStableID: String? var decision: OpenClawWatchExecApprovalDecision var sentAtMs: Int? var transport: String diff --git a/apps/ios/Sources/Services/WatchConnectivityTransport.swift b/apps/ios/Sources/Services/WatchConnectivityTransport.swift index 22e914b8dfc4..4082c7b6f12f 100644 --- a/apps/ios/Sources/Services/WatchConnectivityTransport.swift +++ b/apps/ios/Sources/Services/WatchConnectivityTransport.swift @@ -32,6 +32,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable { private let session: WCSession? private let callbacksLock = NSLock() + private let snapshotContextLock = NSLock() private var callbacks = WatchConnectivityTransportCallbacks() override init() { @@ -145,7 +146,12 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable { } do { - try session.updateApplicationContext(payload) + try self.snapshotContextLock.withLock { + let context = WatchMessagingPayloadCodec.encodeSnapshotApplicationContext( + payload, + merging: session.applicationContext) + try session.updateApplicationContext(context) + } return WatchNotificationSendResult( deliveredImmediately: false, queuedForDelivery: true, diff --git a/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift b/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift index a31299eb9af6..81915c8dce38 100644 --- a/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift +++ b/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift @@ -2,6 +2,11 @@ import Foundation import OpenClawKit enum WatchMessagingPayloadCodec { + private static let durableSnapshotTypes = [ + OpenClawWatchPayloadType.appSnapshot.rawValue, + OpenClawWatchPayloadType.execApprovalSnapshot.rawValue, + ] + static let completedChatReplyTextLimit = 4000 static func nowMs() -> Int { @@ -68,6 +73,9 @@ enum WatchMessagingPayloadCodec { "commandText": item.commandText, "allowedDecisions": item.allowedDecisions.map(\.rawValue), ] + if let gatewayStableID = nonEmpty(item.gatewayStableID) { + payload["gatewayStableID"] = gatewayStableID + } if let commandPreview = nonEmpty(item.commandPreview) { payload["commandPreview"] = commandPreview } @@ -115,6 +123,9 @@ enum WatchMessagingPayloadCodec { "type": OpenClawWatchPayloadType.execApprovalResolved.rawValue, "approvalId": message.approvalId, ] + if let gatewayStableID = nonEmpty(message.gatewayStableID) { + payload["gatewayStableID"] = gatewayStableID + } if let decision = message.decision { payload["decision"] = decision.rawValue } @@ -135,6 +146,9 @@ enum WatchMessagingPayloadCodec { "approvalId": message.approvalId, "reason": message.reason.rawValue, ] + if let gatewayStableID = nonEmpty(message.gatewayStableID) { + payload["gatewayStableID"] = gatewayStableID + } if let expiredAtMs = message.expiredAtMs { payload["expiredAtMs"] = expiredAtMs } @@ -148,6 +162,9 @@ enum WatchMessagingPayloadCodec { "type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue, "approvals": message.approvals.map(self.encodeExecApprovalItem), ] + if let gatewayStableID = nonEmpty(message.gatewayStableID) { + payload["gatewayStableID"] = gatewayStableID + } if let sentAtMs = message.sentAtMs { payload["sentAtMs"] = sentAtMs } @@ -206,6 +223,31 @@ enum WatchMessagingPayloadCodec { return payload } + static func encodeSnapshotApplicationContext( + _ payload: [String: Any], + merging existingContext: [String: Any]) -> [String: Any] + { + guard let payloadType = payload["type"] as? String, + self.durableSnapshotTypes.contains(payloadType) + else { + return payload + } + + // updateApplicationContext retains one dictionary. Nest both logical snapshots while + // keeping the newest one at the top level for older Watch app versions. + var context = payload + for snapshotType in self.durableSnapshotTypes { + if snapshotType == payloadType { + context[snapshotType] = payload + } else if let previous = existingContext[snapshotType] as? [String: Any] { + context[snapshotType] = previous + } else if existingContext["type"] as? String == snapshotType { + context[snapshotType] = existingContext + } + } + return context + } + static func encodeChatCompletionPayload( _ message: OpenClawWatchChatCompletionMessage) -> [String: Any] { @@ -266,10 +308,12 @@ enum WatchMessagingPayloadCodec { return nil } let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString + let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String) let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue return WatchExecApprovalResolveEvent( replyId: replyId, approvalId: approvalId, + gatewayStableID: gatewayStableID, decision: decision, sentAtMs: sentAtMs, transport: transport) diff --git a/apps/ios/Sources/Terminal/TerminalHubScreen.swift b/apps/ios/Sources/Terminal/TerminalHubScreen.swift index 90ac97fdf9df..b24be7252540 100644 --- a/apps/ios/Sources/Terminal/TerminalHubScreen.swift +++ b/apps/ios/Sources/Terminal/TerminalHubScreen.swift @@ -23,7 +23,7 @@ struct TerminalHubScreen: View { var body: some View { let config = self.appModel.activeGatewayConnectConfig - let storedOperatorToken = config == nil ? nil : Self.storedOperatorToken() + let storedOperatorToken = Self.storedOperatorToken(config: config) ZStack { OpenClawProBackground() if let url = Self.terminalURL(config: config) { @@ -105,7 +105,9 @@ struct TerminalHubScreen: View { /// (the same mechanism the macOS Dashboard window uses), so the token never /// appears in the page URL, WebKit history, or gateway request logs. static func terminalAuthUserScript(config: GatewayConnectConfig?) -> String? { - self.terminalAuthUserScript(config: config, storedOperatorToken: self.storedOperatorToken()) + self.terminalAuthUserScript( + config: config, + storedOperatorToken: self.storedOperatorToken(config: config)) } static func terminalAuthUserScript( @@ -160,9 +162,17 @@ struct TerminalHubScreen: View { return hasher.finalize() } - private static func storedOperatorToken() -> String? { + private static func storedOperatorToken(config: GatewayConnectConfig?) -> String? { + guard let config else { return nil } + // Endpoint handoffs may explicitly suppress device-token reuse; every auth surface + // must honor that boundary or a stale token can override the supplied password. + guard config.nodeOptions.allowStoredDeviceAuth else { return nil } + let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID let identity = DeviceIdentityStore.loadOrCreate() - return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator")? + return DeviceAuthStore.loadToken( + deviceId: identity.deviceId, + role: "operator", + gatewayID: gatewayID)? .token } @@ -201,7 +211,7 @@ private struct TerminalWebView: UIViewRepresentable { // Ephemeral store: credentials arrive per load via the auth user // script; nothing needs to persist across loads. config.websiteDataStore = .nonPersistent() - if let authScript = self.authScript { + if let authScript { config.userContentController.addUserScript(WKUserScript( source: authScript, injectionTime: .atDocumentStart, diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index ae74319ecf91..7eb025d679ae 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -114,6 +114,7 @@ final class TalkModeManager: NSObject { private var realtimeRelayStartInFlight = false private var prefetchedRealtimeSession: TalkRealtimeClientSession? private var realtimePrefetchTask: Task? + private var realtimePrefetchGeneration: UInt64 = 0 private var lastHeard: Date? private var lastTranscript: String = "" @@ -335,6 +336,7 @@ final class TalkModeManager: NSObject { if self.isEnabled, !self.isSpeaking { self.statusText = "Offline" } + self.realtimePrefetchGeneration &+= 1 self.realtimePrefetchTask?.cancel() self.realtimePrefetchTask = nil self.prefetchedRealtimeSession = nil @@ -1383,7 +1385,10 @@ final class TalkModeManager: NSObject { } } - func prefetchRealtimeSessionIfReady(reason: String) async { + func prefetchRealtimeSessionIfReady( + reason: String, + shouldApply: @escaping @MainActor @Sendable () -> Bool = { true }) async + { guard self.gatewayConnected, self.realtimeSession == nil, self.realtimeRelaySession == nil, @@ -1395,15 +1400,27 @@ final class TalkModeManager: NSObject { guard self.realtimePrefetchTask == nil else { return } GatewayDiagnostics.log("talk.timeline realtime prefetch scheduled reason=\(reason)") + self.realtimePrefetchGeneration &+= 1 + let prefetchGeneration = self.realtimePrefetchGeneration self.realtimePrefetchTask = Task { @MainActor [weak self] in guard let self else { return } + defer { + if self.realtimePrefetchGeneration == prefetchGeneration { + self.realtimePrefetchTask = nil + } + } let startedAt = Self.nowSeconds() do { + guard !Task.isCancelled, shouldApply(), let gateway = self.gateway else { return } + guard let route = await gateway.currentRoute() else { return } + guard !Task.isCancelled, shouldApply() else { return } let session = try await self.createRealtimeClientSession( + gateway: gateway, + route: route, provider: self.realtimeProvider, model: self.realtimeModelId, voice: self.realtimeVoiceId) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, shouldApply() else { return } self.prefetchedRealtimeSession = session GatewayDiagnostics.log( "talk.timeline realtime prefetch ready elapsedMs=\(Self.elapsedMs(since: startedAt)) " @@ -1414,24 +1431,24 @@ final class TalkModeManager: NSObject { "talk.timeline realtime prefetch failed elapsedMs=\(Self.elapsedMs(since: startedAt)) " + "error=\(error.localizedDescription)") } - self.realtimePrefetchTask = nil } } private func createRealtimeClientSession( + gateway: GatewayNodeSession, + route: GatewayNodeSessionRoute, provider: String?, model: String?, voice: String?) async throws -> TalkRealtimeClientSession { - guard let gateway else { - throw NSError(domain: "TalkMode", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "Gateway not connected", - ]) - } let params = TalkRealtimeClientCreateParams(provider: provider, model: model, voice: voice) let data = try JSONEncoder().encode(params) let json = String(data: data, encoding: .utf8) - let res = try await gateway.request(method: "talk.client.create", paramsJSON: json, timeoutSeconds: 12) + let res = try await gateway.request( + method: "talk.client.create", + paramsJSON: json, + timeoutSeconds: 12, + ifCurrentRoute: route) return try JSONDecoder().decode(TalkRealtimeClientSession.self, from: res) } @@ -2826,12 +2843,12 @@ extension TalkModeManager { + "permission=\(self.gatewayTalkPermissionState.statusLabel)") } - func reloadConfig() async { + func reloadConfig(shouldApply: @MainActor @Sendable () -> Bool = { true }) async { guard let gateway else { return } self.pcmFormatUnavailable = false self.prefetchedRealtimeSession = nil do { - guard let loaded = try await loadTalkConfig(from: gateway) else { return } + guard let loaded = try await loadTalkConfig(from: gateway), shouldApply() else { return } let parsed = TalkModeGatewayConfigParser.parse( config: loaded.config, defaultProvider: Self.defaultTalkProvider, @@ -2844,6 +2861,7 @@ extension TalkModeManager { } self.applyLoadedTalkConfig(parsed, redactedFallbackMissingScope: loaded.redactedFallbackMissingScope) } catch { + guard shouldApply() else { return } self.applyTalkConfigLoadFailure(error) } } diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index 0c1c03f86bb8..42aa4d3dd6c1 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -121,6 +121,16 @@ private func agentAction( #expect(DeepLinkParser.parse(url) == nil) } + @Test func parseGatewayLinkRejectsInvalidPort() { + let url = URL(string: "openclaw://gateway?host=gateway.example.com&port=70000&tls=1")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseGatewayLinkRejectsMalformedPort() { + let url = URL(string: "openclaw://gateway?host=gateway.example.com&port=not-a-port&tls=1")! + #expect(DeepLinkParser.parse(url) == nil) + } + @Test func parseGatewaySetupCodeParsesBase64UrlPayload() { let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) @@ -138,6 +148,24 @@ private func agentAction( #expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil) } + @Test func parseGatewaySetupCodeRejectsInvalidPort() { + let payload = #"{"host":"gateway.example.com","port":70000,"tls":true}"# + #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) + } + + @Test func invalidPortHasNoWebSocketURL() { + let link = GatewayConnectDeepLink( + host: "gateway.example.com", + port: -1, + tls: true, + bootstrapToken: nil, + token: nil, + password: nil) + + #expect(link.websocketURL == nil) + #expect(!link.isValidEndpoint) + } + @Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() { let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) diff --git a/apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift b/apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift index efa5fa571910..72a72ad20e54 100644 --- a/apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift +++ b/apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift @@ -32,33 +32,39 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se } @Suite(.serialized) struct ExecApprovalNotificationBridgeTests { - @Test func parsePromptMapsDefaultNotificationTap() { + @Test func `parse prompt maps default notification tap`() { let prompt = ExecApprovalNotificationBridge.parsePrompt( actionIdentifier: UNNotificationDefaultActionIdentifier, userInfo: [ "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, "approvalId": "approval-123", + "gatewayDeviceId": "gateway-a", ], ]) - #expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123")) + #expect(prompt == ExecApprovalNotificationPrompt( + approvalId: "approval-123", + gatewayDeviceId: "gateway-a")) } - @Test func parsePromptMapsReviewAction() { + @Test func `parse prompt maps review action`() { let prompt = ExecApprovalNotificationBridge.parsePrompt( actionIdentifier: ExecApprovalNotificationBridge.reviewActionIdentifier, userInfo: [ "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, "approvalId": "approval-456", + "gatewayDeviceId": "gateway-b", ], ]) - #expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-456")) + #expect(prompt == ExecApprovalNotificationPrompt( + approvalId: "approval-456", + gatewayDeviceId: "gateway-b")) } - @Test func parsePromptIgnoresUnexpectedActionIdentifiers() { + @Test func `parse prompt ignores unexpected action identifiers`() { let prompt = ExecApprovalNotificationBridge.parsePrompt( actionIdentifier: "openclaw.exec-approval.allow-once", userInfo: [ @@ -71,7 +77,7 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se #expect(prompt == nil) } - @Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async { + @Test @MainActor func `handle resolved push removes matching notifications`() async { let center = MockNotificationCenter() center.delivered = [ NotificationSnapshot( @@ -80,6 +86,7 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, "approvalId": "approval-123", + "gatewayDeviceId": "gateway-a", ], ]), NotificationSnapshot( @@ -87,22 +94,73 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se userInfo: [ "openclaw": [ "kind": ExecApprovalNotificationBridge.requestedKind, - "approvalId": "approval-999", + "approvalId": "approval-123", + "gatewayDeviceId": "gateway-b", ], ]), ] - let handled = await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded( - userInfo: [ - "openclaw": [ - "kind": ExecApprovalNotificationBridge.resolvedKind, - "approvalId": "approval-123", - ], - ], + let push = ExecApprovalNotificationPrompt( + approvalId: "approval-123", + gatewayDeviceId: "gateway-a") + await ExecApprovalNotificationBridge.removeNotifications( + for: push, notificationCenter: center) - #expect(handled) - #expect(center.pendingRemovedIdentifiers == [["exec.approval.approval-123"]]) + #expect(center.pendingRemovedIdentifiers == [["exec.approval.gateway-a.approval-123"]]) #expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]]) } + + @Test func `legacy ownerless approval pushes remain parseable for authenticated route validation`() { + let userInfo: [AnyHashable: Any] = [ + "openclaw": [ + "kind": ExecApprovalNotificationBridge.requestedKind, + "approvalId": "approval-ownerless", + ], + ] + + #expect(ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) == + ExecApprovalNotificationPrompt( + approvalId: "approval-ownerless", + gatewayDeviceId: nil)) + #expect(ExecApprovalNotificationBridge.shouldPresentNotification(userInfo: userInfo)) + } + + @Test @MainActor func `validated cleanup removes legacy ownerless alerts but preserves other owners`() async { + let center = MockNotificationCenter() + center.delivered = [ + NotificationSnapshot( + identifier: "legacy-ownerless", + userInfo: [ + "openclaw": [ + "kind": ExecApprovalNotificationBridge.requestedKind, + "approvalId": "approval-shared", + ], + ]), + NotificationSnapshot( + identifier: "other-owner", + userInfo: [ + "openclaw": [ + "kind": ExecApprovalNotificationBridge.requestedKind, + "approvalId": "approval-shared", + "gatewayDeviceId": "gateway-b", + ], + ]), + ] + let push = ExecApprovalNotificationPrompt( + approvalId: "approval-shared", + gatewayDeviceId: "gateway-a") + + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: center, + includingLegacyOwnerless: true) + + #expect(center.pendingRemovedIdentifiers == [[ + "exec.approval.gateway-a.approval-shared", + "exec.approval.approval-shared", + "exec.approval.legacy.approval-shared", + ]]) + #expect(center.deliveredRemovedIdentifiers == [["legacy-ownerless"]]) + } } diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index bb2fdae21e51..a345a6e711ce 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -1,11 +1,11 @@ import Foundation import Testing import UIKit -@testable import OpenClawKit @testable import OpenClaw +@testable import OpenClawKit @Suite(.serialized) struct GatewayConnectionControllerTests { - @Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() { + @Test @MainActor func `resolved display name sets default when missing`() { let defaults = UserDefaults.standard let displayKey = "node.displayName" @@ -19,7 +19,7 @@ import UIKit } } - @Test @MainActor func currentCapsReflectToggles() { + @Test @MainActor func `current caps reflect toggles`() { withUserDefaults([ "node.instanceId": "ios-test", "node.displayName": "Test Node", @@ -40,7 +40,7 @@ import UIKit } } - @Test @MainActor func currentCommandsIncludeLocationWhenEnabled() { + @Test @MainActor func `current commands include location when enabled`() { withUserDefaults([ "node.instanceId": "ios-test", "location.enabledMode": OpenClawLocationMode.whileUsing.rawValue, @@ -53,7 +53,7 @@ import UIKit } } - @Test @MainActor func locationPermissionRequiresGlobalServicesAndAppAuthorization() { + @Test @MainActor func `location permission requires global services and app authorization`() { #expect(GatewayConnectionController._test_isLocationAvailable( servicesEnabled: true, status: .authorizedWhenInUse)) @@ -68,7 +68,7 @@ import UIKit status: .denied)) } - @Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() { + @Test @MainActor func `current commands exclude dangerous system exec commands`() { withUserDefaults([ "node.instanceId": "ios-test", "camera.enabled": true, @@ -87,7 +87,7 @@ import UIKit } } - @Test @MainActor func operatorConnectOptionsOnlyRequestApprovalScopeWhenEnabled() { + @Test @MainActor func `operator connect options only request approval scope when enabled`() { let appModel = NodeAppModel() let withoutApprovalScope = appModel._test_makeOperatorConnectOptions( clientId: "openclaw-ios", @@ -115,7 +115,7 @@ import UIKit #expect(withAdminScope.scopes.contains("operator.admin")) } - @Test @MainActor func operatorTalkPermissionUpgradeUsesExplicitLeastPrivilegeScopes() { + @Test @MainActor func `operator talk permission upgrade uses explicit least privilege scopes`() { let appModel = NodeAppModel() let options = appModel._test_makeOperatorConnectOptions( clientId: "openclaw-ios", @@ -131,7 +131,7 @@ import UIKit #expect(options.scopes.contains("operator.talk.secrets")) } - @Test func operatorAdminScopeRequestsOnlyWhenSharedAuthOrAlreadyGranted() { + @Test func `operator admin scope requests only when shared auth or already granted`() { #expect( !NodeAppModel._test_shouldRequestOperatorAdminScope( token: nil, @@ -160,7 +160,7 @@ import UIKit forceTalkPermissionUpgradeRequest: true)) } - @Test func storedDeviceTokenScopeGapUsesGatewayScopeCompatibility() { + @Test func `stored device token scope gap uses gateway scope compatibility`() { #expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken( role: "operator", requestedScopes: ["operator.read", "operator.write", "operator.talk.secrets"], @@ -178,7 +178,7 @@ import UIKit storedScopes: ["operator.read"])) } - @Test func operatorApprovalScopeRequestsStayBackwardCompatible() { + @Test func `operator approval scope requests stay backward compatible`() { #expect( !NodeAppModel._test_shouldRequestOperatorApprovalScope( token: nil, @@ -213,7 +213,7 @@ import UIKit forceTalkPermissionUpgradeRequest: true)) } - @Test @MainActor func operatorPairingProblemPreservesPrimaryGatewayConnectionState() { + @Test @MainActor func `operator pairing problem preserves primary gateway connection state`() { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) appModel.gatewayServerName = "gateway.example.com" @@ -252,7 +252,7 @@ import UIKit #expect(appModel.gatewayStatusText == "Connected") } - @Test @MainActor func savedManualEndpointFallbackUsesOnboardingHostWhenAutoConnectIsEnabled() { + @Test @MainActor func `saved manual endpoint fallback uses onboarding host when auto connect is enabled`() { withUserDefaults([ "gateway.autoconnect": true, "gateway.manual.enabled": true, @@ -272,7 +272,7 @@ import UIKit } } - @Test @MainActor func savedManualEndpointFallbackRequiresManualGatewayEnabled() { + @Test @MainActor func `saved manual endpoint fallback requires manual gateway enabled`() { withUserDefaults([ "gateway.autoconnect": true, "gateway.manual.enabled": false, @@ -288,7 +288,7 @@ import UIKit } } - @Test @MainActor func savedManualEndpointFallbackRequiresAutoConnect() { + @Test @MainActor func `saved manual endpoint fallback requires auto connect`() { withUserDefaults([ "gateway.autoconnect": false, "gateway.manual.enabled": true, @@ -304,7 +304,7 @@ import UIKit } } - @Test func gatewayConnectConfigMatchesEquivalentInputs() { + @Test func `gateway connect config matches equivalent inputs`() { let lhs = Self.makeGatewayConnectConfig() let rhs = GatewayConnectConfig( url: lhs.url, @@ -326,23 +326,567 @@ import UIKit #expect(lhs.hasSameConnectionInputs(as: rhs)) } - @Test @MainActor func applyingDifferentGatewayConfigReconnectsActiveTasks() { + @Test func `setup auth override is scoped to scanned endpoint`() { + let link = GatewayConnectDeepLink( + host: "first.gateway.example.com", + port: 443, + tls: true, + bootstrapToken: "bootstrap-token", + token: "source-token", + password: "source-password") + let pending = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link).manualAuthOverride + let firstStableID = GatewayConnectionController.ManualAuthOverride.manualStableID( + host: link.host, + port: link.port) + let secondStableID = GatewayConnectionController.ManualAuthOverride.manualStableID( + host: "second.gateway.example.com", + port: 443) + + let first = GatewayConnectionController.ManualAuthOverride.currentManualInput( + token: "source-token", + pendingOverride: pending, + password: "source-password", + targetStableID: firstStableID) + let second = GatewayConnectionController.ManualAuthOverride.currentManualInput( + token: "source-token", + pendingOverride: pending, + password: "source-password", + targetStableID: secondStableID) + let edited = GatewayConnectionController.ManualAuthOverride.currentManualInput( + token: "replacement-token", + pendingOverride: pending, + password: "source-password", + targetStableID: secondStableID) + let ordinary = GatewayConnectionController.ManualAuthOverride.currentManualInput( + token: "manual-token", + pendingOverride: nil, + password: nil, + targetStableID: secondStableID) + + #expect(first?.token == "source-token") + #expect(first?.bootstrapToken == "bootstrap-token") + #expect(first?.password == "source-password") + #expect(first?.targetStableID == firstStableID) + #expect(first?.suppressStoredDeviceAuth == true) + #expect(second?.token == nil) + #expect(second?.bootstrapToken == nil) + #expect(second?.password == nil) + #expect(second?.targetStableID == secondStableID) + #expect(second?.suppressStoredDeviceAuth == true) + #expect(edited?.token == "replacement-token") + #expect(edited?.password == nil) + #expect(ordinary?.suppressStoredDeviceAuth == false) + } + + @Test func `persisted setup auth stays scoped after view recreation`() throws { + let instanceID = "setup-auth-owner-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let firstStableID = "manual|first.gateway.example.com|443" + let secondStableID = "manual|second.gateway.example.com|443" + GatewaySettingsStore.saveGatewayCredentials( + token: "source-token", + bootstrapToken: "source-bootstrap-token", + password: "source-password", + gatewayStableID: firstStableID, + suppressStoredDeviceAuth: true, + instanceId: instanceID) + + let relaunchedOverride = try #require( + GatewayConnectionController.ManualAuthOverride.persisted(instanceId: instanceID)) + let sameTargetRetryOverride = try #require( + GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: instanceID, + targetStableID: firstStableID)) + #expect(sameTargetRetryOverride.bootstrapToken == "source-bootstrap-token") + #expect(GatewayConnectionController.ManualAuthOverride.persisted( + instanceId: instanceID, + targetStableID: secondStableID) == nil) + let secondGatewayAuth = try #require( + GatewayConnectionController.ManualAuthOverride.currentManualInput( + token: "source-token", + pendingOverride: relaunchedOverride, + password: "source-password", + targetStableID: secondStableID)) + GatewaySettingsStore.saveGatewayCredentials( + token: secondGatewayAuth.token, + bootstrapToken: secondGatewayAuth.bootstrapToken, + password: secondGatewayAuth.password, + gatewayStableID: secondStableID, + suppressStoredDeviceAuth: secondGatewayAuth.suppressStoredDeviceAuth, + instanceId: instanceID) + + #expect(secondGatewayAuth.token == nil) + #expect(secondGatewayAuth.bootstrapToken == nil) + #expect(secondGatewayAuth.password == nil) + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: firstStableID) == .empty) + let persistedCredentiallessHandoff = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: secondStableID) + #expect(!persistedCredentiallessHandoff.hasCredentials) + #expect(persistedCredentiallessHandoff.suppressStoredDeviceAuth) + let nextRelaunchOverride = try #require( + GatewayConnectionController.ManualAuthOverride.persisted(instanceId: instanceID)) + #expect(nextRelaunchOverride.targetStableID == secondStableID) + #expect(nextRelaunchOverride.suppressStoredDeviceAuth) + } + + @Test @MainActor func `empty setup auth does not reuse stored gateway credentials`() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + let defaults = UserDefaults.standard + let previousInstanceID = defaults.object(forKey: "node.instanceId") + let instanceID = "ios-test-\(UUID().uuidString)" + defaults.set(instanceID, forKey: "node.instanceId") + GatewaySettingsStore.saveGatewayCredentials( + token: "stored-token", + bootstrapToken: nil, + password: "stored-password", + gatewayStableID: "manual|stored.example.com|443", + suppressStoredDeviceAuth: false, + instanceId: instanceID) + defer { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) + if let previousInstanceID { + defaults.set(previousInstanceID, forKey: "node.instanceId") + } else { + defaults.removeObject(forKey: "node.instanceId") + } + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let link = GatewayConnectDeepLink( + host: "192.168.1.41", + port: 18789, + tls: false, + bootstrapToken: nil, + token: nil, + password: nil) + let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) let appModel = NodeAppModel() defer { appModel.disconnectGateway() } - let first = Self.makeGatewayConnectConfig( - url: URL(string: "wss://first.gateway.example.com")!, + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + await controller.connectManual( + host: link.host, + port: link.port, + useTLS: link.tls, + authOverride: setupAuth.manualAuthOverride) + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + while appModel.activeGatewayConnectConfig == nil, ContinuousClock().now < deadline { + await Task.yield() + } + + #expect(appModel.activeGatewayConnectConfig != nil) + #expect(appModel.activeGatewayConnectConfig?.token == nil) + #expect(appModel.activeGatewayConnectConfig?.bootstrapToken == nil) + #expect(appModel.activeGatewayConnectConfig?.password == nil) + #expect(appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == false) + #expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == setupAuth.targetStableID) + } + + @Test @MainActor func `legacy auth preserves proven relay credentials and otherwise requires full re-pair`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + let defaults = UserDefaults.standard + let previousInstanceID = defaults.string(forKey: "node.instanceId") + let instanceID = "legacy-relay-\(UUID().uuidString)" + let gatewayService = "ai.openclawfoundation.app.gateway" + let lastConnectionAccount = "lastConnection" + let previousLastConnection = KeychainStore.loadString( + service: gatewayService, + account: lastConnectionAccount) + let previousRelay = ShareGatewayRelaySettings.loadConfig() + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defaults.set(instanceID, forKey: "node.instanceId") + defer { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) + if let previousInstanceID { + defaults.set(previousInstanceID, forKey: "node.instanceId") + } else { + defaults.removeObject(forKey: "node.instanceId") + } + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + if let previousLastConnection { + _ = KeychainStore.saveString( + previousLastConnection, + service: gatewayService, + account: lastConnectionAccount) + } else { + _ = KeychainStore.delete(service: gatewayService, account: lastConnectionAccount) + } + if let previousRelay { + ShareGatewayRelaySettings.saveConfig(previousRelay) + } else { + ShareGatewayRelaySettings.clearConfig() + } + try? FileManager.default.removeItem(at: tempDir) + } + + let stableID = "manual|gateway.example.com|443" + let primaryIdentity = DeviceIdentityStore.loadOrCreate() + let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension) + _ = DeviceAuthStore.storeToken( + deviceId: primaryIdentity.deviceId, + role: "node", + token: "legacy-primary-token") + _ = DeviceAuthStore.storeToken( + deviceId: primaryIdentity.deviceId, + role: "operator", + token: "legacy-operator-token") + _ = DeviceAuthStore.storeToken( + deviceId: shareIdentity.deviceId, + role: "node", + token: "legacy-share-token", + profile: .shareExtension) + GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest( + "unproven-field-token", + instanceId: instanceID) + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "gateway.example.com", + port: 443, + useTLS: true, + stableID: stableID) + ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig( + gatewayURLString: "wss://gateway.example.com", + token: "proven-relay-token", + password: "proven-relay-password", + sessionKey: "main")) + + let extensionConfig = try #require(ShareGatewayRelaySettings.loadConfigDiscardingUnscopedDeviceAuth()) + #expect(extensionConfig.gatewayStableID == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: shareIdentity.deviceId, + role: "node", + profile: .shareExtension) == nil) + + _ = DeviceAuthStore.storeToken( + deviceId: shareIdentity.deviceId, + role: "node", + token: "ambiguous-share-token", + profile: .shareExtension) + _ = GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false) + + #expect(DeviceAuthStore.loadToken( + deviceId: primaryIdentity.deviceId, + role: "node", + gatewayID: stableID)?.token == "legacy-primary-token") + #expect(DeviceAuthStore.loadToken( + deviceId: primaryIdentity.deviceId, + role: "operator", + gatewayID: stableID) == nil) + #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "operator") == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: shareIdentity.deviceId, + role: "node", + gatewayID: stableID, + profile: .shareExtension) == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: shareIdentity.deviceId, + role: "node", + profile: .shareExtension) == nil) + #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node") == nil) + #expect(ShareGatewayRelaySettings.loadConfig()?.gatewayStableID == stableID) + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: stableID) + #expect(credentials.token == "proven-relay-token") + #expect(credentials.password == "proven-relay-password") + #expect(!credentials.suppressStoredDeviceAuth) + #expect(KeychainStore.loadString( + service: gatewayService, + account: "gateway-token.\(instanceID)") == nil) + + let rePairStableID = "manual|pair-again.example.com|443" + _ = DeviceAuthStore.storeToken( + deviceId: primaryIdentity.deviceId, + role: "node", + token: "legacy-node-without-shared-auth") + _ = DeviceAuthStore.storeToken( + deviceId: primaryIdentity.deviceId, + role: "operator", + token: "legacy-operator-without-shared-auth") + GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest( + "unproven-field-token", + instanceId: instanceID) + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "pair-again.example.com", + port: 443, + useTLS: true, + stableID: rePairStableID) + ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig( + gatewayURLString: "wss://pair-again.example.com", + token: nil, + password: nil, + sessionKey: "main")) + + _ = GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false) + + #expect(DeviceAuthStore.loadToken( + deviceId: primaryIdentity.deviceId, + role: "node", + gatewayID: rePairStableID) == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: primaryIdentity.deviceId, + role: "operator", + gatewayID: rePairStableID) == nil) + #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node") == nil) + #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "operator") == nil) + } + + @Test @MainActor func `successful setup handoff enables target scoped auth`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let identity = DeviceIdentityStore.loadOrCreate() + let previousStableID = "manual|previous.gateway.example.com|443" + let stableID = "manual|new.gateway.example.com|443" + let instanceID = "bootstrap-handoff-\(UUID().uuidString)" + let previousInstanceID = UserDefaults.standard.string(forKey: "node.instanceId") + UserDefaults.standard.set(instanceID, forKey: "node.instanceId") + defer { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) + if let previousInstanceID { + UserDefaults.standard.set(previousInstanceID, forKey: "node.instanceId") + } else { + UserDefaults.standard.removeObject(forKey: "node.instanceId") + } + } + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "node", + token: "previous-node-token", + gatewayID: previousStableID) + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "operator", + token: "previous-operator-token", + gatewayID: previousStableID) + var nodeOptions = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone", + allowStoredDeviceAuth: false, + deviceAuthGatewayID: stableID) + let config = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: stableID, + tls: nil, + token: nil, + bootstrapToken: "one-time-bootstrap", + password: nil, + nodeOptions: nodeOptions) + GatewaySettingsStore.saveGatewayCredentials( + token: nil, + bootstrapToken: "one-time-bootstrap", + password: nil, + gatewayStableID: stableID, + suppressStoredDeviceAuth: true, + instanceId: instanceID) + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + appModel.applyGatewayConnectConfig(config) + + let emptyIssuanceOptions = appModel._test_completeSuccessfulGatewayAuthHandoff( + issuedRoles: [], + nodeOptions: nodeOptions) + let operatorOnlyOptions = appModel._test_completeSuccessfulGatewayAuthHandoff( + issuedRoles: ["operator"], + nodeOptions: nodeOptions) + let nodeOnlyOptions = appModel._test_completeSuccessfulGatewayAuthHandoff( + issuedRoles: ["node"], + nodeOptions: nodeOptions) + #expect(emptyIssuanceOptions == nil) + #expect(operatorOnlyOptions == nil) + #expect(nodeOnlyOptions == nil) + #expect(appModel.activeGatewayConnectConfig?.bootstrapToken == "one-time-bootstrap") + #expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID) != nil) + + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "node", + token: "new-node-token", + gatewayID: stableID) + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "operator", + token: "new-operator-token", + gatewayID: stableID) + let bootstrapOptions = nodeOptions + appModel._test_setGatewayLoopTasks(node: nil, operator: Task {}) + nodeOptions = try #require(appModel._test_completeSuccessfulGatewayAuthHandoff( + issuedRoles: ["node", "operator"], + nodeOptions: nodeOptions)) + + #expect(nodeOptions.allowStoredDeviceAuth) + #expect(appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true) + #expect(appModel.activeGatewayConnectConfig?.bootstrapToken == nil) + #expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID) == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: identity.deviceId, + role: "node", + gatewayID: stableID)?.token == "new-node-token") + #expect(DeviceAuthStore.loadToken( + deviceId: identity.deviceId, + role: "operator", + gatewayID: stableID)?.token == "new-operator-token") + #expect(DeviceAuthStore.loadToken( + deviceId: identity.deviceId, + role: "node", + gatewayID: previousStableID)?.token == "previous-node-token") + #expect(appModel._test_hasGatewayLoopTasks().operator) + #expect(appModel._test_currentGatewayReconnectOptions( + stableID: stableID, + fallback: bootstrapOptions).allowStoredDeviceAuth) + } + + @Test @MainActor func `bootstrap pairing clears only the target gateway`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + let gatewayA = "manual|gateway-a-\(UUID().uuidString)|443" + let gatewayB = "manual|gateway-b-\(UUID().uuidString)|443" + defer { + GatewayTLSStore.clearFingerprint(stableID: gatewayA) + GatewayTLSStore.clearFingerprint(stableID: gatewayB) + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let primaryIdentity = DeviceIdentityStore.loadOrCreate() + let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension) + GatewayTLSStore.saveFingerprint("fingerprint-a", stableID: gatewayA) + GatewayTLSStore.saveFingerprint("fingerprint-b", stableID: gatewayB) + let gatewayAOwner = GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayA) + let gatewayBOwner = GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayB) + for (routeID, ownerID) in [(gatewayA, gatewayAOwner), (gatewayB, gatewayBOwner)] { + _ = DeviceAuthStore.storeToken( + deviceId: primaryIdentity.deviceId, + role: "node", + token: "primary-\(routeID)", + gatewayID: ownerID) + _ = DeviceAuthStore.storeToken( + deviceId: shareIdentity.deviceId, + role: "node", + token: "share-\(routeID)", + gatewayID: ownerID, + profile: .shareExtension) + } + + let appModel = NodeAppModel() + GatewayOnboardingReset.prepareForBootstrapPairing( + appModel: appModel, + instanceId: "", + gatewayStableID: gatewayB, + disconnectGateway: false) + + #expect(DeviceAuthStore.loadToken( + deviceId: primaryIdentity.deviceId, + role: "node", + gatewayID: gatewayAOwner) != nil) + #expect(DeviceAuthStore.loadToken( + deviceId: primaryIdentity.deviceId, + role: "node", + gatewayID: gatewayBOwner) == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: shareIdentity.deviceId, + role: "node", + gatewayID: gatewayAOwner, + profile: .shareExtension) != nil) + #expect(DeviceAuthStore.loadToken( + deviceId: shareIdentity.deviceId, + role: "node", + gatewayID: gatewayBOwner, + profile: .shareExtension) == nil) + #expect(GatewayTLSStore.loadFingerprint(stableID: gatewayA) == "fingerprint-a") + #expect(GatewayTLSStore.loadFingerprint(stableID: gatewayB) == nil) + } + + @Test @MainActor func `explicit auth starts operator loop while stored auth is disabled`() throws { + let stableID = "manual|gateway.example.com|443" + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let config = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: stableID, + tls: nil, + token: "shared-token", + bootstrapToken: nil, + password: nil, + nodeOptions: GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone", + allowStoredDeviceAuth: false, + deviceAuthGatewayID: stableID)) + + appModel.applyGatewayConnectConfig(config) + + #expect(appModel._test_hasGatewayLoopTasks().node) + #expect(appModel._test_hasGatewayLoopTasks().operator) + } + + @Test @MainActor func `applying different gateway config reconnects active tasks`() throws { + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let first = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), stableID: "manual|first.gateway.example.com|443") - let second = Self.makeGatewayConnectConfig( - url: URL(string: "wss://second.gateway.example.com")!, + let second = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:2")), stableID: "manual|second.gateway.example.com|443") appModel.applyGatewayConnectConfig(first) + appModel.talkMode.updateGatewayConnected(true) appModel.applyGatewayConnectConfig(second) #expect(appModel.connectedGatewayID == second.stableID) + #expect(!appModel.talkMode.isGatewayConnected) } - @Test @MainActor func forcedReconnectResetClearsActiveGatewayLoopTasks() async { + @Test @MainActor func `forced reconnect reset clears active gateway loop tasks`() async { let appModel = NodeAppModel() defer { appModel.disconnectGateway() } @@ -356,7 +900,892 @@ import UIKit #expect(!appModel._test_hasGatewayLoopTasks().operator) } - @Test @MainActor func foregroundStaleConnectionRestartReappliesActiveGatewayConfig() async { + @Test @MainActor func `forced reconnect reset waits for canceled loop cleanup`() async { + var cleanupStarted = false + var releaseCleanup: CheckedContinuation? + var resetFinished = false + let appModel = NodeAppModel() + let loopTask = Task { @MainActor in + while !Task.isCancelled { + await Task.yield() + } + cleanupStarted = true + await withCheckedContinuation { continuation in + releaseCleanup = continuation + } + } + appModel._test_setGatewayLoopTasks(node: loopTask) + defer { + releaseCleanup?.resume() + appModel.disconnectGateway() + } + + let resetTask = Task { @MainActor in + await appModel.resetGatewaySessionsForForcedReconnect() + resetFinished = true + } + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + while !cleanupStarted, ContinuousClock().now < deadline { + await Task.yield() + } + + #expect(cleanupStarted) + #expect(appModel.hasGatewaySessionResetInFlight) + #expect(!resetFinished) + + releaseCleanup?.resume() + releaseCleanup = nil + await resetTask.value + + #expect(resetFinished) + #expect(!appModel.hasGatewaySessionResetInFlight) + } + + @Test @MainActor func `manual disconnect chains after existing reset to own new loops`() async { + let resetRelease = AsyncStream.makeStream() + let appModel = NodeAppModel() + defer { + resetRelease.continuation.finish() + appModel._test_setGatewaySessionResetTask(nil) + appModel.disconnectGateway() + } + let existingReset = Task { + for await _ in resetRelease.stream { + return + } + } + appModel._test_setGatewaySessionResetTask(existingReset) + appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig()) + #expect(appModel._test_hasGatewayLoopTasks().node) + #expect(appModel._test_hasGatewayLoopTasks().operator) + + appModel.disconnectGateway() + resetRelease.continuation.yield() + resetRelease.continuation.finish() + await appModel.waitForGatewaySessionResetIfNeeded() + + #expect(appModel.activeGatewayConnectConfig == nil) + #expect(!appModel._test_hasGatewayLoopTasks().node) + #expect(!appModel._test_hasGatewayLoopTasks().operator) + } + + @Test @MainActor func `target switch reset clears previous reconnect route`() async { + let defaults = UserDefaults.standard + let reconnectDefaults: [String: Any?] = [ + "gateway.autoconnect": true, + "gateway.manual.enabled": true, + "gateway.manual.host": "previous.gateway.invalid", + "gateway.manual.port": 443, + "gateway.manual.tls": true, + ] + var reconnectDefaultsSnapshot: [String: Any?] = [:] + for key in reconnectDefaults.keys { + reconnectDefaultsSnapshot[key] = defaults.object(forKey: key) + } + let gatewayService = "ai.openclawfoundation.app.gateway" + let lastConnectionAccount = "lastConnection" + let priorLastConnection = KeychainStore.loadString( + service: gatewayService, + account: lastConnectionAccount) + let priorRelayConfig = ShareGatewayRelaySettings.loadConfig() + defer { + if let priorRelayConfig { + ShareGatewayRelaySettings.saveConfig(priorRelayConfig) + } else { + ShareGatewayRelaySettings.clearConfig() + } + } + defer { + for (key, value) in reconnectDefaultsSnapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + if let priorLastConnection { + _ = KeychainStore.saveString( + priorLastConnection, + service: gatewayService, + account: lastConnectionAccount) + } else { + _ = KeychainStore.delete(service: gatewayService, account: lastConnectionAccount) + } + } + for (key, value) in reconnectDefaults { + defaults.set(value, forKey: key) + } + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "previous.gateway.invalid", + port: 443, + useTLS: true, + stableID: "manual|previous.gateway.invalid|443") + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + + ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig( + gatewayURLString: "wss://previous.gateway.invalid", + token: "previous-token", + password: nil, + sessionKey: "main")) + appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig()) + await appModel.resetGatewaySessionsForTargetSwitch() + + #expect(!appModel.gatewayAutoReconnectEnabled) + #expect(!defaults.bool(forKey: "gateway.autoconnect")) + #expect(appModel.activeGatewayConnectConfig == nil) + #expect(appModel.gatewayServerName == nil) + #expect(!appModel._test_hasGatewayLoopTasks().node) + #expect(!appModel._test_hasGatewayLoopTasks().operator) + #expect(ShareGatewayRelaySettings.loadConfig() == nil) + + let relaunchedModel = NodeAppModel() + defer { relaunchedModel.disconnectGateway() } + let relaunchedController = GatewayConnectionController( + appModel: relaunchedModel, + startDiscovery: false) + relaunchedController._test_triggerAutoConnect() + + #expect(!relaunchedController._test_didAutoConnect()) + #expect(relaunchedModel.activeGatewayConnectConfig == nil) + } + + @Test @MainActor func `target switch reset reasserts persisted reconnect pause after teardown`() async { + let defaults = UserDefaults.standard + let priorAutoConnect = defaults.object(forKey: "gateway.autoconnect") + defer { + if let priorAutoConnect { + defaults.set(priorAutoConnect, forKey: "gateway.autoconnect") + } else { + defaults.removeObject(forKey: "gateway.autoconnect") + } + } + defaults.set(true, forKey: "gateway.autoconnect") + + let teardownRelease = AsyncStream.makeStream() + let appModel = NodeAppModel() + defer { + appModel._test_setGatewaySessionResetTask(nil) + appModel.disconnectGateway() + } + let staleTeardownTask = Task { + for await _ in teardownRelease.stream { + defaults.set(true, forKey: "gateway.autoconnect") + return + } + } + appModel._test_setGatewaySessionResetTask(staleTeardownTask) + + let targetResetTask = Task { + await appModel.resetGatewaySessionsForTargetSwitch() + } + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + while defaults.bool(forKey: "gateway.autoconnect"), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(!defaults.bool(forKey: "gateway.autoconnect")) + + teardownRelease.continuation.yield() + teardownRelease.continuation.finish() + await targetResetTask.value + + #expect(!defaults.bool(forKey: "gateway.autoconnect")) + } + + @Test @MainActor func `newer gateway connect generation rejects queued config`() throws { + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let staleGeneration = appModel.beginGatewayConnectAttempt() + let currentGeneration = appModel.beginGatewayConnectAttempt() + let staleConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://stale.gateway.invalid")), + stableID: "manual|stale.gateway.invalid|443") + let currentConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "manual|current.gateway.invalid|443") + + appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration) + #expect(appModel.activeGatewayConnectConfig == nil) + + appModel.applyGatewayConnectConfig(currentConfig, expectedGeneration: currentGeneration) + #expect(appModel.activeGatewayConnectConfig?.stableID == currentConfig.stableID) + } + + @Test @MainActor func `direct gateway apply invalidates older queued config`() throws { + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let staleGeneration = appModel.beginGatewayConnectAttempt() + let staleConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://stale.gateway.invalid")), + stableID: "manual|stale.gateway.invalid|443") + let currentConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "manual|current.gateway.invalid|443") + + appModel.applyGatewayConnectConfig(currentConfig) + appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration) + + #expect(appModel.activeGatewayConnectConfig?.stableID == currentConfig.stableID) + } + + @Test @MainActor func `newer explicit connect immediately invalidates queued config`() async throws { + let host = "new-target.gateway.invalid" + let stableID = "manual|\(host.lowercased())|443" + defer { GatewayTLSStore.clearFingerprint(stableID: stableID) } + GatewayTLSStore.clearFingerprint(stableID: stableID) + + let probeStarted = AsyncStream.makeStream() + let probeResults = AsyncStream.makeStream() + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in true }, + tlsFingerprintProbe: { _ in + probeStarted.continuation.yield() + for await result in probeResults.stream { + return result + } + return .failure(.certificateUnavailable) + }) + let staleGeneration = appModel.beginGatewayConnectAttempt() + let staleConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://old-target.gateway.invalid")), + stableID: "manual|old-target.gateway.invalid|443") + let duringResolutionConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://resolution-window.gateway.invalid")), + stableID: "manual|resolution-window.gateway.invalid|443") + var startedIterator = probeStarted.stream.makeAsyncIterator() + + let connectTask = Task { + await controller.connectManual(host: host, port: 443, useTLS: true) + } + _ = await startedIterator.next() + appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration) + + #expect(appModel.activeGatewayConnectConfig == nil) + + let duringResolutionGeneration = appModel.gatewayConnectGeneration + probeResults.continuation.yield(.fingerprint("new-target-fingerprint")) + probeResults.continuation.finish() + await connectTask.value + await controller.acceptPendingTrustPrompt() + appModel.applyGatewayConnectConfig( + duringResolutionConfig, + expectedGeneration: duringResolutionGeneration) + + #expect(appModel.activeGatewayConnectConfig?.stableID != duringResolutionConfig.stableID) + } + + @Test @MainActor func `trusted certificate keeps device auth route scoped`() async throws { + let host = "127.0.0.1" + let stableID = "manual|\(host)|1" + defer { GatewayTLSStore.clearFingerprint(stableID: stableID) } + GatewayTLSStore.clearFingerprint(stableID: stableID) + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in true }, + tlsFingerprintProbe: { _ in .fingerprint("route-independent-fingerprint") }) + + await controller.connectManual(host: host, port: 1, useTLS: true) + await controller.acceptPendingTrustPrompt() + for _ in 0..<100 where appModel.activeGatewayConnectConfig == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(appModel.activeGatewayConnectConfig?.stableID == stableID) + #expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == stableID) + } + + @Test @MainActor func `first trust aborts when certificate pin is not durable`() async { + let host = "127.0.0.1" + let stableID = "manual|\(host)|2" + defer { GatewayTLSStore.clearFingerprint(stableID: stableID) } + GatewayTLSStore.clearFingerprint(stableID: stableID) + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in true }, + tlsFingerprintProbe: { _ in .fingerprint("unpersisted-fingerprint") }, + persistTLSFingerprint: { _, _ in false }) + + await controller.connectManual(host: host, port: 2, useTLS: true) + await controller.acceptPendingTrustPrompt() + + #expect(controller.pendingTrustPrompt != nil) + #expect(appModel.activeGatewayConnectConfig == nil) + #expect(appModel.gatewayStatusText == "Could not save gateway certificate") + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == nil) + } + + @Test @MainActor func `certificate rotation preserves route scoped device auth`() async throws { + let stableID = "manual|rotation-\(UUID().uuidString)|443" + defer { GatewayTLSStore.clearFingerprint(stableID: stableID) } + #expect(GatewayTLSStore.replaceFingerprint("old-certificate", stableID: stableID)) + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + var options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios", + clientMode: "node", + clientDisplayName: nil, + deviceAuthGatewayID: stableID) + options.allowStoredDeviceAuth = true + let config = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: stableID, + tls: GatewayTLSParams( + required: true, + expectedFingerprint: "old-certificate", + allowTOFU: false, + storeKey: stableID), + token: nil, + bootstrapToken: nil, + password: nil, + nodeOptions: options) + appModel.applyGatewayConnectConfig(config) + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let error = GatewayTLSValidationError( + failure: GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "127.0.0.1", + storeKey: stableID, + expectedFingerprint: "old-certificate", + observedFingerprint: "new-certificate", + systemTrustOk: true), + context: "connect to gateway") + let problem = try #require(GatewayConnectionProblemMapper.map(error: error)) + + let didTrust = await controller.trustRotatedGatewayCertificate(from: problem) + #expect(didTrust) + #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new-certificate") + #expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == stableID) + #expect(appModel.activeGatewayConnectConfig?.tls?.expectedFingerprint == "new-certificate") + } + + @Test @MainActor func `cancel during forced reset restores current gateway`() async throws { + let host = "replacement.gateway.invalid" + let stableID = "manual|\(host)|443" + defer { GatewayTLSStore.clearFingerprint(stableID: stableID) } + GatewayTLSStore.saveFingerprint("replacement-fingerprint", stableID: stableID) + + let resetFinished = AsyncStream.makeStream() + let resetRelease = AsyncStream.makeStream() + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let currentConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "manual|current.gateway.invalid|443") + appModel.applyGatewayConnectConfig(currentConfig) + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + forceReconnectReset: { appModel in + await appModel.resetGatewaySessionsForForcedReconnect() + resetFinished.continuation.yield() + for await _ in resetRelease.stream { + return + } + }) + var finishedIterator = resetFinished.stream.makeAsyncIterator() + + await controller.connectManual(host: host, port: 443, useTLS: true, forceReconnect: true) + _ = await finishedIterator.next() + #expect(!appModel._test_hasGatewayLoopTasks().node) + + controller.cancelPendingConnectionAttempts() + resetRelease.continuation.yield() + resetRelease.continuation.finish() + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + while !appModel._test_hasGatewayLoopTasks().node, ContinuousClock().now < deadline { + await Task.yield() + } + + #expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true) + #expect(appModel._test_hasGatewayLoopTasks().node) + } + + @Test @MainActor func `cancel without pending task preserves reconnect pause`() async { + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let currentConfig = Self.makeGatewayConnectConfig() + appModel.applyGatewayConnectConfig(currentConfig) + await appModel.resetGatewaySessionsForForcedReconnect() + let problem = GatewayConnectionProblem( + kind: .protocolMismatch, + owner: .gateway, + title: "Protocol mismatch", + message: "Upgrade the gateway before reconnecting.", + retryable: false, + pauseReconnect: true) + appModel._test_applyOperatorGatewayConnectionProblem(problem) + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + controller.cancelPendingConnectionAttempts() + for _ in 0..<10 { + await Task.yield() + } + + #expect(!appModel.gatewayPairingPaused) + #expect(appModel.lastGatewayProblem == problem) + #expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true) + #expect(!appModel._test_hasGatewayLoopTasks().node) + } + + @Test @MainActor func `new connect waits for superseded forced reset`() async throws { + let forceHost = "192.168.1.39" + + let resetRelease = AsyncStream.makeStream() + var resetStarted = false + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let currentConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "manual|current.gateway.invalid|443") + appModel.applyGatewayConnectConfig(currentConfig) + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + forceReconnectReset: { appModel in + await appModel.resetGatewaySessionsForForcedReconnect() + resetStarted = true + for await _ in resetRelease.stream { + return + } + }) + + await controller.connectManual(host: forceHost, port: 18789, useTLS: false, forceReconnect: true) + // Simulator WebSocket teardown can take several seconds under the aggregate iOS suite. + // Keep this bounded while allowing the real session barrier to finish before superseding it. + let resetStartDeadline = ContinuousClock().now.advanced(by: .seconds(10)) + while !resetStarted, ContinuousClock().now < resetStartDeadline { + await Task.yield() + } + #expect(resetStarted) + await controller.connectManual(host: "192.168.1.40", port: 18789, useTLS: false) + + #expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true) + #expect(!appModel._test_hasGatewayLoopTasks().node) + + resetRelease.continuation.yield() + resetRelease.continuation.finish() + let replacementStableID = "manual|192.168.1.40|18789" + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + while appModel.activeGatewayConnectConfig?.stableID != replacementStableID, + ContinuousClock().now < deadline + { + await Task.yield() + } + + #expect(appModel.activeGatewayConnectConfig?.stableID == replacementStableID) + #expect(appModel._test_hasGatewayLoopTasks().node) + } + + @Test @MainActor func `new connect waits for model owned reset barrier`() async throws { + let resetRelease = AsyncStream.makeStream() + let appModel = NodeAppModel() + defer { + appModel._test_setGatewaySessionResetTask(nil) + appModel.disconnectGateway() + } + let currentConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "manual|current.gateway.invalid|443") + appModel.applyGatewayConnectConfig(currentConfig) + let modelResetTask = Task { + for await _ in resetRelease.stream { + return + } + } + appModel._test_setGatewaySessionResetTask(modelResetTask) + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + await controller.connectManual(host: "192.168.1.41", port: 18789, useTLS: false) + await Task.yield() + + #expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true) + + resetRelease.continuation.yield() + resetRelease.continuation.finish() + let replacementStableID = "manual|192.168.1.41|18789" + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + while appModel.activeGatewayConnectConfig?.stableID != replacementStableID, + ContinuousClock().now < deadline + { + await Task.yield() + } + + #expect(appModel.activeGatewayConnectConfig?.stableID == replacementStableID) + } + + @Test @MainActor func `trust decline releases suppression without reconnecting unpinned target`() async { + let defaults = UserDefaults.standard + let updates: [String: Any?] = [ + "gateway.autoconnect": false, + "gateway.manual.enabled": true, + "gateway.manual.host": "persisted.gateway.invalid", + "gateway.manual.port": 443, + "gateway.manual.tls": true, + "node.instanceId": "ios-test", + ] + var snapshot: [String: Any?] = [:] + for key in updates.keys { + snapshot[key] = defaults.object(forKey: key) + } + for (key, value) in updates { + defaults.set(value, forKey: key) + } + defer { + for (key, value) in snapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + + let explicitHost = "explicit.gateway.invalid" + let explicitStableID = "manual|\(explicitHost)|443" + defer { GatewayTLSStore.clearFingerprint(stableID: explicitStableID) } + GatewayTLSStore.clearFingerprint(stableID: explicitStableID) + let probeStarted = AsyncStream.makeStream() + let probeResults = AsyncStream.makeStream() + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in true }, + tlsFingerprintProbe: { _ in + probeStarted.continuation.yield() + for await result in probeResults.stream { + return result + } + return .failure(.certificateUnavailable) + }) + defaults.set(true, forKey: "gateway.autoconnect") + var startedIterator = probeStarted.stream.makeAsyncIterator() + + let connectTask = Task { + await controller.connectManual(host: explicitHost, port: 443, useTLS: true) + } + _ = await startedIterator.next() + controller._test_triggerAutoConnect() + + #expect(!controller._test_didAutoConnect()) + #expect(appModel.activeGatewayConnectConfig == nil) + + probeResults.continuation.yield(.fingerprint("explicit-fingerprint")) + probeResults.continuation.finish() + await connectTask.value + #expect(controller.pendingTrustPrompt?.fingerprintSha256 == "explicit-fingerprint") + + controller.declinePendingTrustPrompt() + + #expect(!controller._test_didAutoConnect()) + #expect(!controller._test_isAutoConnectSuppressed()) + #expect(appModel.activeGatewayConnectConfig == nil) + } + + @Test @MainActor func `manual TLS auto connect requires stored pin`() { + let host = "manual-autoconnect-\(UUID().uuidString).example.com" + let stableID = "manual|\(host.lowercased())|443" + let previousStableID = "manual|previous-gateway.example.com|443" + let priorPreviousFingerprint = GatewayTLSStore.loadFingerprint(stableID: previousStableID) + let priorLastConnection = KeychainStore.loadString( + service: "ai.openclawfoundation.app.gateway", + account: "lastConnection") + defer { + GatewayTLSStore.clearFingerprint(stableID: stableID) + if let priorPreviousFingerprint { + GatewayTLSStore.saveFingerprint(priorPreviousFingerprint, stableID: previousStableID) + } else { + GatewayTLSStore.clearFingerprint(stableID: previousStableID) + } + if let priorLastConnection { + _ = KeychainStore.saveString( + priorLastConnection, + service: "ai.openclawfoundation.app.gateway", + account: "lastConnection") + } else { + _ = KeychainStore.delete( + service: "ai.openclawfoundation.app.gateway", + account: "lastConnection") + } + } + GatewayTLSStore.saveFingerprint("previous-certificate", stableID: previousStableID) + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "previous-gateway.example.com", + port: 443, + useTLS: true, + stableID: previousStableID) + + withUserDefaults([ + "gateway.autoconnect": true, + "gateway.manual.enabled": true, + "gateway.manual.host": host, + "gateway.manual.port": 443, + "gateway.manual.tls": true, + "node.instanceId": "ios-test", + "gateway.last.host": nil, + "gateway.last.port": nil, + "gateway.last.tls": nil, + "gateway.last.stableID": nil, + "gateway.last.kind": nil, + "gateway.preferredStableID": nil, + "gateway.lastDiscoveredStableID": nil, + ]) { + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + controller._test_triggerAutoConnect() + #expect(!controller._test_didAutoConnect()) + + GatewayTLSStore.saveFingerprint("trusted-certificate", stableID: stableID) + controller._test_triggerAutoConnect() + #expect(controller._test_didAutoConnect()) + } + } + + @Test @MainActor func `stale cancellation lease cannot release newer suppression`() { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let staleLease = controller.cancelPendingConnectionAttempts() + let currentLease = controller.cancelPendingConnectionAttempts() + + controller.resumeAutoConnect(after: staleLease) + #expect(controller._test_isAutoConnectSuppressed()) + + controller.resumeAutoConnect(after: currentLease) + #expect(!controller._test_isAutoConnectSuppressed()) + } + + @Test @MainActor func `cancellation lease restores previous auto connect state`() { + withUserDefaults([ + "gateway.autoconnect": true, + "gateway.manual.enabled": false, + "gateway.preferredStableID": nil, + "gateway.lastDiscoveredStableID": nil, + ]) { + let appModel = NodeAppModel() + appModel.gatewayAutoReconnectEnabled = true + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let scannerLease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true) + + #expect(!appModel.gatewayAutoReconnectEnabled) + #expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect")) + + let replacementLease = controller.cancelPendingConnectionAttempts() + controller.resumeAutoConnect(after: scannerLease) + #expect(!appModel.gatewayAutoReconnectEnabled) + + controller.resumeAutoConnect(after: replacementLease) + #expect(appModel.gatewayAutoReconnectEnabled) + #expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect")) + } + } + + @Test @MainActor func `completed target switch does not restore auto connect state`() { + withUserDefaults(["gateway.autoconnect": true]) { + let appModel = NodeAppModel() + appModel.gatewayAutoReconnectEnabled = true + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let lease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true) + + controller.releaseAutoConnectSuppression(after: lease) + + #expect(!controller._test_isAutoConnectSuppressed()) + #expect(!appModel.gatewayAutoReconnectEnabled) + #expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect")) + } + } + + @Test @MainActor func `auto connect choice made during target review wins`() throws { + try withUserDefaults(["gateway.autoconnect": true]) { + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let suspendedConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "manual|127.0.0.1|1") + appModel.applyGatewayConnectConfig(suspendedConfig) + appModel.gatewayAutoReconnectEnabled = true + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let lease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true) + + UserDefaults.standard.set(false, forKey: "gateway.autoconnect") + controller.resumeAutoConnect(after: lease) + + #expect(!appModel.gatewayAutoReconnectEnabled) + #expect(appModel.activeGatewayConnectConfig == nil) + #expect(!UserDefaults.standard.bool(forKey: "gateway.autoconnect")) + } + } + + @Test @MainActor func `failed replacement restores inherited scanner reconnect state`() async throws { + let defaults = UserDefaults.standard + let updates: [String: Any?] = [ + "gateway.autoconnect": true, + "gateway.manual.enabled": false, + "node.instanceId": "ios-test", + "gateway.preferredStableID": nil, + "gateway.lastDiscoveredStableID": nil, + ] + var snapshot: [String: Any?] = [:] + for key in updates.keys { + snapshot[key] = defaults.object(forKey: key) + } + for (key, value) in updates { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defer { + for (key, value) in snapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let suspendedConfig = try Self.makeGatewayConnectConfig( + url: #require(URL(string: "ws://127.0.0.1:1")), + stableID: "manual|127.0.0.1|1") + appModel.applyGatewayConnectConfig(suspendedConfig) + appModel.gatewayAutoReconnectEnabled = true + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + _ = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true) + + await controller.connectManual(host: "invalid.example.com", port: 70000, useTLS: true) + + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + while appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: suspendedConfig) != true, + ContinuousClock().now < deadline + { + await Task.yield() + } + #expect(!controller._test_isAutoConnectSuppressed()) + #expect(appModel.gatewayAutoReconnectEnabled) + #expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: suspendedConfig) == true) + #expect(defaults.bool(forKey: "gateway.autoconnect")) + } + + @Test @MainActor func `foreground reconnect cannot replace queued explicit handoff`() async { + let defaults = UserDefaults.standard + let updates: [String: Any?] = [ + "gateway.autoconnect": false, + "gateway.manual.enabled": true, + "gateway.manual.host": "192.168.1.20", + "gateway.manual.port": 18789, + "gateway.manual.tls": false, + "node.instanceId": "ios-test", + ] + var snapshot: [String: Any?] = [:] + for key in updates.keys { + snapshot[key] = defaults.object(forKey: key) + } + for (key, value) in updates { + defaults.set(value, forKey: key) + } + defer { + for (key, value) in snapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + + let resetRelease = AsyncStream.makeStream() + let appModel = NodeAppModel() + defer { + resetRelease.continuation.finish() + appModel._test_setGatewaySessionResetTask(nil) + appModel.disconnectGateway() + } + let modelResetTask = Task { + for await _ in resetRelease.stream { + return + } + } + appModel._test_setGatewaySessionResetTask(modelResetTask) + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + defaults.set(true, forKey: "gateway.autoconnect") + + let explicitStableID = "manual|192.168.1.41|18789" + await controller.connectManual(host: "192.168.1.41", port: 18789, useTLS: false) + #expect(appModel.activeGatewayConnectConfig == nil) + + controller._test_triggerAutoReconnect() + for _ in 0..<10 { + await Task.yield() + } + #expect(controller._test_didAutoConnect()) + #expect(appModel.activeGatewayConnectConfig == nil) + + resetRelease.continuation.yield() + resetRelease.continuation.finish() + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + while appModel.activeGatewayConnectConfig?.stableID != explicitStableID, + ContinuousClock().now < deadline + { + await Task.yield() + } + + #expect(appModel.activeGatewayConnectConfig?.stableID == explicitStableID) + controller._test_triggerAutoConnect() + #expect(controller._test_didAutoConnect()) + #expect(appModel.activeGatewayConnectConfig?.stableID == explicitStableID) + } + + @Test @MainActor func `clearing trust prompt invalidates in flight probe`() async { + let probeStarted = AsyncStream.makeStream() + let probeResults = AsyncStream.makeStream() + let appModel = NodeAppModel() + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in true }, + tlsFingerprintProbe: { _ in + probeStarted.continuation.yield() + for await result in probeResults.stream { + return result + } + return .failure(.certificateUnavailable) + }) + var startedIterator = probeStarted.stream.makeAsyncIterator() + + let connectTask = Task { + await controller.connectManual(host: "trust-probe-cancel.invalid", port: 443, useTLS: true) + } + _ = await startedIterator.next() + controller.clearPendingTrustPrompt() + probeResults.continuation.yield(.fingerprint("stale-fingerprint")) + probeResults.continuation.finish() + await connectTask.value + + #expect(controller.pendingTrustPrompt == nil) + } + + @Test @MainActor func `foreground stale connection restart reapplies active gateway config`() async { let appModel = NodeAppModel() defer { appModel.disconnectGateway() } @@ -370,7 +1799,7 @@ import UIKit #expect(appModel._test_hasGatewayLoopTasks().operator) } - @Test @MainActor func loadLastConnectionReadsSavedValues() { + @Test @MainActor func `load last connection reads saved values`() { let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection") defer { if let prior { @@ -397,7 +1826,7 @@ import UIKit stableID: "manual|gateway.example.com|443")) } - @Test @MainActor func loadLastConnectionReturnsNilForInvalidData() { + @Test @MainActor func `load last connection returns nil for invalid data`() { let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection") defer { if let prior { @@ -425,7 +1854,8 @@ import UIKit } private static func makeGatewayConnectConfig( - url: URL = URL(string: "wss://gateway.example.com")!, + // Fail locally instead of making lifecycle tests depend on DNS or external network timing. + url: URL = URL(string: "wss://127.0.0.1:1")!, stableID: String = "manual|gateway.example.com|443") -> GatewayConnectConfig { GatewayConnectConfig( diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index f41f89886828..f9f3151d5c71 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -210,6 +210,29 @@ import Testing #expect(appModel.gatewayStatusText == "Verify gateway TLS fingerprint") } + @Test @MainActor func staleTrustAcceptanceReleasesAutoConnectSuppression() async { + let host = "gateway-\(UUID().uuidString).example.com" + let port = 18789 + let stableID = "manual|\(host.lowercased())|\(port)" + defer { clearTLSFingerprint(stableID: stableID) } + self.clearTLSFingerprint(stableID: stableID) + + let appModel = NodeAppModel() + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in true }, + tlsFingerprintProbe: { _ in .fingerprint("abc123") }) + + await controller.connectManual(host: host, port: port, useTLS: true) + _ = appModel.beginGatewayConnectAttempt() + await controller.acceptPendingTrustPrompt() + + #expect(controller.pendingTrustPrompt == nil) + #expect(appModel.activeGatewayConnectConfig == nil) + #expect(!controller._test_isAutoConnectSuppressed()) + } + @Test @MainActor func `manual first use TLS probe skips TLS when TCP is unreachable`() async { let host = "gateway-\(UUID().uuidString).example.com" let port = 18789 @@ -284,13 +307,21 @@ import Testing GatewayTLSFingerprintProbeResult.fingerprint("abc123"), .failure(.tlsHandshakeTimeout), ]) + let resolverStarted = AsyncStream.makeStream() + let resolverResults = AsyncStream<(host: String, port: Int)>.makeStream() let appModel = NodeAppModel() let controller = GatewayConnectionController( appModel: appModel, startDiscovery: false, tcpReachabilityProbe: { _, _, _, _ in true }, tlsFingerprintProbe: { _ in tlsResults.withLock { $0.removeFirst() } }, - serviceEndpointResolver: { _ in (host: discoveredHost, port: discoveredPort) }) + serviceEndpointResolver: { _ in + resolverStarted.continuation.yield() + for await result in resolverResults.stream { + return result + } + return nil + }) await controller.connectManual(host: staleHost, port: stalePort, useTLS: true) #expect(controller.pendingTrustPrompt?.fingerprintSha256 == "abc123") @@ -301,14 +332,67 @@ import Testing tailnetDns: discoveredHost, gatewayPort: discoveredPort, fingerprint: nil) - let message = await controller.connectWithDiagnostics(gateway) + var startedIterator = resolverStarted.stream.makeAsyncIterator() + let connectTask = Task { await controller.connectWithDiagnostics(gateway) } + _ = await startedIterator.next() #expect(controller.pendingTrustPrompt == nil) + resolverResults.continuation.yield((host: discoveredHost, port: discoveredPort)) + resolverResults.continuation.finish() + let message = await connectTask.value + #expect(message?.contains("TLS fingerprint verification timed out") == true) #expect(message?.contains("\(discoveredHost):\(discoveredPort)") == true) #expect(appModel.gatewayStatusText == message) } + @Test @MainActor func targetSwitchCancelsSuspendedDiscoveryResolution() async { + let defaults = UserDefaults.standard + let previousInstanceID = defaults.string(forKey: "node.instanceId") + defer { + if let previousInstanceID { + defaults.set(previousInstanceID, forKey: "node.instanceId") + } else { + defaults.removeObject(forKey: "node.instanceId") + } + } + defaults.set("ios-test", forKey: "node.instanceId") + let resolverStarted = AsyncStream.makeStream() + let resolverResults = AsyncStream<(host: String, port: Int)>.makeStream() + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + serviceEndpointResolver: { _ in + resolverStarted.continuation.yield() + for await result in resolverResults.stream { + return result + } + return nil + }) + let stableID = "discovered|\(UUID().uuidString)" + defer { self.clearTLSFingerprint(stableID: stableID) } + let gateway = self.makeDiscoveredGateway( + stableID: stableID, + lanHost: nil, + tailnetDns: nil, + gatewayPort: nil, + fingerprint: nil) + var startedIterator = resolverStarted.stream.makeAsyncIterator() + + let connectTask = Task { await controller.connectWithDiagnostics(gateway) } + _ = await startedIterator.next() + controller.cancelPendingConnectionAttempts() + resolverResults.continuation.yield((host: "stale.gateway.invalid", port: 443)) + resolverResults.continuation.finish() + let message = await connectTask.value + + #expect(message == nil) + #expect(appModel.activeGatewayConnectConfig == nil) + #expect(controller.pendingTrustPrompt == nil) + } + @Test @MainActor func `clear all TLS fingerprints removes stored pins`() { let stableID1 = "test|\(UUID().uuidString)" let stableID2 = "test|\(UUID().uuidString)" diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index ab3606ce43cf..0cac66808962 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawKit import Testing @testable import OpenClaw @@ -94,7 +95,272 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { } @Suite(.serialized) struct GatewaySettingsStoreTests { - @Test func bootstrapCopiesDefaultsToKeychainWhenMissing() { + @Test func `credentials stay bound to their gateway`() { + let instanceID = "credential-owner-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let firstGatewayID = "manual|first.example.com|443" + let secondGatewayID = "manual|second.example.com|443" + + GatewaySettingsStore.saveGatewayCredentials( + token: "first-token", + bootstrapToken: nil, + password: "first-password", + gatewayStableID: firstGatewayID, + suppressStoredDeviceAuth: true, + instanceId: instanceID) + + let first = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: firstGatewayID) + #expect(first.token == "first-token") + #expect(first.password == "first-password") + #expect(first.suppressStoredDeviceAuth) + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: secondGatewayID) == .empty) + + GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID) + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: secondGatewayID) == .empty) + } + + @Test func `shared tls certificate does not alias distinct routes`() { + let instanceID = "tls-owner-\(UUID().uuidString)" + let discoveredID = "bonjour|_openclaw._tcp|local|gateway-\(UUID().uuidString)" + let manualID = "manual|gateway-\(UUID().uuidString).local|443" + let fingerprint = "AA:BB:CC:DD" + defer { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) + GatewayTLSStore.clearFingerprint(stableID: discoveredID) + GatewayTLSStore.clearFingerprint(stableID: manualID) + } + + GatewaySettingsStore.saveGatewayCredentials( + token: "shared-token", + bootstrapToken: nil, + password: "shared-password", + gatewayStableID: discoveredID, + suppressStoredDeviceAuth: false, + instanceId: instanceID) + GatewayTLSStore.saveFingerprint(fingerprint, stableID: discoveredID) + GatewayTLSStore.saveFingerprint(fingerprint, stableID: manualID) + + let manualCredentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: manualID) + #expect(manualCredentials == .empty) + #expect(GatewaySettingsStore.authenticationOwnerID(routeStableID: discoveredID) == discoveredID) + #expect(GatewaySettingsStore.authenticationOwnerID(routeStableID: manualID) == manualID) + #expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID)?.gatewayStableID == + discoveredID) + } + + @Test func `ambiguous legacy credentials are discarded`() { + let instanceID = "legacy-credential-owner-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let firstGatewayID = "manual|first.example.com|443" + let secondGatewayID = "manual|second.example.com|443" + GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest("legacy-token", instanceId: instanceID) + + GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID) + + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: firstGatewayID) == .empty) + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: secondGatewayID) == .empty) + #expect(KeychainStore.loadString( + service: gatewayService, + account: "gateway-token.\(instanceID)") == nil) + #expect(KeychainStore.loadString( + service: gatewayService, + account: "gateway-credentials.\(instanceID)") == nil) + } + + @Test func `proven relay migration does not overwrite a canonical credential bundle`() { + let instanceID = "relay-migration-owner-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let gatewayID = "manual|gateway.example.com|443" + GatewaySettingsStore.saveGatewayCredentials( + token: "current-token", + bootstrapToken: "current-bootstrap", + password: "current-password", + gatewayStableID: gatewayID, + suppressStoredDeviceAuth: true, + instanceId: instanceID) + GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest( + "obsolete-token", + instanceId: instanceID) + + #expect(GatewaySettingsStore.migrateProvenRelayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID, + token: "stale-relay-token", + password: "stale-relay-password")) + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID) + #expect(credentials.token == "current-token") + #expect(credentials.bootstrapToken == "current-bootstrap") + #expect(credentials.password == "current-password") + #expect(credentials.suppressStoredDeviceAuth) + #expect(KeychainStore.loadString( + service: gatewayService, + account: "gateway-token.\(instanceID)") == nil) + } + + @Test func `proven relay credentials are not reimported after legacy cleanup`() { + let instanceID = "completed-relay-migration-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let gatewayID = "manual|gateway.example.com|443" + + #expect(GatewaySettingsStore.migrateProvenRelayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID, + token: "stale-relay-token", + password: "stale-relay-password")) + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID) == .empty) + } + + @Test func `credentialless setup suppresses stored auth until handoff completes`() { + let instanceID = "credentialless-owner-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let gatewayID = "manual|gateway.example.com|443" + + GatewaySettingsStore.saveGatewayCredentials( + token: nil, + bootstrapToken: nil, + password: nil, + gatewayStableID: gatewayID, + suppressStoredDeviceAuth: true, + instanceId: instanceID) + + let pending = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID) + #expect(!pending.hasCredentials) + #expect(pending.suppressStoredDeviceAuth) + + GatewaySettingsStore.completeGatewayCredentialHandoff( + instanceId: instanceID, + gatewayStableID: gatewayID) + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID) == .empty) + #expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID) == nil) + } + + @Test func `bootstrap handoff clears bootstrap while enabling stored auth`() { + let instanceID = "bootstrap-handoff-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let gatewayID = "manual|gateway.example.com|443" + + GatewaySettingsStore.saveGatewayCredentials( + token: "shared-token", + bootstrapToken: "one-time-bootstrap", + password: nil, + gatewayStableID: gatewayID, + suppressStoredDeviceAuth: true, + instanceId: instanceID) + + #expect(GatewaySettingsStore.completeGatewayCredentialHandoff( + instanceId: instanceID, + gatewayStableID: gatewayID)) + let completed = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID) + #expect(completed.token == "shared-token") + #expect(completed.bootstrapToken == nil) + #expect(!completed.suppressStoredDeviceAuth) + } + + @Test func `field edits preserve pending bootstrap handoff for the same gateway`() { + let instanceID = "edited-credential-owner-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let gatewayID = "manual|gateway.example.com|443" + + GatewaySettingsStore.saveGatewayCredentials( + token: nil, + bootstrapToken: "bootstrap-token", + password: nil, + gatewayStableID: gatewayID, + suppressStoredDeviceAuth: true, + instanceId: instanceID) + GatewaySettingsStore.updateGatewayCredentials( + token: "edited-token", + password: "edited-password", + gatewayStableID: gatewayID, + instanceId: instanceID) + + let credentials = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID) + #expect(credentials.token == "edited-token") + #expect(credentials.bootstrapToken == "bootstrap-token") + #expect(credentials.password == "edited-password") + #expect(credentials.suppressStoredDeviceAuth) + } + + @Test func `field edits do not carry pending handoff to another gateway`() { + let instanceID = "switched-credential-owner-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let firstGatewayID = "manual|first.example.com|443" + let secondGatewayID = "manual|second.example.com|443" + + GatewaySettingsStore.saveGatewayCredentials( + token: "first-token", + bootstrapToken: "first-bootstrap-token", + password: "first-password", + gatewayStableID: firstGatewayID, + suppressStoredDeviceAuth: true, + instanceId: instanceID) + GatewaySettingsStore.updateGatewayCredentials( + token: "second-token", + password: nil, + gatewayStableID: secondGatewayID, + instanceId: instanceID) + + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: firstGatewayID) == .empty) + let second = GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: secondGatewayID) + #expect(second.token == "second-token") + #expect(second.bootstrapToken == nil) + #expect(second.password == nil) + #expect(!second.suppressStoredDeviceAuth) + } + + @Test func `clearing ordinary credentials removes their owner metadata`() { + let instanceID = "cleared-credential-owner-\(UUID().uuidString)" + defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) } + let gatewayID = "manual|gateway.example.com|443" + + GatewaySettingsStore.saveGatewayCredentials( + token: "one-time-token", + bootstrapToken: nil, + password: nil, + gatewayStableID: gatewayID, + suppressStoredDeviceAuth: false, + instanceId: instanceID) + GatewaySettingsStore.updateGatewayCredentials( + token: nil, + password: nil, + gatewayStableID: gatewayID, + instanceId: instanceID) + + #expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID) == nil) + #expect(GatewaySettingsStore.loadGatewayCredentials( + instanceId: instanceID, + gatewayStableID: gatewayID) == .empty) + } + + @Test func `bootstrap copies defaults to keychain when missing`() { withBootstrapSnapshots { applyDefaults([ "node.instanceId": "node-test", @@ -115,7 +381,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { } } - @Test func bootstrapCopiesKeychainToDefaultsWhenMissing() { + @Test func `bootstrap copies keychain to defaults when missing`() { withBootstrapSnapshots { applyDefaults([ "node.instanceId": nil, @@ -137,7 +403,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { } } - @Test func lastGateway_manualRoundTrip() { + @Test func `last gateway manual round trip`() { withLastGatewaySnapshot { GatewaySettingsStore.saveLastGatewayConnectionManual( host: "example.com", @@ -150,7 +416,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { } } - @Test func lastGateway_discoveredOverwritesManual() { + @Test func `last gateway discovered overwrites manual`() { withLastGatewaySnapshot { GatewaySettingsStore.saveLastGatewayConnectionManual( host: "10.0.0.99", @@ -164,7 +430,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { } } - @Test func lastGateway_migratesFromUserDefaults() { + @Test func `last gateway migrates from user defaults`() { withLastGatewaySnapshot { // Clear Keychain entry and plant legacy UserDefaults values. applyKeychain([lastGatewayKeychainEntry: nil]) @@ -177,7 +443,11 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { ]) let loaded = GatewaySettingsStore.loadLastGatewayConnection() - #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789")) + #expect(loaded == .manual( + host: "example.org", + port: 18789, + useTLS: false, + stableID: "manual|example.org|18789")) // Legacy keys should be cleaned up after migration. let defaults = UserDefaults.standard diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index b7806f6ba176..12ad985f50bf 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -79,6 +79,14 @@ private func makeProjectedWatchChatRawMessage( return try JSONDecoder().decode(AnyCodable.self, from: data) } +@MainActor +private func waitForMainActorWork(_ condition: () -> Bool) async { + for _ in 0..<100 { + if condition() { return } + await Task.yield() + } +} + @MainActor private func mountScreen(_ screen: ScreenController) throws -> ScreenWebViewCoordinator { let coordinator = ScreenWebViewCoordinator(controller: screen) @@ -105,7 +113,10 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage? var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage? var lastSentExecApprovalSnapshot: OpenClawWatchExecApprovalSnapshotMessage? + var sentExecApprovalSnapshots: [OpenClawWatchExecApprovalSnapshotMessage] = [] var lastSentAppSnapshot: OpenClawWatchAppSnapshotMessage? + var syncExecApprovalSnapshotHandler: ((OpenClawWatchExecApprovalSnapshotMessage) async throws + -> WatchNotificationSendResult)? var lastSentChatCompletion: OpenClawWatchChatCompletionMessage? private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)? private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? @@ -190,6 +201,10 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer _ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult { self.lastSentExecApprovalSnapshot = message + self.sentExecApprovalSnapshots.append(message) + if let syncExecApprovalSnapshotHandler { + return try await syncExecApprovalSnapshotHandler(message) + } if let sendError { throw sendError } @@ -239,33 +254,86 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable { var status: NotificationAuthorizationStatus = .notDetermined + var authorizationStatusHandler: (@Sendable () async -> NotificationAuthorizationStatus)? var addCalls = 0 + var pendingRemovedIdentifiers: [[String]] = [] + var deliveredRemovedIdentifiers: [[String]] = [] + var delivered: [NotificationSnapshot] = [] func authorizationStatus() async -> NotificationAuthorizationStatus { - self.status + if let authorizationStatusHandler { + return await authorizationStatusHandler() + } + return self.status } func add(_: UNNotificationRequest) async throws { self.addCalls += 1 } - func removePendingNotificationRequests(withIdentifiers _: [String]) async {} + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async { + self.pendingRemovedIdentifiers.append(identifiers) + } - func removeDeliveredNotifications(withIdentifiers _: [String]) async {} + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async { + self.deliveredRemovedIdentifiers.append(identifiers) + } func deliveredNotifications() async -> [NotificationSnapshot] { - [] + self.delivered + } +} + +private actor NotificationAuthorizationGate { + private var didStart = false + private var continuation: CheckedContinuation? + + func wait() async -> NotificationAuthorizationStatus { + self.didStart = true + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func hasStarted() -> Bool { + self.didStart + } + + func resume(returning status: NotificationAuthorizationStatus) { + self.continuation?.resume(returning: status) + self.continuation = nil + } +} + +private actor WatchSnapshotSendGate { + private var didStart = false + private var continuation: CheckedContinuation? + + func wait() async { + self.didStart = true + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func hasStarted() -> Bool { + self.didStart + } + + func resume() { + self.continuation?.resume() + self.continuation = nil } } @Suite(.serialized) struct NodeAppModelInvokeTests { - @Test @MainActor func decodeParamsFailsWithoutJSON() { + @Test @MainActor func `decode params fails without JSON`() { #expect(throws: Error.self) { _ = try NodeAppModel._test_decodeParams(OpenClawCanvasNavigateParams.self, from: nil) } } - @Test @MainActor func encodePayloadEmitsJSON() throws { + @Test @MainActor func `encode payload emits JSON`() throws { struct Payload: Codable, Equatable { var value: String } @@ -273,12 +341,12 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(json.contains("\"value\"")) } - @Test @MainActor func chatSessionKeyDefaultsToMainBase() { + @Test @MainActor func `chat session key defaults to main base`() { let appModel = NodeAppModel() #expect(appModel.chatSessionKey == "main") } - @Test @MainActor func initPreservesSavedTalkModePreference() { + @Test @MainActor func `init preserves saved talk mode preference`() { withUserDefaults(["talk.enabled": true]) { let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(talkMode: talkMode) @@ -288,7 +356,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc } } - @Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() { + @Test @MainActor func `chat session key uses agent scoped key for non default agent`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("agent-123") @@ -296,7 +364,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.mainSessionKey == "agent:agent-123:main") } - @Test @MainActor func sessionKeyExtractsCanonicalAgentID() { + @Test @MainActor func `session key extracts canonical agent ID`() { #expect(SessionKey.agentId(from: "agent:rust-claw:mattermost:channel:w6g") == "rust-claw") #expect(SessionKey.agentId(from: " agent:main:main ") == "main") #expect(SessionKey.agentId(from: "main") == nil) @@ -304,7 +372,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(SessionKey.agentId(from: nil) == nil) } - @Test @MainActor func chatAgentNameUsesFocusedCanonicalSessionAgent() { + @Test @MainActor func `chat agent name uses focused canonical session agent`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.gatewayAgents = [ @@ -333,7 +401,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.chatAgentName == "Rust Claw") } - @Test @MainActor func chatAgentNameFallsBackToSelectedAgentForUnscopedSession() { + @Test @MainActor func `chat agent name falls back to selected agent for unscoped session`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.gatewayAgents = [ @@ -353,7 +421,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.chatAgentName == "Rust Claw") } - @Test @MainActor func selectingAgentClearsExplicitChatFocus() { + @Test @MainActor func `selecting agent clears explicit chat focus`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" let rustSessionKey = SessionKey.makeAgentSessionKey(agentId: "rust-claw", baseKey: "main") @@ -368,7 +436,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.chatSessionKey == "main") } - @Test @MainActor func sameSelectedAgentKeepsExplicitChatFocus() { + @Test @MainActor func `same selected agent keeps explicit chat focus`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("main") @@ -379,7 +447,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.chatSessionKey == "incident-42") } - @Test @MainActor func defaultChatSessionKeyIgnoresExplicitChatFocus() { + @Test @MainActor func `default chat session key ignores explicit chat focus`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("rust-claw") @@ -391,7 +459,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.chatSessionKey == "incident-42") } - @Test @MainActor func openingNilChatSessionClearsExplicitChatFocus() { + @Test @MainActor func `opening nil chat session clears explicit chat focus`() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("rust-claw") @@ -407,7 +475,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.chatSessionKey == "main") } - @Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws { + @Test @MainActor func `exec approval prompt presentation tracks latest notification tap`() throws { let appModel = NodeAppModel() try appModel._test_presentExecApprovalPrompt( #require( @@ -445,7 +513,264 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel._test_pendingExecApprovalPrompt() == nil) } - @Test @MainActor func dismissPendingExecApprovalPromptByIdLeavesDifferentPromptVisible() throws { + @Test @MainActor func `gateway switch invalidates privileged approval surfaces`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let notificationCenter = MockBootstrapNotificationCenter() + notificationCenter.delivered = [ + NotificationSnapshot( + identifier: "old-requested-approval", + userInfo: [ + "openclaw": [ + "kind": ExecApprovalNotificationBridge.requestedKind, + "approvalId": "recovery-a", + "gatewayDeviceId": "device-a", + ], + ]), + NotificationSnapshot( + identifier: "new-requested-approval", + userInfo: [ + "openclaw": [ + "kind": ExecApprovalNotificationBridge.requestedKind, + "approvalId": "recovery-b", + "gatewayDeviceId": "device-b", + ], + ]), + ] + let appModel = NodeAppModel( + notificationCenter: notificationCenter, + watchMessagingService: watchService) + defer { appModel.disconnectGateway() } + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone") + let gatewayA = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "gateway-a", + tls: nil, + token: "token-a", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let gatewayB = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:2")), + stableID: "gateway-b", + tls: nil, + token: "token-b", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + + appModel.applyGatewayConnectConfig(gatewayA) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "shared-approval-id", + gatewayStableID: gatewayA.effectiveStableID, + commandText: "deploy gateway A", + allowedDecisions: ["allow-once", "deny"], + host: "gateway-a", + nodeId: nil, + agentId: "main", + expiresAtMs: nil))) + appModel._test_recordPendingWatchExecApprovalRecoveryID( + "recovery-a", + gatewayDeviceId: "device-a") + + appModel.applyGatewayConnectConfig(gatewayB) + for _ in 0..<1000 + where notificationCenter.deliveredRemovedIdentifiers.isEmpty + || watchService.lastSentExecApprovalSnapshot?.approvals.isEmpty != true + { + await Task.yield() + } + + #expect(appModel._test_pendingExecApprovalPrompt() == nil) + #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs().isEmpty) + #expect(watchService.lastSentExecApprovalSnapshot?.approvals.isEmpty == true) + #expect(notificationCenter.pendingRemovedIdentifiers.contains([ + "exec.approval.device-a.recovery-a", + ])) + #expect(notificationCenter.deliveredRemovedIdentifiers.contains([ + "old-requested-approval", + ])) + #expect(!notificationCenter.deliveredRemovedIdentifiers + .flatMap(\.self) + .contains("new-requested-approval")) + + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "shared-approval-id", + gatewayStableID: gatewayB.effectiveStableID, + commandText: "deploy gateway B", + allowedDecisions: ["allow-once", "deny"], + host: "gateway-b", + nodeId: nil, + agentId: "main", + expiresAtMs: nil))) + + watchService.emitExecApprovalResolve(WatchExecApprovalResolveEvent( + replyId: "stale-watch-reply", + approvalId: "shared-approval-id", + gatewayStableID: gatewayA.effectiveStableID, + decision: .allowOnce, + sentAtMs: nil, + transport: "test")) + await Task.yield() + await Task.yield() + + #expect(watchService.lastSentExecApprovalResolved == nil) + #expect(watchService.lastSentExecApprovalExpired == nil) + #expect(watchService.lastSentExecApprovalSnapshot?.approvals.first?.gatewayStableID == gatewayB + .effectiveStableID) + } + + @Test @MainActor func `offline resolution push remains durable until its gateway reconnects`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let push = ExecApprovalNotificationPrompt( + approvalId: "approval-resolved-offline", + gatewayDeviceId: "gateway-device-a") + let notificationCenter = MockBootstrapNotificationCenter() + notificationCenter.delivered = [NotificationSnapshot( + identifier: "offline-request-alert", + userInfo: [ + "openclaw": [ + "kind": ExecApprovalNotificationBridge.requestedKind, + "approvalId": push.approvalId, + "gatewayDeviceId": "gateway-device-a", + ], + ])] + let firstModel = NodeAppModel(notificationCenter: notificationCenter) + + #expect(await firstModel.handleExecApprovalResolvedRemotePush(push)) + #expect(firstModel._test_pendingExecApprovalResolvedPushes() == [push]) + #expect(notificationCenter.pendingRemovedIdentifiers == [[ + "exec.approval.gateway-device-a.approval-resolved-offline", + ]]) + #expect(notificationCenter.deliveredRemovedIdentifiers == [["offline-request-alert"]]) + + let restoredModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) + #expect(restoredModel._test_pendingExecApprovalResolvedPushes() == [push]) + } + + @Test @MainActor func `offline approval request remains durable until its gateway reconnects`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let push = ExecApprovalNotificationPrompt( + approvalId: "approval-requested-offline", + gatewayDeviceId: "gateway-device-a") + let firstModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) + + #expect(await firstModel.handleExecApprovalRequestedRemotePush(push)) + #expect(firstModel._test_pendingWatchExecApprovalRecoveryIDs() == [push.approvalId]) + + let restoredModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) + #expect(restoredModel._test_pendingWatchExecApprovalRecoveryIDs() == [push.approvalId]) + } + + @Test @MainActor func `offline approval notification tap retains watch recovery`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let push = ExecApprovalNotificationPrompt( + approvalId: "approval-notification-offline", + gatewayDeviceId: "gateway-device-a") + let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) + + await appModel.presentExecApprovalNotificationPrompt(push) + + #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == [push.approvalId]) + } + + @Test @MainActor func `late watch snapshot is repaired after gateway switch`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let gate = WatchSnapshotSendGate() + var shouldBlockNextSnapshot = true + watchService.syncExecApprovalSnapshotHandler = { _ in + if shouldBlockNextSnapshot { + shouldBlockNextSnapshot = false + await gate.wait() + } + return watchService.nextSendResult + } + let appModel = NodeAppModel(watchMessagingService: watchService) + defer { appModel.disconnectGateway() } + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone") + let gatewayA = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "watch-route-a", + tls: nil, + token: "token-a", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let gatewayB = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:2")), + stableID: "watch-route-b", + tls: nil, + token: "token-b", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + + appModel.applyGatewayConnectConfig(gatewayA) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-route-a", + gatewayStableID: gatewayA.effectiveStableID, + commandText: "route A", + allowedDecisions: ["deny"], + host: nil, + nodeId: nil, + agentId: nil, + expiresAtMs: nil))) + while await !(gate.hasStarted()) { + await Task.yield() + } + + appModel.applyGatewayConnectConfig(gatewayB) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-route-b", + gatewayStableID: gatewayB.effectiveStableID, + commandText: "route B", + allowedDecisions: ["deny"], + host: nil, + nodeId: nil, + agentId: nil, + expiresAtMs: nil))) + await gate.resume() + + for _ in 0..<1000 + where watchService.sentExecApprovalSnapshots.count < 3 + || watchService.lastSentExecApprovalSnapshot?.approvals.first?.gatewayStableID + != gatewayB.effectiveStableID + { + await Task.yield() + } + #expect(watchService.sentExecApprovalSnapshots.count >= 3) + #expect(watchService.lastSentExecApprovalSnapshot?.approvals.map(\.gatewayStableID) == [ + gatewayB.effectiveStableID, + ]) + } + + @Test @MainActor func `dismiss pending exec approval prompt by id leaves different prompt visible`() throws { let appModel = NodeAppModel() try appModel._test_presentExecApprovalPrompt( #require( @@ -464,7 +789,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(prompt.id == "approval-active") } - @Test @MainActor func presentingExecApprovalPromptSyncsWatchPrompt() async throws { + @Test @MainActor func `presenting exec approval prompt syncs watch prompt`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let prompt = try #require( @@ -488,7 +813,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(sent.resetResolvingState != true) } - @Test @MainActor func watchExecApprovalSnapshotRequestPublishesCachedApprovalsInBackground() async throws { + @Test @MainActor func `watch exec approval snapshot request publishes cached approvals in background`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60000 @@ -516,7 +841,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(snapshot.approvals.map(\.id) == ["approval-watch-snapshot"]) } - @Test @MainActor func watchExecApprovalSnapshotRequestSkipsForegroundRecovery() async throws { + @Test @MainActor func `watch exec approval snapshot request skips foreground recovery`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60000 @@ -543,7 +868,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSentExecApprovalSnapshot == nil) } - @Test @MainActor func watchAppSnapshotRequestPublishesCurrentDashboardState() async throws { + @Test @MainActor func `watch app snapshot request publishes current dashboard state`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() @@ -578,7 +903,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(snapshot.pendingApprovalCount == 0) } - @Test @MainActor func watchAppSnapshotPublishesOfflineWhenOperatorDisconnects() async { + @Test @MainActor func `watch app snapshot publishes offline when operator disconnects`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setGatewayConnected(true) @@ -610,7 +935,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSentAppSnapshot?.gatewayStatusText == "Offline") } - @Test @MainActor func watchAppSnapshotPublishesOnlineWhenOperatorReconnects() async { + @Test @MainActor func `watch app snapshot publishes online when operator reconnects`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel._test_setGatewayConnected(true) @@ -641,7 +966,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSentAppSnapshot?.gatewayStatusText == "Connected") } - @Test @MainActor func watchAppSnapshotUsesConfiguredAgentAvatar() async throws { + @Test @MainActor func `watch app snapshot uses configured agent avatar`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel.gatewayDefaultAgentId = "main" @@ -670,7 +995,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(snapshot.agentAvatarText == "OC") } - @Test @MainActor func watchAppSnapshotIncludesPendingApprovalCount() async throws { + @Test @MainActor func `watch app snapshot includes pending approval count`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() @@ -692,7 +1017,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(snapshot.pendingApprovalCount == 1) } - @Test @MainActor func watchAppCommandControlsTalkThroughPhoneModel() async { + @Test @MainActor func `watch app command controls talk through phone model`() async { let watchService = MockWatchMessagingService() let talkMode = TalkModeManager(allowSimulatorCapture: true) let appModel = NodeAppModel(watchMessagingService: watchService, talkMode: talkMode) @@ -726,7 +1051,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSentAppSnapshot?.talkEnabled == false) } - @Test @MainActor func watchAppCommandOpensChatSessionOnPhoneModel() async { + @Test @MainActor func `watch app command opens chat session on phone model`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) @@ -745,7 +1070,45 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSentAppSnapshot?.sessionKey == "incident-42") } - @Test @MainActor func watchAppCommandSendsChatMessageThroughPhoneModel() async { + @Test @MainActor func `watch app commands reject stale gateway targets`() async { + let watchService = MockWatchMessagingService() + let talkMode = TalkModeManager(allowSimulatorCapture: true) + let appModel = NodeAppModel(watchMessagingService: watchService, talkMode: talkMode) + appModel._test_setConnectedGatewayID("gateway-current") + appModel.setTalkEnabled(false) + + for command in [OpenClawWatchAppCommand.openChat, .startTalk] { + watchService.emitAppCommand( + WatchAppCommandEvent( + commandId: "watch-stale-\(command.rawValue)", + command: command, + sessionKey: "stale-session", + gatewayStableID: "gateway-stale", + text: nil, + sentAtMs: 125, + transport: "transferUserInfo")) + await Task.yield() + } + + #expect(appModel.chatSessionKey != "stale-session") + #expect(appModel.talkMode.isEnabled == false) + + appModel.setTalkEnabled(true) + watchService.emitAppCommand( + WatchAppCommandEvent( + commandId: "watch-stale-stop-talk", + command: .stopTalk, + sessionKey: "stale-session", + gatewayStableID: "gateway-stale", + text: nil, + sentAtMs: 126, + transport: "transferUserInfo")) + await Task.yield() + + #expect(appModel.talkMode.isEnabled == true) + } + + @Test @MainActor func `watch app command sends chat message through phone model`() async { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) appModel.enterAppleReviewDemoMode() @@ -777,7 +1140,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSentChatCompletion?.replyText.contains("Watch says hello") == true) } - @Test func watchChatPreviewKeepsOlderReadableMessagesAfterInternalEvents() throws { + @Test func `watch chat preview keeps older readable messages after internal events`() throws { var rawMessages = try [ makeWatchChatRawMessage( role: "assistant", @@ -798,7 +1161,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(items.map(\.text) == ["Still worth reading"]) } - @Test func watchChatPreviewReadsResponsesOutputText() throws { + @Test func `watch chat preview reads responses output text`() throws { let rawMessages = try [ makeWatchChatRawMessage( role: "assistant", @@ -812,7 +1175,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(items.map(\.text) == ["Responses reply"]) } - @Test func watchVoiceReplyMatchesDirectRunInsteadOfNewestAssistant() throws { + @Test func `watch voice reply matches direct run instead of newest assistant`() throws { let rawMessages = try [ makeWatchChatRawMessage( role: "assistant", @@ -835,7 +1198,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(reply == "Matching reply") } - @Test func watchVoiceReplyAnchorsQueuedRunAfterPersistedUserTurn() throws { + @Test func `watch voice reply anchors queued run after persisted user turn`() throws { let rawMessages = try [ makeWatchChatRawMessage(role: "assistant", text: "Active reply", timestamp: 2000), makeWatchChatRawMessage( @@ -860,7 +1223,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(reply == "Queued reply") } - @Test func watchVoiceReplyFindsCollectedQueuedTurn() throws { + @Test func `watch voice reply finds collected queued turn`() throws { let rawMessages = try [ makeWatchChatRawMessage(role: "assistant", text: "Active reply", timestamp: 2000), makeWatchChatRawMessage( @@ -880,7 +1243,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(reply == "Collected reply") } - @Test func watchVoiceReplyAcceptsTerminalMessageToolMirror() throws { + @Test func `watch voice reply accepts terminal message tool mirror`() throws { let rawMessages = try [ makeWatchChatRawMessage( role: "user", @@ -904,7 +1267,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(reply == "Update sent") } - @Test func watchChatCompletionBoundsReplyText() { + @Test func `watch chat completion bounds reply text`() { let message = OpenClawWatchChatCompletionMessage( commandId: "watch-voice", replyText: String(repeating: "x", count: 5000)) @@ -916,7 +1279,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(reply?.hasSuffix("...") == true) } - @Test func watchChatPreviewDisambiguatesIdenticalFallbackMessages() throws { + @Test func `watch chat preview disambiguates identical fallback messages`() throws { let rawMessages = try [ makeWatchChatRawMessage(role: "assistant", text: "Same", timestamp: 1000), makeWatchChatRawMessage(role: "assistant", text: "Same", timestamp: 1000), @@ -928,7 +1291,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(items[0].id != items[1].id) } - @Test func watchChatPreviewDisambiguatesProjectedRowsSharingServerID() throws { + @Test func `watch chat preview disambiguates projected rows sharing server ID`() throws { let rawMessages = try [ makeProjectedWatchChatRawMessage( role: "toolResult", @@ -949,7 +1312,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(items[0].id != items[1].id) } - @Test func watchChatPreviewKeepsMessageIDsStableWhenWindowRolls() throws { + @Test func `watch chat preview keeps message I ds stable when window rolls`() throws { var rawMessages: [AnyCodable] = [] for index in 0..<5 { try rawMessages.append( @@ -971,7 +1334,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(after.last?.role == "user") } - @Test @MainActor func watchAppCommandQueuesChatMessageWhenOperatorOffline() async { + @Test @MainActor func `watch app command queues chat message when operator offline`() async { NodeAppModel._test_resetPersistedWatchChatQueueState() defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } let watchService = MockWatchMessagingService() @@ -1006,7 +1369,28 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel._test_queuedWatchChatCommandCount() == 1) } - @Test @MainActor func watchAppCommandDropsChatMessageForStaleGatewaySnapshot() async { + @Test @MainActor func `watch app command queues until cold launch restores its gateway`() async { + NodeAppModel._test_resetPersistedWatchChatQueueState() + defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + + watchService.emitAppCommand( + WatchAppCommandEvent( + commandId: "watch-send-chat-before-route", + command: .sendChat, + sessionKey: "main", + gatewayStableID: "gateway-cold-launch", + text: "Keep this until startup restores the route", + sentAtMs: 127, + transport: "transferUserInfo")) + await waitForMainActorWork { appModel._test_queuedWatchChatCommandCount() == 1 } + + #expect(appModel._test_queuedWatchChatCommandCount() == 1) + #expect(watchService.lastSentAppSnapshot == nil) + } + + @Test @MainActor func `watch app command drops chat message for stale gateway snapshot`() async { NodeAppModel._test_resetPersistedWatchChatQueueState() defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } let watchService = MockWatchMessagingService() @@ -1027,7 +1411,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel._test_queuedWatchChatCommandCount() == 0) } - @Test @MainActor func watchAppCommandRestoresQueuedChatMessageAfterModelRestart() async { + @Test @MainActor func `watch app command restores queued chat message after model restart`() async { NodeAppModel._test_resetPersistedWatchChatQueueState() defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } @@ -1068,7 +1452,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(secondAppModel._test_queuedWatchChatCommandIds() == ["watch-send-chat-restore"]) } - @Test @MainActor func watchChatQueueScopesAndOrdersCommandsByGateway() throws { + @Test @MainActor func `watch chat queue scopes and orders commands by gateway`() throws { let suiteName = "watch-chat-queue-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -1123,7 +1507,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc "watch-send-chat-gateway-a-2") } - @Test @MainActor func watchChatRequeueKeepsOriginalGatewayOwner() throws { + @Test @MainActor func `watch chat requeue keeps original gateway owner`() throws { let suiteName = "watch-chat-requeue-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -1149,7 +1533,24 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc "watch-send-chat-retry-gateway-a") } - @Test @MainActor func watchMessageOutboxPrioritizesRepliesOverQueuedChat() throws { + @Test @MainActor func `watch message retry budget resets only on reconnect`() { + let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + let messageID = "watch-message-exhausted" + + appModel._test_setWatchMessageRetryAttempts(3, messageID: messageID) + appModel._test_setOperatorConnected(true) + #expect(appModel._test_watchMessageRetryAttempts(messageID: messageID) == nil) + + appModel._test_setWatchMessageRetryAttempts(2, messageID: messageID) + appModel._test_setOperatorConnected(true) + #expect(appModel._test_watchMessageRetryAttempts(messageID: messageID) == 2) + + appModel._test_setOperatorConnected(false) + appModel._test_setOperatorConnected(true) + #expect(appModel._test_watchMessageRetryAttempts(messageID: messageID) == nil) + } + + @Test @MainActor func `watch message outbox prioritizes replies over queued chat`() throws { let suiteName = "watch-message-priority-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -1179,7 +1580,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(outbox.nextQueuedMessage(isAvailable: true, gatewayStableID: "gateway-a") == reply) } - @Test func watchMessageOutboxDiscardsPermanentGatewayFailures() { + @Test func `watch message outbox discards permanent gateway failures`() { #expect(NodeAppModel._test_shouldDiscardFailedWatchMessage(code: "INVALID_REQUEST")) #expect(!NodeAppModel._test_shouldDiscardFailedWatchMessage( code: "INVALID_REQUEST", @@ -1187,7 +1588,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(!NodeAppModel._test_shouldDiscardFailedWatchMessage(code: "UNAVAILABLE")) } - @Test @MainActor func watchChatRestoreBackfillsGatewayOwnerIntoLegacyQueuedEvent() throws { + @Test @MainActor func `watch chat restore backfills gateway owner into legacy queued event`() throws { let suiteName = "watch-chat-restore-legacy-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -1220,7 +1621,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(restored?.gatewayStableID == "gateway-a") } - @Test @MainActor func watchChatCommandDedupingKeepsOnlyRecentForwardedCommands() throws { + @Test @MainActor func `watch chat command deduping keeps only recent forwarded commands`() throws { let suiteName = "watch-chat-recent-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -1286,7 +1687,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc } } - @Test @MainActor func watchChatCommandDedupingKeepsDeliveredQueuedCommandsRecent() throws { + @Test @MainActor func `watch chat command deduping keeps delivered queued commands recent`() throws { let suiteName = "watch-chat-delivered-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -1336,7 +1737,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc } } - @Test @MainActor func pendingWatchRecoveryIDsAreIncludedWithoutDeliveredNotifications() async { + @Test @MainActor func `pending watch recovery I ds are included without delivered notifications`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } @@ -1347,7 +1748,35 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(ids == ["approval-watch-recovery"]) } - @Test @MainActor func presentingExecApprovalPromptClearsPendingWatchRecoveryID() throws { + @Test @MainActor func `delivered approval becomes durable watch recovery`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let notificationCenter = MockBootstrapNotificationCenter() + notificationCenter.delivered = [NotificationSnapshot( + identifier: "delivered-approval", + userInfo: [ + "openclaw": [ + "kind": ExecApprovalNotificationBridge.requestedKind, + "approvalId": "approval-delivered-recovery", + "gatewayDeviceId": "gateway-device-a", + ], + ])] + let firstModel = NodeAppModel(notificationCenter: notificationCenter) + + #expect(await firstModel._test_pendingExecApprovalIDsForWatchRecovery() == [ + "approval-delivered-recovery", + ]) + #expect(firstModel._test_pendingWatchExecApprovalRecoveryIDs() == [ + "approval-delivered-recovery", + ]) + + let restoredModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) + #expect(restoredModel._test_pendingWatchExecApprovalRecoveryIDs() == [ + "approval-delivered-recovery", + ]) + } + + @Test @MainActor func `route prompt cannot clear ownerful push recovery`() throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } @@ -1366,10 +1795,10 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc agentId: nil, expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000))) - #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs().isEmpty) + #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"]) } - @Test func approvalNotificationErrorClassificationPrefersStructuredDetails() { + @Test func `approval notification error classification prefers structured details`() { let staleError = GatewayResponseError( method: "exec.approval.get", code: "INVALID_REQUEST", @@ -1385,7 +1814,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(NodeAppModel._test_isApprovalNotificationUnavailableError(unavailableError)) } - @Test func backgroundAwareExecApprovalReconnectCoversWatchAndPushPaths() { + @Test func `background aware exec approval reconnect covers watch and push paths`() { #expect( NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect( sourceReason: "watch_request", @@ -1408,14 +1837,29 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc isBackgrounded: false)) } - @Test func execApprovalEventIDDecodesGatewayPayload() { + @Test func `exec approval event ID decodes gateway payload`() { #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1") #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil) #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil) } - @Test @MainActor func operatorGatewayResolvedEventClearsPendingApprovalPrompt() async throws { - let appModel = NodeAppModel() + @Test @MainActor func `operator gateway resolved event leaves unvalidated push recovery`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let notificationCenter = MockBootstrapNotificationCenter() + notificationCenter.delivered = [NotificationSnapshot( + identifier: "approval-event-notification", + userInfo: [ + "openclaw": [ + "kind": ExecApprovalNotificationBridge.requestedKind, + "approvalId": "approval-event-resolved", + "gatewayDeviceId": "gateway-device-a", + ], + ])] + let appModel = NodeAppModel(notificationCenter: notificationCenter) + appModel._test_recordPendingWatchExecApprovalRecoveryID( + "approval-event-resolved", + gatewayDeviceId: "gateway-device-a") try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( @@ -1435,9 +1879,38 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc stateversion: nil)) #expect(appModel._test_pendingExecApprovalPrompt() == nil) + #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-event-resolved"]) + #expect(!notificationCenter.deliveredRemovedIdentifiers.contains([ + "approval-event-notification", + ])) } - @Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() { + @Test @MainActor func `validated resolved push clears only its gateway recovery`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) + appModel._test_setConnectedGatewayID("gateway-a") + let gatewayA = ExecApprovalNotificationPrompt( + approvalId: "shared-approval-id", + gatewayDeviceId: "gateway-device-a") + let gatewayB = ExecApprovalNotificationPrompt( + approvalId: "shared-approval-id", + gatewayDeviceId: "gateway-device-b") + appModel._test_recordPendingWatchExecApprovalRecoveryID( + gatewayA.approvalId, + gatewayDeviceId: "gateway-device-a") + appModel._test_recordPendingWatchExecApprovalRecoveryID( + gatewayB.approvalId, + gatewayDeviceId: "gateway-device-b") + + await appModel._test_handleExecApprovalResolvedForCurrentGateway( + approvalId: gatewayA.approvalId, + recoveryPushGatewayDeviceID: gatewayA.gatewayDeviceId) + + #expect(appModel._test_pendingWatchExecApprovalRecoveryPushes() == [gatewayB]) + } + + @Test func `watch exec approval hydrate fetches only missing I ds`() { let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch( candidateIDs: ["cached", "pending", "cached", "other", "", " pending "], cachedApprovalIDs: ["cached", "also-cached"]) @@ -1445,13 +1918,13 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(idsToFetch == ["pending", "other"]) } - @Test func watchExecApprovalRetryPromptResetsResolvingStateOnlyForRetryReason() { + @Test func `watch exec approval retry prompt resets resolving state only for retry reason`() { #expect(NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "resolve_retry")) #expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "push_request")) #expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "present_prompt")) } - @Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() { + @Test func `operator loop waits for bootstrap handoff before using stored token`() { #expect( !NodeAppModel._test_shouldStartOperatorGatewayLoop( token: nil, @@ -1478,7 +1951,26 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc hasStoredOperatorToken: false)) } - @Test @MainActor func operatorGatewayRequestedEventShowsNotificationGuidanceWhenNotificationsOff() async throws { + @Test func `credential handoff is required only for bootstrap authentication`() { + #expect(NodeAppModel._test_usesBootstrapCredential( + token: nil, + bootstrapToken: "fresh-bootstrap-token", + password: nil)) + #expect(!NodeAppModel._test_usesBootstrapCredential( + token: "shared-token", + bootstrapToken: "fresh-bootstrap-token", + password: nil)) + #expect(!NodeAppModel._test_usesBootstrapCredential( + token: nil, + bootstrapToken: "fresh-bootstrap-token", + password: "shared-password")) + #expect(!NodeAppModel._test_usesBootstrapCredential( + token: nil, + bootstrapToken: nil, + password: nil)) + } + + @Test @MainActor func `operator gateway requested event shows notification guidance when notifications off`() async throws { let center = MockBootstrapNotificationCenter() center.status = .notDetermined let appModel = NodeAppModel(notificationCenter: center) @@ -1496,7 +1988,39 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(prompt.approvalId == "approval-notifications-off") } - @Test @MainActor func suppressedOperatorGatewayRequestedEventDoesNotShowNotificationGuidance() async { + @Test @MainActor func `stale operator event cannot mutate approval UI after suspension`() async { + let center = MockBootstrapNotificationCenter() + let authorizationGate = NotificationAuthorizationGate() + center.authorizationStatusHandler = { await authorizationGate.wait() } + let appModel = NodeAppModel(notificationCenter: center) + appModel._test_resetExecApprovalNotificationGuidanceSuppression() + defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() } + var routeIsCurrent = true + let event = EventFrame( + type: "event", + event: ExecApprovalNotificationBridge.requestedKind, + payload: AnyCodable(["id": "approval-stale-route"]), + seq: nil, + stateversion: nil) + + let handling = Task { @MainActor in + await appModel._test_handleOperatorGatewayServerEvent( + event, + shouldContinue: { routeIsCurrent }) + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !(authorizationGate.hasStarted()), ContinuousClock().now < deadline { + await Task.yield() + } + routeIsCurrent = false + await authorizationGate.resume(returning: .denied) + await handling.value + + #expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil) + #expect(appModel._test_pendingExecApprovalPrompt() == nil) + } + + @Test @MainActor func `suppressed operator gateway requested event does not show notification guidance`() async { let center = MockBootstrapNotificationCenter() center.status = .denied let appModel = NodeAppModel(notificationCenter: center) @@ -1514,7 +2038,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil) } - @Test @MainActor func operatorGatewayResolvedEventClearsNotificationGuidancePrompt() async throws { + @Test @MainActor func `operator gateway resolved event clears notification guidance prompt`() async throws { let center = MockBootstrapNotificationCenter() center.status = .denied let appModel = NodeAppModel(notificationCenter: center) @@ -1539,34 +2063,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil) } - @Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() throws { - let config = try GatewayConnectConfig( - url: #require(URL(string: "wss://gateway.example")), - stableID: "test-gateway", - tls: nil, - token: nil, - bootstrapToken: "spent-bootstrap-token", - password: nil, - nodeOptions: GatewayConnectOptions( - role: "node", - scopes: [], - caps: [], - commands: [], - permissions: [:], - clientId: "openclaw-ios", - clientMode: "node", - clientDisplayName: nil)) - - let cleared = NodeAppModel._test_clearingBootstrapToken(in: config) - #expect(cleared?.bootstrapToken == nil) - #expect(cleared?.url == config.url) - #expect(cleared?.stableID == config.stableID) - #expect(cleared?.token == config.token) - #expect(cleared?.password == config.password) - #expect(cleared?.nodeOptions.role == config.nodeOptions.role) - } - - @Test @MainActor func handleInvokeRejectsBackgroundCommands() async { + @Test @MainActor func `handle invoke rejects background commands`() async { let appModel = NodeAppModel() appModel.setScenePhase(.background) @@ -1576,7 +2073,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(res.error?.code == .backgroundUnavailable) } - @Test @MainActor func handleInvokeRejectsCameraWhenDisabled() async { + @Test @MainActor func `handle invoke rejects camera when disabled`() async { let appModel = NodeAppModel() let req = BridgeInvokeRequest(id: "cam", command: OpenClawCameraCommand.snap.rawValue) @@ -1598,7 +2095,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(res.error?.message.contains("CAMERA_DISABLED") == true) } - @Test @MainActor func systemNotifyReturnsUnavailableWhenNotificationsOff() async throws { + @Test @MainActor func `system notify returns unavailable when notifications off`() async throws { let center = MockBootstrapNotificationCenter() center.status = .notDetermined let appModel = NodeAppModel(notificationCenter: center) @@ -1617,7 +2114,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(center.addCalls == 0) } - @Test @MainActor func systemNotifySchedulesWhenNotificationsAreAlreadyAllowed() async throws { + @Test @MainActor func `system notify schedules when notifications are already allowed`() async throws { let center = MockBootstrapNotificationCenter() center.status = .authorized let appModel = NodeAppModel(notificationCenter: center) @@ -1634,7 +2131,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(center.addCalls == 1) } - @Test @MainActor func apnsRegistrationRequiresDisclosureAndNotificationAuthorization() async { + @Test @MainActor func `apns registration requires disclosure and notification authorization`() async { let center = MockBootstrapNotificationCenter() center.status = .authorized let appModel = NodeAppModel(notificationCenter: center) @@ -1652,7 +2149,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(await appModel._test_canPublishAPNsRegistration()) } - @Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws { + @Test @MainActor func `chat push without speech returns unavailable when notifications off`() async throws { let center = MockBootstrapNotificationCenter() center.status = .notDetermined let appModel = NodeAppModel(notificationCenter: center) @@ -1671,7 +2168,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(center.addCalls == 0) } - @Test @MainActor func chatPushSchedulesWhenNotificationsAreAlreadyAllowed() async throws { + @Test @MainActor func `chat push schedules when notifications are already allowed`() async throws { let center = MockBootstrapNotificationCenter() center.status = .authorized let appModel = NodeAppModel(notificationCenter: center) @@ -1688,7 +2185,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(center.addCalls == 1) } - @Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async { + @Test @MainActor func `handle invoke rejects invalid screen format`() async { let appModel = NodeAppModel() let params = OpenClawScreenRecordParams(format: "gif") let data = try? JSONEncoder().encode(params) @@ -1704,7 +2201,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(res.error?.message.contains("screen format must be mp4") == true) } - @Test @MainActor func handleInvokeCanvasCommandsUpdateScreen() async throws { + @Test @MainActor func `handle invoke canvas commands update screen`() async throws { let appModel = NodeAppModel() let coordinator = try mountScreen(appModel.screen) defer { coordinator.teardown() } @@ -1747,7 +2244,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(payload?["result"] as? String == "2") } - @Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws { + @Test @MainActor func `pending foreground actions replay canvas navigate`() async throws { let appModel = NodeAppModel() let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") let navData = try JSONEncoder().encode(navigateParams) @@ -1763,7 +2260,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.screen.urlString == "http://example.com/") } - @Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws { + @Test @MainActor func `pending foreground actions do not apply while backgrounded`() async throws { let appModel = NodeAppModel() appModel.setScenePhase(.background) let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") @@ -1780,7 +2277,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.screen.urlString.isEmpty) } - @Test @MainActor func handleInvokeA2UICommandsFailWhenLocalHostUnavailable() async throws { + @Test @MainActor func `handle invoke A 2 UI commands fail when local host unavailable`() async throws { let appModel = NodeAppModel() let reset = BridgeInvokeRequest(id: "reset", command: OpenClawCanvasA2UICommand.reset.rawValue) @@ -1801,7 +2298,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(pushRes.error?.message.contains("A2UI_HOST_UNAVAILABLE") == true) } - @Test @MainActor func handleInvokeUnknownCommandReturnsInvalidRequest() async { + @Test @MainActor func `handle invoke unknown command returns invalid request`() async { let appModel = NodeAppModel() let req = BridgeInvokeRequest(id: "unknown", command: "nope") let res = await appModel._test_handleInvoke(req) @@ -1809,7 +2306,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(res.error?.code == .invalidRequest) } - @Test @MainActor func handleInvokeWatchStatusReturnsServiceSnapshot() async throws { + @Test @MainActor func `handle invoke watch status returns service snapshot`() async throws { let watchService = MockWatchMessagingService() watchService.currentStatus = WatchMessagingStatus( supported: true, @@ -1830,7 +2327,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(payload.activationState == "inactive") } - @Test @MainActor func handleInvokeWatchNotifyRoutesToWatchService() async throws { + @Test @MainActor func `handle invoke watch notify routes to watch service`() async throws { let watchService = MockWatchMessagingService() watchService.nextSendResult = WatchNotificationSendResult( deliveredImmediately: false, @@ -1849,7 +2346,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc command: OpenClawWatchCommand.notify.rawValue, paramsJSON: paramsJSON) - let res = await appModel._test_handleInvoke(req) + let res = await appModel._test_handleInvoke(req, gatewayStableID: "gateway-a") #expect(res.ok == true) #expect(watchService.lastSent?.params.title == "OpenClaw") #expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm") @@ -1863,7 +2360,108 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(payload.transport == "transferUserInfo") } - @Test @MainActor func handleInvokeWatchNotifyRejectsEmptyMessage() async throws { + @Test @MainActor func `watch reply codec preserves prompt gateway owner`() throws { + let params = OpenClawWatchNotifyParams( + title: "Approval", + body: "Allow?", + promptId: "prompt-a", + sessionKey: "ios-a", + gatewayStableID: "gateway-a") + let notification = WatchMessagingPayloadCodec.encodeNotificationPayload( + id: "notification-a", + params: params, + gatewayStableID: "gateway-a") + #expect(notification["gatewayStableID"] as? String == "gateway-a") + + let reply = try #require(WatchMessagingPayloadCodec.parseQuickReplyPayload([ + "type": OpenClawWatchPayloadType.reply.rawValue, + "replyId": "reply-a", + "promptId": "prompt-a", + "actionId": "approve", + "gatewayStableID": "gateway-a", + ], transport: "sendMessage")) + #expect(reply.gatewayStableID == "gateway-a") + } + + @Test @MainActor func `watch exec approval codec preserves gateway owner`() throws { + let approval = OpenClawWatchExecApprovalItem( + id: "approval-a", + gatewayStableID: "gateway-a", + commandText: "echo safe", + allowedDecisions: [.allowOnce, .deny]) + let prompt = WatchMessagingPayloadCodec.encodeExecApprovalPromptPayload( + OpenClawWatchExecApprovalPromptMessage(approval: approval)) + let encodedApproval = try #require(prompt["approval"] as? [String: Any]) + #expect(encodedApproval["gatewayStableID"] as? String == "gateway-a") + + let reply = try #require(WatchMessagingPayloadCodec.parseExecApprovalResolvePayload([ + "type": OpenClawWatchPayloadType.execApprovalResolve.rawValue, + "replyId": "reply-a", + "approvalId": "approval-a", + "gatewayStableID": "gateway-a", + "decision": OpenClawWatchExecApprovalDecision.allowOnce.rawValue, + ], transport: "sendMessage")) + #expect(reply.gatewayStableID == "gateway-a") + + let resolved = WatchMessagingPayloadCodec.encodeExecApprovalResolvedPayload( + OpenClawWatchExecApprovalResolvedMessage( + approvalId: "approval-a", + gatewayStableID: "gateway-a")) + let expired = WatchMessagingPayloadCodec.encodeExecApprovalExpiredPayload( + OpenClawWatchExecApprovalExpiredMessage( + approvalId: "approval-a", + gatewayStableID: "gateway-a", + reason: .notFound)) + #expect(resolved["gatewayStableID"] as? String == "gateway-a") + #expect(expired["gatewayStableID"] as? String == "gateway-a") + } + + @Test @MainActor func `watch application context retains app and approval snapshots`() throws { + let appPayload = WatchMessagingPayloadCodec.encodeAppSnapshotPayload( + OpenClawWatchAppSnapshotMessage( + gatewayStatusText: "Connected", + gatewayConnected: true, + agentName: "Main", + sessionKey: "main", + gatewayStableID: "gateway-a", + talkStatusText: "Off", + talkEnabled: false, + talkListening: false, + talkSpeaking: false, + pendingApprovalCount: 1, + snapshotId: "app-a")) + let approvalPayload = WatchMessagingPayloadCodec.encodeExecApprovalSnapshotPayload( + OpenClawWatchExecApprovalSnapshotMessage( + approvals: [ + OpenClawWatchExecApprovalItem( + id: "approval-a", + gatewayStableID: "gateway-a", + commandText: "echo safe", + allowedDecisions: [.allowOnce, .deny]), + ], + gatewayStableID: "gateway-a", + snapshotId: "approval-a")) + + let appContext = WatchMessagingPayloadCodec.encodeSnapshotApplicationContext( + appPayload, + merging: [:]) + let combined = WatchMessagingPayloadCodec.encodeSnapshotApplicationContext( + approvalPayload, + merging: appContext) + + #expect(combined["type"] as? String == OpenClawWatchPayloadType.execApprovalSnapshot.rawValue) + let nestedApp = try #require( + combined[OpenClawWatchPayloadType.appSnapshot.rawValue] as? [String: Any]) + let nestedApprovals = try #require( + combined[OpenClawWatchPayloadType.execApprovalSnapshot.rawValue] as? [String: Any]) + #expect(nestedApp["gatewayStableID"] as? String == "gateway-a") + #expect(nestedApp["snapshotId"] as? String == "app-a") + #expect(nestedApprovals["snapshotId"] as? String == "approval-a") + #expect(nestedApprovals["gatewayStableID"] as? String == "gateway-a") + #expect((nestedApprovals["approvals"] as? [Any])?.count == 1) + } + + @Test @MainActor func `handle invoke watch notify rejects empty message`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams(title: " ", body: "\n") @@ -1880,7 +2478,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSent == nil) } - @Test @MainActor func handleInvokeWatchNotifyAddsDefaultActionsForPrompt() async throws { + @Test @MainActor func `handle invoke watch notify adds default actions for prompt`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams( @@ -1902,7 +2500,37 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(actionIDs == ["done", "snooze_10m", "open_phone", "escalate"]) } - @Test @MainActor func handleInvokeWatchNotifyAddsApprovalDefaults() async throws { + @Test @MainActor func `legacy watch reply binds to latest prompt owner`() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("gateway-a") + let params = OpenClawWatchNotifyParams( + title: "Task", + body: "Action needed", + promptId: "prompt-legacy") + let paramsJSON = try String(decoding: JSONEncoder().encode(params), as: UTF8.self) + let request = BridgeInvokeRequest( + id: "watch-notify-legacy-owner", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + #expect(await appModel._test_handleInvoke(request, gatewayStableID: "gateway-a").ok) + + watchService.emitReply(WatchQuickReplyEvent( + replyId: "legacy-reply", + promptId: "prompt-legacy", + actionId: "done", + actionLabel: "Done", + sessionKey: nil, + gatewayStableID: nil, + note: nil, + sentAtMs: 1234, + transport: "transferUserInfo")) + await Task.yield() + + #expect(appModel._test_queuedWatchReplyCount() == 1) + } + + @Test @MainActor func `handle invoke watch notify adds approval defaults`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams( @@ -1924,7 +2552,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(watchService.lastSent?.params.actions?[1].style == "destructive") } - @Test @MainActor func handleInvokeWatchNotifyDerivesPriorityFromRiskAndCapsActions() async throws { + @Test @MainActor func `handle invoke watch notify derives priority from risk and caps actions`() async throws { let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let params = OpenClawWatchNotifyParams( @@ -1953,7 +2581,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(actionIDs == ["a1", "a2", "a3", "a4"]) } - @Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws { + @Test @MainActor func `handle invoke watch notify returns unavailable on delivery failure`() async throws { let watchService = MockWatchMessagingService() watchService.sendError = NSError( domain: "watch", @@ -1974,7 +2602,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true) } - @Test @MainActor func watchReplyQueuesWhenGatewayOffline() async { + @Test @MainActor func `watch reply queues when gateway offline`() async { NodeAppModel._test_resetPersistedWatchReplyQueueState() defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() @@ -1995,7 +2623,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel._test_queuedWatchReplyCount() == 1) } - @Test @MainActor func watchMessageOutboxRestoresQueuedReplyAfterRestart() throws { + @Test @MainActor func `watch message outbox restores queued reply after restart`() throws { let suiteName = "watch-reply-queue-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -2028,7 +2656,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(secondOutbox.queuedCount() == 0) } - @Test @MainActor func watchMessageOutboxRestoresDeliveryTombstonesAndPromptRoutes() throws { + @Test @MainActor func `watch message outbox restores delivery tombstones and prompt routes`() throws { let suiteName = "watch-message-metadata-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -2070,7 +2698,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc } } - @Test @MainActor func watchReplyDropsStaleGatewayTarget() async { + @Test @MainActor func `watch reply drops stale gateway target`() async { NodeAppModel._test_resetPersistedWatchReplyQueueState() defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() @@ -2094,7 +2722,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.openChatRequestID == 0) } - @Test @MainActor func watchReplyUsesIdempotentChatOutbox() async { + @Test @MainActor func `watch reply uses idempotent chat outbox`() async { NodeAppModel._test_resetPersistedWatchReplyQueueState() defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() @@ -2116,15 +2744,15 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc transport: "sendMessage") watchService.emitReply(event) - await Task.yield() + await waitForMainActorWork { appModel.openChatRequestID == initialOpenChatRequestID + 1 } watchService.emitReply(event) - await Task.yield() + await waitForMainActorWork { appModel._test_queuedWatchReplyCount() == 0 } #expect(appModel.openChatRequestID == initialOpenChatRequestID + 1) #expect(appModel._test_queuedWatchReplyCount() == 0) } - @Test @MainActor func watchReplyBindsLegacyPromptToCurrentGateway() async { + @Test @MainActor func `watch reply rejects legacy prompt without a gateway owner`() async { NodeAppModel._test_resetPersistedWatchReplyQueueState() defer { NodeAppModel._test_resetPersistedWatchReplyQueueState() } let watchService = MockWatchMessagingService() @@ -2147,18 +2775,18 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc watchService.emitReply(event) await Task.yield() - #expect(appModel.openChatRequestID == initialOpenChatRequestID + 1) + #expect(appModel.openChatRequestID == initialOpenChatRequestID) #expect(appModel._test_queuedWatchReplyCount() == 0) } - @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async throws { + @Test @MainActor func `handle deep link sets error when not connected`() async throws { let appModel = NodeAppModel() let url = try #require(URL(string: "openclaw://agent?message=hello")) await appModel.handleDeepLink(url: url) #expect(appModel.screen.errorText?.contains("Gateway not connected") == true) } - @Test @MainActor func handleDeepLinkRejectsOversizedMessage() async throws { + @Test @MainActor func `handle deep link rejects oversized message`() async throws { let appModel = NodeAppModel() let msg = String(repeating: "a", count: 20001) let url = try #require(URL(string: "openclaw://agent?message=\(msg)")) @@ -2166,7 +2794,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.screen.errorText?.contains("Deep link too large") == true) } - @Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async { + @Test @MainActor func `handle deep link requires confirmation when connected and unkeyed`() async { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) appModel._test_setAgentRequestHandler { _ in } @@ -2179,9 +2807,10 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc await appModel.approvePendingAgentDeepLinkPrompt() #expect(appModel.pendingAgentDeepLinkPrompt == nil) #expect(appModel.openChatRequestID == 1) + #expect(appModel.screen.errorText == nil) } - @Test @MainActor func handleDeepLinkCoalescesPromptWhenRateLimited() async throws { + @Test @MainActor func `handle deep link coalesces prompt when rate limited`() async throws { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) @@ -2195,7 +2824,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(coalescedPrompt.messagePreview.contains("second prompt")) } - @Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws { + @Test @MainActor func `handle deep link strips delivery fields when unkeyed`() async throws { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) let url = makeAgentDeepLinkURL( @@ -2211,7 +2840,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(prompt.request.channel == nil) } - @Test @MainActor func handleDeepLinkRejectsLongUnkeyedMessageWhenConnected() async { + @Test @MainActor func `handle deep link rejects long unkeyed message when connected`() async { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) let message = String(repeating: "x", count: 241) @@ -2222,7 +2851,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.screen.errorText?.contains("blocked") == true) } - @Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async { + @Test @MainActor func `handle deep link bypasses prompt with valid key`() async { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true) appModel._test_setAgentRequestHandler { _ in } @@ -2232,9 +2861,10 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc await appModel.handleDeepLink(url: url) #expect(appModel.pendingAgentDeepLinkPrompt == nil) #expect(appModel.openChatRequestID == 1) + #expect(appModel.screen.errorText == nil) } - @Test @MainActor func operatorAdminScopeCacheRefreshesFromStoredToken() throws { + @Test @MainActor func `operator scopes use the active gateway token`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -2250,6 +2880,27 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc } let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let stableID = "manual|gateway.example.com|443" + let authenticationOwnerID = stableID + let config = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: stableID, + tls: nil, + token: nil, + bootstrapToken: nil, + password: nil, + nodeOptions: GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios", + clientMode: "node", + clientDisplayName: nil, + deviceAuthGatewayID: authenticationOwnerID)) + appModel.applyGatewayConnectConfig(config) let identity = DeviceIdentityStore.loadOrCreate() #expect(appModel.hasOperatorAdminScope == false) @@ -2257,23 +2908,37 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc deviceId: identity.deviceId, role: "operator", token: "operator-token", - scopes: ["operator.read", "operator.admin"]) + scopes: ["operator.read", "operator.admin", "operator.approvals"], + gatewayID: authenticationOwnerID) appModel._test_refreshOperatorAdminScopeFromStore() #expect(appModel.hasOperatorAdminScope == true) + #expect(appModel._test_shouldRequestStoredOperatorAdminScope(gatewayID: authenticationOwnerID)) + #expect(appModel._test_shouldRequestStoredOperatorApprovalScope( + gatewayID: authenticationOwnerID, + forceTalkPermissionUpgradeRequest: true)) - DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "operator") + let otherStableID = "manual|other.example.com|443" + #expect(!appModel._test_shouldRequestStoredOperatorAdminScope(gatewayID: otherStableID)) + #expect(!appModel._test_shouldRequestStoredOperatorApprovalScope( + gatewayID: otherStableID, + forceTalkPermissionUpgradeRequest: true)) + + DeviceAuthStore.clearToken( + deviceId: identity.deviceId, + role: "operator", + gatewayID: authenticationOwnerID) appModel._test_refreshOperatorAdminScopeFromStore() #expect(appModel.hasOperatorAdminScope == false) } - @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { + @Test @MainActor func `send voice transcript throws when gateway offline`() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main") } } - @Test @MainActor func canvasA2UIActionDispatchesStatus() async { + @Test @MainActor func `canvas A 2 UI action dispatches status`() async { let appModel = NodeAppModel() let body: [String: Any] = [ "userAction": [ diff --git a/apps/ios/Tests/OpenClawAppDelegateTests.swift b/apps/ios/Tests/OpenClawAppDelegateTests.swift index ffa9de81e5e8..5707dd6b5051 100644 --- a/apps/ios/Tests/OpenClawAppDelegateTests.swift +++ b/apps/ios/Tests/OpenClawAppDelegateTests.swift @@ -1,5 +1,7 @@ import Foundation +import OpenClawKit import Testing +import UIKit @testable import OpenClaw @Suite(.serialized) struct OpenClawAppDelegateTests { @@ -31,4 +33,57 @@ import Testing #expect(delegate._test_wakeRefreshTaskIdentifier() == "\(bundleIdentifier).bgrefresh") } + + @Test @MainActor func `stages a gateway URL when the model is ready`() async throws { + OpenClawAppModelRegistry.appModel = nil + defer { OpenClawAppModelRegistry.appModel = nil } + let model = NodeAppModel() + let delegate = OpenClawAppDelegate() + delegate.appModel = model + let url = try #require(URL( + string: "openclaw://gateway?host=gateway.example.com&port=443&tls=1&token=tok")) + + #expect(delegate.application(UIApplication.shared, open: url)) + let link = await Self.waitForGatewaySetup(in: model) + + #expect(link?.host == "gateway.example.com") + #expect(link?.port == 443) + #expect(link?.tls == true) + #expect(link?.token == "tok") + } + + @Test @MainActor func `replays a gateway URL received before the model is ready`() async throws { + OpenClawAppModelRegistry.appModel = nil + defer { OpenClawAppModelRegistry.appModel = nil } + let delegate = OpenClawAppDelegate() + let url = try #require(URL( + string: "openclaw://gateway?host=gateway.example.com&port=443&tls=1&token=tok")) + + #expect(delegate.application(UIApplication.shared, open: url)) + + let model = NodeAppModel() + delegate.appModel = model + let link = await Self.waitForGatewaySetup(in: model) + + #expect(link?.host == "gateway.example.com") + #expect(link?.token == "tok") + } + + @Test @MainActor func `rejects an invalid URL`() throws { + let delegate = OpenClawAppDelegate() + let url = try #require(URL(string: "https://example.com/gateway")) + + #expect(!delegate.application(UIApplication.shared, open: url)) + } + + @MainActor + private static func waitForGatewaySetup(in model: NodeAppModel) async -> GatewayConnectDeepLink? { + for _ in 0..<20 { + if model.gatewaySetupRequestID > 0 { + return model.consumePendingGatewaySetupLink() + } + await Task.yield() + } + return nil + } } diff --git a/apps/ios/Tests/QRScannerResultHandoffTests.swift b/apps/ios/Tests/QRScannerResultHandoffTests.swift new file mode 100644 index 000000000000..a0dba263feaf --- /dev/null +++ b/apps/ios/Tests/QRScannerResultHandoffTests.swift @@ -0,0 +1,141 @@ +import OpenClawKit +import Testing +@testable import OpenClaw + +@MainActor +struct QRScannerResultHandoffTests { + @Test func `queued result is delivered once after dismissal`() async throws { + let handoff = QRScannerResultHandoff(settlingNanoseconds: 0) + var deliveredResult: QRScannerResult? + + let scanID = handoff.beginScan() + handoff.queue(.setupCode("review-demo"), scanID: scanID) + let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 }) + await task.value + + #expect(deliveredResult == .setupCode("review-demo")) + #expect(handoff.processAfterDismissal { _ in } == nil) + } + + @Test func `cancel prevents queued delivery`() async throws { + let handoff = QRScannerResultHandoff(settlingNanoseconds: 1_000_000_000) + var deliveredResult: QRScannerResult? + + let scanID = handoff.beginScan() + handoff.queue(.setupCode("review-demo"), scanID: scanID) + let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 }) + handoff.cancel() + await task.value + + #expect(deliveredResult == nil) + } + + @Test func `beginning another scan clears stale result`() { + let handoff = QRScannerResultHandoff(settlingNanoseconds: 0) + + let staleScanID = handoff.beginScan() + handoff.queue(.setupCode("stale"), scanID: staleScanID) + handoff.beginScan() + + #expect(handoff.processAfterDismissal { _ in } == nil) + } + + @Test func `late result from cancelled scan cannot replace newer input`() async throws { + let handoff = QRScannerResultHandoff(settlingNanoseconds: 0) + let staleScanID = handoff.beginScan() + handoff.cancel() + let currentScanID = handoff.beginScan() + var deliveredResult: QRScannerResult? + + #expect(!handoff.queue(.setupCode("stale"), scanID: staleScanID)) + #expect(handoff.queue(.setupCode("current"), scanID: currentScanID)) + let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 }) + await task.value + + #expect(deliveredResult == .setupCode("current")) + } + + @Test func `first producer claims the active scan`() async throws { + let handoff = QRScannerResultHandoff(settlingNanoseconds: 0) + let scanID = handoff.beginScan() + var deliveredResult: QRScannerResult? + + #expect(handoff.queue(.setupCode("camera"), scanID: scanID)) + #expect(!handoff.isActive(scanID: scanID)) + #expect(!handoff.queue(.setupCode("photo"), scanID: scanID)) + let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 }) + await task.value + + #expect(deliveredResult == .setupCode("camera")) + } +} + +struct GatewaySetupLinkStagingTests { + private static func link() -> GatewayConnectDeepLink { + GatewayConnectDeepLink( + host: "gateway.example.com", + port: 443, + tls: true, + bootstrapToken: "bootstrap", + token: "token", + password: "password") + } + + @Test func `staged link is consumed once`() { + var staging = GatewaySetupLinkStaging() + let link = Self.link() + + staging.stage(link) + + #expect(staging.take() == link) + #expect(staging.take() == nil) + } + + @Test func `cancel discards staged credentials`() { + var staging = GatewaySetupLinkStaging() + staging.stage(Self.link()) + + let cancelled = staging.cancel() + + #expect(cancelled) + #expect(staging.link == nil) + let cancelledAgain = staging.cancel() + #expect(!cancelledAgain) + } + + @Test func `new setup link replaces the pending candidate`() { + var staging = GatewaySetupLinkStaging() + let replacement = GatewayConnectDeepLink( + host: "replacement.example.com", + port: 8443, + tls: true, + bootstrapToken: nil, + token: nil, + password: nil) + staging.stage(Self.link()) + staging.stage(replacement) + + #expect(staging.take() == replacement) + } +} + +@MainActor +struct GatewayPendingTargetSuppressionTests { + @Test func `new setup target cannot be released by stale scanner dismissal`() { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + var pending = GatewayPendingTargetSuppression() + let scannerLease = controller.cancelPendingConnectionAttempts() + pending.replace(owner: .qrScanner, lease: scannerLease) + let setupLease = controller.cancelPendingConnectionAttempts() + pending.replace(owner: .setupLink, lease: setupLease) + + #expect(pending.take(ifOwnedBy: .qrScanner) == nil) + let activeLease = pending.take(ifOwnedBy: .setupLink) + #expect(activeLease != nil) + if let activeLease { + controller.releaseAutoConnectSuppression(after: activeLease) + } + #expect(!controller._test_isAutoConnectSuppressed()) + } +} diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index 731878150874..cf12453496fc 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -705,7 +705,7 @@ struct RootTabsSourceGuardTests { #expect(rootSource.contains("case .settings:")) #expect(rootSource .matches( - of: /case \.settings:[\s\S]*?SettingsProTab\([\s\S]*?headerLeadingAction: self\.sidebarHeaderLeadingAction,[\s\S]*?ownsNavigationStack: false[\s\S]*?onRouteChange: self\.handleSettingsRouteChange/) + of: /case \.settings:[\s\S]*?SettingsProTab\([\s\S]*?headerLeadingAction: self\.sidebarHeaderLeadingAction,[\s\S]*?ownsNavigationStack: false[\s\S]*?onRouteChange: handleSettingsRouteChange/) .count >= 1) #expect(rootSource .contains( @@ -727,8 +727,8 @@ struct RootTabsSourceGuardTests { #expect(rootSource.contains("self.selectedSettingsRoute = nil")) #expect(rootSource.contains("self.selectedSidebarDestination = .settings")) #expect(rootSource.contains("self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId")) - #expect(rootSource.contains("onRouteChange: self.handleSettingsRouteChange")) - #expect(rootSource.contains("navigateToRoute: self.pushSidebarSettingsRoute")) + #expect(rootSource.contains("onRouteChange: handleSettingsRouteChange")) + #expect(rootSource.contains("navigateToRoute: pushSidebarSettingsRoute")) #expect(rootSource.contains("private func pushSidebarSettingsRoute(_ route: SettingsRoute)")) #expect(rootSource.contains("self.sidebarNavigationPath.append(route)")) #expect(settingsTabSource.contains("let navigateToRoute: ((SettingsRoute) -> Void)?")) @@ -768,12 +768,95 @@ struct RootTabsSourceGuardTests { let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8) let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8) let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8) + let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8) let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8) + let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8) let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let scannerSource = try String(contentsOf: Self.qrScannerSourceURL(), encoding: .utf8) + let settingsScannerSheet = try Self.extract( + settingsSource, + from: "isPresented: self.$showQRScanner,", + to: ".sheet(isPresented: self.$showNotificationRelayDisclosure)") + let settingsOnDismiss = try #require(settingsScannerSheet.range(of: "onDismiss: {")) + let settingsProcessing = try #require(settingsScannerSheet.range(of: "self.processQueuedScannerResult()")) + let settingsContent = try #require(settingsScannerSheet.range(of: "content: {")) + let settingsPendingSetupHandler = try Self.extract( + actionsSource, + from: "func applyGatewaySetupLink(_ link: GatewayConnectDeepLink)", + to: "@discardableResult\n func applySetupCode(attemptID: UUID)") + let settingsScannerCancel = try #require( + settingsPendingSetupHandler.range(of: "self.scannerResultHandoff.cancel()")) + let settingsSetupStaging = try #require( + settingsPendingSetupHandler.range(of: "self.stagedGatewaySetupLink = link")) + let scannerDelivery = try Self.extract( + scannerSource, + from: "private func deliver(_ result: QRScannerResult", + to: "func dataScanner(_: DataScannerViewController, didRemove") + let stopScanning = try #require(scannerDelivery.range(of: "scanner.stopScanning()")) + let deliverResult = try #require(scannerDelivery.range(of: "self.parent.onResult(result)")) + #expect(scannerSource.contains("static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000")) let activeProblemToast = try Self.extract( rootSource, from: "private var activeGatewayProblemToast: GatewayConnectionProblem?", to: "private var gatewayToastAnimation: Animation?") + let gatewaySetupSource = try Self.extract( + rootSource, + from: "private func maybeOpenSettingsForGatewaySetup()", + to: "private func maybeRequestLocalNetworkAccess") + let consumedGatewaySetup = try #require( + gatewaySetupSource.range(of: "appModel.consumePendingGatewaySetupLink()")) + let onboardingSetupOwnerGuard = try #require( + gatewaySetupSource.range(of: "guard !self.showOnboarding else { return }")) + let deliveredGatewaySetup = try #require( + gatewaySetupSource.range(of: "self.gatewaySetupRequest = GatewaySetupRequest")) + let pendingSetupHandler = try Self.extract( + onboardingSource, + from: "private func applyPendingGatewaySetupLinkIfNeeded()", + to: "private func connectStagedGatewaySetupLink()") + let stagedSetupConnect = try Self.extract( + onboardingSource, + from: "private func connectStagedGatewaySetupLink()", + to: "private func clearStagedGatewaySetupLink()") + let stagedValidation = try #require(stagedSetupConnect.range(of: "guard link.isValidEndpoint")) + let stagedConsumption = try #require(stagedSetupConnect.range(of: "self.setupLinkStaging.take()")) + let stagedReset = try #require( + stagedSetupConnect.range(of: "await self.appModel.resetGatewaySessionsForTargetSwitch()")) + let backgroundReconnect = try Self.extract( + modelSource, + from: "private func performBackgroundAliveBeaconIfNeeded(", + to: "private func publishBackgroundAliveBeacon(") + let disconnectGateway = try Self.extract( + modelSource, + from: "func disconnectGateway()", + to: "private func disableGatewayAutoReconnect()") + let operatorGatewayLoop = try Self.extract( + modelSource, + from: "private func startOperatorGatewayLoop(", + to: "private func startNodeGatewayLoop(") + let nodeGatewayLoop = try Self.extract( + modelSource, + from: "private func startNodeGatewayLoop(", + to: "private func makeOperatorConnectOptions(") + let wakeWordRefresh = try Self.extract( + modelSource, + from: "private func refreshWakeWordsFromGateway(", + to: "private func isGatewayHealthMonitorDisabled()") + let onboardingGatewayLink = try Self.extract( + onboardingSource, + from: "private func applyGatewayLink(", + to: "private func handleScannedSetupCode(") + let settingsGatewayLink = try Self.extract( + actionsSource, + from: "func applyGatewayLink(", + to: "func openGatewayQRScanner()") + let onboardingManualConnect = try Self.extract( + onboardingSource, + from: "private func connectCurrentManualGateway(", + to: "private func retryLastAttempt(") + let settingsManualConnect = try Self.extract( + actionsSource, + from: "func connectManual(setupAttemptID: UUID? = nil) async", + to: "func preflightGateway(host: String)") #expect(sectionsSource.contains("var gatewayDestination: some View")) #expect(sectionsSource.contains("self.gatewayActions")) @@ -798,7 +881,7 @@ struct RootTabsSourceGuardTests { #expect(rootSource.contains(".gesture(self.gatewayToastSwipeGesture)")) // Operator auth/pairing problems can coexist with a connected node, so the // root's only remediation surface must not depend on aggregate status. - #expect(activeProblemToast.contains("self.appModel.lastGatewayProblem")) + #expect(activeProblemToast.contains("appModel.lastGatewayProblem")) #expect(!activeProblemToast.contains("gatewayStatus")) // Every problem report re-surfaces a swiped-away toast or shakes the // visible one; value equality alone must not keep the toast hidden. @@ -818,6 +901,10 @@ struct RootTabsSourceGuardTests { #expect(settingsSource.contains("self.resetOnboarding()")) #expect(settingsSource.contains(".onChange(of: self.onboardingRequestID)")) #expect(settingsSource.contains("self.syncAfterOnboardingReset()")) + #expect(settingsSource.contains("let acceptsGatewaySetupRequests: Bool")) + #expect(settingsSource.contains("guard self.acceptsGatewaySetupRequests else { return }")) + #expect(settingsSource.contains(".onChange(of: self.acceptsGatewaySetupRequests)")) + #expect(rootSource.matches(of: /acceptsGatewaySetupRequests: !self\.showOnboarding/).count == 2) #expect(actionsSource.contains("func syncAfterOnboardingReset()")) #expect(actionsSource.contains("self.pendingManualAuthOverride = nil")) // The root toast is the only gateway problem surface outside covers, so it @@ -829,11 +916,223 @@ struct RootTabsSourceGuardTests { #expect(rootSource.contains("await self.gatewayController.connectLastKnown()")) #expect(rootSource.contains("GatewayProblemDetailsSheet(")) + #expect(onboardingSetupOwnerGuard.lowerBound < consumedGatewaySetup.lowerBound) + #expect(consumedGatewaySetup.lowerBound < deliveredGatewaySetup.lowerBound) #expect(settingsSource.contains("QRScannerView(")) + #expect(settingsOnDismiss.lowerBound < settingsProcessing.lowerBound) + #expect(settingsProcessing.lowerBound < settingsContent.lowerBound) + #expect(settingsPendingSetupHandler.contains("self.showQRScanner = false")) + #expect(settingsScannerCancel.lowerBound < settingsSetupStaging.lowerBound) + #expect(settingsPendingSetupHandler.contains( + "self.gatewayController.cancelPendingConnectionAttempts()")) + #expect(!settingsSource.contains(".onChange(of: self.showQRScanner)")) + #expect(actionsSource.contains("case let .gatewayLink(link):")) + #expect(actionsSource.contains("case let .setupCode(code):")) + #expect(stopScanning.lowerBound < deliverResult.lowerBound) #expect(trustSource.contains("Trust this gateway?")) #expect(trustSource.contains("Trust and connect")) + #expect(trustSource.contains("let isEnabled: Bool")) + #expect(rootSource.contains(".gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)")) + #expect(onboardingSource.contains(".gatewayTrustPromptAlert()")) + #expect(onboardingSource.contains("self.applyPendingGatewaySetupLinkIfNeeded()")) + #expect(onboardingSource.contains(".onChange(of: self.appModel.gatewaySetupRequestID)")) + #expect(onboardingSource.contains("self.appModel.consumePendingGatewaySetupLink()")) + #expect(onboardingSource.contains("self.scannerResultHandoff.cancel()")) + #expect(!onboardingSource.contains("pendingScannerResult")) + #expect(onboardingSource.contains("self.setupLinkStaging.stage(link)")) + #expect(pendingSetupHandler.contains("self.gatewayController.cancelPendingConnectionAttempts()")) + #expect(pendingSetupHandler.contains("if self.selectedMode == nil")) + #expect(onboardingSource.contains("Tap Connect to apply.")) + #expect(onboardingSource.contains("self.connectStagedGatewaySetupLink()")) + #expect(onboardingSource.contains("Credentials are applied only after you tap Connect.")) + #expect(onboardingSource.contains("Plaintext (local network)")) + #expect(onboardingSource.contains("self.statusLine = message")) + #expect(!pendingSetupHandler.contains("self.manualHost =")) + #expect(!pendingSetupHandler.contains("self.manualPort =")) + #expect(!pendingSetupHandler.contains("self.manualTLS =")) + #expect(!pendingSetupHandler.contains("self.applyGatewayLink(link)")) + #expect(!pendingSetupHandler.contains("self.handleScannedLink(link)")) + #expect(!pendingSetupHandler.contains("self.connectManual()")) + #expect(stagedValidation.lowerBound < stagedConsumption.lowerBound) + #expect(stagedReset.lowerBound < stagedConsumption.lowerBound) + #expect(!stagedSetupConnect.contains("self.appModel.disconnectGateway()")) + #expect(stagedSetupConnect.contains( + "self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)")) + #expect(stagedSetupConnect.contains("guard self.connectingGatewayID == nil else { return }")) + #expect(onboardingSource.contains("self.setupLinkStaging.link == nil else { return }")) + #expect(onboardingGatewayLink.contains("self.gatewayToken = setupAuth.token")) + #expect(onboardingGatewayLink.contains("self.gatewayPassword = setupAuth.password")) + #expect(settingsGatewayLink.contains("self.gatewayToken = setupAuth.token")) + #expect(settingsGatewayLink.contains("self.gatewayPassword = setupAuth.password")) + #expect(onboardingManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true")) + #expect(onboardingManualConnect.contains("self.pendingManualAuthOverride = nil")) + #expect(onboardingManualConnect.contains("targetStableID: stableID")) + #expect(settingsManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true")) + #expect(settingsManualConnect.contains("self.pendingManualAuthOverride = nil")) + #expect(settingsManualConnect.contains("targetStableID: stableID")) + #expect(!controllerSource.contains("shouldApplyTokenField")) + #expect(!controllerSource.contains("shouldApplyPasswordField")) + #expect(controllerSource.contains("allowStoredDeviceAuth: !suppressStoredDeviceAuth")) + #expect(controllerSource.contains( + "deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID(")) + #expect(controllerSource.contains("DeviceAuthStore.migrateUnscopedToken(")) + #expect(controllerSource.contains("DeviceAuthStore.discardUnscopedTokens(")) + #expect(onboardingSource.contains( + "self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)")) + #expect(actionsSource.contains( + "self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)")) + #expect(onboardingSource.contains( + "self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID")) + #expect(actionsSource.contains( + "self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID")) + #expect(disconnectGateway.contains("self.beginGatewaySessionReset(chainingAfterExisting: true)")) + #expect(!disconnectGateway.contains("Task {")) + #expect(modelSource.contains( + "private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool")) + #expect(modelSource.matches( + of: /self\.isCurrentGatewayRoute\(generation: routeGeneration, stableID: stableID\)/).count >= 2) + #expect(operatorGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"operator_loop\")")) + #expect(nodeGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"node_loop\")")) + #expect(modelSource.contains("refreshWakeWordsFromGateway(shouldApply: shouldContinue)")) + #expect(wakeWordRefresh.matches(of: /guard shouldApply\(\) else \{ return \}/).count >= 2) + #expect(modelSource.contains("if !self.gatewayAutoReconnectEnabled || self.gatewayPairingPaused")) #expect(controllerSource.contains("acceptPendingTrustPrompt()")) #expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)")) + #expect(controllerSource.contains("allowAutoReconnect: false")) + #expect(controllerSource.contains("guard allowAutoReconnect else { return }")) + #expect(controllerSource.contains("guard self.autoConnectSuppressionGeneration == nil else { return }")) + #expect(backgroundReconnect.contains("let generation = self.gatewayConnectGeneration")) + #expect(backgroundReconnect.contains("await self.resetGatewaySessionsForForcedReconnect()")) + #expect(backgroundReconnect.contains("expectedGeneration: generation")) + #expect(modelSource.contains("expectedGeneration: UInt64)")) + #expect(!modelSource.contains("expectedGeneration: UInt64?")) + } + + @Test func `gateway credential fields update before endpoint persistence is available`() throws { + let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8) + let settingsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8) + for source in [onboardingSource, settingsSource] { + let tokenSetter = try Self.extract( + source, + from: "func persistGatewayToken(_ value: String)", + to: "func persistGatewayPassword(_ value: String)") + let passwordSetter = try Self.extract( + source, + from: "func persistGatewayPassword(_ value: String)", + to: "func clearManualCredentialFields()") + let tokenAssignment = try #require(tokenSetter.range(of: "self.gatewayToken = value")) + let tokenEndpointGuard = try #require( + tokenSetter.range(of: "let stableID = self.gatewayCredentialTargetStableID")) + let passwordAssignment = try #require(passwordSetter.range(of: "self.gatewayPassword = value")) + let passwordEndpointGuard = try #require( + passwordSetter.range(of: "let stableID = self.gatewayCredentialTargetStableID")) + + #expect(tokenAssignment.lowerBound < tokenEndpointGuard.lowerBound) + #expect(passwordAssignment.lowerBound < passwordEndpointGuard.lowerBound) + } + } + + @Test func `onboarding mode defaults clear credentials after endpoint changes`() throws { + let source = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8) + let modeDefaults = try Self.extract( + source, + from: "private func applyModeDefaults(_ mode: OnboardingConnectionMode)", + to: "private func gatewayHasResolvableHost") + + #expect(modeDefaults.contains("let previousStableID = self.currentManualGatewayStableID")) + #expect(modeDefaults.contains("previousStableID != self.currentManualGatewayStableID")) + #expect(modeDefaults.contains("self.clearManualCredentialFields()")) + } + + @Test func `watch snapshot bundle applies owner before approvals and clears old chat`() throws { + let receiverSource = try String(contentsOf: Self.watchConnectivityReceiverSourceURL(), encoding: .utf8) + let storeSource = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8) + let consumePayload = try Self.extract( + receiverSource, + from: "private func consumeIncomingPayload(_ payload: [String: Any], transport: String)", + to: "}\n}") + let appSnapshotConsume = try #require( + consumePayload.range(of: "self.store.consume(appSnapshot: appSnapshot)")) + let approvalSnapshotConsume = try #require( + consumePayload.range(of: "self.store.consume(execApprovalSnapshot: execApprovalSnapshot")) + let consumeAppSnapshot = try Self.extract( + storeSource, + from: "func consume(appSnapshot message: WatchAppSnapshotMessage)", + to: "func markAppSnapshotRequestStarted()") + + #expect(appSnapshotConsume.lowerBound < approvalSnapshotConsume.lowerBound) + #expect(consumeAppSnapshot.contains("if hasExistingAppSnapshot, previousGatewayID == nextGatewayID")) + let ownerMatchedMerge = try Self.extract( + consumeAppSnapshot, + from: "if hasExistingAppSnapshot, previousGatewayID == nextGatewayID", + to: "self.appSnapshot = merged") + #expect(ownerMatchedMerge.contains("merged.chatItems = self.appSnapshot?.chatItems")) + #expect(ownerMatchedMerge.contains("merged.chatStatusText = self.appSnapshot?.chatStatusText")) + } + + @Test func `watch generic prompts wait for the active gateway owner`() throws { + let receiverSource = try String(contentsOf: Self.watchConnectivityReceiverSourceURL(), encoding: .utf8) + let source = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8) + let consumeMessage = try Self.extract( + source, + from: "func consume(message: WatchNotifyMessage, transport: String)", + to: "func consume(\n execApprovalPrompt") + let consumeAppSnapshot = try Self.extract( + source, + from: "func consume(appSnapshot message: WatchAppSnapshotMessage)", + to: "func markAppSnapshotRequestStarted()") + + let replay = try Self.extract( + source, + from: "func replayDeferredGatewayPayloads()", + to: "private func clearMessagePrompt()") + let routeGatewayPayload = try Self.extract( + source, + from: "private func routeGatewayPayload(_ payload: DeferredGatewayPayload)", + to: "private func acceptsGatewayOwner") + let acceptsGatewayOwner = try Self.extract( + source, + from: "private func acceptsGatewayOwner(_ gatewayStableID: String?)", + to: "func replayDeferredGatewayPayloads()") + + #expect(consumeMessage.contains("self.routeGatewayPayload(.notification")) + #expect(consumeAppSnapshot.contains("self.clearMessagePrompt()")) + #expect(consumeAppSnapshot.contains("if !hasExistingAppSnapshot || previousGatewayID != nextGatewayID")) + #expect(source.contains("private var deferredGatewayPayloads: [DeferredGatewayPayload]")) + #expect(routeGatewayPayload.contains("guard let activeSnapshot = appSnapshot else { return true }")) + #expect(acceptsGatewayOwner.contains("guard let activeSnapshot = appSnapshot else { return true }")) + #expect(acceptsGatewayOwner.contains("else { return false }")) + #expect(replay.contains("WatchDeferredPayloadOrdering.indicesOldestFirst")) + #expect(replay.contains("WatchDeferredPayloadOrdering.isExpired")) + #expect(replay.contains("WatchDeferredPayloadOrdering.isNewerThanSnapshot")) + #expect(replay.contains("WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot")) + #expect(replay.contains("case let .notification(message, transport):")) + #expect(replay.contains("approvalSnapshotGatewayID == activeGatewayID")) + #expect(replay.contains("payload.isFullyRepresentedByExecApprovalSnapshot")) + #expect(replay.contains("let approval = payload.approvalPrompt")) + #expect(source.contains("if hasSameSnapshotOwner")) + #expect(source.contains("if let sentAtMs = message.sentAtMs")) + #expect(receiverSource.contains("self.store.replayDeferredGatewayPayloads()")) + } + + @Test func `watch approval notifications include their gateway owner`() throws { + let source = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8) + let identifier = try Self.extract( + source, + from: "private static func execApprovalNotificationIdentifier(", + to: "private func pruneExpiredExecApprovals") + let routeChange = try Self.extract( + source, + from: "func consume(appSnapshot message: WatchAppSnapshotMessage)", + to: "func markAppSnapshotRequestStarted()") + + #expect(identifier.contains("gatewayStableID.utf8.count")) + #expect(identifier.contains("gatewayStableID)\\(approvalID)")) + #expect(routeChange.contains("removeExecApprovalNotifications(approvals: invalidatedApprovals)")) + #expect(!source.contains("identifier: \"watch.execApproval.\\(message.approval.id)\"")) + #expect(source.contains("let ownerlessApprovals = state.execApprovals.filter")) + #expect(source.contains("self.lastExecApprovalSnapshotID = nil")) + #expect(source.contains("\"watch.execApproval.\\(approvalID)\"")) } @Test func `setup route probes yield to newer manual actions`() throws { @@ -872,13 +1171,29 @@ struct RootTabsSourceGuardTests { let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8) let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8) let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8) + let onboardingScannerSheet = try Self.extract( + onboardingSource, + from: "isPresented: self.$showQRScanner,", + to: ".sheet(isPresented: self.$showGatewayProblemDetails)") + let onboardingOnDismiss = try #require(onboardingScannerSheet.range(of: "onDismiss: {")) + let onboardingProcessing = try #require(onboardingScannerSheet.range(of: "self.processQueuedScannerResult()")) + let onboardingContent = try #require(onboardingScannerSheet.range(of: "content: {")) #expect(appSource.contains("deferDiscoveryUntilLocalNetworkRequest: true")) - #expect(controllerSource.contains("func requestLocalNetworkAccess(reason: String)")) + #expect(appSource.contains("func application(\n _ app: UIApplication,\n open url: URL,")) + #expect(appSource.contains("self.pendingOpenURLs.append(url)")) + #expect(appSource.contains("model.stageGatewaySetupLink(link)")) + #expect(appSource.contains(".onOpenURL")) + #expect(appSource.contains("self.appDelegate.handleOpenURL(url, model: self.appModel)")) + #expect(controllerSource.contains( + "func requestLocalNetworkAccess(reason: String, allowAutoReconnect: Bool = true)")) #expect(controllerSource.contains("guard self.localNetworkAccessRequested else")) - #expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_manual\")")) - #expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\")")) - #expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_last_known\")")) + #expect(controllerSource.contains( + "self.requestLocalNetworkAccess(reason: \"connect_manual\", allowAutoReconnect: false)")) + #expect(controllerSource.contains( + "self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\", allowAutoReconnect: false)")) + #expect(controllerSource.contains( + "self.requestLocalNetworkAccess(reason: \"connect_last_known\", allowAutoReconnect: false)")) #expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"root_appear\")")) #expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"scene_active\")")) @@ -889,6 +1204,12 @@ struct RootTabsSourceGuardTests { #expect(onboardingSource.contains("self.requestLocalNetworkAccess(reason: \"onboarding_continue\")")) #expect(onboardingSource.contains("self.requestLocalNetworkAccessIfPastIntro(reason: \"onboarding_appear\")")) + #expect(onboardingSource.contains( + "self.applyPendingGatewaySetupLinkIfNeeded()\n self.attemptAutomaticPairingResumeIfNeeded()")) + #expect(onboardingOnDismiss.lowerBound < onboardingProcessing.lowerBound) + #expect(onboardingProcessing.lowerBound < onboardingContent.lowerBound) + #expect(!onboardingSource.contains(".onChange(of: self.showQRScanner)")) + #expect(onboardingSource.matches(of: /self\.showQRScanner = true/).count == 1) #expect(actionsSource .contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")")) } @@ -913,14 +1234,49 @@ struct RootTabsSourceGuardTests { let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8) let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8) let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8) + let transportSource = try String(contentsOf: Self.iOSGatewayChatTransportSourceURL(), encoding: .utf8) #expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2) #expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)")) + #expect(appModelSource.contains("ifCurrentRoute: operatorRoute")) + #expect(transportSource.matches(of: /ifCurrentRoute: expectedRoute/).count == 3) #expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata")) #expect(channelsSource.contains("label: \"ClickClack\"")) #expect(channelsSource.contains("Self-hosted chat bot routing.")) } + @Test func `deferred gateway mutations retain their source gateway`() throws { + let source = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8) + let pendingActions = try Self.extract( + source, + from: "private func resumePendingForegroundNodeActionsIfNeeded(", + to: "private func handleWatchQuickReply(") + let resolvedState = try Self.extract( + source, + from: "private func handleExecApprovalResolvedForCurrentGateway(", + to: "func handleExecApprovalResolvedRemotePush(") + let resolvedPushes = try Self.extract( + source, + from: "func handleExecApprovalResolvedRemotePush(", + to: "func handleSilentPushWake(") + + #expect(pendingActions.contains("ifCurrentRoute: nodeRoute")) + #expect(pendingActions.contains("ifCurrentRoute: expectedRoute")) + #expect(pendingActions.contains("isCurrentGatewaySessionRoute")) + #expect(pendingActions.contains("pendingForegroundActionDrainRequested = true")) + #expect(pendingActions.contains("trigger: \"coalesced\"")) + #expect(pendingActions.contains("pendingForegroundActionDrainInFlight = false")) + #expect(pendingActions.contains("completedPendingForegroundActionIDsByGateway")) + #expect(pendingActions.contains("presentIn: decoded.actions")) + #expect(pendingActions.contains("let currentRoute = await self.nodeGateway.currentRoute()")) + #expect(pendingActions.contains("ifCurrentRoute: expectedRoute")) + #expect(resolvedState.matches(of: /canApplyExecApprovalResolvedState/).count >= 4) + #expect(resolvedState.contains("routeContext: routeContext")) + #expect(resolvedPushes.contains("applyValidatedExecApprovalResolvedPush(push, context: context)")) + #expect(resolvedPushes.contains("session: self.operatorGateway")) + #expect(resolvedPushes.contains("generation: context.routeGeneration")) + } + private static func rootTabsSourceURL() -> URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -935,6 +1291,13 @@ struct RootTabsSourceGuardTests { .appendingPathComponent("Sources/Model/NodeAppModel.swift") } + private static func iOSGatewayChatTransportSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Chat/IOSGatewayChatTransport.swift") + } + private static func phoneHubSourceURL() -> URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -1099,6 +1462,13 @@ struct RootTabsSourceGuardTests { .appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift") } + private static func qrScannerSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Onboarding/QRScannerView.swift") + } + private static func openClawAppSourceURL() -> URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -1141,6 +1511,20 @@ struct RootTabsSourceGuardTests { .appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift") } + private static func watchConnectivityReceiverSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("WatchApp/Sources/WatchConnectivityReceiver.swift") + } + + private static func watchInboxStoreSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("WatchApp/Sources/WatchInboxStore.swift") + } + private static func channelsSourceURL() -> URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/apps/ios/Tests/TerminalHubScreenTests.swift b/apps/ios/Tests/TerminalHubScreenTests.swift index 588008a9ca0d..cd559aafef39 100644 --- a/apps/ios/Tests/TerminalHubScreenTests.swift +++ b/apps/ios/Tests/TerminalHubScreenTests.swift @@ -7,7 +7,9 @@ struct TerminalHubScreenTests { private static func makeConfig( url: URL, token: String? = nil, - password: String? = nil) -> GatewayConnectConfig + password: String? = nil, + allowStoredDeviceAuth: Bool = true, + deviceAuthGatewayID: String? = nil) -> GatewayConnectConfig { GatewayConnectConfig( url: url, @@ -24,7 +26,9 @@ struct TerminalHubScreenTests { permissions: [:], clientId: "ios", clientMode: "node", - clientDisplayName: "Phone")) + clientDisplayName: "Phone", + allowStoredDeviceAuth: allowStoredDeviceAuth, + deviceAuthGatewayID: deviceAuthGatewayID)) } @Test func `terminal URL flips scheme and carries only view parameter`() throws { @@ -77,6 +81,55 @@ struct TerminalHubScreenTests { #expect(script?.contains("\"token\":\"stored-token\"") == true) } + @Test func `auth user script loads the active gateway scoped operator token`() throws { + let gatewayID = "manual|terminal-\(UUID().uuidString)|443" + let identity = DeviceIdentityStore.loadOrCreate() + defer { + DeviceAuthStore.clearToken( + deviceId: identity.deviceId, + role: "operator", + gatewayID: gatewayID) + } + #expect(DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "operator", + token: "scoped-terminal-token", + gatewayID: gatewayID).token == "scoped-terminal-token") + let config = try Self.makeConfig( + url: #require(URL(string: "wss://gateway.example.com:8443")), + deviceAuthGatewayID: gatewayID) + + let script = TerminalHubScreen.terminalAuthUserScript(config: config) + + #expect(script?.contains("\"token\":\"scoped-terminal-token\"") == true) + } + + @Test func `auth user script honors stored device auth suppression`() throws { + let gatewayID = "manual|terminal-suppressed-\(UUID().uuidString)|443" + let identity = DeviceIdentityStore.loadOrCreate() + defer { + DeviceAuthStore.clearToken( + deviceId: identity.deviceId, + role: "operator", + gatewayID: gatewayID) + } + #expect(DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "operator", + token: "stale-terminal-token", + gatewayID: gatewayID).token == "stale-terminal-token") + let config = try Self.makeConfig( + url: #require(URL(string: "wss://gateway.example.com:8443")), + password: "replacement-password", + allowStoredDeviceAuth: false, + deviceAuthGatewayID: gatewayID) + + let script = TerminalHubScreen.terminalAuthUserScript(config: config) + + #expect(script?.contains("stale-terminal-token") == false) + #expect(script?.contains("\"password\":\"replacement-password\"") == true) + } + @Test func `web content identity changes with stored operator token`() throws { let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com"))) diff --git a/apps/ios/Tests/WatchDeferredPayloadOrderingTests.swift b/apps/ios/Tests/WatchDeferredPayloadOrderingTests.swift new file mode 100644 index 000000000000..a182fee33806 --- /dev/null +++ b/apps/ios/Tests/WatchDeferredPayloadOrderingTests.swift @@ -0,0 +1,29 @@ +import Testing + +struct WatchDeferredPayloadOrderingTests { + @Test func `expired payload is not replayable`() { + #expect(WatchDeferredPayloadOrdering.isExpired(expiresAtMs: 100, nowMs: 100)) + #expect(!WatchDeferredPayloadOrdering.isExpired(expiresAtMs: 101, nowMs: 100)) + #expect(!WatchDeferredPayloadOrdering.isExpired(expiresAtMs: nil, nowMs: 100)) + } + + @Test func `ownerless snapshot retains only payloads it cannot supersede`() { + #expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: 201, snapshotSentAtMs: 200)) + #expect(!WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: 200, snapshotSentAtMs: 200)) + #expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: nil, snapshotSentAtMs: 200)) + } + + @Test func `snapshot freshness treats an undated payload as preexisting`() { + #expect(WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: 100, snapshotSentAtMs: 100)) + #expect(!WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: 101, snapshotSentAtMs: 100)) + #expect(WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: nil, snapshotSentAtMs: 100)) + } + + @Test func `replays reversed deliveries in event order`() { + #expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [200, 100]) == [1, 0]) + } + + @Test func `replays missing timestamps first in receipt order`() { + #expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [nil, 200, nil, 100]) == [0, 2, 3, 1]) + } +} diff --git a/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift b/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift index e3a852743eea..65f6a973d12d 100644 --- a/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift +++ b/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift @@ -27,12 +27,13 @@ struct OpenClawWatchApp: App { self.inboxStore.markReplyResult(result, actionLabel: action.label) } }, - onExecApprovalDecision: { approvalId, decision in + onExecApprovalDecision: { approvalId, gatewayStableID, decision in guard let receiver = self.receiver else { return } self.inboxStore.markExecApprovalSending(approvalId: approvalId, decision: decision) Task { @MainActor in let result = await receiver.sendExecApprovalResolve( approvalId: approvalId, + gatewayStableID: gatewayStableID, decision: decision) self.inboxStore.markExecApprovalSendResult( approvalId: approvalId, @@ -144,10 +145,11 @@ struct OpenClawWatchApp: App { extension WatchInboxStore { fileprivate func configureScreenshotFixture() { let sentAtMs = Int(Date().timeIntervalSince1970 * 1000) - self.greetingTextOverride = "Good morning" + greetingTextOverride = "Good morning" self.consume( execApprovalSnapshot: WatchExecApprovalSnapshotMessage( approvals: [], + gatewayStableID: "watch-screenshot-gateway", sentAtMs: sentAtMs, snapshotId: nil), transport: "screenshot") diff --git a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift index 2b6c11aa1f45..26b38c18dca7 100644 --- a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift @@ -130,6 +130,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { func sendExecApprovalResolve( approvalId: String, + gatewayStableID: String?, decision: WatchExecApprovalDecision) async -> WatchReplySendResult { await self.ensureActivated() @@ -144,6 +145,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let payload = Self.encodeExecApprovalResolvePayload( WatchExecApprovalResolveMessage( approvalId: approvalId, + gatewayStableID: gatewayStableID, decision: decision, replyId: UUID().uuidString, sentAtMs: Self.nowMs())) @@ -302,6 +304,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let host = (payload["host"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let nodeId = (payload["nodeId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayStableID = (payload["gatewayStableID"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let risk = WatchRiskLevel(rawValue: riskRaw) @@ -310,6 +314,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { } return WatchExecApprovalItem( id: id, + gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil, commandText: commandText, commandPreview: commandPreview, host: host, @@ -350,11 +355,14 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !approvalId.isEmpty else { return nil } let decision = Self.parseExecApprovalDecision(payload["decision"]) + let gatewayStableID = (payload["gatewayStableID"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) let resolvedAtMs = (payload["resolvedAtMs"] as? Int) ?? (payload["resolvedAtMs"] as? NSNumber)?.intValue let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) return WatchExecApprovalResolvedMessage( approvalId: approvalId, + gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil, decision: decision, resolvedAtMs: resolvedAtMs, source: source) @@ -376,8 +384,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { return nil } let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue + let gatewayStableID = (payload["gatewayStableID"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) return WatchExecApprovalExpiredMessage( approvalId: approvalId, + gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil, reason: reason, expiredAtMs: expiredAtMs) } @@ -393,10 +404,13 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let approvals = (payload["approvals"] as? [Any] ?? []).compactMap { item in Self.parseExecApprovalItem(item) } + let gatewayStableID = (payload["gatewayStableID"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) return WatchExecApprovalSnapshotMessage( approvals: approvals, + gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil, sentAtMs: sentAtMs, snapshotId: snapshotId) } @@ -557,6 +571,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { "decision": message.decision.rawValue, "replyId": message.replyId, ] + if let gatewayStableID = message.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines), + !gatewayStableID.isEmpty + { + payload["gatewayStableID"] = gatewayStableID + } if let sentAtMs = message.sentAtMs { payload["sentAtMs"] = sentAtMs } @@ -602,6 +621,26 @@ extension WatchConnectivityReceiver: WCSessionDelegate { } private func consumeIncomingPayload(_ payload: [String: Any], transport: String) { + let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any]) + .flatMap(Self.parseAppSnapshotPayload) + let execApprovalSnapshot = + (payload[WatchPayloadType.execApprovalSnapshot.rawValue] as? [String: Any]) + .flatMap(Self.parseExecApprovalSnapshotPayload) + if appSnapshot != nil || execApprovalSnapshot != nil { + // Owner state must land first so approvals are filtered against this context's route. + Task { @MainActor in + if let appSnapshot { + self.store.consume(appSnapshot: appSnapshot) + } + if let execApprovalSnapshot { + self.store.consume(execApprovalSnapshot: execApprovalSnapshot, transport: transport) + } + if appSnapshot != nil { + self.store.replayDeferredGatewayPayloads() + } + } + return + } if let incoming = Self.parseNotificationPayload(payload) { Task { @MainActor in self.store.consume(message: incoming, transport: transport) @@ -635,6 +674,7 @@ extension WatchConnectivityReceiver: WCSessionDelegate { if let snapshot = Self.parseAppSnapshotPayload(payload) { Task { @MainActor in self.store.consume(appSnapshot: snapshot) + self.store.replayDeferredGatewayPayloads() } return } diff --git a/apps/ios/WatchApp/Sources/WatchDeferredPayloadOrdering.swift b/apps/ios/WatchApp/Sources/WatchDeferredPayloadOrdering.swift new file mode 100644 index 000000000000..29885fd24708 --- /dev/null +++ b/apps/ios/WatchApp/Sources/WatchDeferredPayloadOrdering.swift @@ -0,0 +1,26 @@ +enum WatchDeferredPayloadOrdering { + static func isExpired(expiresAtMs: Int?, nowMs: Int) -> Bool { + expiresAtMs.map { $0 <= nowMs } == true + } + + static func isNewerThanSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool { + guard let payloadSentAtMs, let snapshotSentAtMs else { return true } + return payloadSentAtMs > snapshotSentAtMs + } + + static func isAtOrBeforeSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool { + guard let snapshotSentAtMs else { return false } + return payloadSentAtMs.map { $0 <= snapshotSentAtMs } ?? true + } + + static func indicesOldestFirst(for timestamps: [Int?]) -> [Int] { + timestamps.indices.sorted { lhs, rhs in + let lhsTimestamp = timestamps[lhs] ?? .min + let rhsTimestamp = timestamps[rhs] ?? .min + if lhsTimestamp != rhsTimestamp { + return lhsTimestamp < rhsTimestamp + } + return lhs < rhs + } + } +} diff --git a/apps/ios/WatchApp/Sources/WatchInboxStore.swift b/apps/ios/WatchApp/Sources/WatchInboxStore.swift index d40432d66f8a..a07989b4b8d0 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxStore.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxStore.swift @@ -39,6 +39,7 @@ enum WatchExecApprovalCloseReason: String, Codable, Equatable { struct WatchExecApprovalItem: Codable, Equatable, Identifiable { var id: String + var gatewayStableID: String? var commandText: String var commandPreview: String? var host: String? @@ -58,6 +59,7 @@ struct WatchExecApprovalPromptMessage: Codable, Equatable { struct WatchExecApprovalResolvedMessage: Codable, Equatable { var approvalId: String + var gatewayStableID: String? var decision: WatchExecApprovalDecision? var resolvedAtMs: Int? var source: String? @@ -65,12 +67,14 @@ struct WatchExecApprovalResolvedMessage: Codable, Equatable { struct WatchExecApprovalExpiredMessage: Codable, Equatable { var approvalId: String + var gatewayStableID: String? var reason: WatchExecApprovalCloseReason var expiredAtMs: Int? } struct WatchExecApprovalSnapshotMessage: Codable, Equatable { var approvals: [WatchExecApprovalItem] + var gatewayStableID: String? var sentAtMs: Int? var snapshotId: String? } @@ -82,6 +86,7 @@ struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable { struct WatchExecApprovalResolveMessage: Codable, Equatable { var approvalId: String + var gatewayStableID: String? var decision: WatchExecApprovalDecision var replyId: String var sentAtMs: Int? @@ -147,7 +152,7 @@ struct WatchPromptAction: Codable, Equatable, Identifiable { var style: String? } -struct WatchNotifyMessage { +struct WatchNotifyMessage: Codable { var id: String? var title: String var body: String @@ -177,6 +182,73 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } @MainActor @Observable final class WatchInboxStore { + private enum DeferredGatewayPayload: Codable { + case notification(message: WatchNotifyMessage, transport: String) + case execApprovalPrompt(message: WatchExecApprovalPromptMessage, transport: String) + case execApprovalResolved(message: WatchExecApprovalResolvedMessage) + case execApprovalExpired(message: WatchExecApprovalExpiredMessage) + case execApprovalSnapshot(message: WatchExecApprovalSnapshotMessage, transport: String) + + var gatewayStableID: String? { + switch self { + case let .notification(message, _): + message.gatewayStableID + case let .execApprovalPrompt(message, _): + message.approval.gatewayStableID + case let .execApprovalResolved(message): + message.gatewayStableID + case let .execApprovalExpired(message): + message.gatewayStableID + case let .execApprovalSnapshot(message, _): + if let gatewayStableID = WatchInboxStore.normalizedGatewayID(message.gatewayStableID) { + gatewayStableID + } else { + WatchInboxStore.onlyGatewayStableID(in: message.approvals) + } + } + } + + var sentAtMs: Int? { + switch self { + case let .notification(message, _): + message.sentAtMs + case let .execApprovalPrompt(message, _): + message.sentAtMs + case let .execApprovalResolved(message): + message.resolvedAtMs + case let .execApprovalExpired(message): + message.expiredAtMs + case let .execApprovalSnapshot(message, _): + message.sentAtMs + } + } + + var expiresAtMs: Int? { + switch self { + case let .notification(message, _): + message.expiresAtMs + case let .execApprovalPrompt(message, _): + message.approval.expiresAtMs + case .execApprovalResolved, .execApprovalExpired, .execApprovalSnapshot: + nil + } + } + + var approvalPrompt: WatchExecApprovalItem? { + guard case let .execApprovalPrompt(message, _) = self else { return nil } + return message.approval + } + + var isFullyRepresentedByExecApprovalSnapshot: Bool { + switch self { + case .execApprovalResolved, .execApprovalExpired, .execApprovalSnapshot: + true + case .notification, .execApprovalPrompt: + false + } + } + } + private struct PersistedState: Codable { var title: String var body: String @@ -196,15 +268,19 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var execApprovals: [WatchExecApprovalRecord] var selectedExecApprovalID: String? var lastExecApprovalSnapshotID: String? + var lastExecApprovalSnapshotGatewayStableID: String? + var lastExecApprovalSnapshotSentAtMs: Int? var lastExecApprovalOutcomeText: String? var lastExecApprovalOutcomeAt: Date? var appSnapshot: WatchAppSnapshotMessage? var appSnapshotUpdatedAt: Date? var appSnapshotStatusText: String? var appCommandStatusText: String? + var deferredGatewayPayloads: [DeferredGatewayPayload]? } private static let persistedStateKey = "watch.inbox.state.v2" + private static let maxDeferredGatewayPayloads = 32 private static let defaultTitle = "OpenClaw" private static let defaultBody = "Waiting for messages from your iPhone." private let defaults: UserDefaults @@ -238,8 +314,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var execApprovalReviewStatusText: String? var execApprovalReviewStatusAt: Date? private var lastExecApprovalSnapshotID: String? + private var lastExecApprovalSnapshotGatewayStableID: String? + private var lastExecApprovalSnapshotSentAtMs: Int? private var hasCompletedExecApprovalSnapshotRefreshInSession = false private var lastDeliveryKey: String? + /// WatchConnectivity does not order application-context updates against user-info + /// transfers. Persist a bounded handoff queue so a new route's alert is not lost + /// before its owner snapshot arrives. + private var deferredGatewayPayloads: [DeferredGatewayPayload] = [] init( defaults: UserDefaults = .standard, @@ -347,6 +429,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } func consume(message: WatchNotifyMessage, transport: String) { + guard self.routeGatewayPayload(.notification(message: message, transport: transport)) else { return } let messageID = message.id? .trimmingCharacters(in: .whitespacesAndNewlines) let deliveryKey = self.deliveryKey( @@ -381,7 +464,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { identifier: deliveryKey, title: normalizedTitle, body: message.body, - risk: message.risk) + risk: message.risk, + stillCurrent: { self.lastDeliveryKey == deliveryKey }) } } @@ -389,22 +473,33 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { execApprovalPrompt message: WatchExecApprovalPromptMessage, transport: String) { + guard self.routeGatewayPayload(.execApprovalPrompt(message: message, transport: transport)) else { return } self.pruneExpiredExecApprovals(nowMs: Self.nowMs()) self.upsertExecApproval( message.approval, transport: transport, keepSelectionIfPossible: true, resetResolvingState: message.resetResolvingState == true) + let approvalID = message.approval.id + let approvalGatewayID = message.approval.gatewayStableID + guard let notificationIdentifier = Self.execApprovalNotificationIdentifier(for: message.approval) else { + return + } self.markExecApprovalReviewLoaded() self.lastExecApprovalOutcomeText = nil self.lastExecApprovalOutcomeAt = nil Task { await self.postLocalNotification( - identifier: "watch.execApproval.\(message.approval.id)", + identifier: notificationIdentifier, title: "Exec approval required", body: message.approval.commandPreview ?? message.approval.commandText, - risk: message.approval.risk?.rawValue) + risk: message.approval.risk?.rawValue, + stillCurrent: { + self.execApprovals.contains { record in + record.id == approvalID && record.approval.gatewayStableID == approvalGatewayID + } + }) } } @@ -412,20 +507,55 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { execApprovalSnapshot message: WatchExecApprovalSnapshotMessage, transport: String) { + let deferredPayload = DeferredGatewayPayload.execApprovalSnapshot( + message: message, + transport: transport) + if deferredPayload.gatewayStableID != nil { + guard self.routeGatewayPayload(deferredPayload) else { return } + } + let snapshotGatewayID = Self.normalizedGatewayID(deferredPayload.gatewayStableID) + let previousSnapshotGatewayID = Self.normalizedGatewayID( + self.lastExecApprovalSnapshotGatewayStableID) + let hasSameSnapshotOwner = snapshotGatewayID == previousSnapshotGatewayID let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines) - if let snapshotID, !snapshotID.isEmpty, snapshotID == lastExecApprovalSnapshotID { + if hasSameSnapshotOwner, + let snapshotID, + !snapshotID.isEmpty, + snapshotID == lastExecApprovalSnapshotID + { + return + } + if hasSameSnapshotOwner, + let sentAtMs = message.sentAtMs, + let lastSentAtMs = lastExecApprovalSnapshotSentAtMs, + sentAtMs < lastSentAtMs + { return } + let existingRecords = self.execApprovals let existingRecordsByID = Dictionary( - uniqueKeysWithValues: execApprovals.map { ($0.id, $0) }) - self.execApprovals = message.approvals.map { approval in + uniqueKeysWithValues: existingRecords.map { ($0.id, $0) }) + self.execApprovals = message.approvals.filter { approval in + self.acceptsGatewayOwner(approval.gatewayStableID) + }.map { approval in self.mergedExecApprovalRecord( approval: approval, transport: transport, existingRecord: existingRecordsByID[approval.id]) } - self.lastExecApprovalSnapshotID = snapshotID + if hasSameSnapshotOwner { + if let snapshotID, !snapshotID.isEmpty { + self.lastExecApprovalSnapshotID = snapshotID + } + if let sentAtMs = message.sentAtMs { + self.lastExecApprovalSnapshotSentAtMs = sentAtMs + } + } else { + self.lastExecApprovalSnapshotID = snapshotID + self.lastExecApprovalSnapshotSentAtMs = message.sentAtMs + } + self.lastExecApprovalSnapshotGatewayStableID = snapshotGatewayID self.hasCompletedExecApprovalSnapshotRefreshInSession = true if let selectedExecApprovalID, !self.execApprovals.contains(where: { $0.id == selectedExecApprovalID }) @@ -435,6 +565,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { selectedExecApprovalID = self.sortedExecApprovals.first?.id } self.pruneExpiredExecApprovals(nowMs: Self.nowMs()) + let currentNotificationIdentifiers = Set(execApprovals.compactMap { record in + Self.execApprovalNotificationIdentifier(for: record.approval) + }) + let removedApprovals = existingRecords.map(\.approval).filter { approval in + guard let identifier = Self.execApprovalNotificationIdentifier(for: approval) else { return false } + return !currentNotificationIdentifiers.contains(identifier) + } + self.removeExecApprovalNotifications(approvals: removedApprovals) self.markExecApprovalReviewLoaded() self.persistState() } @@ -444,16 +582,48 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { if let snapshotID, !snapshotID.isEmpty, snapshotID == appSnapshot?.snapshotId { return } - var merged = message - if merged.chatItems == nil { - merged.chatItems = self.appSnapshot?.chatItems + if let sentAtMs = message.sentAtMs, + let currentSentAtMs = appSnapshot?.sentAtMs, + sentAtMs < currentSentAtMs + { + return } - if merged.chatStatusText == nil { - merged.chatStatusText = self.appSnapshot?.chatStatusText + let hasExistingAppSnapshot = self.appSnapshot != nil + let previousGatewayID = Self.normalizedGatewayID(self.appSnapshot?.gatewayStableID) + let nextGatewayID = Self.normalizedGatewayID(message.gatewayStableID) + var merged = message + if hasExistingAppSnapshot, previousGatewayID == nextGatewayID { + if merged.chatItems == nil { + merged.chatItems = self.appSnapshot?.chatItems + } + if merged.chatStatusText == nil { + merged.chatStatusText = self.appSnapshot?.chatStatusText + } } self.appSnapshot = merged self.appSnapshotUpdatedAt = Date() self.appSnapshotStatusText = nil + if !hasExistingAppSnapshot || previousGatewayID != nextGatewayID { + if Self.normalizedGatewayID(self.gatewayStableID) != nextGatewayID { + self.clearMessagePrompt() + } + let invalidatedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in + guard let nextGatewayID else { return record.approval } + return Self.normalizedGatewayID(record.approval.gatewayStableID) == nextGatewayID + ? nil + : record.approval + } + self.execApprovals.removeAll { record in + guard let nextGatewayID else { return true } + return Self.normalizedGatewayID(record.approval.gatewayStableID) != nextGatewayID + } + self.removeExecApprovalNotifications(approvals: invalidatedApprovals) + if let selectedExecApprovalID, + !self.execApprovals.contains(where: { $0.id == selectedExecApprovalID }) + { + self.selectedExecApprovalID = self.sortedExecApprovals.first?.id + } + } self.persistState() } @@ -520,7 +690,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) { - self.removeExecApproval(id: message.approvalId) + guard self.routeGatewayPayload(.execApprovalResolved(message: message)) else { return } + self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID) let statusText = switch message.decision { case .allowOnce: "Allowed once" @@ -535,7 +706,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) { - self.removeExecApproval(id: message.approvalId) + guard self.routeGatewayPayload(.execApprovalExpired(message: message)) else { return } + self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID) let statusText = switch message.reason { case .expired: "Approval expired" @@ -643,21 +815,204 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { statusAt: statusAt) } - private func removeExecApproval(id: String) { + private func removeExecApproval(id: String, gatewayStableID: String?) { let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedID.isEmpty else { return } - self.execApprovals.removeAll { $0.id == normalizedID } + let normalizedGatewayID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) + let removedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in + guard record.id == normalizedID else { return nil } + // Legacy ownerless lifecycle messages may only close legacy ownerless prompts. + return record.approval.gatewayStableID == normalizedGatewayID ? record.approval : nil + } + self.execApprovals.removeAll { record in + guard record.id == normalizedID else { return false } + // Legacy ownerless lifecycle messages may only close legacy ownerless prompts. + return record.approval.gatewayStableID == normalizedGatewayID + } + self.removeExecApprovalNotifications(approvals: removedApprovals) if self.selectedExecApprovalID == normalizedID { self.selectedExecApprovalID = self.sortedExecApprovals.first?.id } self.persistState() } + private func routeGatewayPayload(_ payload: DeferredGatewayPayload) -> Bool { + guard let incomingGatewayID = Self.normalizedGatewayID(payload.gatewayStableID) else { + return false + } + guard let activeSnapshot = appSnapshot else { return true } + let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID) + guard incomingGatewayID != activeGatewayID else { return true } + if let payloadSentAtMs = payload.sentAtMs, + let snapshotSentAtMs = activeSnapshot.sentAtMs, + payloadSentAtMs <= snapshotSentAtMs + { + return false + } + if WatchDeferredPayloadOrdering.isExpired( + expiresAtMs: payload.expiresAtMs, + nowMs: Self.nowMs()) + { + return false + } + + self.deferredGatewayPayloads.append(payload) + if self.deferredGatewayPayloads.count > Self.maxDeferredGatewayPayloads { + self.deferredGatewayPayloads.removeFirst( + self.deferredGatewayPayloads.count - Self.maxDeferredGatewayPayloads) + } + self.persistState() + return false + } + + private func acceptsGatewayOwner(_ gatewayStableID: String?) -> Bool { + guard let incomingGatewayID = Self.normalizedGatewayID(gatewayStableID) else { return false } + guard let activeSnapshot = appSnapshot else { return true } + guard let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID) else { return false } + return incomingGatewayID == activeGatewayID + } + + func replayDeferredGatewayPayloads() { + guard let activeGatewayID = Self.normalizedGatewayID(appSnapshot?.gatewayStableID) else { + let snapshotSentAtMs = self.appSnapshot?.sentAtMs + let nowMs = Self.nowMs() + self.deferredGatewayPayloads.removeAll { payload in + WatchDeferredPayloadOrdering.isExpired( + expiresAtMs: payload.expiresAtMs, + nowMs: nowMs) + || !WatchDeferredPayloadOrdering.isNewerThanSnapshot( + payloadSentAtMs: payload.sentAtMs, + snapshotSentAtMs: snapshotSentAtMs) + } + self.persistState() + return + } + + let snapshotSentAtMs = self.appSnapshot?.sentAtMs + let approvalSnapshotGatewayID = Self.normalizedGatewayID( + self.lastExecApprovalSnapshotGatewayStableID) + let nowMs = Self.nowMs() + var ready: [DeferredGatewayPayload] = [] + var future: [DeferredGatewayPayload] = [] + for payload in self.deferredGatewayPayloads { + if WatchDeferredPayloadOrdering.isExpired( + expiresAtMs: payload.expiresAtMs, + nowMs: nowMs) + { + continue + } + if Self.normalizedGatewayID(payload.gatewayStableID) == activeGatewayID { + let isPreexistingApprovalPayload = approvalSnapshotGatewayID == activeGatewayID + && WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot( + payloadSentAtMs: payload.sentAtMs, + snapshotSentAtMs: self.lastExecApprovalSnapshotSentAtMs) + if isPreexistingApprovalPayload, + payload.isFullyRepresentedByExecApprovalSnapshot + { + continue + } + if isPreexistingApprovalPayload, + let approval = payload.approvalPrompt, + !self.execApprovals.contains(where: { record in + record.id == approval.id + && Self.normalizedGatewayID(record.approval.gatewayStableID) == activeGatewayID + }) + { + continue + } + ready.append(payload) + } else if let payloadSentAtMs = payload.sentAtMs, + let snapshotSentAtMs, + payloadSentAtMs > snapshotSentAtMs + { + future.append(payload) + } + } + self.deferredGatewayPayloads = future + self.persistState() + + let replayOrder = WatchDeferredPayloadOrdering.indicesOldestFirst( + for: ready.map(\.sentAtMs)) + for index in replayOrder { + let payload = ready[index] + switch payload { + case let .notification(message, transport): + self.consume(message: message, transport: transport) + case let .execApprovalPrompt(message, transport): + self.consume(execApprovalPrompt: message, transport: transport) + case let .execApprovalResolved(message): + self.consume(execApprovalResolved: message) + case let .execApprovalExpired(message): + self.consume(execApprovalExpired: message) + case let .execApprovalSnapshot(message, transport): + self.consume(execApprovalSnapshot: message, transport: transport) + } + } + } + + private func clearMessagePrompt() { + let notificationIdentifier = self.lastDeliveryKey + self.title = Self.defaultTitle + self.body = Self.defaultBody + self.transport = "none" + self.updatedAt = nil + self.lastDeliveryKey = nil + self.promptId = nil + self.sessionKey = nil + self.gatewayStableID = nil + self.kind = nil + self.details = nil + self.expiresAtMs = nil + self.risk = nil + self.actions = [] + self.replyStatusText = nil + self.replyStatusAt = nil + self.isReplySending = false + + guard let notificationIdentifier else { return } + self.removeLocalNotifications(identifiers: [notificationIdentifier]) + } + + private func removeExecApprovalNotifications(approvals: [WatchExecApprovalItem]) { + self.removeLocalNotifications(identifiers: approvals.compactMap { approval in + Self.execApprovalNotificationIdentifier(for: approval) + }) + } + + private func removeLocalNotifications(identifiers: [String]) { + guard !identifiers.isEmpty else { return } + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: identifiers) + center.removeDeliveredNotifications(withIdentifiers: identifiers) + } + + private nonisolated static func normalizedGatewayID(_ gatewayStableID: String?) -> String? { + let normalized = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return normalized.isEmpty ? nil : normalized + } + + private nonisolated static func onlyGatewayStableID(in approvals: [WatchExecApprovalItem]) -> String? { + let gatewayIDs = Set(approvals.compactMap { self.normalizedGatewayID($0.gatewayStableID) }) + return gatewayIDs.count == 1 ? gatewayIDs.first : nil + } + + private static func execApprovalNotificationIdentifier(for approval: WatchExecApprovalItem) -> String? { + guard let gatewayStableID = normalizedGatewayID(approval.gatewayStableID) else { return nil } + let approvalID = approval.id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !approvalID.isEmpty else { return nil } + return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)" + } + private func pruneExpiredExecApprovals(nowMs: Int) { + let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in + guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil } + return record.approval + } self.execApprovals.removeAll { record in guard let expiresAtMs = record.approval.expiresAtMs else { return false } return expiresAtMs <= nowMs } + self.removeExecApprovalNotifications(approvals: expiredApprovals) if let selectedExecApprovalID, !self.execApprovals.contains(where: { $0.id == selectedExecApprovalID }) { @@ -688,15 +1043,64 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { self.actions = state.actions ?? [] self.replyStatusText = state.replyStatusText self.replyStatusAt = state.replyStatusAt - self.execApprovals = state.execApprovals - self.selectedExecApprovalID = state.selectedExecApprovalID + let ownerlessApprovals = state.execApprovals.filter { record in + Self.normalizedGatewayID(record.approval.gatewayStableID) == nil + } + let taggedApprovals = state.execApprovals.filter { record in + Self.normalizedGatewayID(record.approval.gatewayStableID) != nil + } + let activeGatewayID = state.appSnapshot.flatMap { snapshot in + Self.normalizedGatewayID(snapshot.gatewayStableID) + } + let invalidatedApprovals: [WatchExecApprovalRecord] + if state.appSnapshot != nil { + self.execApprovals = taggedApprovals.filter { record in + Self.normalizedGatewayID(record.approval.gatewayStableID) == activeGatewayID + } + invalidatedApprovals = taggedApprovals.filter { record in + Self.normalizedGatewayID(record.approval.gatewayStableID) != activeGatewayID + } + } else { + self.execApprovals = taggedApprovals + invalidatedApprovals = [] + } + selectedExecApprovalID = state.selectedExecApprovalID self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID + self.lastExecApprovalSnapshotGatewayStableID = state.lastExecApprovalSnapshotGatewayStableID + self.lastExecApprovalSnapshotSentAtMs = state.lastExecApprovalSnapshotSentAtMs self.lastExecApprovalOutcomeText = state.lastExecApprovalOutcomeText self.lastExecApprovalOutcomeAt = state.lastExecApprovalOutcomeAt self.appSnapshot = state.appSnapshot self.appSnapshotUpdatedAt = state.appSnapshotUpdatedAt self.appSnapshotStatusText = state.appSnapshotStatusText self.appCommandStatusText = state.appCommandStatusText + self.deferredGatewayPayloads = Array( + (state.deferredGatewayPayloads ?? []).suffix(Self.maxDeferredGatewayPayloads)) + + if state.appSnapshot != nil, + Self.normalizedGatewayID(self.lastExecApprovalSnapshotGatewayStableID) != activeGatewayID + { + self.lastExecApprovalSnapshotID = nil + self.lastExecApprovalSnapshotGatewayStableID = nil + self.lastExecApprovalSnapshotSentAtMs = nil + } + if let selectedExecApprovalID, + !self.execApprovals.contains(where: { $0.id == selectedExecApprovalID }) + { + self.selectedExecApprovalID = self.sortedExecApprovals.first?.id + } + self.removeExecApprovalNotifications(approvals: invalidatedApprovals.map(\.approval)) + + guard !ownerlessApprovals.isEmpty else { return } + // Older Watch state has no gateway owner and cannot be resolved safely after + // gateway switches. Drop it, clear its old alert keys, and force a fresh snapshot. + self.lastExecApprovalSnapshotID = nil + self.lastExecApprovalSnapshotGatewayStableID = nil + self.lastExecApprovalSnapshotSentAtMs = nil + self.removeLocalNotifications(identifiers: ownerlessApprovals.compactMap { record in + let approvalID = record.id.trimmingCharacters(in: .whitespacesAndNewlines) + return approvalID.isEmpty ? nil : "watch.execApproval.\(approvalID)" + }) } private func persistState() { @@ -720,12 +1124,15 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { execApprovals: execApprovals, selectedExecApprovalID: selectedExecApprovalID, lastExecApprovalSnapshotID: lastExecApprovalSnapshotID, + lastExecApprovalSnapshotGatewayStableID: lastExecApprovalSnapshotGatewayStableID, + lastExecApprovalSnapshotSentAtMs: lastExecApprovalSnapshotSentAtMs, lastExecApprovalOutcomeText: lastExecApprovalOutcomeText, lastExecApprovalOutcomeAt: lastExecApprovalOutcomeAt, appSnapshot: appSnapshot, appSnapshotUpdatedAt: appSnapshotUpdatedAt, appSnapshotStatusText: appSnapshotStatusText, - appCommandStatusText: appCommandStatusText) + appCommandStatusText: appCommandStatusText, + deferredGatewayPayloads: deferredGatewayPayloads) guard let data = try? JSONEncoder().encode(state) else { return } self.defaults.set(data, forKey: Self.persistedStateKey) } @@ -794,7 +1201,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { self.persistState() } - private func postLocalNotification(identifier: String, title: String, body: String, risk: String?) async { + private func postLocalNotification( + identifier: String, + title: String, + body: String, + risk: String?, + stillCurrent: @MainActor @Sendable () -> Bool = { true }) async + { + guard stillCurrent() else { return } let content = UNMutableNotificationContent() content.title = title content.body = body @@ -806,7 +1220,12 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { content: content, trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false)) - _ = try? await UNUserNotificationCenter.current().add(request) + let center = UNUserNotificationCenter.current() + _ = try? await center.add(request) + guard stillCurrent() else { + self.removeLocalNotifications(identifiers: [identifier]) + return + } WKInterfaceDevice.current().play(self.mapHapticRisk(risk)) } diff --git a/apps/ios/WatchApp/Sources/WatchInboxView.swift b/apps/ios/WatchApp/Sources/WatchInboxView.swift index dd2b8243e68a..148d67d4fff3 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxView.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxView.swift @@ -4,7 +4,7 @@ import WatchKit struct WatchInboxView: View { var store: WatchInboxStore var onAction: ((WatchPromptAction) -> Void)? - var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)? + var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)? var onRefreshExecApprovalReview: (() -> Void)? var onRefreshAppSnapshot: (() -> Void)? var onAppCommand: ((WatchAppCommand) -> Void)? @@ -28,7 +28,7 @@ struct WatchInboxView: View { private struct WatchControlSurfaceView: View { var store: WatchInboxStore var onAction: ((WatchPromptAction) -> Void)? - var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)? + var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)? var onRefreshExecApprovalReview: (() -> Void)? var onRefreshAppSnapshot: (() -> Void)? var onAppCommand: ((WatchAppCommand) -> Void)? @@ -183,7 +183,7 @@ private struct WatchControlSurfaceView: View { subtitle: self.store.body, accessory: self.updatedText) - if let details = self.promptDetails { + if let details = promptDetails { WatchDetailText(text: details) } @@ -197,7 +197,7 @@ private struct WatchControlSurfaceView: View { .disabled(self.store.isReplySending) } - if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty { + if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty { WatchTinyStatus(text: replyStatusText) } } @@ -249,13 +249,19 @@ private struct WatchControlSurfaceView: View { HStack(spacing: 8) { if record.approval.allowedDecisions.contains(.allowOnce) { WatchDecisionButton(title: "Approve", color: .green) { - self.onExecApprovalDecision?(record.id, .allowOnce) + self.onExecApprovalDecision?( + record.id, + record.approval.gatewayStableID, + .allowOnce) } } if record.approval.allowedDecisions.contains(.deny) { WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) { - self.onExecApprovalDecision?(record.id, .deny) + self.onExecApprovalDecision?( + record.id, + record.approval.gatewayStableID, + .deny) } } } @@ -307,7 +313,7 @@ private struct WatchControlSurfaceView: View { } @ViewBuilder private var primaryDestination: some View { - if let record = self.store.activeExecApproval { + if let record = store.activeExecApproval { WatchExecApprovalDetailView( store: self.store, record: record, @@ -334,7 +340,7 @@ private struct WatchControlSurfaceView: View { } private var connectionLine: String { - if let snapshot = self.store.appSnapshot { + if let snapshot = store.appSnapshot { return snapshot.gatewayConnected ? "AI agent online" : "Reconnect on iPhone" } return "Pair iPhone" @@ -346,7 +352,7 @@ private struct WatchControlSurfaceView: View { } private var primaryTitle: String { - if let record = self.store.activeExecApproval { + if let record = store.activeExecApproval { return record.approval.commandPreview ?? record.approval.commandText } if self.chatCount > 0 { @@ -370,7 +376,7 @@ private struct WatchControlSurfaceView: View { } private var approvalSubtitle: String { - guard let record = self.store.activeExecApproval else { return "No approvals waiting" } + guard let record = store.activeExecApproval else { return "No approvals waiting" } return record.approval.commandPreview ?? record.approval.commandText } @@ -380,7 +386,7 @@ private struct WatchControlSurfaceView: View { private func approvalDecisionSubtitle(_ record: WatchExecApprovalRecord) -> String { var parts: [String] = [] - if let expiresText = self.expiryText(record.approval.expiresAtMs) { + if let expiresText = expiryText(record.approval.expiresAtMs) { parts.append("Expires in \(expiresText)") } if let host = record.approval.host, !host.isEmpty { @@ -396,7 +402,7 @@ private struct WatchControlSurfaceView: View { if record.isResolving { return "Sending" } - if let risk = self.approvalRiskText(record.approval.risk) { + if let risk = approvalRiskText(record.approval.risk) { return risk } return "Review" @@ -416,7 +422,7 @@ private struct WatchControlSurfaceView: View { } private var chatPreviewTitle: String { - guard let item = self.chatItems.last else { return "No chat synced" } + guard let item = chatItems.last else { return "No chat synced" } return self.roleTitle(item.role) } @@ -425,7 +431,7 @@ private struct WatchControlSurfaceView: View { } private var chatStatusText: String { - if let status = self.store.appSnapshot?.chatStatusText, !status.isEmpty { + if let status = store.appSnapshot?.chatStatusText, !status.isEmpty { return status } if self.chatCount > 0 { @@ -435,14 +441,14 @@ private struct WatchControlSurfaceView: View { } private var chatSendStatusText: String? { - guard let status = self.store.appCommandStatusText, status.hasPrefix("Chat:") else { + guard let status = store.appCommandStatusText, status.hasPrefix("Chat:") else { return nil } return status } private var greetingText: String { - if let greetingTextOverride = self.store.greetingTextOverride { + if let greetingTextOverride = store.greetingTextOverride { return greetingTextOverride } let hour = Calendar.current.component(.hour, from: Date()) @@ -452,20 +458,20 @@ private struct WatchControlSurfaceView: View { } private var statusLine: String { - if let status = self.store.appSnapshotStatusText, !status.isEmpty { + if let status = store.appSnapshotStatusText, !status.isEmpty { return status } - if let commandStatus = self.store.appCommandStatusText, !commandStatus.isEmpty { + if let commandStatus = store.appCommandStatusText, !commandStatus.isEmpty { return commandStatus } - if let replyStatus = self.store.replyStatusText, !replyStatus.isEmpty { + if let replyStatus = store.replyStatusText, !replyStatus.isEmpty { return replyStatus } return self.store.hasAppSnapshot ? "Synced" : "Waiting for iPhone" } private var updatedText: String { - guard let updatedAt = self.store.updatedAt else { return "Just now" } + guard let updatedAt = store.updatedAt else { return "Just now" } return updatedAt.formatted(date: .omitted, time: .shortened) } @@ -538,7 +544,7 @@ private enum WatchAvatarSource { } static func dataImage(from source: String?) -> UIImage? { - guard let source = self.normalized(source), + guard let source = normalized(source), source.lowercased().hasPrefix("data:image/"), let commaIndex = source.firstIndex(of: ",") else { @@ -552,7 +558,7 @@ private enum WatchAvatarSource { } static func remoteURL(from source: String?) -> URL? { - guard let source = self.normalized(source), + guard let source = normalized(source), let url = URL(string: source), let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" @@ -589,11 +595,11 @@ private struct WatchClawAvatar: View { } @ViewBuilder private var avatarContent: some View { - if let image = self.dataImage { + if let image = dataImage { Image(uiImage: image) .resizable() .scaledToFill() - } else if let url = WatchAvatarSource.remoteURL(from: self.imageSource) { + } else if let url = WatchAvatarSource.remoteURL(from: imageSource) { AsyncImage(url: url) { phase in switch phase { case let .success(image): @@ -610,7 +616,7 @@ private struct WatchClawAvatar: View { } @ViewBuilder private var fallbackContent: some View { - if let text = WatchAvatarSource.normalized(self.text) { + if let text = WatchAvatarSource.normalized(text) { Text(String(text.prefix(3))) .font(WatchClawType.avatar(size: self.size * 0.42)) .foregroundStyle(.white) @@ -1289,7 +1295,7 @@ private enum WatchNativeTextInput { private struct WatchExecApprovalListView: View { var store: WatchInboxStore - var onDecision: ((String, WatchExecApprovalDecision) -> Void)? + var onDecision: ((String, String?, WatchExecApprovalDecision) -> Void)? var body: some View { WatchDetailScroll(title: "Approvals") { @@ -1353,7 +1359,7 @@ private struct WatchExecApprovalListView: View { private struct WatchExecApprovalDetailView: View { var store: WatchInboxStore let record: WatchExecApprovalRecord - var onDecision: ((String, WatchExecApprovalDecision) -> Void)? + var onDecision: ((String, String?, WatchExecApprovalDecision) -> Void)? var body: some View { WatchDetailScroll(title: "Approval") { @@ -1375,13 +1381,19 @@ private struct WatchExecApprovalDetailView: View { HStack(spacing: 8) { if currentRecord.approval.allowedDecisions.contains(.allowOnce) { WatchDecisionButton(title: "Approve", color: .green) { - self.onDecision?(currentRecord.id, .allowOnce) + self.onDecision?( + currentRecord.id, + currentRecord.approval.gatewayStableID, + .allowOnce) } } if currentRecord.approval.allowedDecisions.contains(.deny) { WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) { - self.onDecision?(currentRecord.id, .deny) + self.onDecision?( + currentRecord.id, + currentRecord.approval.gatewayStableID, + .deny) } } } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 4e3e119618cd..de69442d44f7 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -372,6 +372,8 @@ targets: - path: Tests excludes: - Logic + - path: WatchApp/Sources/WatchDeferredPayloadOrdering.swift + group: WatchApp/Sources dependencies: - target: OpenClaw - package: Swabble diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift index bd5bd78685d8..14de8070eab4 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift @@ -237,7 +237,7 @@ struct GatewayChannelConnectTests { @Test func `stored device token connect scopes reuse cached scopes`() async throws { try await self.withTemporaryStateDir { let identity = DeviceIdentityStore.loadOrCreate() - let storedEntry = DeviceAuthStore.storeToken( + let storedEntry: DeviceAuthEntry = DeviceAuthStore.storeToken( deviceId: identity.deviceId, role: "operator", token: "bootstrap-device-token", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index da3209e9c001..5cb0cb8c3185 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -74,8 +74,17 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { } public var websocketURL: URL? { - let scheme = self.tls ? "wss" : "ws" - return URL(string: "\(scheme)://\(self.host):\(self.port)") + guard (1...65535).contains(self.port) else { return nil } + var components = URLComponents() + components.scheme = self.tls ? "wss" : "ws" + components.host = self.host + components.port = self.port + return components.url + } + + public var isValidEndpoint: Bool { + guard (1...65535).contains(self.port), self.websocketURL?.host != nil else { return false } + return self.tls || LoopbackHost.isLocalNetworkHost(self.host) } public var connectionEndpoints: [GatewayConnectEndpoint] { @@ -196,7 +205,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { if !tls, !LoopbackHost.isLocalNetworkHost(host) { return nil } - return GatewayConnectDeepLink( + return GatewayConnectDeepLink.validated( host: host, port: payload.port ?? defaultGatewayPort(tls: tls), tls: tls, @@ -223,7 +232,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { if !tls, !LoopbackHost.isLocalNetworkHost(hostname) { return nil } - return GatewayConnectDeepLink( + return GatewayConnectDeepLink.validated( host: hostname, port: parsed.port ?? defaultGatewayPort(tls: tls), tls: tls, @@ -232,6 +241,24 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { password: password) } + fileprivate static func validated( + host: String, + port: Int, + tls: Bool, + bootstrapToken: String?, + token: String?, + password: String?) -> GatewayConnectDeepLink? + { + let link = GatewayConnectDeepLink( + host: host, + port: port, + tls: tls, + bootstrapToken: bootstrapToken, + token: token, + password: password) + return link.isValidEndpoint ? link : nil + } + private static func decodeBase64Url(_ input: String) -> Data? { var base64 = input .replacingOccurrences(of: "-", with: "+") @@ -342,18 +369,27 @@ public enum DeepLinkParser { return nil } let tls = (query["tls"] as NSString?)?.boolValue ?? false - let port = query["port"].flatMap { Int($0) } ?? defaultGatewayPort(tls: tls) + let port: Int + if let rawPort = query["port"] { + guard let parsedPort = Int(rawPort) else { return nil } + port = parsedPort + } else { + port = defaultGatewayPort(tls: tls) + } if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) { return nil } - return .gateway( - .init( - host: hostParam, - port: port, - tls: tls, - bootstrapToken: nil, - token: query["token"], - password: query["password"])) + guard let link = GatewayConnectDeepLink.validated( + host: hostParam, + port: port, + tls: tls, + bootstrapToken: nil, + token: query["token"], + password: query["password"]) + else { + return nil + } + return .gateway(link) case "dashboard": return .dashboard diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift index 2b13e3e96834..653822a293b6 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift @@ -5,12 +5,14 @@ public struct DeviceAuthEntry: Codable, Sendable { public let role: String public let scopes: [String] public let updatedAtMs: Int + public let gatewayID: String? - public init(token: String, role: String, scopes: [String], updatedAtMs: Int) { + public init(token: String, role: String, scopes: [String], updatedAtMs: Int, gatewayID: String? = nil) { self.token = token self.role = role self.scopes = scopes self.updatedAtMs = updatedAtMs + self.gatewayID = gatewayID } } @@ -24,11 +26,11 @@ public enum DeviceAuthStore { public static func loadToken( deviceId: String, role: String, + gatewayID: String? = nil, profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry? { guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil } - let role = self.normalizeRole(role) - return store.tokens[role] + return store.tokens[self.tokenKey(role: role, gatewayID: gatewayID)] } public static func storeToken( @@ -36,7 +38,25 @@ public enum DeviceAuthStore { role: String, token: String, scopes: [String] = [], + gatewayID: String? = nil, profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry + { + self.storeTokenResult( + deviceId: deviceId, + role: role, + token: token, + scopes: scopes, + gatewayID: gatewayID, + profile: profile).entry + } + + static func storeTokenResult( + deviceId: String, + role: String, + token: String, + scopes: [String] = [], + gatewayID: String? = nil, + profile: GatewayDeviceIdentityProfile = .primary) -> (entry: DeviceAuthEntry, persisted: Bool) { let normalizedRole = self.normalizeRole(role) var next = self.readStore(profile: profile) @@ -47,26 +67,31 @@ public enum DeviceAuthStore { token: token, role: normalizedRole, scopes: normalizeScopes(scopes), - updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)) + updatedAtMs: Int(Date().timeIntervalSince1970 * 1000), + gatewayID: self.normalizeGatewayID(gatewayID)) if next == nil { next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) } - next?.tokens[normalizedRole] = entry - if let store = next { - self.writeStore(store, profile: profile) - } - return entry + next?.tokens[self.tokenKey(role: normalizedRole, gatewayID: gatewayID)] = entry + let persisted = next.map { self.writeStore($0, profile: profile) } ?? false + return (entry, persisted) } public static func clearToken( deviceId: String, role: String, + gatewayID: String? = nil, profile: GatewayDeviceIdentityProfile = .primary) { guard var store = readStore(profile: profile), store.deviceId == deviceId else { return } let normalizedRole = self.normalizeRole(role) - guard store.tokens[normalizedRole] != nil else { return } - store.tokens.removeValue(forKey: normalizedRole) + if gatewayID == nil { + store.tokens = store.tokens.filter { _, entry in + self.normalizeRole(entry.role) != normalizedRole + } + } else { + store.tokens.removeValue(forKey: self.tokenKey(role: normalizedRole, gatewayID: gatewayID)) + } self.writeStore(store, profile: profile) } @@ -74,10 +99,66 @@ public enum DeviceAuthStore { try? FileManager.default.removeItem(at: self.fileURL(profile: profile)) } + /// Claims one legacy role token for a caller-proven gateway identity. + /// Roles can have different gateway owners, so bulk migration is never safe. + @discardableResult + public static func migrateUnscopedToken( + deviceId: String, + role: String, + toGatewayID gatewayID: String, + profile: GatewayDeviceIdentityProfile = .primary) -> Bool + { + guard let gatewayID = self.normalizeGatewayID(gatewayID), + var store = self.readStore(profile: profile), + store.deviceId == deviceId + else { return false } + + let normalizedRole = self.normalizeRole(role) + let legacyKey = self.tokenKey(role: normalizedRole, gatewayID: nil) + guard let entry = store.tokens[legacyKey], entry.gatewayID == nil else { return false } + let scopedKey = self.tokenKey(role: normalizedRole, gatewayID: gatewayID) + if store.tokens[scopedKey] == nil { + store.tokens[scopedKey] = DeviceAuthEntry( + token: entry.token, + role: normalizedRole, + scopes: entry.scopes, + updatedAtMs: entry.updatedAtMs, + gatewayID: gatewayID) + } + store.tokens.removeValue(forKey: legacyKey) + return self.writeStore(store, profile: profile) + } + + /// Removes legacy tokens when the app cannot prove which gateway issued them. + @discardableResult + public static func discardUnscopedTokens( + deviceId: String, + profile: GatewayDeviceIdentityProfile = .primary) -> Int + { + guard var store = self.readStore(profile: profile), store.deviceId == deviceId else { return 0 } + let legacyKeys = store.tokens.compactMap { key, entry in entry.gatewayID == nil ? key : nil } + guard !legacyKeys.isEmpty else { return 0 } + for key in legacyKeys { + store.tokens.removeValue(forKey: key) + } + return self.writeStore(store, profile: profile) ? legacyKeys.count : 0 + } + private static func normalizeRole(_ role: String) -> String { role.trimmingCharacters(in: .whitespacesAndNewlines) } + private static func normalizeGatewayID(_ gatewayID: String?) -> String? { + let trimmed = gatewayID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func tokenKey(role: String, gatewayID: String?) -> String { + let normalizedRole = self.normalizeRole(role) + guard let gatewayID = self.normalizeGatewayID(gatewayID) else { return normalizedRole } + return "\(gatewayID)\u{1F}\(normalizedRole)" + } + private static func normalizeScopes(_ scopes: [String]) -> [String] { let trimmed = scopes .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -101,7 +182,11 @@ public enum DeviceAuthStore { return decoded } - private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) { + @discardableResult + private static func writeStore( + _ store: DeviceAuthStoreFile, + profile: GatewayDeviceIdentityProfile) -> Bool + { let url = self.fileURL(profile: profile) do { try FileManager.default.createDirectory( @@ -110,8 +195,9 @@ public enum DeviceAuthStore { let data = try JSONEncoder().encode(store) try data.write(to: url, options: [.atomic]) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + return true } catch { - // best-effort only + return false } } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index d206934a8b82..72d0a82fda52 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -112,6 +112,12 @@ public struct GatewayConnectOptions: Sendable { /// device-scoped auth (role/scope upgrades will require pairing). Keep this true for /// role/scoped sessions such as operator UI clients. public var includeDeviceIdentity: Bool + /// Set false for an endpoint handoff whose explicit credentials (including none) must be + /// tried without reusing a device token issued by a different gateway. + public var allowStoredDeviceAuth: Bool + /// Stable gateway owner for device tokens. Nil preserves legacy unscoped storage for clients + /// that have not adopted endpoint ownership yet. + public var deviceAuthGatewayID: String? public init( role: String, @@ -124,7 +130,9 @@ public struct GatewayConnectOptions: Sendable { clientMode: String, clientDisplayName: String?, deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary, - includeDeviceIdentity: Bool = true) + includeDeviceIdentity: Bool = true, + allowStoredDeviceAuth: Bool = true, + deviceAuthGatewayID: String? = nil) { self.role = role self.scopes = scopes @@ -137,6 +145,8 @@ public struct GatewayConnectOptions: Sendable { self.clientDisplayName = clientDisplayName self.deviceIdentityProfile = deviceIdentityProfile self.includeDeviceIdentity = includeDeviceIdentity + self.allowStoredDeviceAuth = allowStoredDeviceAuth + self.deviceAuthGatewayID = deviceAuthGatewayID } } @@ -276,10 +286,11 @@ public actor GatewayChannelActor { private var keepaliveTask: Task? private var pendingDeviceTokenRetry = false private var deviceTokenRetryBudgetUsed = false + private var issuedDeviceAuthRoles = Set() private var reconnectPausedForAuthFailure = false private let defaultRequestTimeoutMs: Double = 15000 private let pushHandler: (@Sendable (GatewayPush) async -> Void)? - private let connectOptions: GatewayConnectOptions? + private var connectOptions: GatewayConnectOptions? private let disconnectHandler: (@Sendable (String) async -> Void)? public init( @@ -470,10 +481,14 @@ public actor GatewayChannelActor { let requestedScopes = options.scopes let scopesAreExplicit = options.scopesAreExplicit let includeDeviceIdentity = options.includeDeviceIdentity + let allowStoredDeviceAuth = options.allowStoredDeviceAuth + let deviceAuthGatewayID = options.deviceAuthGatewayID let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil let selectedAuth = self.selectConnectAuth( role: role, includeDeviceIdentity: includeDeviceIdentity, + allowStoredDeviceAuth: allowStoredDeviceAuth, + deviceAuthGatewayID: deviceAuthGatewayID, deviceIdentityProfile: deviceIdentityProfile, deviceId: identity?.deviceId, requestedScopes: requestedScopes) @@ -564,11 +579,17 @@ public actor GatewayChannelActor { try await self.task?.send(.data(data)) do { let response = try await self.waitForConnectResponse(reqId: reqId) - try await self.handleConnectResponse( + let issuedRoles = try await self.handleConnectResponse( response, identity: identity, role: role, + deviceAuthGatewayID: deviceAuthGatewayID, deviceIdentityProfile: deviceIdentityProfile) + self.issuedDeviceAuthRoles.formUnion(issuedRoles) + if issuedRoles.contains(role) { + // Only a token persisted from this endpoint may unlock stored auth for its role. + self.connectOptions?.allowStoredDeviceAuth = true + } self.pendingDeviceTokenRetry = false self.deviceTokenRetryBudgetUsed = false } catch { @@ -589,6 +610,7 @@ public actor GatewayChannelActor { DeviceAuthStore.clearToken( deviceId: identity.deviceId, role: role, + gatewayID: deviceAuthGatewayID, profile: deviceIdentityProfile) } throw error @@ -598,6 +620,8 @@ public actor GatewayChannelActor { private func selectConnectAuth( role: String, includeDeviceIdentity: Bool, + allowStoredDeviceAuth: Bool, + deviceAuthGatewayID: String?, deviceIdentityProfile: GatewayDeviceIdentityProfile, deviceId: String?, requestedScopes: [String]) -> SelectedConnectAuth @@ -607,8 +631,12 @@ public actor GatewayChannelActor { self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let storedEntry = - (includeDeviceIdentity && deviceId != nil) - ? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile) + (includeDeviceIdentity && allowStoredDeviceAuth && deviceId != nil) + ? DeviceAuthStore.loadToken( + deviceId: deviceId!, + role: role, + gatewayID: deviceAuthGatewayID, + profile: deviceIdentityProfile) : nil let storedToken = storedEntry?.token let storedScopes = storedEntry?.scopes ?? [] @@ -793,22 +821,25 @@ public actor GatewayChannelActor { return requestedScopes } + @discardableResult private func persistBootstrapHandoffToken( deviceId: String, role: String, token: String, scopes: [String], - deviceIdentityProfile: GatewayDeviceIdentityProfile) + deviceAuthGatewayID: String?, + deviceIdentityProfile: GatewayDeviceIdentityProfile) -> Bool { guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else { - return + return false } - _ = DeviceAuthStore.storeToken( + return DeviceAuthStore.storeTokenResult( deviceId: deviceId, role: role, token: token, scopes: filteredScopes, - profile: deviceIdentityProfile) + gatewayID: deviceAuthGatewayID, + profile: deviceIdentityProfile).persisted } private func persistIssuedDeviceToken( @@ -817,33 +848,36 @@ public actor GatewayChannelActor { role: String, token: String, scopes: [String], - deviceIdentityProfile: GatewayDeviceIdentityProfile) + deviceAuthGatewayID: String?, + deviceIdentityProfile: GatewayDeviceIdentityProfile) -> Bool { if authSource == .bootstrapToken { guard self.shouldPersistBootstrapHandoffTokens() else { - return + return false } - self.persistBootstrapHandoffToken( + return self.persistBootstrapHandoffToken( deviceId: deviceId, role: role, token: token, scopes: scopes, + deviceAuthGatewayID: deviceAuthGatewayID, deviceIdentityProfile: deviceIdentityProfile) - return } - _ = DeviceAuthStore.storeToken( + return DeviceAuthStore.storeTokenResult( deviceId: deviceId, role: role, token: token, scopes: scopes, - profile: deviceIdentityProfile) + gatewayID: deviceAuthGatewayID, + profile: deviceIdentityProfile).persisted } private func handleConnectResponse( _ res: ResponseFrame, identity: DeviceIdentity?, role: String, - deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws + deviceAuthGatewayID: String?, + deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws -> Set { if res.ok == false { let error = res.error @@ -900,18 +934,23 @@ public actor GatewayChannelActor { self.tickIntervalMs = Double(tick) } let auth = ok.auth + var issuedRoles = Set() if let identity { if let deviceToken = auth["deviceToken"]?.value as? String { let authRole = auth["role"]?.value as? String ?? role let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])? .compactMap { $0.value as? String } ?? [] - self.persistIssuedDeviceToken( + if self.persistIssuedDeviceToken( authSource: self.lastAuthSource, deviceId: identity.deviceId, role: authRole, token: deviceToken, scopes: scopes, + deviceAuthGatewayID: deviceAuthGatewayID, deviceIdentityProfile: deviceIdentityProfile) + { + issuedRoles.insert(authRole) + } } if self.shouldPersistBootstrapHandoffTokens(), let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable] @@ -925,12 +964,16 @@ public actor GatewayChannelActor { } let scopes = (rawEntry["scopes"]?.value as? [ProtoAnyCodable])? .compactMap { $0.value as? String } ?? [] - self.persistBootstrapHandoffToken( + if self.persistBootstrapHandoffToken( deviceId: identity.deviceId, role: authRole, token: deviceToken, scopes: scopes, + deviceAuthGatewayID: deviceAuthGatewayID, deviceIdentityProfile: deviceIdentityProfile) + { + issuedRoles.insert(authRole) + } } } } @@ -943,6 +986,11 @@ public actor GatewayChannelActor { if let pushHandler = self.pushHandler { Task { await pushHandler(.snapshot(ok)) } } + return issuedRoles + } + + public func currentIssuedDeviceAuthRoles() -> Set { + self.issuedDeviceAuthRoles } private func listen() { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index b71c06f534aa..67e33ffc0400 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -42,6 +42,12 @@ func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? { return parsed.string ?? trimmed } +/// Binds suspended work to one installed gateway channel generation. +/// Callers use this lease so an actor hop cannot retarget a payload to a replacement gateway. +public struct GatewayNodeSessionRoute: Sendable, Equatable { + fileprivate let channelGeneration: UInt64 +} + public actor GatewayNodeSession { private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") private let decoder = JSONDecoder() @@ -54,6 +60,7 @@ public actor GatewayNodeSession { private var activePassword: String? private var activeConnectOptionsKey: String? private var activeSessionIdentity: ObjectIdentifier? + private var channelGeneration: UInt64 = 0 private var connectOptions: GatewayConnectOptions? private var onConnected: (@Sendable () async -> Void)? private var onDisconnected: (@Sendable (String) async -> Void)? @@ -164,6 +171,9 @@ public actor GatewayNodeSession { let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let deviceIdentityProfile = options.deviceIdentityProfile.rawValue let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0" + let allowStoredDeviceAuth = options.allowStoredDeviceAuth ? "1" : "0" + let deviceAuthGatewayID = options.deviceAuthGatewayID? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let permissions = options.permissions .map { key, value in let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) @@ -182,6 +192,8 @@ public actor GatewayNodeSession { clientDisplayName, deviceIdentityProfile, includeDeviceIdentity, + allowStoredDeviceAuth, + deviceAuthGatewayID, permissions, ].joined(separator: "|") } @@ -212,11 +224,20 @@ public actor GatewayNodeSession { self.onDisconnected = onDisconnected self.onInvoke = onInvoke + let channelGeneration: UInt64 if shouldReconnect { + self.channelGeneration &+= 1 + channelGeneration = self.channelGeneration self.resetConnectionState() if let existing = self.channel { + // Detach before suspension so callers cannot lease the old channel with + // the replacement generation while shutdown is in flight. + self.channel = nil await existing.shutdown() } + // A newer connect or disconnect can run while shutdown suspends. Never let the + // superseded call install its endpoint or credentials afterward. + guard self.channelGeneration == channelGeneration else { throw CancellationError() } let channel = GatewayChannelActor( url: url, token: token, @@ -224,11 +245,11 @@ public actor GatewayNodeSession { password: password, session: sessionBox, pushHandler: { [weak self] push in - await self?.handlePush(push) + await self?.handlePush(push, channelGeneration: channelGeneration) }, connectOptions: connectOptions, disconnectHandler: { [weak self] reason in - await self?.handleChannelDisconnected(reason) + await self?.handleChannelDisconnected(reason, channelGeneration: channelGeneration) }) self.channel = channel self.activeURL = url @@ -237,6 +258,8 @@ public actor GatewayNodeSession { self.activePassword = password self.activeConnectOptionsKey = nextOptionsKey self.activeSessionIdentity = nextSessionIdentity + } else { + channelGeneration = self.channelGeneration } guard let channel = self.channel else { @@ -247,7 +270,13 @@ public actor GatewayNodeSession { do { try await channel.connect() + guard self.channelGeneration == channelGeneration, + self.channel === channel + else { throw CancellationError() } _ = await self.waitForSnapshot(timeoutMs: 500) + guard self.channelGeneration == channelGeneration, + self.channel === channel + else { throw CancellationError() } await self.notifyConnectedIfNeeded() } catch { throw error @@ -255,7 +284,8 @@ public actor GatewayNodeSession { } public func disconnect() async { - await self.channel?.shutdown() + self.channelGeneration &+= 1 + let channel = self.channel self.channel = nil self.activeURL = nil self.activeToken = nil @@ -265,6 +295,12 @@ public actor GatewayNodeSession { self.activeSessionIdentity = nil self.hasEverConnected = false self.resetConnectionState() + await channel?.shutdown() + } + + public func currentIssuedDeviceAuthRoles() async -> Set { + guard let channel else { return [] } + return await channel.currentIssuedDeviceAuthRoles() } public func currentCanvasHostUrl() -> String? { @@ -300,16 +336,31 @@ public actor GatewayNodeSession { return "\(host):\(port)" } - public func sendEvent(event: String, payloadJSON: String?) async { - guard let channel = self.channel else { return } + public func currentRoute() -> GatewayNodeSessionRoute? { + guard self.channel != nil else { return nil } + return GatewayNodeSessionRoute(channelGeneration: self.channelGeneration) + } + + @discardableResult + public func sendEvent( + event: String, + payloadJSON: String?, + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async -> Bool + { + if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration { + return false + } + guard let channel = self.channel else { return false } let params: [String: AnyCodable] = [ "event": AnyCodable(event), "payloadJSON": AnyCodable(payloadJSON ?? NSNull()), ] do { try await channel.send(method: "node.event", params: params) + return true } catch { self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)") + return false } } @@ -324,7 +375,15 @@ public actor GatewayNodeSession { try await channel.send(method: method, params: params) } - public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data { + public func request( + method: String, + paramsJSON: String?, + timeoutSeconds: Int = 15, + ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async throws -> Data + { + if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration { + throw CancellationError() + } guard let channel = self.channel else { throw NSError(domain: "Gateway", code: 11, userInfo: [ NSLocalizedDescriptionKey: "not connected", @@ -349,7 +408,8 @@ public actor GatewayNodeSession { } } - private func handlePush(_ push: GatewayPush) async { + private func handlePush(_ push: GatewayPush, channelGeneration: UInt64) async { + guard self.channelGeneration == channelGeneration else { return } switch push { case let .snapshot(ok): self.pluginSurfaceUrls = self.normalizePluginSurfaceUrls(ok.pluginsurfaceurls) @@ -361,7 +421,11 @@ public actor GatewayNodeSession { self.markSnapshotReceived() await self.notifyConnectedIfNeeded() case let .event(evt): - await self.handleEvent(evt) + guard let channel = self.channel else { return } + await self.handleEvent( + evt, + channel: channel, + channelGeneration: channelGeneration) default: break } @@ -373,7 +437,8 @@ public actor GatewayNodeSession { self.drainSnapshotWaiters(returning: false) } - private func handleChannelDisconnected(_ reason: String) async { + private func handleChannelDisconnected(_ reason: String, channelGeneration: UInt64) async { + guard self.channelGeneration == channelGeneration else { return } // The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh // onConnected callback once a new snapshot arrives after reconnect. self.resetConnectionState() @@ -456,7 +521,11 @@ public actor GatewayNodeSession { } } - private func handleEvent(_ evt: EventFrame) async { + private func handleEvent( + _ evt: EventFrame, + channel: GatewayChannelActor, + channelGeneration: UInt64) async + { self.broadcastServerEvent(evt) guard evt.event == "node.invoke.request" else { return } self.logger.info("node invoke request received") @@ -477,9 +546,14 @@ public actor GatewayNodeSession { request: req, timeoutMs: request.timeoutMs, onInvoke: onInvoke) + // Invoke output belongs to the requesting channel. A target switch while the device + // command is running must discard it instead of disclosing it to the replacement. + guard self.channelGeneration == channelGeneration, + self.channel === channel + else { return } self.logger.info( "node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") - await self.sendInvokeResult(request: request, response: response) + await self.sendInvokeResult(request: request, response: response, channel: channel) } catch { self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") } @@ -497,8 +571,11 @@ public actor GatewayNodeSession { } } - private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async { - guard let channel = self.channel else { return } + private func sendInvokeResult( + request: NodeInvokeRequestPayload, + response: BridgeInvokeResponse, + channel: GatewayChannelActor) async + { self.logger.info( "node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") var params: [String: AnyCodable] = [ diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift index 1880aee4521b..dba3f3f8793b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift @@ -2,6 +2,7 @@ import Foundation public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable { public let gatewayURLString: String + public let gatewayStableID: String? public let token: String? public let password: String? public let sessionKey: String @@ -10,6 +11,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable { public init( gatewayURLString: String, + gatewayStableID: String? = nil, token: String?, password: String?, sessionKey: String, @@ -17,6 +19,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable { deliveryTo: String? = nil) { self.gatewayURLString = gatewayURLString + self.gatewayStableID = gatewayStableID self.token = token self.password = password self.sessionKey = sessionKey @@ -26,7 +29,10 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable { } public enum ShareGatewayRelaySettings { - private static var suiteName: String { OpenClawAppGroup.identifier } + private static var suiteName: String { + OpenClawAppGroup.identifier + } + private static let relayConfigKey = "share.gatewayRelay.config.v1" private static let lastEventKey = "share.gatewayRelay.event.v1" @@ -39,6 +45,22 @@ public enum ShareGatewayRelaySettings { return try? JSONDecoder().decode(ShareGatewayRelayConfig.self, from: data) } + /// An endpoint is not a gateway identity. If the extension launches before the + /// host can prove a stable ID, discard unscoped device auth and use explicit auth only. + public static func loadConfigDiscardingUnscopedDeviceAuth() -> ShareGatewayRelayConfig? { + guard let config = self.loadConfig() else { return nil } + if let gatewayID = config.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines), + !gatewayID.isEmpty + { + return config + } + let identity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension) + DeviceAuthStore.discardUnscopedTokens( + deviceId: identity.deviceId, + profile: .shareExtension) + return config + } + public static func saveConfig(_ config: ShareGatewayRelayConfig) { guard let data = try? JSONEncoder().encode(config) else { return } self.defaults.set(data, forKey: self.relayConfigKey) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift index aaac8c303884..09fc76a02c4d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift @@ -53,6 +53,7 @@ public struct OpenClawWatchAction: Codable, Sendable, Equatable { public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable { public var id: String + public var gatewayStableID: String? public var commandText: String public var commandPreview: String? public var host: String? @@ -64,6 +65,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident public init( id: String, + gatewayStableID: String? = nil, commandText: String, commandPreview: String? = nil, host: String? = nil, @@ -74,6 +76,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident risk: OpenClawWatchRisk? = nil) { self.id = id + self.gatewayStableID = gatewayStableID self.commandText = commandText self.commandPreview = commandPreview self.host = host @@ -109,18 +112,21 @@ public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatab public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equatable { public var type: OpenClawWatchPayloadType public var approvalId: String + public var gatewayStableID: String? public var decision: OpenClawWatchExecApprovalDecision public var replyId: String public var sentAtMs: Int? public init( approvalId: String, + gatewayStableID: String? = nil, decision: OpenClawWatchExecApprovalDecision, replyId: String, sentAtMs: Int? = nil) { self.type = .execApprovalResolve self.approvalId = approvalId + self.gatewayStableID = gatewayStableID self.decision = decision self.replyId = replyId self.sentAtMs = sentAtMs @@ -130,18 +136,21 @@ public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equata public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equatable { public var type: OpenClawWatchPayloadType public var approvalId: String + public var gatewayStableID: String? public var decision: OpenClawWatchExecApprovalDecision? public var resolvedAtMs: Int? public var source: String? public init( approvalId: String, + gatewayStableID: String? = nil, decision: OpenClawWatchExecApprovalDecision? = nil, resolvedAtMs: Int? = nil, source: String? = nil) { self.type = .execApprovalResolved self.approvalId = approvalId + self.gatewayStableID = gatewayStableID self.decision = decision self.resolvedAtMs = resolvedAtMs self.source = source @@ -151,16 +160,19 @@ public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equat public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equatable { public var type: OpenClawWatchPayloadType public var approvalId: String + public var gatewayStableID: String? public var reason: OpenClawWatchExecApprovalCloseReason public var expiredAtMs: Int? public init( approvalId: String, + gatewayStableID: String? = nil, reason: OpenClawWatchExecApprovalCloseReason, expiredAtMs: Int? = nil) { self.type = .execApprovalExpired self.approvalId = approvalId + self.gatewayStableID = gatewayStableID self.reason = reason self.expiredAtMs = expiredAtMs } @@ -169,16 +181,19 @@ public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equata public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable { public var type: OpenClawWatchPayloadType public var approvals: [OpenClawWatchExecApprovalItem] + public var gatewayStableID: String? public var sentAtMs: Int? public var snapshotId: String? public init( approvals: [OpenClawWatchExecApprovalItem], + gatewayStableID: String? = nil, sentAtMs: Int? = nil, snapshotId: String? = nil) { self.type = .execApprovalSnapshot self.approvals = approvals + self.gatewayStableID = gatewayStableID self.sentAtMs = sentAtMs self.snapshotId = snapshotId } @@ -361,6 +376,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { public var priority: OpenClawNotificationPriority? public var promptId: String? public var sessionKey: String? + public var gatewayStableID: String? public var kind: String? public var details: String? public var expiresAtMs: Int? @@ -373,6 +389,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { priority: OpenClawNotificationPriority? = nil, promptId: String? = nil, sessionKey: String? = nil, + gatewayStableID: String? = nil, kind: String? = nil, details: String? = nil, expiresAtMs: Int? = nil, @@ -384,6 +401,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { self.priority = priority self.promptId = promptId self.sessionKey = sessionKey + self.gatewayStableID = gatewayStableID self.kind = kind self.details = details self.expiresAtMs = expiresAtMs diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 995f636b7242..e059d7c32590 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2471,7 +2471,7 @@ public struct SessionsPatchParams: Codable, Sendable { key: String, agentid: String? = nil, label: AnyCodable?, - category: AnyCodable?, + category: AnyCodable? = nil, archived: Bool? = nil, pinned: Bool? = nil, thinkinglevel: AnyCodable?, @@ -7199,7 +7199,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable { alloweddecisions: [String]?, agentid: String? = nil, sessionkey: String?, - approvalreviewerdeviceids: [String]?, + approvalreviewerdeviceids: [String]? = nil, turnsourcechannel: String?, turnsourceto: String?, turnsourceaccountid: String?, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift index 16a56b8656f0..efddc3de178a 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -5,6 +5,154 @@ import Testing @Suite(.serialized) struct DeviceIdentityStoreTests { + @Test(.stateDirectoryIsolated) + func `device auth store reports failed durable writes`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false) + try Data().write(to: blocker) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", blocker.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let compatibleEntry: DeviceAuthEntry = DeviceAuthStore.storeToken( + deviceId: "unwritable-device", + role: "node", + token: "must-not-be-acknowledged") + let stored = DeviceAuthStore.storeTokenResult( + deviceId: "unwritable-device", + role: "node", + token: "must-not-be-acknowledged") + + #expect(compatibleEntry.token == "must-not-be-acknowledged") + #expect(!stored.persisted) + #expect(DeviceAuthStore.loadToken(deviceId: "unwritable-device", role: "node") == nil) + } + + @Test(.stateDirectoryIsolated) + func `device auth tokens are isolated by gateway owner`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let deviceID = "test-device" + _ = DeviceAuthStore.storeToken(deviceId: deviceID, role: "node", token: "legacy-token") + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a") == nil) + + _ = DeviceAuthStore.storeToken( + deviceId: deviceID, + role: "node", + token: "gateway-a-token", + gatewayID: "gateway-a") + _ = DeviceAuthStore.storeToken( + deviceId: deviceID, + role: "node", + token: "gateway-b-token", + gatewayID: "gateway-b") + + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-token") + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "gateway-a")?.token == "gateway-a-token") + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "gateway-b")?.token == "gateway-b-token") + + DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: "gateway-b") + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "gateway-a")?.token == "gateway-a-token") + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-b") == nil) + + DeviceAuthStore.clearToken(deviceId: deviceID, role: "node") + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil) + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a") == nil) + } + + @Test(.stateDirectoryIsolated) + func `legacy device auth migration claims only the proven role`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let deviceID = "legacy-device" + _ = DeviceAuthStore.storeToken( + deviceId: deviceID, + role: "node", + token: "legacy-node-token") + _ = DeviceAuthStore.storeToken( + deviceId: deviceID, + role: "operator", + token: "legacy-operator-token") + + #expect(DeviceAuthStore.migrateUnscopedToken( + deviceId: deviceID, + role: "node", + toGatewayID: "trusted-gateway")) + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "trusted-gateway")?.token == "legacy-node-token") + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "operator", + gatewayID: "trusted-gateway") == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "operator")?.token == "legacy-operator-token") + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "other-gateway") == nil) + #expect(!DeviceAuthStore.migrateUnscopedToken( + deviceId: deviceID, + role: "node", + toGatewayID: "other-gateway")) + + _ = DeviceAuthStore.storeToken( + deviceId: deviceID, + role: "node", + token: "ambiguous-legacy-token") + #expect(DeviceAuthStore.discardUnscopedTokens(deviceId: deviceID) == 2) + #expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: deviceID, + role: "node", + gatewayID: "trusted-gateway")?.token == "legacy-node-token") + } + @Test func `state directory override wins over shared app group storage`() { let tempDir = FileManager.default.temporaryDirectory @@ -48,7 +196,7 @@ struct DeviceIdentityStoreTests { #expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path)) } - @Test + @Test(.stateDirectoryIsolated) func `secondary profiles use separate identity and auth files`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift index d332a4a8738a..32fe12dd55a1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift @@ -3,7 +3,28 @@ import Testing struct GatewayModelsCompatibilityTests { @Test - func messageActionParamsKeepsRequesterAccountAdditive() { + func `plugin approval request params keeps reviewer devices additive`() { + let params = PluginApprovalRequestParams( + pluginid: nil, + title: "Install plugin", + description: "Review requested", + severity: nil, + toolname: nil, + toolcallid: nil, + alloweddecisions: nil, + sessionkey: nil, + turnsourcechannel: nil, + turnsourceto: nil, + turnsourceaccountid: nil, + turnsourcethreadid: nil, + timeoutms: nil, + twophase: nil) + + #expect(params.approvalreviewerdeviceids == nil) + } + + @Test + func `message action params keeps requester account additive`() { let params = MessageActionParams( channel: "slack", action: "member-info", @@ -14,8 +35,7 @@ struct GatewayModelsCompatibilityTests { sessionkey: nil, sessionid: nil, toolcontext: nil, - idempotencykey: "test" - ) + idempotencykey: "test") #expect(params.requesteraccountid == nil) } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index ca1f13e50af8..5d4fcc442808 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -49,21 +49,63 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke } } +private final class FirstCancelGate: @unchecked Sendable { + private let condition = NSCondition() + private var shouldBlock = true + private var started = false + private var released = false + + func blockIfNeeded() { + self.condition.lock() + guard self.shouldBlock else { + self.condition.unlock() + return + } + self.shouldBlock = false + self.started = true + self.condition.broadcast() + while !self.released { + self.condition.wait() + } + self.condition.unlock() + } + + func hasStarted() -> Bool { + self.condition.lock() + defer { self.condition.unlock() } + return self.started + } + + func release() { + self.condition.lock() + self.released = true + self.condition.broadcast() + self.condition.unlock() + } +} + private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable { private let lock = NSLock() private let helloAuth: [String: Any]? private let connectError: [String: Any]? + private let cancelGate: FirstCancelGate? private var _state: URLSessionTask.State = .suspended private var connectRequestId: String? private var connectAuth: [String: Any]? private var connectDevice: [String: Any]? + private var sentRequestMethods: [String] = [] private var receivePhase = 0 private var pendingReceiveHandler: (@Sendable (Result) -> Void)? - init(helloAuth: [String: Any]? = nil, connectError: [String: Any]? = nil) { + init( + helloAuth: [String: Any]? = nil, + connectError: [String: Any]? = nil, + cancelGate: FirstCancelGate? = nil) + { self.helloAuth = helloAuth self.connectError = connectError + self.cancelGate = cancelGate } var state: URLSessionTask.State { @@ -78,6 +120,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { _ = (closeCode, reason) self.state = .canceling + self.cancelGate?.blockIfNeeded() let handler = self.lock.withLock { () -> (@Sendable (Result< URLSessionWebSocketTask.Message, Error, @@ -97,9 +140,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda guard let data else { return } if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], obj["type"] as? String == "req", - obj["method"] as? String == "connect", - let id = obj["id"] as? String + let method = obj["method"] as? String { + self.lock.withLock { self.sentRequestMethods.append(method) } + guard method == "connect", let id = obj["id"] as? String else { return } let params = obj["params"] as? [String: Any] let auth = (params?["auth"] as? [String: Any]) ?? [:] let device = params?["device"] as? [String: Any] @@ -119,6 +163,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda self.lock.withLock { self.connectDevice } } + func sentRequestCount(method: String) -> Int { + self.lock.withLock { self.sentRequestMethods.count(where: { $0 == method }) } + } + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { pongReceiveHandler(nil) } @@ -166,6 +214,17 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda handler?(Result.failure(URLError(.networkConnectionLost))) } + func emitInvokeRequest(id: String, command: String) { + let handler = self.lock.withLock { () -> (@Sendable (Result< + URLSessionWebSocketTask.Message, + Error, + >) -> Void)? in + defer { self.pendingReceiveHandler = nil } + return self.pendingReceiveHandler + } + handler?(.success(.data(Self.invokeRequestData(id: id, command: command)))) + } + private static func connectChallengeData(nonce: String) -> Data { let frame: [String: Any] = [ "type": "event", @@ -224,18 +283,38 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda ] return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() } + + private static func invokeRequestData(id: String, command: String) -> Data { + let frame: [String: Any] = [ + "type": "event", + "event": "node.invoke.request", + "payload": [ + "id": id, + "nodeId": "test-node", + "command": command, + "paramsJSON": "{}", + ], + ] + return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() + } } private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable { private let lock = NSLock() private let helloAuth: [String: Any]? private let connectError: [String: Any]? + private let cancelGate: FirstCancelGate? private var tasks: [FakeGatewayWebSocketTask] = [] private var makeCount = 0 - init(helloAuth: [String: Any]? = nil, connectError: [String: Any]? = nil) { + init( + helloAuth: [String: Any]? = nil, + connectError: [String: Any]? = nil, + cancelGate: FirstCancelGate? = nil) + { self.helloAuth = helloAuth self.connectError = connectError + self.cancelGate = cancelGate } func snapshotMakeCount() -> Int { @@ -250,7 +329,10 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked _ = url return self.lock.withLock { self.makeCount += 1 - let task = FakeGatewayWebSocketTask(helloAuth: self.helloAuth, connectError: self.connectError) + let task = FakeGatewayWebSocketTask( + helloAuth: self.helloAuth, + connectError: self.connectError, + cancelGate: self.cancelGate) self.tasks.append(task) return WebSocketTaskBox(task: task) } @@ -268,6 +350,18 @@ private actor SeqGapProbe { } } +private actor DisconnectProbe { + private var reasons: [String] = [] + + func record(_ reason: String) { + self.reasons.append(reason) + } + + func values() -> [String] { + self.reasons + } +} + @Suite(.serialized) struct GatewayNodeSessionTests { @Test @@ -290,6 +384,243 @@ struct GatewayNodeSessionTests { } @Test + func `superseded channel callbacks do not reach replacement connection`() async throws { + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let disconnects = DisconnectProbe() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: false) + + try await gateway.connect( + url: #require(URL(string: "ws://first.example.invalid")), + token: nil, + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { reason in await disconnects.record("first:\(reason)") }, + onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) + try await gateway.connect( + url: #require(URL(string: "ws://second.example.invalid")), + token: nil, + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { reason in await disconnects.record("second:\(reason)") }, + onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) + + for _ in 0..<20 { + await Task.yield() + } + let replacementDisconnects = await disconnects.values() + #expect(replacementDisconnects.isEmpty) + + await gateway.disconnect() + for _ in 0..<20 { + await Task.yield() + } + let finalDisconnects = await disconnects.values() + #expect(finalDisconnects.isEmpty) + } + + @Test + func `route bound operations never use a replacement channel`() async throws { + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: false) + + try await gateway.connect( + url: #require(URL(string: "ws://first.example.invalid")), + token: nil, + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) + let firstRoute = try #require(await gateway.currentRoute()) + + try await gateway.connect( + url: #require(URL(string: "ws://second.example.invalid")), + token: nil, + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) + + let sent = await gateway.sendEvent( + event: "push.apns.register", + payloadJSON: "{}", + ifCurrentRoute: firstRoute) + #expect(!sent) + do { + _ = try await gateway.request( + method: "exec.approval.get", + paramsJSON: "{}", + ifCurrentRoute: firstRoute) + Issue.record("stale route request unexpectedly reached the replacement channel") + } catch is CancellationError { + // Expected: the route lease belongs to the first channel. + } + let replacementTask = try #require(session.latestTask()) + #expect(replacementTask.sentRequestCount(method: "node.event") == 0) + #expect(replacementTask.sentRequestCount(method: "exec.approval.get") == 0) + } + + @Test + func `disconnect during channel shutdown prevents stale channel install`() async throws { + let cancelGate = FirstCancelGate() + let session = FakeGatewayWebSocketSession(cancelGate: cancelGate) + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: false) + + try await gateway.connect( + url: #require(URL(string: "ws://first.example.invalid")), + token: "first-token", + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) + + let replacement = Task { + try await gateway.connect( + url: #require(URL(string: "ws://stale.example.invalid")), + token: "stale-token", + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while !cancelGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(cancelGate.hasStarted()) + #expect(await gateway.currentRoute() == nil) + + let release = Task.detached { + try? await Task.sleep(nanoseconds: 10_000_000) + cancelGate.release() + } + await gateway.disconnect() + await release.value + do { + try await replacement.value + Issue.record("superseded replacement unexpectedly connected") + } catch is CancellationError { + // Expected: disconnect advanced the generation while old-channel shutdown was suspended. + } + + #expect(session.snapshotMakeCount() == 1) + } + + @Test + func `invoke result is discarded after target switch`() async throws { + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let invokeStarted = AsyncStream.makeStream() + let invokeRelease = AsyncStream.makeStream() + var startedIterator = invokeStarted.stream.makeAsyncIterator() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: ["camera.snap"], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: false) + + try await gateway.connect( + url: #require(URL(string: "ws://first.example.invalid")), + token: "first-token", + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { request in + invokeStarted.continuation.yield() + for await _ in invokeRelease.stream { + return BridgeInvokeResponse( + id: request.id, + ok: true, + payloadJSON: #"{"sensitive":"camera-result"}"#, + error: nil) + } + return BridgeInvokeResponse(id: request.id, ok: false, payloadJSON: nil, error: nil) + }) + let firstTask = try #require(session.latestTask()) + firstTask.emitInvokeRequest(id: "invoke-old", command: "camera.snap") + _ = await startedIterator.next() + + try await gateway.connect( + url: #require(URL(string: "ws://replacement.example.invalid")), + token: "replacement-token", + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) + let replacementTask = try #require(session.latestTask()) + + invokeRelease.continuation.yield() + invokeRelease.continuation.finish() + for _ in 0..<100 { + await Task.yield() + } + + #expect(firstTask.sentRequestCount(method: "node.invoke.result") == 0) + #expect(replacementTask.sentRequestCount(method: "node.invoke.result") == 0) + await gateway.disconnect() + } + + @Test(.stateDirectoryIsolated) func `scanned setup code prefers bootstrap auth over stored device token`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -345,7 +676,129 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } - @Test + @Test(.stateDirectoryIsolated) + func `credentialless setup handoff does not send a stored device token`() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let identity = DeviceIdentityStore.loadOrCreate() + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "node", + token: "previous-gateway-device-token") + + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: true, + allowStoredDeviceAuth: false) + + try await gateway.connect( + url: #require(URL(string: "ws://new-gateway.example.invalid")), + token: nil, + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + let task = try #require(session.latestTask()) + let auth = try #require(task.latestConnectAuth()) + #expect(auth["token"] == nil) + #expect(auth["bootstrapToken"] == nil) + #expect(auth["deviceToken"] == nil) + #expect(task.latestConnectDevice() != nil) + #expect(await gateway.currentIssuedDeviceAuthRoles() == []) + + await gateway.disconnect() + } + + @Test(.stateDirectoryIsolated) + func `stored device token cannot cross gateway owner`() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let identity = DeviceIdentityStore.loadOrCreate() + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "node", + token: "gateway-a-device-token", + gatewayID: "gateway-a") + + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: true, + allowStoredDeviceAuth: true, + deviceAuthGatewayID: "gateway-b") + + try await gateway.connect( + url: #require(URL(string: "ws://gateway-b.example.invalid")), + token: nil, + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + let auth = try #require(session.latestTask()?.latestConnectAuth()) + #expect(auth["token"] == nil) + #expect(auth["deviceToken"] == nil) + #expect(DeviceAuthStore.loadToken( + deviceId: identity.deviceId, + role: "node", + gatewayID: "gateway-a")?.token == "gateway-a-device-token") + + await gateway.disconnect() + } + + @Test(.stateDirectoryIsolated) func `share extension identity profile uses separate node identity and token store`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -556,7 +1009,7 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } - @Test + @Test(.stateDirectoryIsolated) func `bootstrap hello stores additional device tokens`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -631,11 +1084,64 @@ struct GatewayNodeSessionTests { "operator.talk.secrets", "operator.write", ]) + #expect(await gateway.currentIssuedDeviceAuthRoles() == ["node", "operator"]) await gateway.disconnect() } - @Test + @Test(.stateDirectoryIsolated) + func `failed device token write is not reported as an issued role`() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false) + try Data().write(to: blocker) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", blocker.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let session = FakeGatewayWebSocketSession(helloAuth: [ + "deviceToken": "node-device-token", + "role": "node", + "scopes": [], + ]) + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: true) + + try await gateway.connect( + url: #require(URL(string: "wss://example.invalid")), + token: nil, + bootstrapToken: "fresh-bootstrap-token", + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + #expect(await gateway.currentIssuedDeviceAuthRoles().isEmpty) + await gateway.disconnect() + } + + @Test(.stateDirectoryIsolated) func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -697,7 +1203,7 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } - @Test + @Test(.stateDirectoryIsolated) func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -760,7 +1266,7 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } - @Test + @Test(.stateDirectoryIsolated) func `private lan bootstrap persists handoff tokens for reconnect`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/StateDirectoryIsolationTrait.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/StateDirectoryIsolationTrait.swift new file mode 100644 index 000000000000..f7075045cafa --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/StateDirectoryIsolationTrait.swift @@ -0,0 +1,50 @@ +import Testing + +private actor StateDirectoryTestGate { + private var locked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !self.locked { + self.locked = true + return + } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + + func release() { + guard !self.waiters.isEmpty else { + self.locked = false + return + } + self.waiters.removeFirst().resume() + } +} + +/// Prevents process-wide state-directory overrides from crossing suite boundaries. +struct StateDirectoryIsolationTrait: TestTrait, TestScoping { + private static let gate = StateDirectoryTestGate() + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void) async throws + { + await Self.gate.acquire() + do { + try await function() + } catch { + await Self.gate.release() + throw error + } + await Self.gate.release() + } +} + +extension Trait where Self == StateDirectoryIsolationTrait { + static var stateDirectoryIsolated: Self { + Self() + } +} diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index c0ef75798c25..01217bdb76ca 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -54,7 +54,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[] ["SessionsMessagesUnsubscribeParams", ["agentId"]], ["SessionsAbortParams", ["agentId"]], ["SessionsListParams", ["archived"]], - ["SessionsPatchParams", ["agentId", "archived", "pinned"]], + ["SessionsPatchParams", ["agentId", "category", "archived", "pinned"]], ["SessionsResetParams", ["agentId"]], [ "SessionsDeleteParams", @@ -78,6 +78,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[] ["CronListParams", ["compact"]], ["CronRunLogEntry", ["errorReason", "failureNotificationDelivery"]], ["ExecApprovalRequestParams", ["requireDeliveryRoute", "suppressDelivery"]], + ["PluginApprovalRequestParams", ["approvalReviewerDeviceIds"]], ["DevicePairSetupCodeResult", ["gatewayUrls"]], ["AgentSummary", ["thinkingLevels", "thinkingOptions", "thinkingDefault"]], ["ModelChoice", ["available"]], diff --git a/src/gateway/exec-approval-ios-push.test.ts b/src/gateway/exec-approval-ios-push.test.ts index 24556517df7d..43c01f7199e6 100644 --- a/src/gateway/exec-approval-ios-push.test.ts +++ b/src/gateway/exec-approval-ios-push.test.ts @@ -115,6 +115,10 @@ vi.mock("../infra/device-pairing.js", async () => { }; }); +vi.mock("../infra/device-identity.js", () => ({ + loadOrCreateProcessDeviceIdentity: () => ({ deviceId: "gateway-device-1" }), +})); + vi.mock("../infra/push-apns.js", () => ({ loadApnsRegistration: loadApnsRegistrationMock, loadApnsRegistrations: loadApnsRegistrationsMock, @@ -168,7 +172,19 @@ describe("createExecApprovalIosPushDelivery", () => { expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled(); }); - it("targets iOS devices when the active operator token includes operator.approvals", async () => { + it("does not target approval-only iOS devices that cannot validate gateway ownership", async () => { + mockPairedIosOperator(["operator.approvals"]); + + const delivery = createExecApprovalIosPushDelivery({ log: {} }); + + const accepted = await delivery.handleRequested(approvalRequest("approval-no-read")); + + expect(accepted).toBe(false); + expect(loadApnsRegistrationsMock).not.toHaveBeenCalled(); + expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled(); + }); + + it("targets iOS devices when the active operator token can approve and validate ownership", async () => { mockPairedIosOperator(["operator.approvals", "operator.read"]); const delivery = createExecApprovalIosPushDelivery({ log: {} }); @@ -178,6 +194,9 @@ describe("createExecApprovalIosPushDelivery", () => { expect(accepted).toBe(true); expect(loadApnsRegistrationsMock).toHaveBeenCalledWith(["ios-device-1"]); expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1); + expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledWith( + expect.objectContaining({ gatewayDeviceId: "gateway-device-1" }), + ); }); it("loads APNs registrations in one bulk read for all visible iOS operators", async () => { @@ -185,7 +204,7 @@ describe("createExecApprovalIosPushDelivery", () => { pairedIosOperator({ deviceId: "ios-device-1", publicKey: "pub-1", - scopes: ["operator.approvals"], + scopes: ["operator.approvals", "operator.read"], token: "operator-token-1", }), pairedIosOperator({ @@ -193,7 +212,7 @@ describe("createExecApprovalIosPushDelivery", () => { publicKey: "pub-2", platform: "iPadOS 18", approvedAtMs: 2, - scopes: ["operator.approvals"], + scopes: ["operator.approvals", "operator.write"], token: "operator-token-2", }), ); diff --git a/src/gateway/exec-approval-ios-push.ts b/src/gateway/exec-approval-ios-push.ts index 0abce032cd84..2bd56e76fd74 100644 --- a/src/gateway/exec-approval-ios-push.ts +++ b/src/gateway/exec-approval-ios-push.ts @@ -2,6 +2,7 @@ // Sends APNs request/resolution wakes to paired operator devices. import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { getRuntimeConfig } from "../config/io.js"; +import { loadOrCreateProcessDeviceIdentity } from "../infra/device-identity.js"; import { hasEffectivePairedDeviceRole, listDevicePairing, @@ -25,9 +26,11 @@ import { import { roleScopesAllow } from "../shared/operator-scope-compat.js"; // iOS exec-approval push delivery targets paired operator devices with APNs -// registrations. Request pushes require approval scope; cleanup/resolved pushes -// reuse the original targets so badges can clear even after scope changes. +// registrations. Request pushes require approval scope plus identity-read access +// so the client can validate gateway ownership before presenting or resolving. +// Cleanup pushes reuse original targets so badges can clear after scope changes. const APPROVALS_SCOPE = "operator.approvals"; +const READ_SCOPE = "operator.read"; const OPERATOR_ROLE = "operator"; type GatewayLikeLogger = { @@ -82,14 +85,14 @@ function resolveActiveOperatorToken(device: PairedDevice): DeviceAuthToken | nul return operatorToken; } -function canApproveExecRequests(device: PairedDevice): boolean { +function canReceiveExecApprovalRequests(device: PairedDevice): boolean { const operatorToken = resolveActiveOperatorToken(device); if (!operatorToken) { return false; } return roleScopesAllow({ role: OPERATOR_ROLE, - requestedScopes: [APPROVALS_SCOPE], + requestedScopes: [APPROVALS_SCOPE, READ_SCOPE], allowedScopes: operatorToken.scopes, }); } @@ -107,7 +110,7 @@ function shouldTargetDevice(params: { if (!params.requireApprovalScope) { return true; } - return canApproveExecRequests(params.device); + return canReceiveExecApprovalRequests(params.device); } async function loadRegisteredTargets(params: { @@ -231,6 +234,7 @@ async function sendRequestedPushes(params: { plan: DeliveryPlan; log: GatewayLikeLogger; }): Promise<{ attempted: number; delivered: number }> { + const gatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId; return await sendApprovalPushes({ approvalId: params.request.id, plan: params.plan, @@ -243,12 +247,14 @@ async function sendRequestedPushes(params: { registration: target.registration, nodeId: target.nodeId, approvalId, + gatewayDeviceId, auth: plan.directAuth!, }) : await sendApnsExecApprovalAlert({ registration: target.registration, nodeId: target.nodeId, approvalId, + gatewayDeviceId, relayConfig: plan.relayConfig!, }), }); @@ -301,6 +307,7 @@ async function sendResolvedPushes(params: { plan: DeliveryPlan; log: GatewayLikeLogger; }): Promise { + const gatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId; await sendApprovalPushes({ approvalId: params.approvalId, plan: params.plan, @@ -313,12 +320,14 @@ async function sendResolvedPushes(params: { registration: target.registration, nodeId: target.nodeId, approvalId, + gatewayDeviceId, auth: plan.directAuth!, }) : await sendApnsExecApprovalResolvedWake({ registration: target.registration, nodeId: target.nodeId, approvalId, + gatewayDeviceId, relayConfig: plan.relayConfig!, }), }); diff --git a/src/gateway/server-methods/system.ts b/src/gateway/server-methods/system.ts index 15e909af7bc7..3c031b247666 100644 --- a/src/gateway/server-methods/system.ts +++ b/src/gateway/server-methods/system.ts @@ -8,7 +8,7 @@ import { import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js"; import { - loadOrCreateDeviceIdentity, + loadOrCreateProcessDeviceIdentity, publicKeyRawBase64UrlFromPem, } from "../../infra/device-identity.js"; import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js"; @@ -21,7 +21,7 @@ import type { GatewayRequestHandlers } from "./types.js"; /** Gateway handlers for identity, heartbeat toggles, and system presence events. */ export const systemHandlers: GatewayRequestHandlers = { "gateway.identity.get": ({ respond }) => { - const identity = loadOrCreateDeviceIdentity(); + const identity = loadOrCreateProcessDeviceIdentity(); respond( true, { diff --git a/src/gateway/server-node-events.runtime.ts b/src/gateway/server-node-events.runtime.ts index 1dc722817144..7cda19e39ad7 100644 --- a/src/gateway/server-node-events.runtime.ts +++ b/src/gateway/server-node-events.runtime.ts @@ -9,7 +9,7 @@ export { createOutboundSendDeps } from "../cli/outbound-send-deps.js"; export { agentCommandFromIngress } from "../commands/agent.js"; export { getRuntimeConfig } from "../config/io.js"; export { canonicalizeSessionEntryAliases } from "../config/sessions.js"; -export { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +export { loadOrCreateProcessDeviceIdentity } from "../infra/device-identity.js"; export { requestHeartbeat } from "../infra/heartbeat-wake.js"; export { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; export { resolveOutboundTarget } from "../infra/outbound/targets.js"; diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 06fb46e92b89..dc1cf984925a 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -46,7 +46,7 @@ const buildSessionLookup = ( const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const registerApnsRegistrationMock = vi.hoisted(() => vi.fn()); -const loadOrCreateDeviceIdentityMock = vi.hoisted(() => +const loadOrCreateProcessDeviceIdentityMock = vi.hoisted(() => vi.fn(() => ({ deviceId: "gateway-device-1", publicKeyPem: "public", @@ -83,7 +83,7 @@ const runtimeMocks = vi.hoisted(() => ({ enqueueSystemEvent: vi.fn(), formatForLog: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))), getRuntimeConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })), - loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock, + loadOrCreateProcessDeviceIdentity: loadOrCreateProcessDeviceIdentityMock, loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)), canonicalizeSessionEntryAliases: vi.fn(), normalizeChannelId: normalizeChannelIdMock, @@ -265,7 +265,7 @@ describe("node exec events", () => { enqueueSystemEventMock.mockReturnValue(true); requestHeartbeatMock.mockClear(); registerApnsRegistrationVi.mockClear(); - loadOrCreateDeviceIdentityMock.mockClear(); + loadOrCreateProcessDeviceIdentityMock.mockClear(); normalizeChannelIdVi.mockClear(); normalizeChannelIdVi.mockImplementation((channel?: string | null) => channel ?? null); sanitizeInboundSystemTagsMock.mockClear(); diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 225e894d45fb..f8fac609d329 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -30,7 +30,7 @@ import { enqueueSystemEvent, formatForLog, getRuntimeConfig, - loadOrCreateDeviceIdentity, + loadOrCreateProcessDeviceIdentity, loadSessionEntry, normalizeChannelId, normalizeMainKey, @@ -819,7 +819,7 @@ export const handleNodeEvent = async ( try { if (transport === "relay") { const gatewayDeviceId = normalizeOptionalString(obj.gatewayDeviceId) ?? ""; - const currentGatewayDeviceId = loadOrCreateDeviceIdentity().deviceId; + const currentGatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId; if (!gatewayDeviceId || gatewayDeviceId !== currentGatewayDeviceId) { ctx.logGateway.warn( `push relay register rejected node=${nodeId}: gateway identity mismatch`, diff --git a/src/infra/device-identity.test.ts b/src/infra/device-identity.test.ts index b20992817262..f672ce75295f 100644 --- a/src/infra/device-identity.test.ts +++ b/src/infra/device-identity.test.ts @@ -7,6 +7,7 @@ import { deriveDeviceIdFromPublicKey, loadDeviceIdentityIfPresent, loadOrCreateDeviceIdentity, + loadOrCreateProcessDeviceIdentity, normalizeDevicePublicKeyBase64Url, publicKeyRawBase64UrlFromPem, signDevicePayload, @@ -132,8 +133,10 @@ describe("device identity crypto helpers", () => { expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull(); const loaded = loadOrCreateDeviceIdentity(identityPath); + const processIdentity = loadOrCreateProcessDeviceIdentity(identityPath); expect(loaded.deviceId).not.toBe("stale-device-id"); + expect(loadOrCreateProcessDeviceIdentity(identityPath)).toBe(processIdentity); expect(fs.readFileSync(identityPath, "utf8")).toBe(before); }); }); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index 333f5237d5da..5b91c184b2cc 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -264,6 +264,23 @@ export function loadOrCreateDeviceIdentity( return identity; } +let processDeviceIdentity: { filePath: string; identity: DeviceIdentity } | undefined; + +/** + * Keep one identity stable for the lifetime of the active state-dir process. + * Recognizable invalid stores yield transient keys, so independent reloads would split gateway ownership. + */ +export function loadOrCreateProcessDeviceIdentity( + filePath: string = resolveDefaultIdentityPath(), +): DeviceIdentity { + if (processDeviceIdentity?.filePath === filePath) { + return processDeviceIdentity.identity; + } + const identity = loadOrCreateDeviceIdentity(filePath); + processDeviceIdentity = { filePath, identity }; + return identity; +} + /** Load a valid persisted device identity without creating, repairing, or migrating files. */ export function loadDeviceIdentityIfPresent( filePath: string = resolveDefaultIdentityPath(), diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index 6c94506a3c01..1b9f58414f92 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -7,7 +7,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import type { GatewayConfig } from "../config/types.gateway.js"; import { - loadOrCreateDeviceIdentity, + loadOrCreateProcessDeviceIdentity, signDevicePayload, type DeviceIdentity, } from "./device-identity.js"; @@ -330,7 +330,7 @@ export async function sendApnsRelayPush(params: { requestSender?: ApnsRelayRequestSender; }): Promise { const sender = params.requestSender ?? sendApnsRelayRequest; - const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateDeviceIdentity(); + const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateProcessDeviceIdentity(); const signedAtMs = Date.now(); const bodyJson = JSON.stringify({ relayHandle: params.relayHandle, diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index fc51034a9f70..ccf06f3ba78c 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -498,6 +498,7 @@ describe("push APNs send semantics", () => { registration, nodeId: "ios-node-approval-alert", approvalId: "approval-123", + gatewayDeviceId: "gateway-device-123", auth, requestSender: send, }); @@ -519,6 +520,7 @@ describe("push APNs send semantics", () => { expectRecordFields(openclawPayload, { kind: "exec.approval.requested", approvalId: "approval-123", + gatewayDeviceId: "gateway-device-123", }); expect(typeof openclawPayload.ts).toBe("number"); expectNoProperties(openclawPayload, [ @@ -548,6 +550,7 @@ describe("push APNs send semantics", () => { registration, nodeId: "ios-node-approval-cleanup", approvalId: "approval-123", + gatewayDeviceId: "gateway-device-123", auth, requestSender: send, }); @@ -563,6 +566,7 @@ describe("push APNs send semantics", () => { expectRecordFields(openclawPayload, { kind: "exec.approval.resolved", approvalId: "approval-123", + gatewayDeviceId: "gateway-device-123", }); expect(typeof openclawPayload.ts).toBe("number"); expect(result.ok).toBe(true); @@ -790,6 +794,7 @@ describe("push APNs send semantics", () => { registration, nodeId: "ios-node-relay-approval-alert", approvalId: "approval-relay-1", + gatewayDeviceId: "gateway-device-relay", relayConfig, relayGatewayIdentity: gatewayIdentity, relayRequestSender: send, @@ -809,6 +814,7 @@ describe("push APNs send semantics", () => { expectRecordFields(openclawPayload, { kind: "exec.approval.requested", approvalId: "approval-relay-1", + gatewayDeviceId: "gateway-device-relay", }); expect(typeof openclawPayload.ts).toBe("number"); expectNoProperties(openclawPayload, [ diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index a758f9687643..d7cacf053b9d 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -930,7 +930,10 @@ function resolveExecApprovalAlertBody(): string { return EXEC_APPROVAL_GENERIC_ALERT_BODY; } -function createExecApprovalAlertPayload(params: { nodeId: string; approvalId: string }): object { +function createExecApprovalAlertPayload(params: { + approvalId: string; + gatewayDeviceId: string; +}): object { return { aps: { alert: { @@ -944,12 +947,16 @@ function createExecApprovalAlertPayload(params: { nodeId: string; approvalId: st openclaw: { kind: "exec.approval.requested", approvalId: params.approvalId, + gatewayDeviceId: params.gatewayDeviceId, ts: Date.now(), }, }; } -function createExecApprovalResolvedPayload(params: { nodeId: string; approvalId: string }): object { +function createExecApprovalResolvedPayload(params: { + approvalId: string; + gatewayDeviceId: string; +}): object { return { aps: { "content-available": 1, @@ -957,6 +964,7 @@ function createExecApprovalResolvedPayload(params: { nodeId: string; approvalId: openclaw: { kind: "exec.approval.resolved", approvalId: params.approvalId, + gatewayDeviceId: params.gatewayDeviceId, ts: Date.now(), }, }; @@ -1012,6 +1020,7 @@ type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & { type ApnsExecApprovalAlertCommonParams = { nodeId: string; approvalId: string; + gatewayDeviceId: string; timeoutMs?: number; }; @@ -1035,6 +1044,7 @@ type RelayApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & { type ApnsExecApprovalResolvedCommonParams = { nodeId: string; approvalId: string; + gatewayDeviceId: string; timeoutMs?: number; }; @@ -1127,8 +1137,8 @@ export async function sendApnsExecApprovalAlert( params: DirectApnsExecApprovalAlertParams | RelayApnsExecApprovalAlertParams, ): Promise { const payload = createExecApprovalAlertPayload({ - nodeId: params.nodeId, approvalId: params.approvalId, + gatewayDeviceId: params.gatewayDeviceId, }); if (params.registration.transport === "relay") { @@ -1160,8 +1170,8 @@ export async function sendApnsExecApprovalResolvedWake( params: DirectApnsExecApprovalResolvedParams | RelayApnsExecApprovalResolvedParams, ): Promise { const payload = createExecApprovalResolvedPayload({ - nodeId: params.nodeId, approvalId: params.approvalId, + gatewayDeviceId: params.gatewayDeviceId, }); if (params.registration.transport === "relay") {