diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index b0b18ec7b33..4d9b24ede5b 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -9387,7 +9387,7 @@ }, { "kind": "ui-modifier", - "line": 143, + "line": 145, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Settings", "surface": "apple", @@ -9395,7 +9395,7 @@ }, { "kind": "ui-modifier", - "line": 247, + "line": 249, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Scan QR Code", "surface": "apple", @@ -9403,7 +9403,7 @@ }, { "kind": "ui-modifier", - "line": 269, + "line": 271, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset Onboarding?", "surface": "apple", @@ -9411,7 +9411,7 @@ }, { "kind": "ui-call", - "line": 273, + "line": 275, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset", "surface": "apple", @@ -9419,7 +9419,7 @@ }, { "kind": "ui-call", - "line": 281, + "line": 283, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This disconnects, clears saved gateway credentials, and reopens onboarding.", "surface": "apple", @@ -9427,7 +9427,7 @@ }, { "kind": "ui-modifier", - "line": 284, + "line": 286, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -9435,7 +9435,7 @@ }, { "kind": "ui-call", - "line": 291, + "line": 293, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "OK", "surface": "apple", @@ -9443,7 +9443,7 @@ }, { "kind": "ui-call", - "line": 308, + "line": 310, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Forget Gateway", "surface": "apple", @@ -9451,7 +9451,7 @@ }, { "kind": "ui-call", - "line": 314, + "line": 316, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Cancel", "surface": "apple", @@ -9459,7 +9459,7 @@ }, { "kind": "ui-call", - "line": 318, + "line": 320, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This removes saved credentials, device access, TLS trust, and cached chats for this gateway.", "surface": "apple", @@ -9467,7 +9467,7 @@ }, { "kind": "ui-call", - "line": 369, + "line": 371, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Enable OpenClaw Hosted Push Relay?", "surface": "apple", @@ -9475,7 +9475,7 @@ }, { "kind": "ui-call", - "line": 383, + "line": 385, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Continue", "surface": "apple", @@ -9483,7 +9483,7 @@ }, { "kind": "ui-call", - "line": 391, + "line": 393, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Not Now", "surface": "apple", @@ -9587,7 +9587,23 @@ }, { "kind": "conditional-branch", - "line": 970, + "line": 830, + "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", + "source": "Setup queued for the watch. Open OpenClaw before the code expires.", + "surface": "apple", + "id": "native.apple.a9a778465dfe1198" + }, + { + "kind": "conditional-branch", + "line": 830, + "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", + "source": "Setup sent. Open OpenClaw on the watch to connect.", + "surface": "apple", + "id": "native.apple.3503aca018f04865" + }, + { + "kind": "conditional-branch", + "line": 986, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Configured", "surface": "apple", @@ -9595,7 +9611,7 @@ }, { "kind": "conditional-branch", - "line": 970, + "line": 986, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not configured", "surface": "apple", @@ -9603,7 +9619,7 @@ }, { "kind": "conditional-branch", - "line": 1035, + "line": 1051, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connect to the gateway.", "surface": "apple", @@ -9611,7 +9627,7 @@ }, { "kind": "conditional-branch", - "line": 1035, + "line": 1051, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Gateway requests will appear here.", "surface": "apple", @@ -9619,7 +9635,7 @@ }, { "kind": "conditional-branch", - "line": 1077, + "line": 1093, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "High", "surface": "apple", @@ -9627,7 +9643,7 @@ }, { "kind": "conditional-branch", - "line": 1077, + "line": 1093, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Resolving", "surface": "apple", @@ -9635,7 +9651,7 @@ }, { "kind": "conditional-branch", - "line": 1082, + "line": 1098, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "One-time approval", "surface": "apple", @@ -9643,7 +9659,7 @@ }, { "kind": "conditional-branch", - "line": 1082, + "line": 1098, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Permission can be saved", "surface": "apple", @@ -9651,7 +9667,7 @@ }, { "kind": "conditional-branch", - "line": 1084, + "line": 1100, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Medium", "surface": "apple", @@ -9659,7 +9675,7 @@ }, { "kind": "conditional-branch", - "line": 1084, + "line": 1100, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Review", "surface": "apple", @@ -9667,7 +9683,7 @@ }, { "kind": "conditional-branch", - "line": 1105, + "line": 1121, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "\\(diagnosticsIssueCount)", "surface": "apple", @@ -9675,7 +9691,7 @@ }, { "kind": "conditional-branch", - "line": 1116, + "line": 1132, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location off", "surface": "apple", @@ -9683,7 +9699,7 @@ }, { "kind": "conditional-branch", - "line": 1118, + "line": 1134, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using", "surface": "apple", @@ -9691,7 +9707,7 @@ }, { "kind": "conditional-branch", - "line": 1120, + "line": 1136, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using, effective Off", "surface": "apple", @@ -9699,7 +9715,7 @@ }, { "kind": "conditional-branch", - "line": 1122, + "line": 1138, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using, effective Always", "surface": "apple", @@ -9707,7 +9723,7 @@ }, { "kind": "conditional-branch", - "line": 1124, + "line": 1140, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always", "surface": "apple", @@ -9715,7 +9731,7 @@ }, { "kind": "conditional-branch", - "line": 1126, + "line": 1142, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always, effective While Using", "surface": "apple", @@ -9723,7 +9739,7 @@ }, { "kind": "conditional-branch", - "line": 1128, + "line": 1144, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always, effective Off", "surface": "apple", @@ -9795,7 +9811,7 @@ }, { "kind": "ui-named-argument", - "line": 179, + "line": 184, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "About", "surface": "apple", @@ -9803,7 +9819,7 @@ }, { "kind": "ui-named-argument", - "line": 191, + "line": 196, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Licenses", "surface": "apple", @@ -9811,7 +9827,7 @@ }, { "kind": "ui-named-argument", - "line": 269, + "line": 280, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway", "surface": "apple", @@ -9819,7 +9835,7 @@ }, { "kind": "ui-call", - "line": 275, + "line": 286, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Address", "surface": "apple", @@ -9827,7 +9843,7 @@ }, { "kind": "ui-call", - "line": 276, + "line": 287, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Server", "surface": "apple", @@ -9835,7 +9851,7 @@ }, { "kind": "ui-call", - "line": 277, + "line": 288, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovered", "surface": "apple", @@ -9843,7 +9859,7 @@ }, { "kind": "ui-call", - "line": 279, + "line": 290, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Agents", "surface": "apple", @@ -9851,7 +9867,7 @@ }, { "kind": "ui-modifier", - "line": 332, + "line": 343, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Switch Gateway", "surface": "apple", @@ -9859,7 +9875,7 @@ }, { "kind": "ui-named-argument", - "line": 339, + "line": 350, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Approvals", "surface": "apple", @@ -9867,7 +9883,7 @@ }, { "kind": "conditional-branch", - "line": 342, + "line": 353, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No gateway actions are waiting for review.", "surface": "apple", @@ -9875,7 +9891,7 @@ }, { "kind": "conditional-branch", - "line": 342, + "line": 353, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Review the pending gateway action.", "surface": "apple", @@ -9883,15 +9899,79 @@ }, { "kind": "conditional-branch", - "line": 346, + "line": 357, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "1 waiting", "surface": "apple", "id": "native.apple.6156b1e3a8cec56c" }, + { + "kind": "ui-named-argument", + "line": 374, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Apple Watch", + "surface": "apple", + "id": "native.apple.561d865ad24910d2" + }, + { + "kind": "conditional-branch", + "line": 376, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Install the OpenClaw watch app before enabling direct mode.", + "surface": "apple", + "id": "native.apple.1bd0f0b41f4d4750" + }, + { + "kind": "conditional-branch", + "line": 376, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Relay remains available; direct mode adds an independent Gateway node.", + "surface": "apple", + "id": "native.apple.df05bfb397806b0d" + }, + { + "kind": "conditional-branch", + "line": 378, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Installed", + "surface": "apple", + "id": "native.apple.cfa1c0be2d607257" + }, + { + "kind": "conditional-branch", + "line": 378, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Unavailable", + "surface": "apple", + "id": "native.apple.59405b82eb08ae1e" + }, { "kind": "ui-call", - "line": 361, + "line": 385, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Enable Direct Gateway Connection", + "surface": "apple", + "id": "native.apple.0177670438314602" + }, + { + "kind": "ui-call", + "line": 401, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "The watch receives a one-time pairing code and stores its own device token. A reachable secure Gateway URL is required away from the iPhone.", + "surface": "apple", + "id": "native.apple.eff9b913b3ae1c89" + }, + { + "kind": "ui-call", + "line": 406, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Direct node features", + "surface": "apple", + "id": "native.apple.c46c07933202448e" + }, + { + "kind": "ui-call", + "line": 416, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Notifications are off", "surface": "apple", @@ -9899,7 +9979,7 @@ }, { "kind": "ui-call", - "line": 363, + "line": 418, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Enable Notifications to receive approval alerts while OpenClaw is not open.", "surface": "apple", @@ -9907,7 +9987,7 @@ }, { "kind": "ui-call", - "line": 372, + "line": 427, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Open Notifications", "surface": "apple", @@ -9915,7 +9995,7 @@ }, { "kind": "ui-call", - "line": 394, + "line": 449, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Allow", "surface": "apple", @@ -9923,7 +10003,7 @@ }, { "kind": "ui-call", - "line": 402, + "line": 457, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Always Allow", "surface": "apple", @@ -9931,7 +10011,7 @@ }, { "kind": "ui-call", - "line": 410, + "line": 465, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Deny", "surface": "apple", @@ -9939,7 +10019,7 @@ }, { "kind": "ui-call", - "line": 419, + "line": 474, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No approvals waiting", "surface": "apple", @@ -9947,7 +10027,7 @@ }, { "kind": "ui-named-argument", - "line": 436, + "line": 491, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Camera", "surface": "apple", @@ -9955,7 +10035,7 @@ }, { "kind": "ui-named-argument", - "line": 442, + "line": 497, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Keep Awake", "surface": "apple", @@ -9963,7 +10043,7 @@ }, { "kind": "ui-named-argument", - "line": 453, + "line": 508, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice & Talk", "surface": "apple", @@ -9971,7 +10051,7 @@ }, { "kind": "ui-named-argument", - "line": 468, + "line": 523, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Health Check", "surface": "apple", @@ -9979,7 +10059,7 @@ }, { "kind": "ui-named-argument", - "line": 469, + "line": 524, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run app, permission, and gateway-adjacent checks without editing setup.", "surface": "apple", @@ -9987,7 +10067,7 @@ }, { "kind": "ui-call", - "line": 477, + "line": 532, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Diagnostics", "surface": "apple", @@ -9995,7 +10075,7 @@ }, { "kind": "ui-call", - "line": 487, + "line": 542, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Platform", "surface": "apple", @@ -10003,7 +10083,7 @@ }, { "kind": "ui-call", - "line": 488, + "line": 543, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "App", "surface": "apple", @@ -10011,7 +10091,7 @@ }, { "kind": "ui-call", - "line": 489, + "line": 544, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Model", "surface": "apple", @@ -10019,7 +10099,7 @@ }, { "kind": "ui-named-argument", - "line": 502, + "line": 557, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Privacy", "surface": "apple", @@ -10027,7 +10107,7 @@ }, { "kind": "ui-named-argument", - "line": 503, + "line": 558, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Control what device context OpenClaw can expose to the gateway.", "surface": "apple", @@ -10035,7 +10115,7 @@ }, { "kind": "ui-named-argument", - "line": 508, + "line": 563, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Camera Access", "surface": "apple", @@ -10043,7 +10123,7 @@ }, { "kind": "ui-named-argument", - "line": 514, + "line": 569, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Background Listening", "surface": "apple", @@ -10051,7 +10131,7 @@ }, { "kind": "ui-call", - "line": 538, + "line": 593, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Notifications", "surface": "apple", @@ -10059,7 +10139,7 @@ }, { "kind": "ui-modifier", - "line": 543, + "line": 598, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Turns OpenClaw notification delivery on or off", "surface": "apple", @@ -10067,7 +10147,7 @@ }, { "kind": "ui-named-argument", - "line": 563, + "line": 618, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reconnect", "surface": "apple", @@ -10075,7 +10155,7 @@ }, { "kind": "ui-named-argument", - "line": 573, + "line": 628, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Diagnose", "surface": "apple", @@ -10083,7 +10163,7 @@ }, { "kind": "ui-call", - "line": 589, + "line": 644, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "License files are not available in this build.", "surface": "apple", @@ -10091,7 +10171,7 @@ }, { "kind": "ui-call", - "line": 606, + "line": 661, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "apple", @@ -10099,7 +10179,7 @@ }, { "kind": "ui-call", - "line": 646, + "line": 701, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "OpenClaw", "surface": "apple", @@ -10107,7 +10187,7 @@ }, { "kind": "ui-call", - "line": 648, + "line": 703, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Personal AI on your devices", "surface": "apple", @@ -10115,7 +10195,7 @@ }, { "kind": "ui-call", - "line": 662, + "line": 717, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "OpenClaw app version", "surface": "apple", @@ -10123,7 +10203,7 @@ }, { "kind": "ui-call", - "line": 664, + "line": 719, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "iOS", "surface": "apple", @@ -10131,7 +10211,7 @@ }, { "kind": "ui-named-argument", - "line": 669, + "line": 724, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Website", "surface": "apple", @@ -10139,7 +10219,7 @@ }, { "kind": "ui-named-argument", - "line": 674, + "line": 729, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Docs", "surface": "apple", @@ -10147,7 +10227,7 @@ }, { "kind": "ui-named-argument", - "line": 679, + "line": 734, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "GitHub", "surface": "apple", @@ -10155,7 +10235,7 @@ }, { "kind": "ui-named-argument", - "line": 684, + "line": 739, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discord", "surface": "apple", @@ -10163,7 +10243,7 @@ }, { "kind": "ui-call", - "line": 689, + "line": 744, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "apple", @@ -10171,7 +10251,7 @@ }, { "kind": "ui-call", - "line": 695, + "line": 750, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Title", "surface": "apple", @@ -10179,7 +10259,7 @@ }, { "kind": "ui-call", - "line": 734, + "line": 789, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Controls whether location can be shared with gateway tools.", "surface": "apple", @@ -10187,7 +10267,7 @@ }, { "kind": "ui-call", - "line": 746, + "line": 801, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Location", "surface": "apple", @@ -10195,7 +10275,7 @@ }, { "kind": "ui-call", - "line": 747, + "line": 802, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Off", "surface": "apple", @@ -10203,7 +10283,7 @@ }, { "kind": "ui-call", - "line": 750, + "line": 805, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "While Using", "surface": "apple", @@ -10211,7 +10291,7 @@ }, { "kind": "ui-call", - "line": 753, + "line": 808, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Always", "surface": "apple", @@ -10219,7 +10299,7 @@ }, { "kind": "ui-call", - "line": 776, + "line": 831, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Agent", "surface": "apple", @@ -10227,7 +10307,7 @@ }, { "kind": "ui-call", - "line": 777, + "line": 832, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default", "surface": "apple", @@ -10235,7 +10315,7 @@ }, { "kind": "ui-call", - "line": 787, + "line": 842, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Used for new Chat and Talk sessions.", "surface": "apple", @@ -10243,7 +10323,7 @@ }, { "kind": "ui-call", - "line": 794, + "line": 849, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Paste setup code", "surface": "apple", @@ -10251,7 +10331,7 @@ }, { "kind": "ui-named-argument", - "line": 800, + "line": 855, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Scan QR", "surface": "apple", @@ -10259,7 +10339,7 @@ }, { "kind": "ui-named-argument", - "line": 810, + "line": 865, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect", "surface": "apple", @@ -10267,7 +10347,7 @@ }, { "kind": "ui-call", - "line": 819, + "line": 874, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Setup Code", "surface": "apple", @@ -10275,7 +10355,7 @@ }, { "kind": "ui-call", - "line": 832, + "line": 887, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovered Gateways", "surface": "apple", @@ -10283,7 +10363,7 @@ }, { "kind": "ui-call", - "line": 834, + "line": 889, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No gateways found yet. Use manual setup if Bonjour is blocked.", "surface": "apple", @@ -10291,7 +10371,7 @@ }, { "kind": "ui-call", - "line": 848, + "line": 903, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Pair a gateway to make it available here.", "surface": "apple", @@ -10299,7 +10379,7 @@ }, { "kind": "ui-call", - "line": 857, + "line": 912, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Paired Gateways", "surface": "apple", @@ -10307,7 +10387,7 @@ }, { "kind": "ui-call", - "line": 860, + "line": 915, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Switch gateways without pairing again.", "surface": "apple", @@ -10315,7 +10395,7 @@ }, { "kind": "ui-modifier", - "line": 888, + "line": 943, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Gateway", "surface": "apple", @@ -10323,7 +10403,7 @@ }, { "kind": "ui-call", - "line": 900, + "line": 955, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget", "surface": "apple", @@ -10331,7 +10411,7 @@ }, { "kind": "ui-call", - "line": 912, + "line": 967, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget Gateway", "surface": "apple", @@ -10339,7 +10419,7 @@ }, { "kind": "ui-call", - "line": 949, + "line": 1004, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Manual Gateway", "surface": "apple", @@ -10347,7 +10427,7 @@ }, { "kind": "ui-call", - "line": 950, + "line": 1005, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Use Manual Gateway", "surface": "apple", @@ -10355,7 +10435,7 @@ }, { "kind": "ui-call", - "line": 951, + "line": 1006, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Host", "surface": "apple", @@ -10363,7 +10443,7 @@ }, { "kind": "ui-call", - "line": 955, + "line": 1010, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Port", "surface": "apple", @@ -10371,7 +10451,7 @@ }, { "kind": "ui-call", - "line": 958, + "line": 1013, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connection security", "surface": "apple", @@ -10379,7 +10459,7 @@ }, { "kind": "ui-call", - "line": 959, + "line": 1014, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Unencrypted", "surface": "apple", @@ -10387,7 +10467,7 @@ }, { "kind": "ui-call", - "line": 962, + "line": 1017, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Secure (TLS)", "surface": "apple", @@ -10395,7 +10475,7 @@ }, { "kind": "ui-named-argument", - "line": 974, + "line": 1029, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect Manual", "surface": "apple", @@ -10403,7 +10483,7 @@ }, { "kind": "ui-call", - "line": 1004, + "line": 1059, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Auto-connect on launch", "surface": "apple", @@ -10411,7 +10491,7 @@ }, { "kind": "ui-call", - "line": 1005, + "line": 1060, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -10419,7 +10499,7 @@ }, { "kind": "ui-call", - "line": 1006, + "line": 1061, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Password", "surface": "apple", @@ -10427,7 +10507,7 @@ }, { "kind": "ui-call", - "line": 1011, + "line": 1066, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Custom Headers", "surface": "apple", @@ -10435,7 +10515,7 @@ }, { "kind": "ui-call", - "line": 1018, + "line": 1073, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reset Onboarding", "surface": "apple", @@ -10443,7 +10523,7 @@ }, { "kind": "ui-call", - "line": 1045, + "line": 1100, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Wake", "surface": "apple", @@ -10451,7 +10531,7 @@ }, { "kind": "ui-call", - "line": 1048, + "line": 1103, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Talk Mode", "surface": "apple", @@ -10459,7 +10539,7 @@ }, { "kind": "ui-call", - "line": 1056, + "line": 1111, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speech Language", "surface": "apple", @@ -10467,7 +10547,7 @@ }, { "kind": "ui-call", - "line": 1063, + "line": 1118, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speakerphone", "surface": "apple", @@ -10475,7 +10555,7 @@ }, { "kind": "ui-call", - "line": 1067, + "line": 1122, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Wake Words", "surface": "apple", @@ -10483,7 +10563,7 @@ }, { "kind": "ui-call", - "line": 1088, + "line": 1143, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice", "surface": "apple", @@ -10491,7 +10571,7 @@ }, { "kind": "ui-call", - "line": 1089, + "line": 1144, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Provider", "surface": "apple", @@ -10499,7 +10579,7 @@ }, { "kind": "ui-call", - "line": 1096, + "line": 1151, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Realtime Voice", "surface": "apple", @@ -10507,7 +10587,7 @@ }, { "kind": "ui-call", - "line": 1097, + "line": 1152, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Default", "surface": "apple", @@ -10515,7 +10595,7 @@ }, { "kind": "ui-call", - "line": 1104, + "line": 1159, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Mode", "surface": "apple", @@ -10523,7 +10603,7 @@ }, { "kind": "ui-call", - "line": 1105, + "line": 1160, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Voice", "surface": "apple", @@ -10531,7 +10611,7 @@ }, { "kind": "ui-call", - "line": 1107, + "line": 1162, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Last Voice Issue", "surface": "apple", @@ -10539,7 +10619,7 @@ }, { "kind": "ui-call", - "line": 1109, + "line": 1164, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Transport", "surface": "apple", @@ -10547,7 +10627,7 @@ }, { "kind": "ui-call", - "line": 1110, + "line": 1165, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "API Key", "surface": "apple", @@ -10555,7 +10635,7 @@ }, { "kind": "ui-call", - "line": 1117, + "line": 1172, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Show Talk Control", "surface": "apple", @@ -10563,7 +10643,7 @@ }, { "kind": "ui-call", - "line": 1118, + "line": 1173, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Share Instruction", "surface": "apple", @@ -10571,7 +10651,7 @@ }, { "kind": "ui-call", - "line": 1125, + "line": 1180, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Share Self-Test", "surface": "apple", @@ -10579,7 +10659,7 @@ }, { "kind": "ui-call", - "line": 1142, + "line": 1197, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Debug Logs", "surface": "apple", @@ -10587,7 +10667,7 @@ }, { "kind": "ui-call", - "line": 1145, + "line": 1200, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Debug Screen Status", "surface": "apple", @@ -10595,7 +10675,7 @@ }, { "kind": "ui-call", - "line": 1149, + "line": 1204, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Logs", "surface": "apple", @@ -10603,7 +10683,7 @@ }, { "kind": "ui-call", - "line": 1155, + "line": 1210, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device", "surface": "apple", @@ -10611,7 +10691,7 @@ }, { "kind": "ui-call", - "line": 1156, + "line": 1211, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device Name", "surface": "apple", @@ -10619,7 +10699,7 @@ }, { "kind": "ui-call", - "line": 1158, + "line": 1213, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Instance ID", "surface": "apple", @@ -10627,7 +10707,7 @@ }, { "kind": "conditional-branch", - "line": 1181, + "line": 1236, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "On", "surface": "apple", @@ -10635,7 +10715,7 @@ }, { "kind": "conditional-branch", - "line": 131, + "line": 132, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Checking", "surface": "apple", @@ -10643,7 +10723,7 @@ }, { "kind": "conditional-branch", - "line": 132, + "line": 133, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Enabled", "surface": "apple", @@ -10651,7 +10731,7 @@ }, { "kind": "conditional-branch", - "line": 133, + "line": 134, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Off", "surface": "apple", @@ -10659,7 +10739,7 @@ }, { "kind": "conditional-branch", - "line": 134, + "line": 135, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Setup", "surface": "apple", @@ -10667,7 +10747,7 @@ }, { "kind": "conditional-branch", - "line": 135, + "line": 136, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Denied", "surface": "apple", @@ -10675,7 +10755,7 @@ }, { "kind": "conditional-branch", - "line": 136, + "line": 137, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Not Enabled", "surface": "apple", @@ -10683,7 +10763,7 @@ }, { "kind": "conditional-branch", - "line": 137, + "line": 138, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Unknown", "surface": "apple", @@ -10691,7 +10771,7 @@ }, { "kind": "conditional-branch", - "line": 143, + "line": 144, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Checking iOS notification permission.", "surface": "apple", @@ -10699,7 +10779,7 @@ }, { "kind": "conditional-branch", - "line": 145, + "line": 146, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "OpenClaw can show approval prompts and event alerts when the app is not active.", "surface": "apple", @@ -10707,7 +10787,7 @@ }, { "kind": "conditional-branch", - "line": 147, + "line": 148, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "OpenClaw notifications are off.", "surface": "apple", @@ -10715,7 +10795,7 @@ }, { "kind": "conditional-branch", - "line": 149, + "line": 150, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Finish notification setup to receive alerts when the app is not active.", "surface": "apple", @@ -10723,7 +10803,7 @@ }, { "kind": "conditional-branch", - "line": 151, + "line": 152, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Notifications have been denied. Enable them in iOS Settings.", "surface": "apple", @@ -10731,7 +10811,7 @@ }, { "kind": "conditional-branch", - "line": 153, + "line": 154, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Enable notifications to receive approval prompts and event alerts outside the app.", "surface": "apple", @@ -10739,7 +10819,7 @@ }, { "kind": "conditional-branch", - "line": 155, + "line": 156, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "OpenClaw cannot determine the current notification permission state.", "surface": "apple", @@ -10747,7 +10827,7 @@ }, { "kind": "ui-call", - "line": 279, + "line": 280, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Connected", "surface": "apple", @@ -10755,7 +10835,7 @@ }, { "kind": "ui-named-argument", - "line": 281, + "line": 282, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Gateway online", "surface": "apple", @@ -10763,7 +10843,7 @@ }, { "kind": "ui-named-argument", - "line": 282, + "line": 283, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Connected to openclaw-gateway.tailnet.ts.net.", "surface": "apple", @@ -10771,7 +10851,7 @@ }, { "kind": "ui-call", - "line": 292, + "line": 293, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Loading", "surface": "apple", @@ -10779,7 +10859,7 @@ }, { "kind": "ui-named-argument", - "line": 294, + "line": 295, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Checking gateway", "surface": "apple", @@ -10787,7 +10867,7 @@ }, { "kind": "ui-named-argument", - "line": 295, + "line": 296, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Refreshing connection, discovery, and device trust state.", "surface": "apple", @@ -10795,7 +10875,7 @@ }, { "kind": "ui-call", - "line": 301, + "line": 302, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Empty", "surface": "apple", @@ -10803,7 +10883,7 @@ }, { "kind": "ui-named-argument", - "line": 303, + "line": 304, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "No gateway configured", "surface": "apple", @@ -10811,7 +10891,7 @@ }, { "kind": "ui-named-argument", - "line": 304, + "line": 305, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Scan a setup QR code, paste a setup code, or choose a discovered gateway.", "surface": "apple", @@ -10819,7 +10899,7 @@ }, { "kind": "ui-call", - "line": 310, + "line": 311, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Error", "surface": "apple", @@ -10827,7 +10907,7 @@ }, { "kind": "ui-named-argument", - "line": 312, + "line": 313, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Tailscale warning", "surface": "apple", @@ -10835,7 +10915,7 @@ }, { "kind": "ui-named-argument", - "line": 313, + "line": 314, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Tailscale is off on this device. Turn it on, then try again.", "surface": "apple", @@ -10843,7 +10923,7 @@ }, { "kind": "ui-call", - "line": 362, + "line": 363, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Address", "surface": "apple", @@ -10851,7 +10931,7 @@ }, { "kind": "ui-call", - "line": 364, + "line": 365, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Server", "surface": "apple", @@ -10859,7 +10939,7 @@ }, { "kind": "ui-call", - "line": 366, + "line": 367, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Discovered", "surface": "apple", @@ -10867,7 +10947,7 @@ }, { "kind": "ui-call", - "line": 368, + "line": 369, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Default Agent", "surface": "apple", @@ -10875,7 +10955,7 @@ }, { "kind": "ui-call", - "line": 390, + "line": 391, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Reconnect", "surface": "apple", @@ -10883,7 +10963,7 @@ }, { "kind": "ui-call", - "line": 391, + "line": 392, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Diagnose", "surface": "apple", @@ -10891,7 +10971,7 @@ }, { "kind": "ui-call", - "line": 400, + "line": 401, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Scan QR", "surface": "apple", @@ -10899,7 +10979,7 @@ }, { "kind": "ui-call", - "line": 401, + "line": 402, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Connect", "surface": "apple", @@ -10907,7 +10987,7 @@ }, { "kind": "ui-call", - "line": 403, + "line": 404, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Discovered gateways and manual setup live here when the gateway has not connected yet.", "surface": "apple", @@ -11971,7 +12051,7 @@ }, { "kind": "conditional-branch", - "line": 2895, + "line": 2942, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -11979,7 +12059,7 @@ }, { "kind": "conditional-branch", - "line": 2895, + "line": 2942, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -11987,7 +12067,7 @@ }, { "kind": "conditional-branch", - "line": 3457, + "line": 3504, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -11995,7 +12075,7 @@ }, { "kind": "conditional-branch", - "line": 3457, + "line": 3504, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -12003,7 +12083,7 @@ }, { "kind": "conditional-branch", - "line": 3461, + "line": 3508, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -12011,7 +12091,7 @@ }, { "kind": "conditional-branch", - "line": 3461, + "line": 3508, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -12019,7 +12099,7 @@ }, { "kind": "conditional-branch", - "line": 3753, + "line": 3800, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -12027,7 +12107,7 @@ }, { "kind": "conditional-branch", - "line": 3753, + "line": 3800, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", @@ -13617,9 +13697,41 @@ "surface": "apple", "id": "native.apple.5590d661cdc81fbf" }, + { + "kind": "plist-string", + "line": 24, + "path": "apps/ios/WatchApp/Info.plist", + "source": "OpenClaw connects this Apple Watch directly to your Gateway on trusted local networks.", + "surface": "apple", + "id": "native.apple.12a4a51d251f65c0" + }, + { + "kind": "conditional-branch", + "line": 115, + "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", + "source": "Ready to connect", + "surface": "apple", + "id": "native.apple.d4c7af4a7bfeb382" + }, + { + "kind": "conditional-branch", + "line": 187, + "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", + "source": "Direct connection is off", + "surface": "apple", + "id": "native.apple.01062f110734602f" + }, + { + "kind": "conditional-branch", + "line": 187, + "path": "apps/ios/WatchApp/Sources/WatchDirectNode.swift", + "source": "Use iPhone Settings to enable direct connection.", + "surface": "apple", + "id": "native.apple.0e0763442f318e2f" + }, { "kind": "ui-named-argument", - "line": 96, + "line": 101, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Talk to Claw", "surface": "apple", @@ -13627,7 +13739,7 @@ }, { "kind": "ui-named-argument", - "line": 114, + "line": 119, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "What needs you", "surface": "apple", @@ -13635,7 +13747,7 @@ }, { "kind": "ui-named-argument", - "line": 132, + "line": 137, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Caught up", "surface": "apple", @@ -13643,7 +13755,7 @@ }, { "kind": "conditional-branch", - "line": 133, + "line": 138, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No chats or approvals need you", "surface": "apple", @@ -13651,7 +13763,7 @@ }, { "kind": "ui-named-argument", - "line": 140, + "line": 145, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Continue on iPhone", "surface": "apple", @@ -13659,7 +13771,7 @@ }, { "kind": "ui-named-argument", - "line": 152, + "line": 157, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Chat", "surface": "apple", @@ -13667,7 +13779,7 @@ }, { "kind": "conditional-branch", - "line": 225, + "line": 230, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Nothing waiting", "surface": "apple", @@ -13675,7 +13787,7 @@ }, { "kind": "ui-named-argument", - "line": 241, + "line": 246, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approval needed", "surface": "apple", @@ -13683,7 +13795,7 @@ }, { "kind": "ui-named-argument", - "line": 281, + "line": 286, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Review again", "surface": "apple", @@ -13691,7 +13803,7 @@ }, { "kind": "ui-named-argument", - "line": 291, + "line": 296, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Open all approvals", "surface": "apple", @@ -13699,7 +13811,103 @@ }, { "kind": "conditional-branch", - "line": 335, + "line": 308, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Direct Gateway", + "surface": "apple", + "id": "native.apple.577dd5b3738b6aec" + }, + { + "kind": "conditional-branch", + "line": 308, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Watch node online", + "surface": "apple", + "id": "native.apple.b5ac093277f18109" + }, + { + "kind": "conditional-branch", + "line": 315, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Direct", + "surface": "apple", + "id": "native.apple.82fa10a36cbd5773" + }, + { + "kind": "conditional-branch", + "line": 315, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Setup", + "surface": "apple", + "id": "native.apple.e0f952b8832b1ca4" + }, + { + "kind": "conditional-branch", + "line": 318, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Open iPhone Settings → Apple Watch", + "surface": "apple", + "id": "native.apple.08583448d9b2455e" + }, + { + "kind": "conditional-branch", + "line": 318, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Uses Wi-Fi or cellular while OpenClaw is active", + "surface": "apple", + "id": "native.apple.c7d87356e6cdb374" + }, + { + "kind": "conditional-branch", + "line": 320, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Offline", + "surface": "apple", + "id": "native.apple.f748dad8f2ce22ae" + }, + { + "kind": "conditional-branch", + "line": 320, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Online", + "surface": "apple", + "id": "native.apple.60cdcd03d8b51965" + }, + { + "kind": "ui-named-argument", + "line": 323, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Direct mode supports device info, status, and notifications. Chat, Talk, and approvals still use the iPhone.", + "surface": "apple", + "id": "native.apple.088f07637cc141c9" + }, + { + "kind": "ui-call", + "line": 330, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Direct connection", + "surface": "apple", + "id": "native.apple.d7000ec82e411988" + }, + { + "kind": "ui-named-argument", + "line": 336, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Forget direct setup", + "surface": "apple", + "id": "native.apple.72270e305e05cf02" + }, + { + "kind": "ui-named-argument", + "line": 341, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "The iPhone securely sends a one-time setup code. Existing relay features stay available.", + "surface": "apple", + "id": "native.apple.aef0e6d8d3fa0903" + }, + { + "kind": "conditional-branch", + "line": 383, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.chatCount)", "surface": "apple", @@ -13707,7 +13915,7 @@ }, { "kind": "conditional-branch", - "line": 339, + "line": 387, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.approvalCount)", "surface": "apple", @@ -13715,7 +13923,7 @@ }, { "kind": "conditional-branch", - "line": 344, + "line": 392, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "AI agent online", "surface": "apple", @@ -13723,7 +13931,7 @@ }, { "kind": "conditional-branch", - "line": 344, + "line": 392, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Reconnect on iPhone", "surface": "apple", @@ -13731,7 +13939,7 @@ }, { "kind": "conditional-branch", - "line": 351, + "line": 399, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Pairing", "surface": "apple", @@ -13739,7 +13947,7 @@ }, { "kind": "conditional-branch", - "line": 351, + "line": 399, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Running", "surface": "apple", @@ -13747,7 +13955,7 @@ }, { "kind": "conditional-branch", - "line": 371, + "line": 419, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Ready for quick actions", "surface": "apple", @@ -13755,7 +13963,7 @@ }, { "kind": "conditional-branch", - "line": 371, + "line": 419, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Waiting for iPhone sync", "surface": "apple", @@ -13763,7 +13971,7 @@ }, { "kind": "conditional-branch", - "line": 375, + "line": 423, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "1 approval waiting", "surface": "apple", @@ -13771,7 +13979,7 @@ }, { "kind": "conditional-branch", - "line": 375, + "line": 423, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.approvalCount) approvals", "surface": "apple", @@ -13779,7 +13987,7 @@ }, { "kind": "conditional-branch", - "line": 384, + "line": 432, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Decide from watch", "surface": "apple", @@ -13787,7 +13995,7 @@ }, { "kind": "conditional-branch", - "line": 384, + "line": 432, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No approvals", "surface": "apple", @@ -13795,7 +14003,7 @@ }, { "kind": "conditional-branch", - "line": 438, + "line": 486, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "1 recent message", "surface": "apple", @@ -13803,7 +14011,7 @@ }, { "kind": "conditional-branch", - "line": 438, + "line": 486, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.chatCount) recent messages", "surface": "apple", @@ -13811,7 +14019,7 @@ }, { "kind": "conditional-branch", - "line": 440, + "line": 488, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No messages synced", "surface": "apple", @@ -13819,7 +14027,7 @@ }, { "kind": "conditional-branch", - "line": 470, + "line": 518, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Synced", "surface": "apple", @@ -13827,7 +14035,7 @@ }, { "kind": "conditional-branch", - "line": 470, + "line": 518, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Waiting for iPhone", "surface": "apple", @@ -13835,7 +14043,7 @@ }, { "kind": "ui-named-argument", - "line": 736, + "line": 784, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Inbox", "surface": "apple", @@ -13843,7 +14051,7 @@ }, { "kind": "ui-named-argument", - "line": 912, + "line": 960, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "OpenClaw", "surface": "apple", @@ -13851,7 +14059,7 @@ }, { "kind": "conditional-branch", - "line": 1001, + "line": 1049, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "You", "surface": "apple", @@ -13859,7 +14067,7 @@ }, { "kind": "conditional-branch", - "line": 1003, + "line": 1051, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "System", "surface": "apple", @@ -13867,7 +14075,7 @@ }, { "kind": "ui-named-argument", - "line": 1048, + "line": 1096, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Refresh", "surface": "apple", @@ -13875,7 +14083,7 @@ }, { "kind": "ui-call", - "line": 1143, + "line": 1191, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No chat synced", "surface": "apple", @@ -13883,7 +14091,7 @@ }, { "kind": "ui-call", - "line": 1150, + "line": 1198, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Tap the message pill below to start from your watch.", "surface": "apple", @@ -13891,7 +14099,7 @@ }, { "kind": "ui-call", - "line": 1198, + "line": 1246, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Message OpenClaw", "surface": "apple", @@ -13899,7 +14107,7 @@ }, { "kind": "ui-named-argument", - "line": 1301, + "line": 1349, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approvals", "surface": "apple", @@ -13907,7 +14115,7 @@ }, { "kind": "ui-named-argument", - "line": 1304, + "line": 1352, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Clear", "surface": "apple", @@ -13915,7 +14123,7 @@ }, { "kind": "ui-named-argument", - "line": 1305, + "line": 1353, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No approvals waiting", "surface": "apple", @@ -13923,7 +14131,7 @@ }, { "kind": "ui-named-argument", - "line": 1365, + "line": 1413, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approval", "surface": "apple", @@ -13931,7 +14139,7 @@ }, { "kind": "ui-named-argument", - "line": 1379, + "line": 1427, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Sending decision...", "surface": "apple", @@ -13939,7 +14147,7 @@ }, { "kind": "ui-named-argument", - "line": 1383, + "line": 1431, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approve", "surface": "apple", @@ -13947,7 +14155,7 @@ }, { "kind": "ui-named-argument", - "line": 1392, + "line": 1440, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Deny", "surface": "apple", diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index e370b952531..b94ef2a87d3 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -63,6 +63,8 @@ struct SettingsProTab: View { @State var showResetOnboardingAlert = false @State var suppressCredentialPersist = false @State var locationStatusText: String? + @State var watchDirectSetupStatusText: String? + @State var isSendingWatchDirectSetup = false @State var locationPermissionSummary = LocationPermissionSummary( desiredMode: .off, locationServicesEnabled: true, diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index 24c4a9c537f..37fea1803ff 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -806,6 +806,7 @@ extension SettingsProTab { func title(for route: SettingsRoute) -> String { switch route { case .gateway: "Gateway" + case .appleWatch: "Apple Watch" case .approvals: "Approvals" case .permissions: "Permissions" case .channels: "Channels" @@ -818,6 +819,21 @@ extension SettingsProTab { } } + func sendDirectWatchSetup() async { + guard !self.isSendingWatchDirectSetup else { return } + self.isSendingWatchDirectSetup = true + self.watchDirectSetupStatusText = "Preparing one-time setup…" + defer { self.isSendingWatchDirectSetup = false } + do { + let result = try await self.appModel.sendDirectWatchSetup() + self.watchDirectSetupStatusText = result.deliveredImmediately + ? "Setup sent. Open OpenClaw on the watch to connect." + : "Setup queued for the watch. Open OpenClaw before the code expires." + } catch { + self.watchDirectSetupStatusText = error.localizedDescription + } + } + var manualPortBinding: Binding { Binding( get: { self.manualGatewayPortText }, diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 0f69566f814..e567a5883e9 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -173,6 +173,11 @@ extension SettingsProTab { iconColor: .indigo, title: "Privacy", route: .privacy) + self.settingsListRow( + icon: "applewatch", + iconColor: .green, + title: "Apple Watch", + route: .appleWatch) self.settingsListRow( icon: "info.circle.fill", iconColor: .gray, @@ -224,6 +229,8 @@ extension SettingsProTab { switch route { case .gateway: self.gatewayDestination + case .appleWatch: + self.appleWatchDestination case .approvals: self.approvalsDestination case .permissions: @@ -247,6 +254,10 @@ extension SettingsProTab { .font(OpenClawType.body) .navigationTitle(title(for: route)) .navigationBarTitleDisplayMode(.inline) + .task(id: route) { + guard route == .appleWatch else { return } + await self.appModel.refreshWatchMessagingStatus() + } .toolbar { if let headerLeadingAction { ToolbarItem(placement: .topBarLeading) { @@ -355,6 +366,50 @@ extension SettingsProTab { } } + var appleWatchDestination: some View { + Group { + let watchStatus = self.appModel.watchMessagingStatus + self.detailStatusCard( + icon: "applewatch", + title: "Apple Watch", + detail: watchStatus.appInstalled + ? "Relay remains available; direct mode adds an independent Gateway node." + : "Install the OpenClaw watch app before enabling direct mode.", + value: watchStatus.reachable ? "Reachable" : (watchStatus.appInstalled ? "Installed" : "Unavailable"), + color: watchStatus.appInstalled ? OpenClawBrand.ok : OpenClawBrand.warn) + + Section { + Button { + Task { await self.sendDirectWatchSetup() } + } label: { + Label("Enable Direct Gateway Connection", systemImage: "point.3.connected.trianglepath.dotted") + .font(OpenClawType.body) + } + .disabled( + self.isSendingWatchDirectSetup + || !self.appModel.isOperatorGatewayConnected + || !self.appModel.hasOperatorAdminScope + || !watchStatus.appInstalled) + + if let statusText = self.watchDirectSetupStatusText { + Text(statusText) + .font(OpenClawType.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } footer: { + Text( + "The watch receives a one-time pairing code and stores its own device token. A reachable secure Gateway URL is required away from the iPhone.") + .font(OpenClawType.footnote) + } + + Section("Direct node features") { + SettingsDetailRow("Device", value: "Info and status") + SettingsDetailRow("Notifications", value: "While app is active") + } + } + } + var approvalNotificationsWarningCard: some View { Section { VStack(alignment: .leading, spacing: 4) { diff --git a/apps/ios/Sources/Design/SettingsProTabSupport.swift b/apps/ios/Sources/Design/SettingsProTabSupport.swift index 236f474ed55..f80c24f333a 100644 --- a/apps/ios/Sources/Design/SettingsProTabSupport.swift +++ b/apps/ios/Sources/Design/SettingsProTabSupport.swift @@ -5,6 +5,7 @@ import UserNotifications enum SettingsRoute: Hashable { case gateway + case appleWatch case approvals case permissions case channels diff --git a/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift b/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift index c16aebfe12a..fed874f0de9 100644 --- a/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift +++ b/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift @@ -162,7 +162,7 @@ private struct ExecApprovalPromptCard: View { return trimmed.isEmpty ? nil : trimmed } - private func expiresText(_ expiresAtMs: Int?) -> String? { + private func expiresText(_ expiresAtMs: Int64?) -> String? { guard let expiresAtMs else { return nil } let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970) if remainingSeconds <= 0 { diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 0de6265e546..8b06424202c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -110,7 +110,7 @@ final class NodeAppModel { let host: String? let nodeId: String? let agentId: String? - let expiresAtMs: Int? + let expiresAtMs: Int64? var allowsAllowAlways: Bool { self.allowedDecisions.contains("allow-always") @@ -493,6 +493,12 @@ final class NodeAppModel { var cameraHUDKind: CameraHUDKind? var cameraFlashNonce: Int = 0 var screenRecordActive: Bool = false + private(set) var watchMessagingStatus = WatchMessagingStatus( + supported: false, + paired: false, + appInstalled: false, + reachable: false, + activationState: "notActivated") init( screen: ScreenController = ScreenController(), @@ -2295,6 +2301,47 @@ extension NodeAppModel { } } + func sendDirectWatchSetup() async throws -> WatchNotificationSendResult { + struct SetupCodeResponse: Decodable { + var setupCode: String + } + + guard self.isOperatorGatewayConnected else { + throw NSError(domain: "WatchDirectSetup", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Connect the iPhone to a Gateway first.", + ]) + } + guard self.hasOperatorAdminScope else { + throw NSError(domain: "WatchDirectSetup", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "The iPhone connection needs operator.admin access.", + ]) + } + let status = await watchMessagingService.status() + guard status.supported, status.paired, status.appInstalled else { + throw NSError(domain: "WatchDirectSetup", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "Pair an Apple Watch and install the OpenClaw watch app first.", + ]) + } + + let response = try await operatorGateway.request( + method: "device.pair.setupCode", + paramsJSON: #"{"includeQr":false,"bootstrapProfile":"node"}"#, + timeoutSeconds: 20) + let setup = try JSONDecoder().decode(SetupCodeResponse.self, from: response) + guard let setupLink = GatewayConnectDeepLink.fromSetupCode(setup.setupCode), + setupLink.connectionEndpoints.contains(where: \.tls) + else { + throw NSError(domain: "WatchDirectSetup", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "Direct Apple Watch mode requires a trusted HTTPS Gateway endpoint.", + ]) + } + return try await self.watchMessagingService.sendDirectNodeSetup(setupCode: setup.setupCode) + } + + func refreshWatchMessagingStatus() async { + self.watchMessagingStatus = await self.watchMessagingService.status() + } + private func locationMode() -> OpenClawLocationMode { let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" return OpenClawLocationMode(rawValue: raw) ?? .off @@ -4359,8 +4406,8 @@ extension NodeAppModel { private func persistWatchExecApprovalBridgeState() { self.pruneExpiredWatchExecApprovalPrompts() let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in - let lhsExpires = lhs.expiresAtMs ?? Int.max - let rhsExpires = rhs.expiresAtMs ?? Int.max + let lhsExpires = lhs.expiresAtMs ?? Int64.max + let rhsExpires = rhs.expiresAtMs ?? Int64.max if lhsExpires != rhsExpires { return lhsExpires < rhsExpires } @@ -4384,8 +4431,8 @@ extension NodeAppModel { UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey) } - private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int? = nil) { - let currentNowMs = nowMs ?? Int(Date().timeIntervalSince1970 * 1000) + private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int64? = nil) { + let currentNowMs = nowMs ?? Int64(Date().timeIntervalSince1970 * 1000) self.watchExecApprovalPromptsByID = self.watchExecApprovalPromptsByID.filter { _, prompt in guard let expiresAtMs = prompt.expiresAtMs else { return true } return expiresAtMs > currentNowMs @@ -4393,6 +4440,7 @@ extension NodeAppModel { } private func handleWatchMessagingStatusChanged(_ status: WatchMessagingStatus) async { + self.watchMessagingStatus = status GatewayDiagnostics.log( "watch exec approval: status changed " + "reachable=\(status.reachable) activation=\(status.activationState) " @@ -4493,7 +4541,7 @@ extension NodeAppModel { let deliveryGeneration = self.gatewayConnectGeneration let message = OpenClawWatchExecApprovalPromptMessage( approval: Self.makeWatchExecApprovalItem(from: prompt), - sentAtMs: Int(Date().timeIntervalSince1970 * 1000), + sentAtMs: Int64(Date().timeIntervalSince1970 * 1000), deliveryId: UUID().uuidString, resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason)) do { @@ -4533,7 +4581,7 @@ extension NodeAppModel { approvalId: normalizedApprovalID, gatewayStableID: gatewayStableID, decision: decision, - resolvedAtMs: Int(Date().timeIntervalSince1970 * 1000), + resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000), source: source) do { _ = try await self.watchMessagingService.sendExecApprovalResolved(message) @@ -4562,7 +4610,7 @@ extension NodeAppModel { approvalId: normalizedApprovalID, gatewayStableID: gatewayStableID, reason: reason, - expiredAtMs: Int(Date().timeIntervalSince1970 * 1000)) + expiredAtMs: Int64(Date().timeIntervalSince1970 * 1000)) do { _ = try await self.watchMessagingService.sendExecApprovalExpired(message) } catch { @@ -4590,8 +4638,8 @@ extension NodeAppModel { let approvals = self.watchExecApprovalPromptsByID.values .filter(self.isExecApprovalPromptCurrent) .sorted { lhs, rhs in - let lhsExpires = lhs.expiresAtMs ?? Int.max - let rhsExpires = rhs.expiresAtMs ?? Int.max + let lhsExpires = lhs.expiresAtMs ?? Int64.max + let rhsExpires = rhs.expiresAtMs ?? Int64.max if lhsExpires != rhsExpires { return lhsExpires < rhsExpires } @@ -4601,7 +4649,7 @@ extension NodeAppModel { let message = OpenClawWatchExecApprovalSnapshotMessage( approvals: approvals, gatewayStableID: currentExecApprovalGatewayStableID(), - sentAtMs: Int(Date().timeIntervalSince1970 * 1000), + sentAtMs: Int64(Date().timeIntervalSince1970 * 1000), snapshotId: UUID().uuidString) do { guard shouldContinue() else { return } @@ -4655,7 +4703,7 @@ extension NodeAppModel { from raw: [OpenClawKit.AnyCodable], runId: String, submittedText: String, - submittedAtMs: Int) -> String? + submittedAtMs: Int64) -> String? { let entries = raw.compactMap(self.decodeWatchChatMessage) if let directReply = entries.last(where: { @@ -4775,7 +4823,7 @@ extension NodeAppModel { return "\(trimmed.prefix(237))..." } - private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int? { + private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int64? { guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil } let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000 let maxReasonableEpochMs: Double = 32_503_680_000_000 @@ -4785,7 +4833,7 @@ extension NodeAppModel { else { return nil } - return Int(milliseconds) + return Int64(milliseconds) } private func makeWatchAppSnapshot( @@ -4813,7 +4861,7 @@ extension NodeAppModel { pendingApprovalCount: self.watchExecApprovalPromptsByID.count, chatItems: chatPreview?.items, chatStatusText: chatPreview?.statusText, - sentAtMs: Int(Date().timeIntervalSince1970 * 1000), + sentAtMs: Int64(Date().timeIntervalSince1970 * 1000), snapshotId: UUID().uuidString) } @@ -4998,7 +5046,7 @@ extension NodeAppModel { let thinking = messageKind == .quickReply ? "low" : "auto" do { - let submittedAtMs = Int(Date().timeIntervalSince1970 * 1000) + let submittedAtMs = Int64(Date().timeIntervalSince1970 * 1000) if self.isAppleReviewDemoModeEnabled { let response = try await appleReviewDemoChatTransport.sendMessage( sessionKey: sessionKey, @@ -5114,7 +5162,7 @@ extension NodeAppModel { sessionKey: String, runId: String, submittedText: String, - submittedAtMs: Int, + submittedAtMs: Int64, deadline: Date, expectedRoute: GatewayNodeSessionRoute) async -> String? { @@ -5143,7 +5191,7 @@ extension NodeAppModel { OpenClawWatchChatCompletionMessage( commandId: commandId, replyText: replyText, - sentAtMs: Int(Date().timeIntervalSince1970 * 1000))) + sentAtMs: Int64(Date().timeIntervalSince1970 * 1000))) } catch { GatewayDiagnostics.log( "watch chat completion failed commandId=\(commandId) error=\(error.localizedDescription)") @@ -5963,7 +6011,7 @@ extension NodeAppModel { var host: String? var nodeId: String? var agentId: String? - var expiresAtMs: Int? + var expiresAtMs: Int64? } func presentExecApprovalNotificationPrompt( @@ -7143,7 +7191,7 @@ extension NodeAppModel { from raw: [OpenClawKit.AnyCodable], runId: String, submittedText: String, - submittedAtMs: Int) -> String? + submittedAtMs: Int64) -> String? { self.watchChatReplyText( from: raw, @@ -7318,7 +7366,7 @@ extension NodeAppModel { host: String?, nodeId: String?, agentId: String?, - expiresAtMs: Int?) -> ExecApprovalPrompt? + expiresAtMs: Int64?) -> ExecApprovalPrompt? { self.makeExecApprovalPrompt( from: ExecApprovalGetResponse( diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 4f4559241e1..1ecbaee95bd 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -660,7 +660,7 @@ extension NodeAppModel { sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil, gatewayStableID: (normalizedGatewayStableID?.isEmpty == false) ? normalizedGatewayStableID : nil, note: "source=ios.notification", - sentAtMs: Int(Date().timeIntervalSince1970 * 1000), + sentAtMs: Int64(Date().timeIntervalSince1970 * 1000), transport: "ios.notification") await _bridgeConsumeMirroredWatchReply(event) } diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index 215e8f50aac..16374c0ed73 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -84,7 +84,7 @@ struct WatchQuickReplyEvent: Codable, Equatable { var sessionKey: String? var gatewayStableID: String? var note: String? - var sentAtMs: Int? + var sentAtMs: Int64? var transport: String } @@ -98,19 +98,19 @@ struct WatchExecApprovalResolveEvent: Codable, Equatable { var approvalId: String var gatewayStableID: String? var decision: OpenClawWatchExecApprovalDecision - var sentAtMs: Int? + var sentAtMs: Int64? var transport: String } struct WatchExecApprovalSnapshotRequestEvent: Equatable { var requestId: String - var sentAtMs: Int? + var sentAtMs: Int64? var transport: String } struct WatchAppSnapshotRequestEvent: Equatable { var requestId: String - var sentAtMs: Int? + var sentAtMs: Int64? var transport: String } @@ -120,7 +120,7 @@ struct WatchAppCommandEvent: Codable, Equatable { var sessionKey: String? var gatewayStableID: String? var text: String? - var sentAtMs: Int? + var sentAtMs: Int64? var transport: String var messageKind: WatchMessageKind? = nil } @@ -140,6 +140,7 @@ protocol WatchMessagingServicing: AnyObject, Sendable { _ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?) func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?) func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) + func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult func sendNotification( id: String, params: OpenClawWatchNotifyParams, diff --git a/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift b/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift index 81915c8dce3..42176407a65 100644 --- a/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift +++ b/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift @@ -9,8 +9,8 @@ enum WatchMessagingPayloadCodec { static let completedChatReplyTextLimit = 4000 - static func nowMs() -> Int { - Int(Date().timeIntervalSince1970 * 1000) + static func nowMs() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) } static func nonEmpty(_ value: String?) -> String? { @@ -67,6 +67,14 @@ enum WatchMessagingPayloadCodec { return payload } + static func encodeDirectNodeSetupPayload(setupCode: String) -> [String: Any] { + [ + "type": OpenClawWatchPayloadType.directNodeSetup.rawValue, + "setupCode": setupCode, + "sentAtMs": self.nowMs(), + ] + } + static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] { var payload: [String: Any] = [ "id": item.id, @@ -280,7 +288,7 @@ enum WatchMessagingPayloadCodec { let sessionKey = self.nonEmpty(payload["sessionKey"] as? String) let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String) let note = self.nonEmpty(payload["note"] as? String) - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchQuickReplyEvent( replyId: replyId, @@ -309,7 +317,7 @@ enum WatchMessagingPayloadCodec { } 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 + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchExecApprovalResolveEvent( replyId: replyId, approvalId: approvalId, @@ -327,7 +335,7 @@ enum WatchMessagingPayloadCodec { return nil } let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchExecApprovalSnapshotRequestEvent( requestId: requestId, sentAtMs: sentAtMs, @@ -342,7 +350,7 @@ enum WatchMessagingPayloadCodec { return nil } let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchAppSnapshotRequestEvent( requestId: requestId, sentAtMs: sentAtMs, @@ -365,7 +373,7 @@ enum WatchMessagingPayloadCodec { let sessionKey = self.nonEmpty(payload["sessionKey"] as? String) let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String) let text = self.nonEmpty(payload["text"] as? String) - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchAppCommandEvent( commandId: commandId, command: command, diff --git a/apps/ios/Sources/Services/WatchMessagingService.swift b/apps/ios/Sources/Services/WatchMessagingService.swift index f8beafb6b55..5aa26c0ac49 100644 --- a/apps/ios/Sources/Services/WatchMessagingService.swift +++ b/apps/ios/Sources/Services/WatchMessagingService.swift @@ -127,6 +127,11 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing { return try await self.transport.sendPayload(payload) } + func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult { + try await self.transport.sendPayload( + WatchMessagingPayloadCodec.encodeDirectNodeSetupPayload(setupCode: setupCode)) + } + func sendExecApprovalPrompt( _ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult { diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 93ca7d21044..f1a951165ce 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -131,6 +131,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer transport: "sendMessage") var sendError: Error? var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)? + var lastDirectNodeSetupCode: String? var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage? var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage? var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage? @@ -155,6 +156,11 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer self.statusHandler = handler } + func emitStatus(_ status: WatchMessagingStatus) { + self.currentStatus = status + self.statusHandler?(status) + } + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { self.replyHandler = handler } @@ -189,6 +195,14 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer return self.nextSendResult } + func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult { + self.lastDirectNodeSetupCode = setupCode + if let sendError { + throw sendError + } + return self.nextSendResult + } + func sendExecApprovalPrompt( _ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult { @@ -942,7 +956,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi @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 + let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000 try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( @@ -970,7 +984,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi @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 + let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000 try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( @@ -1764,7 +1778,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi sessionKey: "main", gatewayStableID: nil, text: "Message \(index)", - sentAtMs: index, + sentAtMs: Int64(index), transport: "sendMessage") if case .forward = coordinator.ingest( event, @@ -1830,7 +1844,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi sessionKey: "main", gatewayStableID: nil, text: "Queued \(index)", - sentAtMs: index, + sentAtMs: Int64(index), transport: "transferUserInfo") if case .queue = coordinator.ingest( event, @@ -1920,7 +1934,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi host: "gateway", nodeId: nil, agentId: nil, - expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000))) + expiresAtMs: Int64(Date().timeIntervalSince1970 * 1000) + 60000))) #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"]) } @@ -1996,7 +2010,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi host: "gateway", nodeId: nil, agentId: nil, - expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000))) + expiresAtMs: Int64(Date().timeIntervalSince1970 * 1000) + 60000))) await appModel._test_handleOperatorGatewayServerEvent(EventFrame( type: "event", @@ -2484,6 +2498,38 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(payload.activationState == "inactive") } + @Test @MainActor func `watch status refresh publishes service snapshot`() async { + let watchService = MockWatchMessagingService() + let status = WatchMessagingStatus( + supported: true, + paired: true, + appInstalled: true, + reachable: false, + activationState: "activated") + watchService.currentStatus = status + let appModel = NodeAppModel(watchMessagingService: watchService) + + await appModel.refreshWatchMessagingStatus() + + #expect(appModel.watchMessagingStatus == status) + } + + @Test @MainActor func `watch status callback publishes reachability changes`() async { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let status = WatchMessagingStatus( + supported: true, + paired: true, + appInstalled: true, + reachable: true, + activationState: "activated") + + watchService.emitStatus(status) + await waitForMainActorWork { appModel.watchMessagingStatus == status } + + #expect(appModel.watchMessagingStatus == status) + } + @Test @MainActor func `handle invoke watch notify routes to watch service`() async throws { let watchService = MockWatchMessagingService() watchService.nextSendResult = WatchNotificationSendResult( @@ -2573,6 +2619,54 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(expired["gatewayStableID"] as? String == "gateway-a") } + @Test @MainActor func `watch direct node setup codec carries opaque setup code`() { + let payload = WatchMessagingPayloadCodec.encodeDirectNodeSetupPayload( + setupCode: "opaque-bootstrap-code") + + #expect(payload["type"] as? String == OpenClawWatchPayloadType.directNodeSetup.rawValue) + #expect(payload["setupCode"] as? String == "opaque-bootstrap-code") + #expect(payload["sentAtMs"] is Int64) + #expect(payload["token"] == nil) + #expect(payload["password"] == nil) + } + + @Test @MainActor func `watch payload codec preserves 64 bit epoch milliseconds`() throws { + let sentAtMs: Int64 = 1_725_000_000_123 + let encodedTimestamp = NSNumber(value: sentAtMs) + + let reply = try #require(WatchMessagingPayloadCodec.parseQuickReplyPayload([ + "type": OpenClawWatchPayloadType.reply.rawValue, + "actionId": "approve", + "sentAtMs": encodedTimestamp, + ], transport: "sendMessage")) + let resolution = try #require(WatchMessagingPayloadCodec.parseExecApprovalResolvePayload([ + "type": OpenClawWatchPayloadType.execApprovalResolve.rawValue, + "approvalId": "approval-a", + "decision": OpenClawWatchExecApprovalDecision.allowOnce.rawValue, + "sentAtMs": encodedTimestamp, + ], transport: "sendMessage")) + let approvalSnapshotRequest = try #require( + WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "sentAtMs": encodedTimestamp, + ], transport: "sendMessage")) + let appSnapshotRequest = try #require(WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.appSnapshotRequest.rawValue, + "sentAtMs": encodedTimestamp, + ], transport: "sendMessage")) + let appCommand = try #require(WatchMessagingPayloadCodec.parseAppCommandPayload([ + "type": OpenClawWatchPayloadType.appCommand.rawValue, + "command": OpenClawWatchAppCommand.refresh.rawValue, + "sentAtMs": encodedTimestamp, + ], transport: "sendMessage")) + + #expect(reply.sentAtMs == sentAtMs) + #expect(resolution.sentAtMs == sentAtMs) + #expect(approvalSnapshotRequest.sentAtMs == sentAtMs) + #expect(appSnapshotRequest.sentAtMs == sentAtMs) + #expect(appCommand.sentAtMs == sentAtMs) + } + @Test @MainActor func `watch application context retains app and approval snapshots`() throws { let appPayload = WatchMessagingPayloadCodec.encodeAppSnapshotPayload( OpenClawWatchAppSnapshotMessage( @@ -2838,7 +2932,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi sessionKey: "main", gatewayStableID: "gateway-a", text: "Pending \(index)", - sentAtMs: index + 2, + sentAtMs: Int64(index + 2), transport: "transferUserInfo") _ = firstOutbox.ingest(pending, isAvailable: false, gatewayStableID: "gateway-a") } diff --git a/apps/ios/Tests/WatchDeferredPayloadOrderingTests.swift b/apps/ios/Tests/WatchDeferredPayloadOrderingTests.swift index a182fee3380..7cc6d659d6b 100644 --- a/apps/ios/Tests/WatchDeferredPayloadOrderingTests.swift +++ b/apps/ios/Tests/WatchDeferredPayloadOrderingTests.swift @@ -26,4 +26,15 @@ struct WatchDeferredPayloadOrderingTests { @Test func `replays missing timestamps first in receipt order`() { #expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [nil, 200, nil, 100]) == [0, 2, 3, 1]) } + + @Test func `epoch values preserve ordering above 32 bit range`() { + let earlier: Int64 = 1_700_000_000_000 + let later = earlier + 1 + + #expect(WatchDeferredPayloadOrdering.isExpired(expiresAtMs: earlier, nowMs: later)) + #expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot( + payloadSentAtMs: later, + snapshotSentAtMs: earlier)) + #expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [later, earlier]) == [1, 0]) + } } diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index d74398fea48..2a4842b2836 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -20,6 +20,10 @@ $(OPENCLAW_MARKETING_VERSION) CFBundleVersion $(OPENCLAW_BUILD_VERSION) + NSLocalNetworkUsageDescription + OpenClaw connects this Apple Watch directly to your Gateway on trusted local networks. + WKPrefersNetworkUponForeground + UIAppFonts RedHatDisplay[wght].ttf diff --git a/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift b/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift index 65f6a973d12..d656f029b50 100644 --- a/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift +++ b/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift @@ -1,10 +1,25 @@ import SwiftUI +import UserNotifications + +private final class WatchNotificationPresentationDelegate: NSObject, UNUserNotificationCenterDelegate, + @unchecked Sendable +{ + func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) + { + completionHandler([.banner, .list, .sound]) + } +} @main struct OpenClawWatchApp: App { @Environment(\.scenePhase) private var scenePhase @State private var inboxStore = WatchInboxStore( requestNotificationAuthorization: !OpenClawWatchApp.isScreenshotMode) + @State private var directNode = WatchDirectNode() + @State private var notificationDelegate = WatchNotificationPresentationDelegate() @State private var receiver: WatchConnectivityReceiver? @State private var execApprovalRefreshTask: Task? @@ -18,6 +33,7 @@ struct OpenClawWatchApp: App { WindowGroup { WatchInboxView( store: self.inboxStore, + directNode: self.directNode, onAction: { action in guard let receiver = self.receiver else { return } let draft = self.inboxStore.makeReplyDraft(action: action) @@ -54,22 +70,37 @@ struct OpenClawWatchApp: App { self.sendChatMessage(text) }) .task { + UNUserNotificationCenter.current().delegate = self.notificationDelegate if OpenClawWatchApp.isScreenshotMode { self.inboxStore.configureScreenshotFixture() return } if self.receiver == nil { - let receiver = WatchConnectivityReceiver(store: self.inboxStore) + let receiver = WatchConnectivityReceiver( + store: self.inboxStore, + directNodeSetupHandler: { [weak directNode] setupCode, sentAtMs in + directNode?.configure(setupCode: setupCode, sentAtMs: sentAtMs) + }) receiver.activate() self.receiver = receiver } + if self.scenePhase == .active { + self.directNode.connectForForeground() + } self.refreshAppSnapshot() self.refreshExecApprovalReview() } .onChange(of: self.scenePhase) { _, newPhase in - guard newPhase == .active else { return } - self.refreshAppSnapshot() - self.refreshExecApprovalReview() + switch newPhase { + case .active: + self.directNode.connectForForeground() + self.refreshAppSnapshot() + self.refreshExecApprovalReview() + case .inactive, .background: + self.directNode.disconnectForBackground() + @unknown default: + break + } } } } @@ -144,7 +175,7 @@ struct OpenClawWatchApp: App { @MainActor extension WatchInboxStore { fileprivate func configureScreenshotFixture() { - let sentAtMs = Int(Date().timeIntervalSince1970 * 1000) + let sentAtMs = Int64(Date().timeIntervalSince1970 * 1000) greetingTextOverride = "Good morning" self.consume( execApprovalSnapshot: WatchExecApprovalSnapshotMessage( diff --git a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift index a1f476089f5..e9165ff25db 100644 --- a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift @@ -9,7 +9,7 @@ struct WatchReplyDraft { var sessionKey: String? var gatewayStableID: String? var note: String? - var sentAtMs: Int + var sentAtMs: Int64 } struct WatchReplySendResult: Equatable { @@ -25,9 +25,14 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { private let store: WatchInboxStore private let session: WCSession? private let activationGate = WatchSessionActivationGate() + private let directNodeSetupHandler: @MainActor @Sendable (String, Int64) -> Void - init(store: WatchInboxStore) { + init( + store: WatchInboxStore, + directNodeSetupHandler: @escaping @MainActor @Sendable (String, Int64) -> Void) + { self.store = store + self.directNodeSetupHandler = directNodeSetupHandler if WCSession.isSupported() { self.session = WCSession.default } else { @@ -203,8 +208,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { errorMessage: error.localizedDescription) } - private static func nowMs() -> Int { - Int(Date().timeIntervalSince1970 * 1000) + private static func nowMs() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) } private static func normalizeObject(_ value: Any) -> [String: Any]? { @@ -261,7 +266,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let id = (payload["id"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value let promptId = (payload["promptId"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) let sessionKey = (payload["sessionKey"] as? String)? @@ -272,7 +277,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { .trimmingCharacters(in: .whitespacesAndNewlines) let details = (payload["details"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue + let expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value let risk = (payload["risk"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) let actions = Self.parseActions(payload["actions"]) @@ -314,7 +319,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { 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 expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let risk = WatchRiskLevel(rawValue: riskRaw) let allowedDecisions = (payload["allowedDecisions"] as? [Any] ?? []).compactMap { @@ -342,7 +347,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { else { return nil } - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value let deliveryId = (payload["deliveryId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let resetResolvingState = payload["resetResolvingState"] as? Bool return WatchExecApprovalPromptMessage( @@ -365,8 +370,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { 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 resolvedAtMs = (payload["resolvedAtMs"] as? NSNumber)?.int64Value let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) return WatchExecApprovalResolvedMessage( approvalId: approvalId, @@ -391,7 +395,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { else { return nil } - let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue + let expiredAtMs = (payload["expiredAtMs"] as? NSNumber)?.int64Value let gatewayStableID = (payload["gatewayStableID"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) return WatchExecApprovalExpiredMessage( @@ -414,7 +418,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { } let gatewayStableID = (payload["gatewayStableID"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) return WatchExecApprovalSnapshotMessage( approvals: approvals, @@ -446,7 +450,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int) ?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue ?? 0 - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem) let chatStatusText = (payload["chatStatusText"] as? String)? @@ -481,8 +485,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let replyText = (payload["replyText"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !commandId.isEmpty, !replyText.isEmpty else { return nil } - let sentAtMs = (payload["sentAtMs"] as? Int) - ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchChatCompletionMessage( commandId: commandId, replyText: replyText, @@ -499,7 +502,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let trimmedRole = (dict["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) guard let text, !text.isEmpty else { return nil } - let timestampMs = (dict["timestampMs"] as? Int) ?? (dict["timestampMs"] as? NSNumber)?.intValue + let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value return WatchChatItem( id: id, role: trimmedRole.isEmpty ? "assistant" : trimmedRole, @@ -632,6 +635,16 @@ extension WatchConnectivityReceiver: WCSessionDelegate { } private func consumeIncomingPayload(_ payload: [String: Any], transport: String) { + if let type = payload["type"] as? String, + type == WatchPayloadType.directNodeSetup.rawValue, + let setupCode = payload["setupCode"] as? String, + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value + { + Task { @MainActor in + self.directNodeSetupHandler(setupCode, sentAtMs) + } + return + } let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any]) .flatMap(Self.parseAppSnapshotPayload) let execApprovalSnapshot = diff --git a/apps/ios/WatchApp/Sources/WatchDeferredPayloadOrdering.swift b/apps/ios/WatchApp/Sources/WatchDeferredPayloadOrdering.swift index 29885fd2470..81941078ed3 100644 --- a/apps/ios/WatchApp/Sources/WatchDeferredPayloadOrdering.swift +++ b/apps/ios/WatchApp/Sources/WatchDeferredPayloadOrdering.swift @@ -1,19 +1,19 @@ enum WatchDeferredPayloadOrdering { - static func isExpired(expiresAtMs: Int?, nowMs: Int) -> Bool { + static func isExpired(expiresAtMs: Int64?, nowMs: Int64) -> Bool { expiresAtMs.map { $0 <= nowMs } == true } - static func isNewerThanSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool { + static func isNewerThanSnapshot(payloadSentAtMs: Int64?, snapshotSentAtMs: Int64?) -> Bool { guard let payloadSentAtMs, let snapshotSentAtMs else { return true } return payloadSentAtMs > snapshotSentAtMs } - static func isAtOrBeforeSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool { + static func isAtOrBeforeSnapshot(payloadSentAtMs: Int64?, snapshotSentAtMs: Int64?) -> Bool { guard let snapshotSentAtMs else { return false } return payloadSentAtMs.map { $0 <= snapshotSentAtMs } ?? true } - static func indicesOldestFirst(for timestamps: [Int?]) -> [Int] { + static func indicesOldestFirst(for timestamps: [Int64?]) -> [Int] { timestamps.indices.sorted { lhs, rhs in let lhsTimestamp = timestamps[lhs] ?? .min let rhsTimestamp = timestamps[rhs] ?? .min diff --git a/apps/ios/WatchApp/Sources/WatchDirectNode.swift b/apps/ios/WatchApp/Sources/WatchDirectNode.swift new file mode 100644 index 00000000000..70f7b6bcacf --- /dev/null +++ b/apps/ios/WatchApp/Sources/WatchDirectNode.swift @@ -0,0 +1,820 @@ +import Foundation +import Observation +import OpenClawKit +import OpenClawProtocol +import UserNotifications +import WatchKit + +@MainActor @Observable +final class WatchDirectNode { + private struct PersistedConfiguration: Codable { + var link: GatewayConnectDeepLink + var gatewayID: String + var setupSentAtMs: Int64? + } + + private struct ActiveSession: Equatable { + let baseURL: URL + let token: String + } + + private struct ConnectResponse: Decodable { + let sessionToken: String + let deviceToken: String + } + + private enum ConnectCredential { + case bootstrap(String) + case device(String) + + var token: String { + switch self { + case let .bootstrap(token), let .device(token): token + } + } + } + + private struct ChallengeResponse: Decodable { + let nonce: String + } + + private struct PollResponse: Decodable { + let event: NodeEvent? + } + + private struct NodeEvent: Decodable { + let event: String + let payload: InvokeRequest? + } + + private struct InvokeRequest: Decodable { + let id: String + let nodeId: String + let command: String + let paramsJSON: String? + } + + private struct HTTPError: LocalizedError { + let status: Int + let detail: String + + var errorDescription: String? { + self.detail.isEmpty ? "Gateway HTTP error (\(self.status))" : self.detail + } + } + + private static let keychainService = "ai.openclaw.watch.direct-node" + private static let keychainAccount = "gateway" + private static let enabledDefaultsKey = "watch.directNode.enabled" + private static let lastSetupSentAtDefaultsKey = "watch.directNode.lastSetupSentAtMs" + private static let maximumSetupAgeMs: Int64 = 12 * 60 * 1000 + private static let maximumSetupClockSkewMs: Int64 = 2 * 60 * 1000 + private static let commands = [ + OpenClawDeviceCommand.info.rawValue, + OpenClawDeviceCommand.status.rawValue, + OpenClawSystemCommand.notify.rawValue, + ] + + private let networkMetrics: WatchURLSessionMetrics + private let urlSession: URLSession + private var configuration: PersistedConfiguration? + private var connectTask: Task? + private var activeSession: ActiveSession? + private var isForeground = false + private var connectionGeneration = 0 + + private(set) var isEnabled: Bool + private(set) var isConnected = false + private(set) var statusText = "Use iPhone Settings to enable direct connection." + private(set) var endpointText: String? + + var isConfigured: Bool { + self.configuration != nil + } + + init() { + let sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.waitsForConnectivity = true + sessionConfiguration.timeoutIntervalForRequest = 30 + sessionConfiguration.timeoutIntervalForResource = 35 + let networkMetrics = WatchURLSessionMetrics() + self.networkMetrics = networkMetrics + self.urlSession = URLSession( + configuration: sessionConfiguration, + delegate: networkMetrics, + delegateQueue: nil) + self.isEnabled = UserDefaults.standard.bool(forKey: Self.enabledDefaultsKey) + self.configuration = Self.loadConfiguration() + if let setupSentAtMs = configuration?.setupSentAtMs, + setupSentAtMs > Self.lastAcceptedSetupSentAtMs() + { + Self.saveLastAcceptedSetupSentAtMs(setupSentAtMs) + } + self.endpointText = self.configuration.map(Self.endpointText) + if self.configuration != nil { + self.statusText = self.isEnabled ? "Ready to connect" : "Direct connection is off" + } + } + + func configure(setupCode: String, sentAtMs: Int64) { + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let oldestAcceptedMs = nowMs - Self.maximumSetupAgeMs + let newestAcceptedMs = nowMs + Self.maximumSetupClockSkewMs + guard (oldestAcceptedMs...newestAcceptedMs).contains(sentAtMs) else { + self.statusText = "Ignored an expired direct connection setup. Send setup again from iPhone." + return + } + let newestInstalledSetupMs = configuration?.setupSentAtMs ?? 0 + guard sentAtMs > max(Self.lastAcceptedSetupSentAtMs(), newestInstalledSetupMs) else { return } + guard let link = GatewayConnectDeepLink.fromSetupCode(setupCode), + link.isValidEndpoint, + link.bootstrapToken != nil, + link.token == nil, + link.password == nil, + let secureEndpoint = link.connectionEndpoints.first(where: \.tls) + else { + self.statusText = "Direct mode requires a trusted HTTPS Gateway endpoint." + return + } + let secureEndpoints = link.connectionEndpoints.filter(\.tls) + let secureLink = GatewayConnectDeepLink( + host: secureEndpoint.host, + port: secureEndpoint.port, + tls: true, + bootstrapToken: link.bootstrapToken, + token: nil, + password: nil, + fallbackEndpoints: Array(secureEndpoints.dropFirst())) + let previousConfiguration = self.configuration + let configuration = PersistedConfiguration( + link: secureLink, + gatewayID: Self.gatewayID(for: secureLink), + setupSentAtMs: sentAtMs) + guard Self.saveConfiguration(configuration) else { + self.statusText = "Could not save direct connection securely." + return + } + if let previousConfiguration, + previousConfiguration.gatewayID != configuration.gatewayID, + let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) + { + DeviceAuthStore.clearToken( + deviceId: identity.deviceId, + role: "node", + gatewayID: previousConfiguration.gatewayID, + profile: .primary) + } + Self.saveLastAcceptedSetupSentAtMs(sentAtMs) + self.disconnectActiveSession() + self.configuration = configuration + self.endpointText = Self.endpointText(configuration) + self.statusText = "Setup received. Connecting…" + self.setEnabled(true) + } + + func setEnabled(_ enabled: Bool) { + self.isEnabled = enabled + UserDefaults.standard.set(enabled, forKey: Self.enabledDefaultsKey) + if enabled { + self.connect() + } else { + self.disconnectActiveSession() + self.connectionGeneration &+= 1 + self.connectTask?.cancel() + self.connectTask = nil + self.isConnected = false + self.statusText = self.isConfigured + ? "Direct connection is off" + : "Use iPhone Settings to enable direct connection." + } + } + + func connect() { + guard self.isForeground, self.isEnabled, let configuration else { return } + self.disconnectActiveSession() + self.connectTask?.cancel() + self.connectionGeneration &+= 1 + let generation = self.connectionGeneration + self.connectTask = Task { [weak self] in + await self?.run(configuration, generation: generation) + } + } + + func connectForForeground() { + self.isForeground = true + self.connect() + } + + func disconnectForBackground() { + self.isForeground = false + self.disconnectActiveSession() + self.connectionGeneration &+= 1 + self.connectTask?.cancel() + self.connectTask = nil + self.isConnected = false + if self.isEnabled, self.isConfigured { + self.statusText = "Reconnects when OpenClaw is active" + } + } + + func forget() { + self.disconnectActiveSession() + self.connectionGeneration &+= 1 + self.connectTask?.cancel() + self.connectTask = nil + if let configuration { + if let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) { + DeviceAuthStore.clearToken( + deviceId: identity.deviceId, + role: "node", + gatewayID: configuration.gatewayID, + profile: .primary) + } + } + _ = GenericPasswordKeychainStore.delete( + service: Self.keychainService, + account: Self.keychainAccount) + configuration = nil + self.endpointText = nil + self.isConnected = false + self.setEnabled(false) + } + + private func run(_ configuration: PersistedConfiguration, generation: Int) async { + while self.isCurrentConnection(generation, configuration: configuration) { + var lastError: Error? + for endpoint in configuration.link.connectionEndpoints { + guard self.isCurrentConnection(generation, configuration: configuration) else { return } + let link = configuration.link.selectingEndpoint(endpoint) + guard let baseURL = Self.httpBaseURL(for: link) else { continue } + do { + try await self.connectAndPoll( + configuration: configuration, + link: link, + baseURL: baseURL, + generation: generation) + return + } catch is CancellationError { + return + } catch let error as URLError where error.code == .cancelled { + return + } catch { + guard self.isCurrentConnection(generation, configuration: configuration) else { return } + lastError = error + self.isConnected = false + } + } + guard self.isCurrentConnection(generation, configuration: configuration) else { return } + self.statusText = lastError.map { + "Direct connection failed: \($0.localizedDescription)" + } ?? "No usable Gateway endpoint" + do { + try await Task.sleep(for: .seconds(3)) + } catch { + return + } + } + } + + private func connectAndPoll( + configuration: PersistedConfiguration, + link: GatewayConnectDeepLink, + baseURL: URL, + generation: Int) async throws + { + try self.requireCurrentConnection(generation, configuration: configuration) + self.statusText = "Connecting directly…" + guard let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) else { + throw HTTPError(status: 0, detail: "Could not save the watch device identity") + } + let storedToken = DeviceAuthStore.loadToken( + deviceId: identity.deviceId, + role: "node", + gatewayID: configuration.gatewayID, + profile: .primary)?.token + guard storedToken != nil || link.bootstrapToken != nil else { + throw HTTPError(status: 401, detail: "No watch device credential") + } + let response: ConnectResponse + if let bootstrapToken = link.bootstrapToken { + do { + response = try await self.establishSession( + identity: identity, + baseURL: baseURL, + credential: .bootstrap(bootstrapToken)) + } catch let error as HTTPError where error.status == 401 { + guard let storedToken else { throw error } + response = try await self.establishSession( + identity: identity, + baseURL: baseURL, + credential: .device(storedToken)) + } + } else if let storedToken { + response = try await self.establishSession( + identity: identity, + baseURL: baseURL, + credential: .device(storedToken)) + } else { + throw HTTPError(status: 401, detail: "No watch device credential") + } + // A successful bootstrap response has already consumed the one-time code. + // Finish that durable handoff across background/toggle cancellation, but + // never let an obsolete attempt overwrite a forgotten or newer setup. + do { + guard self.isInstalledConfiguration(configuration) else { throw CancellationError() } + guard DeviceAuthStore.storeTokenPersisted( + deviceId: identity.deviceId, + role: "node", + token: response.deviceToken, + scopes: [], + gatewayID: configuration.gatewayID, + profile: .primary) + else { + throw HTTPError(status: 0, detail: "Could not save the watch device credential") + } + if link.bootstrapToken != nil { + try self.finishCredentialHandoff(configuration: configuration) + } + try self.requireCurrentConnection(generation, configuration: configuration) + } catch { + self.sendDisconnect(ActiveSession(baseURL: baseURL, token: response.sessionToken)) + throw error + } + let session = ActiveSession(baseURL: baseURL, token: response.sessionToken) + self.activeSession = session + defer { releaseActiveSession(session) } + self.isConnected = true + self.statusText = "Connected directly" + while self.isCurrentConnection(generation, configuration: configuration) { + let pollData = try await request( + baseURL: baseURL, + path: "poll", + method: "POST", + token: response.sessionToken) + try self.requireCurrentConnection(generation, configuration: configuration) + let poll = try JSONDecoder().decode(PollResponse.self, from: pollData) + guard let event = poll.event else { continue } + guard event.event == "node.invoke.request", let invoke = event.payload else { continue } + let invokeRequest = BridgeInvokeRequest( + id: invoke.id, + command: invoke.command, + paramsJSON: invoke.paramsJSON, + nodeId: invoke.nodeId) + let result = await handleInvoke(invokeRequest) + try requireCurrentConnection(generation, configuration: configuration) + _ = try await self.request( + baseURL: baseURL, + path: "result", + method: "POST", + token: response.sessionToken, + body: result) + try self.requireCurrentConnection(generation, configuration: configuration) + } + } + + private func isCurrentConnection( + _ generation: Int, + configuration: PersistedConfiguration) -> Bool + { + !Task.isCancelled + && generation == self.connectionGeneration + && self.isForeground + && self.isEnabled + && self.isInstalledConfiguration(configuration) + } + + private func isInstalledConfiguration(_ configuration: PersistedConfiguration) -> Bool { + configuration.gatewayID == self.configuration?.gatewayID + && configuration.setupSentAtMs == self.configuration?.setupSentAtMs + } + + private func requireCurrentConnection( + _ generation: Int, + configuration: PersistedConfiguration) throws + { + guard self.isCurrentConnection(generation, configuration: configuration) else { + throw CancellationError() + } + } + + private func establishSession( + identity: DeviceIdentity, + baseURL: URL, + credential: ConnectCredential) async throws -> ConnectResponse + { + let challengeData = try await request( + baseURL: baseURL, + path: "challenge", + method: "GET", + token: nil) + let challenge = try JSONDecoder().decode(ChallengeResponse.self, from: challengeData) + let notificationSettings = await UNUserNotificationCenter.current().notificationSettings() + let params = try connectParams( + identity: identity, + nonce: challenge.nonce, + credential: credential, + notificationsAuthorized: notificationSettings.authorizationStatus == .authorized + || notificationSettings.authorizationStatus == .provisional) + let connectData = try await request( + baseURL: baseURL, + path: "connect", + method: "POST", + token: nil, + body: params) + return try JSONDecoder().decode(ConnectResponse.self, from: connectData) + } + + private func connectParams( + identity: DeviceIdentity, + nonce: String, + credential: ConnectCredential, + notificationsAuthorized: Bool) throws -> ConnectParams + { + let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000) + let payload = GatewayDeviceAuthPayload.buildV3( + deviceId: identity.deviceId, + clientId: "openclaw-watchos", + clientMode: "node", + role: "node", + scopes: [], + signedAtMs: signedAtMs, + token: credential.token, + nonce: nonce, + platform: InstanceIdentity.platformString, + deviceFamily: InstanceIdentity.deviceFamily) + guard let device = GatewayDeviceAuthPayload.signedDeviceDictionary( + payload: payload, + identity: identity, + signedAtMs: signedAtMs, + nonce: nonce) + else { + throw HTTPError(status: 0, detail: "Could not sign watch identity") + } + var client: [String: AnyCodable] = [ + "id": AnyCodable("openclaw-watchos"), + "displayName": AnyCodable(InstanceIdentity.displayName), + "version": AnyCodable( + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), + "platform": AnyCodable(InstanceIdentity.platformString), + "deviceFamily": AnyCodable(InstanceIdentity.deviceFamily), + "mode": AnyCodable("node"), + "instanceId": AnyCodable(InstanceIdentity.instanceId), + ] + if let modelIdentifier = InstanceIdentity.modelIdentifier { + client["modelIdentifier"] = AnyCodable(modelIdentifier) + } + let auth: [String: AnyCodable] = switch credential { + case let .device(token): + ["deviceToken": AnyCodable(token)] + case let .bootstrap(token): + ["bootstrapToken": AnyCodable(token)] + } + return ConnectParams( + minprotocol: GATEWAY_MIN_PROTOCOL_VERSION, + maxprotocol: GATEWAY_PROTOCOL_VERSION, + client: client, + caps: [], + commands: Self.commands, + permissions: ["notifications": AnyCodable(notificationsAuthorized)], + pathenv: nil, + role: "node", + scopes: [], + device: device, + auth: auth, + locale: Locale.preferredLanguages.first ?? Locale.current.identifier, + useragent: ProcessInfo.processInfo.operatingSystemVersionString) + } + + private func request( + baseURL: URL, + path: String, + method: String, + token: String?) async throws -> Data + { + try await self.performRequest( + baseURL: baseURL, + path: path, + method: method, + token: token, + body: nil) + } + + private func request( + baseURL: URL, + path: String, + method: String, + token: String?, + body: some Encodable) async throws -> Data + { + let encodedBody = try JSONEncoder().encode(body) + return try await self.performRequest( + baseURL: baseURL, + path: path, + method: method, + token: token, + body: encodedBody) + } + + private func performRequest( + baseURL: URL, + path: String, + method: String, + token: String?, + body: Data?) async throws -> Data + { + let url = baseURL + .appendingPathComponent("api") + .appendingPathComponent("nodes") + .appendingPathComponent("watch") + .appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = path == "poll" ? 25 : 8 + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + let (data, response) = try await urlSession.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw HTTPError(status: 0, detail: "Invalid Gateway response") + } + guard (200..<300).contains(http.statusCode) else { + let detail = String(data: data, encoding: .utf8) ?? "" + throw HTTPError(status: http.statusCode, detail: detail) + } + return data + } + + private func finishCredentialHandoff(configuration: PersistedConfiguration) throws { + let link = configuration.link + let sanitized = PersistedConfiguration( + link: GatewayConnectDeepLink( + host: link.host, + port: link.port, + tls: link.tls, + bootstrapToken: nil, + token: nil, + password: nil, + fallbackEndpoints: link.fallbackEndpoints), + gatewayID: configuration.gatewayID, + setupSentAtMs: configuration.setupSentAtMs) + guard Self.saveConfiguration(sanitized) else { + throw HTTPError(status: 0, detail: "Paired, but could not finish secure setup") + } + self.configuration = sanitized + self.endpointText = Self.endpointText(sanitized) + } + + private func handleInvoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse { + do { + switch request.command { + case OpenClawDeviceCommand.info.rawValue: + return try self.encodedResponse(id: request.id, payload: self.deviceInfo()) + case OpenClawDeviceCommand.status.rawValue: + return try self.encodedResponse(id: request.id, payload: self.deviceStatus()) + case OpenClawSystemCommand.notify.rawValue: + return try await self.handleNotification(request) + default: + return Self.errorResponse( + id: request.id, + code: .invalidRequest, + message: "INVALID_REQUEST: unsupported watchOS command") + } + } catch { + return Self.errorResponse( + id: request.id, + code: .unavailable, + message: error.localizedDescription) + } + } + + private func handleNotification(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decode(OpenClawSystemNotifyParams.self, from: request.paramsJSON) + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty || !body.isEmpty else { + return Self.errorResponse( + id: request.id, + code: .invalidRequest, + message: "INVALID_REQUEST: empty notification") + } + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { + return Self.errorResponse( + id: request.id, + code: .unavailable, + message: "NOT_AUTHORIZED: notifications") + } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + switch params.priority ?? .active { + case .passive: + content.interruptionLevel = .passive + case .timeSensitive: + content.interruptionLevel = .timeSensitive + case .active: + content.interruptionLevel = .active + } + let sound = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + content.sound = sound.map { ["none", "silent", "off"].contains($0) } == true ? nil : .default + try await center.add(UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil)) + return BridgeInvokeResponse(id: request.id, ok: true) + } + + private func deviceInfo() -> OpenClawDeviceInfoPayload { + let device = WKInterfaceDevice.current() + let info = Bundle.main.infoDictionary ?? [:] + let appVersion = (info["CFBundleShortVersionString"] as? String) ?? "0" + let appBuild = (info["CFBundleVersion"] as? String) ?? "0" + return OpenClawDeviceInfoPayload( + deviceName: device.name, + modelIdentifier: InstanceIdentity.modelIdentifier ?? "Apple Watch", + systemName: "watchOS", + systemVersion: device.systemVersion, + appVersion: appVersion, + appBuild: appBuild, + locale: Locale.preferredLanguages.first ?? Locale.current.identifier) + } + + private func deviceStatus() -> OpenClawDeviceStatusPayload { + let device = WKInterfaceDevice.current() + device.isBatteryMonitoringEnabled = true + let batteryState: OpenClawBatteryState = switch device.batteryState { + case .charging: .charging + case .full: .full + case .unplugged: .unplugged + case .unknown: .unknown + @unknown default: .unknown + } + let battery = OpenClawBatteryStatusPayload( + level: device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil, + state: batteryState, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled) + let thermalState: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState { + case .nominal: .nominal + case .fair: .fair + case .serious: .serious + case .critical: .critical + @unknown default: .nominal + } + let attributes = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:] + let total = (attributes[.systemSize] as? NSNumber)?.int64Value ?? 0 + let free = (attributes[.systemFreeSize] as? NSNumber)?.int64Value ?? 0 + let networkMetrics = self.networkMetrics.snapshot() + return OpenClawDeviceStatusPayload( + battery: battery, + thermal: OpenClawThermalStatusPayload(state: thermalState), + storage: OpenClawStorageStatusPayload( + totalBytes: total, + freeBytes: free, + usedBytes: max(0, total - free)), + network: OpenClawNetworkStatusPayload( + status: self.isConnected ? .satisfied : .requiresConnection, + isExpensive: networkMetrics?.isExpensive ?? false, + isConstrained: networkMetrics?.isConstrained ?? false, + interfaces: networkMetrics?.isCellular == true ? [.cellular] : [.other]), + uptimeSeconds: ProcessInfo.processInfo.systemUptime) + } + + private func encodedResponse(id: String, payload: some Encodable) throws -> BridgeInvokeResponse { + let data = try JSONEncoder().encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + return BridgeInvokeResponse(id: id, ok: true, payloadJSON: json) + } + + private static func decode(_ type: T.Type, from json: String?) throws -> T { + try JSONDecoder().decode(type, from: Data((json ?? "{}").utf8)) + } + + private static func errorResponse( + id: String, + code: OpenClawNodeErrorCode, + message: String) -> BridgeInvokeResponse + { + BridgeInvokeResponse( + id: id, + ok: false, + error: OpenClawNodeError(code: code, message: message)) + } + + private static func httpBaseURL(for link: GatewayConnectDeepLink) -> URL? { + guard link.tls else { return nil } + var components = URLComponents() + components.scheme = "https" + components.host = link.host + components.port = link.port + return components.url + } + + private static func gatewayID(for link: GatewayConnectDeepLink) -> String { + "watch-direct:\(link.tls ? "https" : "http")://\(link.host.lowercased()):\(link.port)" + } + + private static func endpointText(_ configuration: PersistedConfiguration) -> String { + "\(configuration.link.tls ? "https" : "http")://\(configuration.link.host):\(configuration.link.port)" + } + + private static func loadConfiguration() -> PersistedConfiguration? { + guard let raw = GenericPasswordKeychainStore.loadString( + service: keychainService, + account: keychainAccount), + let data = raw.data(using: .utf8) + else { return nil } + return try? JSONDecoder().decode(PersistedConfiguration.self, from: data) + } + + private static func saveConfiguration(_ configuration: PersistedConfiguration) -> Bool { + guard let data = try? JSONEncoder().encode(configuration), + let raw = String(data: data, encoding: .utf8) + else { return false } + return GenericPasswordKeychainStore.saveString( + raw, + service: self.keychainService, + account: self.keychainAccount) + } + + private func disconnectActiveSession() { + self.isConnected = false + guard let session = activeSession else { return } + self.activeSession = nil + self.sendDisconnect(session) + } + + private func releaseActiveSession(_ session: ActiveSession) { + guard self.activeSession == session else { return } + self.activeSession = nil + self.sendDisconnect(session) + } + + private func sendDisconnect(_ session: ActiveSession) { + Task { [weak self] in + _ = try? await self?.request( + baseURL: session.baseURL, + path: "disconnect", + method: "POST", + token: session.token) + } + } + + private static func lastAcceptedSetupSentAtMs() -> Int64 { + (UserDefaults.standard.object(forKey: self.lastSetupSentAtDefaultsKey) as? NSNumber)?.int64Value ?? 0 + } + + private static func saveLastAcceptedSetupSentAtMs(_ sentAtMs: Int64) { + UserDefaults.standard.set(NSNumber(value: sentAtMs), forKey: self.lastSetupSentAtDefaultsKey) + } +} + +private struct WatchNetworkMetricsSnapshot { + let isCellular: Bool + let isExpensive: Bool + let isConstrained: Bool +} + +private final class WatchURLSessionMetrics: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let lock = NSLock() + private var latest: WatchNetworkMetricsSnapshot? + + func snapshot() -> WatchNetworkMetricsSnapshot? { + self.lock.lock() + defer { lock.unlock() } + return self.latest + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics) + { + guard let transaction = metrics.transactionMetrics.last else { return } + let snapshot = WatchNetworkMetricsSnapshot( + isCellular: transaction.isCellular, + isExpensive: transaction.isExpensive, + isConstrained: transaction.isConstrained) + self.lock.lock() + self.latest = snapshot + self.lock.unlock() + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest _: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + completionHandler(nil) + } +} diff --git a/apps/ios/WatchApp/Sources/WatchInboxStore.swift b/apps/ios/WatchApp/Sources/WatchInboxStore.swift index a07989b4b8d..3a569713db4 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxStore.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxStore.swift @@ -5,6 +5,7 @@ import WatchKit enum WatchPayloadType: String, Codable, Equatable { case notify = "watch.notify" + case directNodeSetup = "watch.node.setup" case reply = "watch.reply" case appSnapshot = "watch.app.snapshot" case appSnapshotRequest = "watch.app.snapshotRequest" @@ -45,14 +46,14 @@ struct WatchExecApprovalItem: Codable, Equatable, Identifiable { var host: String? var nodeId: String? var agentId: String? - var expiresAtMs: Int? + var expiresAtMs: Int64? var allowedDecisions: [WatchExecApprovalDecision] var risk: WatchRiskLevel? } struct WatchExecApprovalPromptMessage: Codable, Equatable { var approval: WatchExecApprovalItem - var sentAtMs: Int? + var sentAtMs: Int64? var deliveryId: String? var resetResolvingState: Bool? } @@ -61,7 +62,7 @@ struct WatchExecApprovalResolvedMessage: Codable, Equatable { var approvalId: String var gatewayStableID: String? var decision: WatchExecApprovalDecision? - var resolvedAtMs: Int? + var resolvedAtMs: Int64? var source: String? } @@ -69,19 +70,19 @@ struct WatchExecApprovalExpiredMessage: Codable, Equatable { var approvalId: String var gatewayStableID: String? var reason: WatchExecApprovalCloseReason - var expiredAtMs: Int? + var expiredAtMs: Int64? } struct WatchExecApprovalSnapshotMessage: Codable, Equatable { var approvals: [WatchExecApprovalItem] var gatewayStableID: String? - var sentAtMs: Int? + var sentAtMs: Int64? var snapshotId: String? } struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable { var requestId: String - var sentAtMs: Int? + var sentAtMs: Int64? } struct WatchExecApprovalResolveMessage: Codable, Equatable { @@ -89,7 +90,7 @@ struct WatchExecApprovalResolveMessage: Codable, Equatable { var gatewayStableID: String? var decision: WatchExecApprovalDecision var replyId: String - var sentAtMs: Int? + var sentAtMs: Int64? } struct WatchAppSnapshotMessage: Codable, Equatable { @@ -107,26 +108,26 @@ struct WatchAppSnapshotMessage: Codable, Equatable { var pendingApprovalCount: Int var chatItems: [WatchChatItem]? var chatStatusText: String? - var sentAtMs: Int? + var sentAtMs: Int64? var snapshotId: String? } struct WatchChatCompletionMessage: Codable, Equatable { var commandId: String var replyText: String - var sentAtMs: Int? + var sentAtMs: Int64? } struct WatchChatItem: Codable, Equatable, Identifiable { var id: String var role: String var text: String - var timestampMs: Int? + var timestampMs: Int64? } struct WatchAppSnapshotRequestMessage: Codable, Equatable { var requestId: String - var sentAtMs: Int? + var sentAtMs: Int64? } enum WatchAppCommand: String, Codable, Equatable { @@ -143,7 +144,7 @@ struct WatchAppCommandMessage: Codable, Equatable { var sessionKey: String? var gatewayStableID: String? var text: String? - var sentAtMs: Int? + var sentAtMs: Int64? } struct WatchPromptAction: Codable, Equatable, Identifiable { @@ -156,13 +157,13 @@ struct WatchNotifyMessage: Codable { var id: String? var title: String var body: String - var sentAtMs: Int? + var sentAtMs: Int64? var promptId: String? var sessionKey: String? var gatewayStableID: String? var kind: String? var details: String? - var expiresAtMs: Int? + var expiresAtMs: Int64? var risk: String? var actions: [WatchPromptAction] } @@ -208,7 +209,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } } - var sentAtMs: Int? { + var sentAtMs: Int64? { switch self { case let .notification(message, _): message.sentAtMs @@ -223,7 +224,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } } - var expiresAtMs: Int? { + var expiresAtMs: Int64? { switch self { case let .notification(message, _): message.expiresAtMs @@ -260,7 +261,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var gatewayStableID: String? var kind: String? var details: String? - var expiresAtMs: Int? + var expiresAtMs: Int64? var risk: String? var actions: [WatchPromptAction]? var replyStatusText: String? @@ -269,7 +270,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var selectedExecApprovalID: String? var lastExecApprovalSnapshotID: String? var lastExecApprovalSnapshotGatewayStableID: String? - var lastExecApprovalSnapshotSentAtMs: Int? + var lastExecApprovalSnapshotSentAtMs: Int64? var lastExecApprovalOutcomeText: String? var lastExecApprovalOutcomeAt: Date? var appSnapshot: WatchAppSnapshotMessage? @@ -294,7 +295,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var gatewayStableID: String? var kind: String? var details: String? - var expiresAtMs: Int? + var expiresAtMs: Int64? var risk: String? var actions: [WatchPromptAction] = [] var replyStatusText: String? @@ -315,7 +316,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var execApprovalReviewStatusAt: Date? private var lastExecApprovalSnapshotID: String? private var lastExecApprovalSnapshotGatewayStableID: String? - private var lastExecApprovalSnapshotSentAtMs: Int? + private var lastExecApprovalSnapshotSentAtMs: Int64? private var hasCompletedExecApprovalSnapshotRefreshInSession = false private var lastDeliveryKey: String? /// WatchConnectivity does not order application-context updates against user-info @@ -339,8 +340,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var sortedExecApprovals: [WatchExecApprovalRecord] { self.execApprovals.sorted { lhs, rhs in - let lhsExpires = lhs.approval.expiresAtMs ?? Int.max - let rhsExpires = rhs.approval.expiresAtMs ?? Int.max + let lhsExpires = lhs.approval.expiresAtMs ?? Int64.max + let rhsExpires = rhs.approval.expiresAtMs ?? Int64.max if lhsExpires != rhsExpires { return lhsExpires < rhsExpires } @@ -1003,7 +1004,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)" } - private func pruneExpiredExecApprovals(nowMs: Int) { + private func pruneExpiredExecApprovals(nowMs: Int64) { let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil } return record.approval @@ -1137,7 +1138,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { self.defaults.set(data, forKey: Self.persistedStateKey) } - private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String { + private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int64?) -> String { if let messageID, messageID.isEmpty == false { return "id:\(messageID)" } @@ -1253,7 +1254,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } } - private static func nowMs() -> Int { - Int(Date().timeIntervalSince1970 * 1000) + private static func nowMs() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) } } diff --git a/apps/ios/WatchApp/Sources/WatchInboxView.swift b/apps/ios/WatchApp/Sources/WatchInboxView.swift index 148d67d4fff..fe0a5e95b41 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxView.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxView.swift @@ -3,6 +3,7 @@ import WatchKit struct WatchInboxView: View { var store: WatchInboxStore + var directNode: WatchDirectNode var onAction: ((WatchPromptAction) -> Void)? var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)? var onRefreshExecApprovalReview: (() -> Void)? @@ -14,6 +15,7 @@ struct WatchInboxView: View { NavigationStack { WatchControlSurfaceView( store: self.store, + directNode: self.directNode, onAction: self.onAction, onExecApprovalDecision: self.onExecApprovalDecision, onRefreshExecApprovalReview: self.onRefreshExecApprovalReview, @@ -27,6 +29,7 @@ struct WatchInboxView: View { private struct WatchControlSurfaceView: View { var store: WatchInboxStore + var directNode: WatchDirectNode var onAction: ((WatchPromptAction) -> Void)? var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)? var onRefreshExecApprovalReview: (() -> Void)? @@ -43,6 +46,8 @@ private struct WatchControlSurfaceView: View { .tag(1) self.approvalsFace .tag(2) + self.connectionFace + .tag(3) } .tabViewStyle(.page(indexDisplayMode: .never)) .background(WatchClawStyle.background.ignoresSafeArea()) @@ -50,7 +55,7 @@ private struct WatchControlSurfaceView: View { } private var faceCount: Int { - 3 + 4 } private var pageRail: some View { @@ -295,6 +300,49 @@ private struct WatchControlSurfaceView: View { } } + private var connectionFace: some View { + WatchFaceScroll { + self.pageRail + WatchFaceHeader( + section: "Connection", + title: self.directNode.isConnected ? "Watch node online" : "Direct Gateway", + subtitle: self.directNode.statusText, + isOnline: self.directNode.isConnected, + avatarImageSource: self.avatarImageSource, + avatarText: self.avatarText) + + WatchHeroCard( + label: self.directNode.isConnected ? "Direct" : "Setup", + title: self.directNode.endpointText ?? "Enable from iPhone", + subtitle: self.directNode.isConfigured + ? "Uses Wi-Fi or cellular while OpenClaw is active" + : "Open iPhone Settings → Apple Watch", + accessory: self.directNode.isConnected ? "Online" : "Offline") + + WatchDetailText( + text: "Direct mode supports device info, status, and notifications. Chat, Talk, and approvals still use the iPhone.") + + if self.directNode.isConfigured { + Toggle(isOn: Binding( + get: { self.directNode.isEnabled }, + set: { self.directNode.setEnabled($0) })) + { + Text("Direct connection") + .font(WatchClawType.body(size: 13)) + } + .tint(WatchClawStyle.accent) + .padding(.horizontal, 8) + + WatchSecondaryButton(title: "Forget direct setup") { + self.directNode.forget() + } + } else { + WatchDetailText( + text: "The iPhone securely sends a one-time setup code. Existing relay features stay available.") + } + } + } + private var chatItems: [WatchChatItem] { self.store.appSnapshot?.chatItems ?? [] } @@ -497,9 +545,9 @@ private struct WatchControlSurfaceView: View { } } - private func expiryText(_ expiresAtMs: Int?) -> String? { + private func expiryText(_ expiresAtMs: Int64?) -> String? { guard let expiresAtMs else { return nil } - let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000) + let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000) if deltaSeconds < 60 { return "<1m" } @@ -1346,9 +1394,9 @@ private struct WatchExecApprovalListView: View { return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ") } - private static func expiresText(_ expiresAtMs: Int?) -> String? { + private static func expiresText(_ expiresAtMs: Int64?) -> String? { guard let expiresAtMs else { return nil } - let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000) + let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000) if deltaSeconds < 60 { return "Expires in <1m" } @@ -1437,9 +1485,9 @@ private struct WatchExecApprovalDetailView: View { } } - private static func expiresText(_ expiresAtMs: Int?) -> String? { + private static func expiresText(_ expiresAtMs: Int64?) -> String? { guard let expiresAtMs else { return nil } - let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000) + let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000) if deltaSeconds < 60 { return "<1 minute" } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index a9308653f8f..ee4870417ea 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -318,6 +318,7 @@ targets: - path: Resources/Localizable.xcstrings buildPhase: resources dependencies: + - package: OpenClawKit - sdk: AppIntents.framework - sdk: AVFAudio.framework - sdk: WatchConnectivity.framework @@ -356,6 +357,8 @@ targets: CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)" WKCompanionAppBundleIdentifier: "$(OPENCLAW_COMPANION_APP_BUNDLE_ID)" WKApplication: true + WKPrefersNetworkUponForeground: true + NSLocalNetworkUsageDescription: OpenClaw connects this Apple Watch directly to your Gateway on trusted local networks. UIAppFonts: - RedHatDisplay[wght].ttf - Inter[opsz,wght].ttf diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index dd5653259c8..6ebd6df35ae 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -273,7 +273,7 @@ actor GatewayWizardClient { } let connectNonce = try await self.waitForConnectChallenge() let identity = DeviceIdentityStore.loadOrCreate() - let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) + let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000) let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload( deviceId: identity.deviceId, clientId: clientId, diff --git a/apps/shared/OpenClawKit/Package.swift b/apps/shared/OpenClawKit/Package.swift index 43e4b1cdec1..ec4bb6018cb 100644 --- a/apps/shared/OpenClawKit/Package.swift +++ b/apps/shared/OpenClawKit/Package.swift @@ -7,6 +7,7 @@ let package = Package( platforms: [ .iOS(.v18), .macOS(.v15), + .watchOS(.v11), ], products: [ .library(name: "OpenClawProtocol", targets: ["OpenClawProtocol"]), @@ -33,7 +34,10 @@ let package = Package( name: "OpenClawKit", dependencies: [ "OpenClawProtocol", - .product(name: "ElevenLabsKit", package: "ElevenLabsKit", condition: .when(traits: ["Talk"])), + .product( + name: "ElevenLabsKit", + package: "ElevenLabsKit", + condition: .when(platforms: [.iOS, .macOS], traits: ["Talk"])), ], path: "Sources/OpenClawKit", resources: [ diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift index e5ef8f83ad1..c6f32bcc2ca 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift @@ -1,4 +1,4 @@ -#if Talk +#if Talk && canImport(ElevenLabsKit) import Foundation @MainActor diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift index 604b21ae47f..c9988b987b2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift @@ -1,5 +1,6 @@ import Foundation +#if !os(watchOS) public enum BonjourServiceResolverSupport { public static func start(_ service: NetService, timeout: TimeInterval = 2.0) { service.schedule(in: .main, forMode: .common) @@ -12,3 +13,4 @@ public enum BonjourServiceResolverSupport { return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed } } +#endif diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift index c7c1182eca3..cfb1bca6b47 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift @@ -1,5 +1,6 @@ import AVFoundation +#if !os(watchOS) public enum CameraAuthorization { public static func isAuthorized(for mediaType: AVMediaType) async -> Bool { let status = AVCaptureDevice.authorizationStatus(for: mediaType) @@ -19,3 +20,4 @@ public enum CameraAuthorization { } } } +#endif diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift index 075761a76b3..434ee30ccf4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift @@ -1,6 +1,7 @@ import AVFoundation import Foundation +#if !os(watchOS) public enum CameraCapturePipelineSupport { public static func preparePhotoSession( preferFrontCamera: Bool, @@ -149,3 +150,4 @@ public enum CameraCapturePipelineSupport { } } } +#endif diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift index 748315ebc02..eb579683238 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift @@ -1,6 +1,7 @@ import AVFoundation import CoreMedia +#if !os(watchOS) public enum CameraSessionConfigurationError: LocalizedError { case addCameraInputFailed case addPhotoOutputFailed @@ -68,3 +69,4 @@ public enum CameraSessionConfiguration { return output } } +#endif diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift index 34da8d8f7f8..c03c9386fe4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift @@ -8,7 +8,7 @@ public enum GatewayDeviceAuthPayload { clientMode: String, role: String, scopes: [String], - signedAtMs: Int, + signedAtMs: Int64, token: String?, nonce: String) -> String { @@ -36,7 +36,7 @@ public enum GatewayDeviceAuthPayload { clientMode: String, role: String, scopes: [String], - signedAtMs: Int, + signedAtMs: Int64, token: String?, nonce: String, platform: String?, @@ -85,7 +85,7 @@ public enum GatewayDeviceAuthPayload { public static func signedDeviceDictionary( payload: String, identity: DeviceIdentity, - signedAtMs: Int, + signedAtMs: Int64, nonce: String) -> [String: OpenClawProtocol.AnyCodable]? { guard let signature = DeviceIdentityStore.signPayload(payload, identity: identity), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift index 653822a293b..b1ee7aa7c99 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift @@ -4,10 +4,10 @@ public struct DeviceAuthEntry: Codable, Sendable { public let token: String public let role: String public let scopes: [String] - public let updatedAtMs: Int + public let updatedAtMs: Int64 public let gatewayID: String? - public init(token: String, role: String, scopes: [String], updatedAtMs: Int, gatewayID: String? = nil) { + public init(token: String, role: String, scopes: [String], updatedAtMs: Int64, gatewayID: String? = nil) { self.token = token self.role = role self.scopes = scopes @@ -50,6 +50,25 @@ public enum DeviceAuthStore { profile: profile).entry } + /// Stores a token and reports whether the durable write succeeded. + @discardableResult + public static func storeTokenPersisted( + deviceId: String, + role: String, + token: String, + scopes: [String] = [], + gatewayID: String? = nil, + profile: GatewayDeviceIdentityProfile = .primary) -> Bool + { + self.storeTokenResult( + deviceId: deviceId, + role: role, + token: token, + scopes: scopes, + gatewayID: gatewayID, + profile: profile).persisted + } + static func storeTokenResult( deviceId: String, role: String, @@ -67,7 +86,7 @@ public enum DeviceAuthStore { token: token, role: normalizedRole, scopes: normalizeScopes(scopes), - updatedAtMs: Int(Date().timeIntervalSince1970 * 1000), + updatedAtMs: Int64(Date().timeIntervalSince1970 * 1000), gatewayID: self.normalizeGatewayID(gatewayID)) if next == nil { next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift index f9e20bc80e6..eed07d93ad1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift @@ -36,9 +36,9 @@ public struct DeviceIdentity: Codable, Sendable { public var deviceId: String public var publicKey: String public var privateKey: String - public var createdAtMs: Int + public var createdAtMs: Int64 - public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) { + public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int64) { self.deviceId = deviceId self.publicKey = publicKey self.privateKey = privateKey @@ -196,6 +196,15 @@ public enum DeviceIdentityStore { migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile)) } + /// Loads or creates an identity, returning nil unless its key material was durably persisted. + public static func loadOrCreatePersisted( + profile: GatewayDeviceIdentityProfile = .primary) -> DeviceIdentity? + { + self.loadOrCreatePersisted( + fileURL: self.fileURL(profile: profile), + migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile)) + } + static func loadOrCreate( fileURL url: URL, migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity @@ -221,6 +230,22 @@ public enum DeviceIdentityStore { return identity } + static func loadOrCreatePersisted( + fileURL url: URL, + migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity? + { + let identity = self.loadOrCreate(fileURL: url, migrationSource: migrationSource) + guard let data = try? Data(contentsOf: url), + case let .identity(stored) = self.decodeStoredIdentity(data), + stored.deviceId == identity.deviceId, + stored.publicKey == identity.publicKey, + stored.privateKey == identity.privateKey + else { + return nil + } + return stored + } + /// One-time upgrade path for builds that lost App Group storage: it runs only while the /// selected store has no identity file, so steady state never re-reads the old container. private static func migratedIdentity( @@ -314,7 +339,7 @@ public enum DeviceIdentityStore { deviceId: deviceId, publicKey: publicKeyData.base64EncodedString(), privateKey: privateKeyData.base64EncodedString(), - createdAtMs: Int(Date().timeIntervalSince1970 * 1000)) + createdAtMs: Int64(Date().timeIntervalSince1970 * 1000)) } private static func base64UrlEncode(_ data: Data) -> String { @@ -417,5 +442,5 @@ private struct PemDeviceIdentity: Codable { var deviceId: String var publicKeyPem: String var privateKeyPem: String - var createdAtMs: Int + var createdAtMs: Int64 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift index e9cd76fcb46..1bfaa02abf0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift @@ -1,4 +1,4 @@ -#if Talk +#if Talk && canImport(ElevenLabsKit) @_exported import ElevenLabsKit public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 73448871068..e6554baeef1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -564,7 +564,7 @@ public actor GatewayChannelActor { } else if let password = selectedAuth.authPassword { params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } - let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) + let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000) let connectNonce = try await self.waitForConnectChallenge() if includeDeviceIdentity, let identity { let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift index 8e04c6ac1b7..a7ab072c9c5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift @@ -1,7 +1,9 @@ import Foundation -#if canImport(UIKit) +#if os(iOS) import UIKit +#elseif os(watchOS) +import WatchKit #endif public enum InstanceIdentity { @@ -12,7 +14,7 @@ public enum InstanceIdentity { UserDefaults(suiteName: suiteName) ?? .standard } - #if canImport(UIKit) + #if os(iOS) || os(watchOS) private static func readMainActor(_ body: @MainActor () -> T) -> T { if Thread.isMainThread { return MainActor.assumeIsolated { body() } @@ -38,11 +40,16 @@ public enum InstanceIdentity { }() public static let displayName: String = { - #if canImport(UIKit) + #if os(iOS) let name = Self.readMainActor { UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) } return name.isEmpty ? "openclaw" : name + #elseif os(watchOS) + let name = Self.readMainActor { + WKInterfaceDevice.current().name.trimmingCharacters(in: .whitespacesAndNewlines) + } + return name.isEmpty ? "Apple Watch" : name #else if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty @@ -54,7 +61,7 @@ public enum InstanceIdentity { }() public static let modelIdentifier: String? = { - #if canImport(UIKit) + #if os(iOS) || os(watchOS) var systemInfo = utsname() uname(&systemInfo) let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in @@ -77,7 +84,7 @@ public enum InstanceIdentity { }() public static let deviceFamily: String = { - #if canImport(UIKit) + #if os(iOS) return Self.readMainActor { switch UIDevice.current.userInterfaceIdiom { case .pad: "iPad" @@ -85,6 +92,8 @@ public enum InstanceIdentity { default: "iOS" } } + #elseif os(watchOS) + return "Apple Watch" #else return "Mac" #endif @@ -92,7 +101,7 @@ public enum InstanceIdentity { public static let platformString: String = { let v = ProcessInfo.processInfo.operatingSystemVersion - #if canImport(UIKit) + #if os(iOS) let name = Self.readMainActor { switch UIDevice.current.userInterfaceIdiom { case .pad: "iPadOS" @@ -101,6 +110,8 @@ public enum InstanceIdentity { } } return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + #elseif os(watchOS) + return "watchOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" #else return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" #endif diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift index 09fc76a02c4..7a561771a0c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift @@ -7,6 +7,7 @@ public enum OpenClawWatchCommand: String, Codable, Sendable { public enum OpenClawWatchPayloadType: String, Codable, Sendable, Equatable { case notify = "watch.notify" + case directNodeSetup = "watch.node.setup" case reply = "watch.reply" case appSnapshot = "watch.app.snapshot" case appSnapshotRequest = "watch.app.snapshotRequest" @@ -59,7 +60,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident public var host: String? public var nodeId: String? public var agentId: String? - public var expiresAtMs: Int? + public var expiresAtMs: Int64? public var allowedDecisions: [OpenClawWatchExecApprovalDecision] public var risk: OpenClawWatchRisk? @@ -71,7 +72,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident host: String? = nil, nodeId: String? = nil, agentId: String? = nil, - expiresAtMs: Int? = nil, + expiresAtMs: Int64? = nil, allowedDecisions: [OpenClawWatchExecApprovalDecision] = [], risk: OpenClawWatchRisk? = nil) { @@ -91,13 +92,13 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatable { public var type: OpenClawWatchPayloadType public var approval: OpenClawWatchExecApprovalItem - public var sentAtMs: Int? + public var sentAtMs: Int64? public var deliveryId: String? public var resetResolvingState: Bool? public init( approval: OpenClawWatchExecApprovalItem, - sentAtMs: Int? = nil, + sentAtMs: Int64? = nil, deliveryId: String? = nil, resetResolvingState: Bool? = nil) { @@ -115,14 +116,14 @@ public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equata public var gatewayStableID: String? public var decision: OpenClawWatchExecApprovalDecision public var replyId: String - public var sentAtMs: Int? + public var sentAtMs: Int64? public init( approvalId: String, gatewayStableID: String? = nil, decision: OpenClawWatchExecApprovalDecision, replyId: String, - sentAtMs: Int? = nil) + sentAtMs: Int64? = nil) { self.type = .execApprovalResolve self.approvalId = approvalId @@ -138,14 +139,14 @@ public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equat public var approvalId: String public var gatewayStableID: String? public var decision: OpenClawWatchExecApprovalDecision? - public var resolvedAtMs: Int? + public var resolvedAtMs: Int64? public var source: String? public init( approvalId: String, gatewayStableID: String? = nil, decision: OpenClawWatchExecApprovalDecision? = nil, - resolvedAtMs: Int? = nil, + resolvedAtMs: Int64? = nil, source: String? = nil) { self.type = .execApprovalResolved @@ -162,13 +163,13 @@ public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equata public var approvalId: String public var gatewayStableID: String? public var reason: OpenClawWatchExecApprovalCloseReason - public var expiredAtMs: Int? + public var expiredAtMs: Int64? public init( approvalId: String, gatewayStableID: String? = nil, reason: OpenClawWatchExecApprovalCloseReason, - expiredAtMs: Int? = nil) + expiredAtMs: Int64? = nil) { self.type = .execApprovalExpired self.approvalId = approvalId @@ -182,13 +183,13 @@ public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equat public var type: OpenClawWatchPayloadType public var approvals: [OpenClawWatchExecApprovalItem] public var gatewayStableID: String? - public var sentAtMs: Int? + public var sentAtMs: Int64? public var snapshotId: String? public init( approvals: [OpenClawWatchExecApprovalItem], gatewayStableID: String? = nil, - sentAtMs: Int? = nil, + sentAtMs: Int64? = nil, snapshotId: String? = nil) { self.type = .execApprovalSnapshot @@ -202,9 +203,9 @@ public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equat public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable { public var type: OpenClawWatchPayloadType public var requestId: String - public var sentAtMs: Int? + public var sentAtMs: Int64? - public init(requestId: String, sentAtMs: Int? = nil) { + public init(requestId: String, sentAtMs: Int64? = nil) { self.type = .execApprovalSnapshotRequest self.requestId = requestId self.sentAtMs = sentAtMs @@ -215,13 +216,13 @@ public struct OpenClawWatchChatItem: Codable, Sendable, Equatable, Identifiable public var id: String public var role: String public var text: String - public var timestampMs: Int? + public var timestampMs: Int64? public init( id: String, role: String, text: String, - timestampMs: Int? = nil) + timestampMs: Int64? = nil) { self.id = id self.role = role @@ -234,9 +235,9 @@ public struct OpenClawWatchChatCompletionMessage: Codable, Sendable, Equatable { public var type: OpenClawWatchPayloadType public var commandId: String public var replyText: String - public var sentAtMs: Int? + public var sentAtMs: Int64? - public init(commandId: String, replyText: String, sentAtMs: Int? = nil) { + public init(commandId: String, replyText: String, sentAtMs: Int64? = nil) { self.type = .chatCompletion self.commandId = commandId self.replyText = replyText @@ -260,7 +261,7 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable { public var pendingApprovalCount: Int public var chatItems: [OpenClawWatchChatItem]? public var chatStatusText: String? - public var sentAtMs: Int? + public var sentAtMs: Int64? public var snapshotId: String? public init( @@ -278,7 +279,7 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable { pendingApprovalCount: Int, chatItems: [OpenClawWatchChatItem]? = nil, chatStatusText: String? = nil, - sentAtMs: Int? = nil, + sentAtMs: Int64? = nil, snapshotId: String? = nil) { self.type = .appSnapshot @@ -304,9 +305,9 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable { public struct OpenClawWatchAppSnapshotRequestMessage: Codable, Sendable, Equatable { public var type: OpenClawWatchPayloadType public var requestId: String - public var sentAtMs: Int? + public var sentAtMs: Int64? - public init(requestId: String, sentAtMs: Int? = nil) { + public init(requestId: String, sentAtMs: Int64? = nil) { self.type = .appSnapshotRequest self.requestId = requestId self.sentAtMs = sentAtMs @@ -328,7 +329,7 @@ public struct OpenClawWatchAppCommandMessage: Codable, Sendable, Equatable { public var sessionKey: String? public var gatewayStableID: String? public var text: String? - public var sentAtMs: Int? + public var sentAtMs: Int64? public init( command: OpenClawWatchAppCommand, @@ -336,7 +337,7 @@ public struct OpenClawWatchAppCommandMessage: Codable, Sendable, Equatable { sessionKey: String? = nil, gatewayStableID: String? = nil, text: String? = nil, - sentAtMs: Int? = nil) + sentAtMs: Int64? = nil) { self.type = .appCommand self.command = command @@ -379,7 +380,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { public var gatewayStableID: String? public var kind: String? public var details: String? - public var expiresAtMs: Int? + public var expiresAtMs: Int64? public var risk: OpenClawWatchRisk? public var actions: [OpenClawWatchAction]? @@ -392,7 +393,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { gatewayStableID: String? = nil, kind: String? = nil, details: String? = nil, - expiresAtMs: Int? = nil, + expiresAtMs: Int64? = nil, risk: OpenClawWatchRisk? = nil, actions: [OpenClawWatchAction]? = nil) { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift index 2a9b37cb9c7..ca95ad43f4f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift @@ -1,4 +1,5 @@ import Foundation +#if canImport(WebKit) import WebKit public enum WebViewJavaScriptSupport { @@ -55,3 +56,4 @@ public enum WebViewJavaScriptSupport { return "null" } } +#endif diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift index 90ef6574451..2f3a46e11a3 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift @@ -14,6 +14,7 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { let container = try decoder.singleValueContainer() if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } if let intVal = try? container.decode(Int.self) { self.value = intVal; return } + if let int64Val = try? container.decode(Int64.self) { self.value = int64Val; return } if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return } if let stringVal = try? container.decode(String.self) { self.value = stringVal; return } if container.decodeNil() { self.value = NSNull(); return } @@ -27,6 +28,7 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { switch self.value { case let boolVal as Bool: try container.encode(boolVal) case let intVal as Int: try container.encode(intVal) + case let int64Val as Int64: try container.encode(int64Val) case let doubleVal as Double: try container.encode(doubleVal) case let stringVal as String: try container.encode(stringVal) case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID(): @@ -66,6 +68,9 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { switch (lhs.value, rhs.value) { case let (l as Bool, r as Bool): l == r case let (l as Int, r as Int): l == r + case let (l as Int64, r as Int64): l == r + case let (l as Int, r as Int64): Int64(l) == r + case let (l as Int64, r as Int): l == Int64(r) case let (l as Double, r as Double): l == r case let (l as String, r as String): l == r case (_ as NSNull, _ as NSNull): true @@ -81,6 +86,8 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { case let v as Bool: hasher.combine(2); hasher.combine(v) case let v as Int: + hasher.combine(0); hasher.combine(Int64(v)) + case let v as Int64: hasher.combine(0); hasher.combine(v) case let v as Double: hasher.combine(1); hasher.combine(v) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 21356b0b516..ef46dbd7be8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -8679,21 +8679,25 @@ public struct DevicePairSetupCodeParams: Codable, Sendable { public let publicurl: String? public let preferremoteurl: Bool? public let includeqr: Bool? + public let bootstrapprofile: String? public init( publicurl: String?, preferremoteurl: Bool?, - includeqr: Bool?) + includeqr: Bool?, + bootstrapprofile: String?) { self.publicurl = publicurl self.preferremoteurl = preferremoteurl self.includeqr = includeqr + self.bootstrapprofile = bootstrapprofile } private enum CodingKeys: String, CodingKey { case publicurl = "publicUrl" case preferremoteurl = "preferRemoteUrl" case includeqr = "includeQr" + case bootstrapprofile = "bootstrapProfile" } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift index 3835f1186c0..b3c97fa9342 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift @@ -3,6 +3,22 @@ import Testing import OpenClawProtocol struct AnyCodableTests { + @Test + func roundTripsEpochMillisecondsBeyondInt32() throws { + let epochMilliseconds: Int64 = 1_800_000_000_000 + let original = AnyCodable(epochMilliseconds) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + let decodedMilliseconds = (decoded.value as? Int64) + ?? (decoded.value as? Int).map(Int64.init) + + #expect(String(decoding: data, as: UTF8.self) == "1800000000000") + #expect(decodedMilliseconds == epochMilliseconds) + #expect(decoded == original) + #expect(Set([decoded, original]).count == 1) + } + @Test func encodesNSNumberBooleansAsJSONBooleans() throws { let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true))) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift index fa3a2c1178c..7abe21e7a3b 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift @@ -1,3 +1,5 @@ +import CryptoKit +import Foundation import Testing @testable import OpenClawKit @@ -5,29 +7,31 @@ import Testing struct DeviceAuthPayloadTests { @Test func `builds Swift connect compatibility payload with v2 canonical fields`() { + let signedAtMs: Int64 = 1_800_000_000_000 let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload( deviceId: "dev-1", clientId: "openclaw-macos", clientMode: "ui", role: "operator", scopes: ["operator.admin", "operator.read"], - signedAtMs: 1_700_000_000_000, + signedAtMs: signedAtMs, token: "tok-123", nonce: "nonce-abc") #expect( payload - == "v2|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc") + == "v2|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1800000000000|tok-123|nonce-abc") } @Test func `builds canonical v3 payload vector`() { + let signedAtMs: Int64 = 1_800_000_000_000 let payload = GatewayDeviceAuthPayload.buildV3( deviceId: "dev-1", clientId: "openclaw-macos", clientMode: "ui", role: "operator", scopes: ["operator.admin", "operator.read"], - signedAtMs: 1_700_000_000_000, + signedAtMs: signedAtMs, token: "tok-123", nonce: "nonce-abc", platform: " IOS ", @@ -35,7 +39,47 @@ struct DeviceAuthPayloadTests { #expect( payload == - "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone") + "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1800000000000|tok-123|nonce-abc|ios|iphone") + } + + @Test + func `signed device dictionary preserves 64-bit timestamp`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let identityURL = tempDir.appendingPathComponent("device.json", isDirectory: false) + defer { try? FileManager.default.removeItem(at: tempDir) } + let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL) + let signedAtMs: Int64 = 1_800_000_000_000 + let payload = GatewayDeviceAuthPayload.buildV3( + deviceId: identity.deviceId, + clientId: "openclaw-watchos", + clientMode: "node", + role: "node", + scopes: [], + signedAtMs: signedAtMs, + token: "device-token", + nonce: "nonce-abc", + platform: "watchOS", + deviceFamily: "Apple Watch") + + let device = try #require(GatewayDeviceAuthPayload.signedDeviceDictionary( + payload: payload, + identity: identity, + signedAtMs: signedAtMs, + nonce: "nonce-abc")) + let signature = try #require(device["signature"]?.value as? String) + let signatureBase64 = signature + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let signaturePadding = String(repeating: "=", count: (4 - signatureBase64.count % 4) % 4) + let signatureData = try #require(Data(base64Encoded: signatureBase64 + signaturePadding)) + let publicKeyData = try #require(Data(base64Encoded: identity.publicKey)) + let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) + let data = try JSONEncoder().encode(device) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(publicKey.isValidSignature(signatureData, for: Data(payload.utf8))) + #expect((object["signedAt"] as? NSNumber)?.int64Value == signedAtMs) } @Test diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift index 6ccac0dd711..a880b1d7c69 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -25,12 +25,49 @@ struct DeviceIdentityStoreTests { deviceId: "unwritable-device", role: "node", token: "must-not-be-acknowledged") + let publicWritePersisted = DeviceAuthStore.storeTokenPersisted( + deviceId: "unwritable-device", + role: "node", + token: "also-must-not-be-acknowledged") + let durableIdentity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) #expect(compatibleEntry.token == "must-not-be-acknowledged") #expect(!stored.persisted) + #expect(!publicWritePersisted) + #expect(durableIdentity == nil) #expect(DeviceAuthStore.loadToken(deviceId: "unwritable-device", role: "node") == nil) } + @Test + func `device auth entry round-trips epoch milliseconds beyond Int32`() throws { + let epochMilliseconds: Int64 = 1_800_000_000_000 + let entry = DeviceAuthEntry( + token: "device-token", + role: "node", + scopes: [], + updatedAtMs: epochMilliseconds) + + let data = try JSONEncoder().encode(entry) + let decoded = try JSONDecoder().decode(DeviceAuthEntry.self, from: data) + + #expect(decoded.updatedAtMs == epochMilliseconds) + } + + @Test + func `durable identity creation verifies persisted key material`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let identityURL = tempDir.appendingPathComponent("device.json", isDirectory: false) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let identity = try #require(DeviceIdentityStore.loadOrCreatePersisted(fileURL: identityURL)) + let reloaded = DeviceIdentityStore.loadOrCreate(fileURL: identityURL) + + #expect(reloaded.deviceId == identity.deviceId) + #expect(reloaded.publicKey == identity.publicKey) + #expect(reloaded.privateKey == identity.privateKey) + } + @Test(.stateDirectoryIsolated) func `device auth tokens are isolated by gateway owner`() { let deviceID = "test-device" @@ -262,6 +299,7 @@ struct DeviceIdentityStoreTests { #expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c") #expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=") #expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=") + #expect(identity.createdAtMs == 1_800_000_000_000) #expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg") let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity)) let publicKeyData = try #require(Data(base64Encoded: identity.publicKey)) @@ -500,7 +538,7 @@ struct DeviceIdentityStoreTests { "deviceId": "stale-device-id", "publicKeyPem": publicKeyPem, "privateKeyPem": privateKeyPem, - "createdAtMs": 1_700_000_000_000, + "createdAtMs": Int64(1_800_000_000_000), ] let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) return String(decoding: data, as: UTF8.self) + "\n" diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 9885fa6bbe7..d52a469cb2c 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -1575,6 +1575,20 @@ struct GatewayNodeSessionTests { #expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token") } + @Test + func `watch invoke payload decodes without bridge frame type`() throws { + let data = Data( + #"{"id":"invoke-1","nodeId":"watch-1","command":"device.info","paramsJSON":null,"timeoutMs":2000}"# + .utf8) + let request = try JSONDecoder().decode(NodeInvokeRequestEvent.self, from: data) + + #expect(request.id == "invoke-1") + #expect(request.nodeid == "watch-1") + #expect(request.command == "device.info") + #expect(request.paramsjson == nil) + #expect(request.timeoutms == 2000) + } + @Test func `invoke with timeout returns underlying response before timeout`() async { let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index 77f4e3a6dae..0e691eb4043 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -71,7 +71,7 @@ openclaw nodes location get --node --accuracy precise openclaw nodes screen record --node --duration 10s --fps 10 --out ./clip.mp4 ``` -- `notify` sends a local notification on a node (macOS only). Requires `--title` or `--body`. Options: `--sound `, `--priority `, `--delivery ` (default `system`), `--invoke-timeout ` (default `15000`). +- `notify` sends a local notification on a node that declares `system.notify`, including macOS, iOS, Android, and direct watchOS nodes. Direct watchOS delivery requires OpenClaw to be active. Requires `--title` or `--body`. Options: `--sound `, `--priority `, `--delivery ` (default `system`), `--invoke-timeout ` (default `15000`). - `push` sends an APNs test push to an iOS node. Options: `--title ` (default `OpenClaw`), `--body `, `--environment ` to override the detected APNs environment. - `location get` fetches the node's current location. Options: `--max-age ` (reuse a cached fix), `--accuracy `, `--location-timeout ` (default `10000`), `--invoke-timeout ` (default `20000`). - `screen record` captures a short clip and prints the saved path (or writes JSON with `--json`). Options: `--screen ` (default `0`), `--duration ` (default `10000`), `--fps ` (default `10`), `--no-audio`, `--out `, `--invoke-timeout ` (default `120000`). diff --git a/docs/docs_map.md b/docs/docs_map.md index ca54ed161b3..8aa6972564e 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -4943,6 +4943,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What it does - H2: Requirements - H2: Quick start (pair + connect) + - H2: Optional direct Apple Watch node - H2: Relay-backed push for official builds - H2: Background alive beacons - H2: Authentication and trust flow diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 170935f19c6..e041ccfd72d 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -1,13 +1,13 @@ --- summary: "Nodes: pairing, capabilities, permissions, and CLI helpers for canvas/camera/screen/device/notifications/system" read_when: - - Pairing iOS/Android nodes to a gateway + - Pairing iOS/watchOS/Android nodes to a gateway - Using node canvas/camera for agent context - Adding new node commands or CLI helpers title: "Nodes" --- -A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `device.*`, `notifications.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol). +A **node** is a companion device (macOS/iOS/watchOS/Android/headless) that connects to the Gateway with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `device.*`, `notifications.*`, `system.*`) via `node.invoke`. Most nodes use the Gateway WebSocket on the operator port. The optional direct Apple Watch node uses signed HTTPS polling on that same port because watchOS blocks generic low-level networking for ordinary apps. Protocol details: [Gateway protocol](/gateway/protocol). Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; historical only for current nodes). @@ -19,7 +19,7 @@ Troubleshooting runbook: [/nodes/troubleshooting](/nodes/troubleshooting) ## Pairing + status -WS nodes use **device pairing**. A node presents a device identity during `connect`; the Gateway creates a device pairing request for `role: node`. Approve via the devices CLI (or UI). +Nodes use **device pairing**. A node presents a signed device identity during connect; the Gateway creates a device pairing request for `role: node`. Approve via the devices CLI (or UI). The direct Apple Watch setup uses an admin-minted, short-lived node-only setup code to approve its fixed low-risk command surface; later capability expansion still requires normal approval. ```bash openclaw devices list @@ -33,7 +33,7 @@ Pending pairing requests expire 5 minutes after the device's last retry — a de - `nodes status` marks a node as **paired** when its device pairing role includes `node`. - The device pairing record is the durable approved-role contract. Token rotation stays inside that contract; it cannot upgrade a paired node into a role that pairing approval never granted. -- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate, gateway-owned node pairing store that tracks the node's approved command/capability surface across reconnects. It does **not** gate the WS `connect` handshake — device pairing does that. +- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate, gateway-owned node pairing store that tracks the node's approved command/capability surface across reconnects. It does **not** gate transport authentication — device pairing does that. - `openclaw nodes remove --node ` removes a node pairing. For a device-backed node it revokes the device's `node` role in `devices/paired.json` and disconnects that device's node-role sessions: a mixed-role device keeps its row and only loses the `node` role, while a node-only device row is deleted. It also clears any matching entry from the separate node pairing store. `operator.pairing` may remove non-operator node rows on other devices; a device-token caller revoking its own node role on a mixed-role device additionally needs `operator.admin`. - Approval scope follows the pending request's declared commands: - commandless request: `operator.pairing` @@ -42,7 +42,7 @@ Pending pairing requests expire 5 minutes after the device's last retry — a de ## Version skew and upgrade order -The Gateway accepts authenticated node clients across an N-1 protocol window. +The Gateway WebSocket accepts authenticated node clients across an N-1 protocol window. The current v4 Gateway therefore accepts v3 nodes when the connection declares both `role: "node"` and `client.mode: "node"`. Operator and UI sessions must still use the current protocol. @@ -55,6 +55,9 @@ Plugin-owned capabilities and commands stay hidden until the node upgrades to the current protocol. Nodes older than N-1 require an out-of-band upgrade before reconnecting. +The direct watchOS HTTPS transport requires the current protocol version; update +the watch app with the Gateway before enabling direct mode. + ## Remote node host (system.run) Use a **node host** when your Gateway runs on one machine and you want commands to execute on another. The model still talks to the **gateway**; the gateway forwards `exec` calls to the **node host** when `host=node` is selected. @@ -203,7 +206,7 @@ openclaw nodes invoke --node --command canvas.eval --params '{"ja Node commands must pass two gates before they can be invoked: -1. The node must declare the command in its WebSocket `connect.commands` list. +1. The node must declare the command in its authenticated connect metadata (`connect.commands`). 2. The gateway's platform-and-approval-derived allowlist must include the declared command. Default allowlists by platform (before plugin defaults and `allowCommands`/`denyCommands` overrides): @@ -211,6 +214,7 @@ Default allowlists by platform (before plugin defaults and `allowCommands`/`deny | Platform | Commands allowed by default | | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | iOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` | +| watchOS | `device.info`, `device.status`, `system.notify` | | Android | `camera.list`, `location.get`, `notifications.list`, `notifications.actions`, `system.notify`, `device.info`, `device.status`, `device.permissions`, `device.health`, `device.apps`, `contacts.search`, `calendar.events`, `callLog.search`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer` | | macOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` | | Windows | `camera.list`, `location.get`, `device.info`, `device.status`, `system.notify` | diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 7eebc7f7eed..53db22dcaad 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -2,6 +2,7 @@ summary: "iOS node app: connect to the Gateway, pairing, canvas, and troubleshooting" read_when: - Pairing or reconnecting the iOS node + - Enabling or troubleshooting the direct Apple Watch node - Running the iOS app from source - Debugging gateway discovery or canvas commands title: "iOS app" @@ -53,12 +54,6 @@ creation has a token or password auth path. 4. The official app connects automatically. If **Pending approval** shows a request, review its role and scopes before approving it. -The Apple Watch companion does not have a separate OpenClaw pairing approval. -Pair the Watch with the iPhone in Apple's Watch app, install OpenClaw from -**Watch app -> My Watch -> Available Apps**, then open OpenClaw once on both -devices. OpenClaw follows Apple Watch pairing and install changes immediately; -the Gateway's device approval covers the iPhone node. - The Control UI button requires an already paired session with `operator.admin`. As a terminal fallback, pick a discovered gateway in the iOS app (or enable Manual Host and enter host/port), then approve the request on the Gateway host: @@ -93,6 +88,55 @@ openclaw nodes status openclaw gateway call node.list --params "{}" ``` +By default, the Apple Watch companion keeps using the existing iPhone relay and +does not need a separate Gateway pairing. Pair the Watch with the iPhone in +Apple's Watch app, install OpenClaw from **Watch app -> My Watch -> Available +Apps**, then open OpenClaw once on both devices. + +## Optional direct Apple Watch node + +Direct mode gives the watch its own signed node identity and Gateway connection. +Supported node commands continue to work over watch Wi-Fi or cellular while +OpenClaw is active, even when the paired iPhone is unavailable. + +Requirements: + +- The iPhone is connected to the Gateway with `operator.admin` scope. +- The setup code advertises a `wss://` Gateway endpoint with a certificate trusted + by watchOS; the watch polls the corresponding `https://` origin. Plain HTTP and + self-signed or fingerprint-only trust are unsupported. See [Gateway-owned + pairing](/gateway/pairing) for endpoint configuration. Loopback, iPhone-only, + and tailnet-only routes are not independently reachable by the watch. +- Cellular use requires a cellular-capable Apple Watch with active service. +- OpenClaw is active on the watch. Apple does not allow ordinary watchOS apps to + keep generic WebSocket/TCP connections, so the direct node uses short HTTPS + polls and reconnects when the app returns to the foreground. See Apple's + [watchOS low-level networking guidance](https://developer.apple.com/documentation/technotes/tn3135-low-level-networking-on-watchOS). + +Setup: + +1. On iPhone, open **Settings -> Apple Watch**. +2. Tap **Enable Direct Gateway Connection**. +3. Open OpenClaw on the watch before the short-lived setup code expires. +4. Verify the separate Apple Watch row with `openclaw nodes status`. + +The setup code contains a short-lived, node-only bootstrap credential; treat it +like a password until it expires. It never contains the iPhone's saved Gateway +password or token. After pairing, the watch stores its own device token and +deletes the bootstrap credential. Direct mode covers only the commands below. +Chat, Talk, approvals, and the existing `watch.*` notification flow remain +iPhone-relay features and still require the paired iPhone. + +Direct watchOS node commands: + +| Surface | Commands | Notes | +| ------------- | ------------------------------ | ------------------------------------------------------- | +| Device | `device.info`, `device.status` | Watch identity, battery, thermal, storage, and network. | +| Notifications | `system.notify` | While the app is active; requires watch permission. | + +watchOS does not expose WebKit to third-party apps, so the direct watch node +does not advertise Canvas commands. + ## Relay-backed push for official builds Official distributed iOS builds use an external push relay instead of publishing the raw APNs token to the gateway. Official App Store builds from the public release lane use the hosted relay at `https://ios-push-relay.openclaw.ai`; this base URL is hardcoded for App Store distribution and does not read any override. diff --git a/packages/gateway-protocol/src/client-info.ts b/packages/gateway-protocol/src/client-info.ts index 0b1ca5fdee1..815c3cc9f41 100644 --- a/packages/gateway-protocol/src/client-info.ts +++ b/packages/gateway-protocol/src/client-info.ts @@ -22,6 +22,7 @@ export const GATEWAY_CLIENT_IDS = { GATEWAY_CLIENT: "gateway-client", MACOS_APP: "openclaw-macos", IOS_APP: "openclaw-ios", + WATCHOS_APP: "openclaw-watchos", ANDROID_APP: "openclaw-android", NODE_HOST: "node-host", TEST: "test", diff --git a/packages/gateway-protocol/src/schema/devices.ts b/packages/gateway-protocol/src/schema/devices.ts index 183281963ec..85acf6bb6c0 100644 --- a/packages/gateway-protocol/src/schema/devices.ts +++ b/packages/gateway-protocol/src/schema/devices.ts @@ -92,13 +92,15 @@ const SetupCodeQrDataUrlSchema = Type.String({ * a short-lived bootstrap token that hands off broad operator scopes * (read/write/approvals/talk.secrets), so this method requires operator.admin * (enforced by the core method descriptor's method-scope policy, not the handler) - * and is not advertised. + * and is not advertised. `bootstrapProfile: "node"` narrows the handoff to a + * node role with no operator scopes for companion devices such as watchOS. */ export const DevicePairSetupCodeParamsSchema = Type.Object( { publicUrl: Type.Optional(NonEmptyString), preferRemoteUrl: Type.Optional(Type.Boolean()), includeQr: Type.Optional(Type.Boolean()), + bootstrapProfile: Type.Optional(Type.Literal("node")), }, { additionalProperties: false }, ); diff --git a/src/cli/nodes-cli/register.notify.ts b/src/cli/nodes-cli/register.notify.ts index 1d5f4b78ac5..433a60d365e 100644 --- a/src/cli/nodes-cli/register.notify.ts +++ b/src/cli/nodes-cli/register.notify.ts @@ -17,7 +17,7 @@ export function registerNodesNotifyCommand(nodes: Command) { nodesCallOpts( nodes .command("notify") - .description("Send a local notification on a node (mac only)") + .description("Send a local notification on a node") .requiredOption("--node ", "Node id, name, or IP") .option("--title ", "Notification title") .option("--body ", "Notification body") diff --git a/src/gateway/auth-rate-limit.ts b/src/gateway/auth-rate-limit.ts index 006394fd497..4cd4b23aa96 100644 --- a/src/gateway/auth-rate-limit.ts +++ b/src/gateway/auth-rate-limit.ts @@ -55,6 +55,9 @@ export const AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL = "node-reapproval"; // device signature can queue the bootstrap-pairing flow behind their // requests, blocking legitimate node onboarding during the attack. export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token"; +// Public watchOS challenge issuance is throttled separately from credential +// failures so challenge floods cannot displace legitimate device handshakes. +export const AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE = "watch-challenge"; export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth"; const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:"; const IDENTITY_RATE_LIMIT_KEY_PREFIX = "identity:"; diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index 5301d459e41..a449a3bb595 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -246,6 +246,42 @@ describe("gateway/node-command-policy", () => { expect(macAllowlist.has("system.run")).toBe(false); expect(macAllowlist.has("system.which")).toBe(false); expect(macAllowlist.has("screen.snapshot")).toBe(false); + + const watchAllowlist = resolveNodeCommandAllowlist(cfg, { + platform: "watchOS 11.5.0", + deviceFamily: "Apple Watch", + }); + expect(watchAllowlist.has("device.info")).toBe(true); + expect(watchAllowlist.has("device.status")).toBe(true); + expect(watchAllowlist.has("system.notify")).toBe(true); + expect(watchAllowlist.has("camera.list")).toBe(false); + expect(watchAllowlist.has("system.run")).toBe(false); + }); + + it("requires matching watchOS platform and device-family metadata", () => { + const cfg = {} as OpenClawConfig; + const mismatch = resolveNodeCommandAllowlist(cfg, { + platform: "watchOS 11.5.0", + deviceFamily: "iPhone", + }); + expect(mismatch.has("device.info")).toBe(false); + + const familyOnly = resolveNodeCommandAllowlist(cfg, { deviceFamily: "Apple Watch" }); + expect(familyOnly.has("device.info")).toBe(true); + expect(familyOnly.has("system.run")).toBe(false); + }); + + it("keeps plugin defaults out of the fixed watchOS command surface", () => { + installCanvasPluginDefaults(); + + const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, { + platform: "watchOS 11.5.0", + deviceFamily: "Apple Watch", + }); + + expect(allowlist.has("device.info")).toBe(true); + expect(allowlist.has("canvas.snapshot")).toBe(false); + expect(allowlist.has("canvas.present")).toBe(false); }); it("keeps explicitly approved host commands for desktop platforms", () => { diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index cf6282c214f..1566ffb67f8 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -99,6 +99,7 @@ const PLATFORM_DEFAULTS: Record = { ...MOTION_COMMANDS, ...IOS_SYSTEM_COMMANDS, ], + watchos: [...DEVICE_COMMANDS, ...IOS_SYSTEM_COMMANDS], android: [ ...CAMERA_COMMANDS, ...LOCATION_COMMANDS, @@ -140,10 +141,11 @@ const PLATFORM_DEFAULTS: Record = { unknown: [...UNKNOWN_PLATFORM_COMMANDS], }; -type PlatformId = "ios" | "android" | "macos" | "windows" | "linux" | "unknown"; +type PlatformId = "ios" | "watchos" | "android" | "macos" | "windows" | "linux" | "unknown"; const CANONICAL_PLATFORM_IDS = new Set>([ "ios", + "watchos", "android", "macos", "windows", @@ -155,6 +157,7 @@ const DEVICE_FAMILY_TOKEN_RULES: ReadonlyArray<{ tokens: readonly string[]; }> = [ { id: "ios", tokens: ["iphone", "ipad", "ios"] }, + { id: "watchos", tokens: ["apple watch", "watchos"] }, { id: "android", tokens: ["android"] }, { id: "macos", tokens: ["mac"] }, { id: "windows", tokens: ["windows"] }, @@ -175,6 +178,8 @@ function platformMatchesDeviceFamily( switch (platformId) { case "ios": return family === "" || /^(?:iphone|ipad|ios)$/.test(family); + case "watchos": + return family === "apple watch" || family === "watchos"; case "android": return family === "" || family === "android"; case "macos": @@ -194,6 +199,9 @@ function resolvePlatformIdByNativeLabel( if (/^(?:ios|ipados) \d+(?:\.\d+){0,2}$/.test(platform)) { return /^(?:iphone|ipad|ios)$/.test(deviceFamily) ? "ios" : undefined; } + if (/^watchos \d+(?:\.\d+){0,2}$/.test(platform)) { + return /^(?:apple watch|watchos)$/.test(deviceFamily) ? "watchos" : undefined; + } if (/^macos \d+(?:\.\d+){0,2}$/.test(platform)) { return deviceFamily === "mac" ? "macos" : undefined; } @@ -249,6 +257,11 @@ export function listDangerousPluginNodeCommands(): string[] { } function listDefaultPluginNodeCommands(platformId: PlatformId): string[] { + // The direct watch transport has a fixed, minimal command surface. Do not let + // generic plugin defaults silently expand it when plugins are installed. + if (platformId === "watchos") { + return []; + } const registry = getActivePluginGatewayNodePolicyRegistry(); if (!registry) { return []; diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index e28ef991f04..1395fe3a398 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -72,7 +72,7 @@ type NodeInvokeResult = { }; /** Connectivity probe result for a registered node. */ -type NodeConnectivityResult = +export type NodeConnectivityResult = | { ok: true } | { ok: false; error: { code: string; message: string } }; @@ -98,6 +98,13 @@ export type SerializedEventPayload = { readonly [SERIALIZED_EVENT_PAYLOAD]: true; }; +/** Event transport for nodes that cannot keep a WebSocket open, such as watchOS. */ +export type NodeEventTransport = { + send: (event: string, payload: unknown) => boolean; + sendRaw: (event: string, payloadJSON?: SerializedEventPayload | null) => boolean; + checkConnectivity?: (timeoutMs: number) => Promise; +}; + /** Serialize an event payload once so fanout can reuse the same JSON string. */ export function serializeEventPayload(payload: unknown): SerializedEventPayload | null { if (payload === undefined) { @@ -177,11 +184,29 @@ function withSystemRunEventRunId(params: { command: string; params?: unknown }): export class NodeRegistry { private nodesById = new Map(); private nodesByConn = new Map(); + private eventTransportsByConn = new Map(); private pendingInvokes = new Map(); private authorizedSystemRunEvents = new Map(); /** Register a websocket client as the current connection for its node id. */ register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) { + return this.registerSession(client, opts); + } + + /** Register a node whose events are delivered by an HTTP polling transport. */ + registerTransport( + client: GatewayWsClient, + opts: { remoteIp?: string | undefined }, + transport: NodeEventTransport, + ) { + return this.registerSession(client, opts, transport); + } + + private registerSession( + client: GatewayWsClient, + opts: { remoteIp?: string | undefined }, + transport?: NodeEventTransport, + ) { const connect = client.connect; const nodeId = connect.device?.id ?? connect.client.id; const caps = Array.isArray(connect.caps) ? connect.caps : []; @@ -249,6 +274,11 @@ export class NodeRegistry { }; this.nodesById.set(nodeId, session); this.nodesByConn.set(client.connId, nodeId); + if (transport) { + this.eventTransportsByConn.set(client.connId, transport); + } else { + this.eventTransportsByConn.delete(client.connId); + } return session; } @@ -259,6 +289,7 @@ export class NodeRegistry { return null; } this.nodesByConn.delete(connId); + this.eventTransportsByConn.delete(connId); const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId; if (unregistersCurrentNode) { this.nodesById.delete(nodeId); @@ -298,6 +329,10 @@ export class NodeRegistry { error: { code: "NOT_CONNECTED", message: "node not connected" }, }; } + const eventTransport = this.eventTransportsByConn.get(node.connId); + if (eventTransport) { + return eventTransport.checkConnectivity?.(timeoutMs) ?? { ok: true }; + } const socket = node.client.socket as PingableSocket; if (socket.readyState !== WEBSOCKET_OPEN_READY_STATE) { return { @@ -707,6 +742,10 @@ export class NodeRegistry { } private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean { + const eventTransport = this.eventTransportsByConn.get(node.connId); + if (eventTransport) { + return eventTransport.send(event, payload); + } if (this.rejectSlowNodeSocket(node)) { return false; } @@ -736,6 +775,10 @@ export class NodeRegistry { ) { return false; } + const eventTransport = this.eventTransportsByConn.get(node.connId); + if (eventTransport) { + return eventTransport.sendRaw(event, payloadJSON); + } if (this.rejectSlowNodeSocket(node)) { return false; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index fede151bac4..61b4f6bfb43 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -54,6 +54,8 @@ type PluginHttpRequestHandler = ( }, ) => Promise; +type WatchNodeHttpRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; + type PluginHttpUpgradeHandler = ( req: IncomingMessage, socket: import("node:stream").Duplex, @@ -442,6 +444,7 @@ export function createGatewayHttpServer(opts: { openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; strictTransportSecurityHeader?: string; handleHooksRequest: HooksRequestHandler; + handleWatchNodeRequest?: WatchNodeHttpRequestHandler; handlePluginRequest?: PluginHttpRequestHandler; handlePluginUpgrade?: PluginHttpUpgradeHandler; shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; @@ -556,6 +559,12 @@ export function createGatewayHttpServer(opts: { run: () => handleHooksRequest(req, res), }, ]; + if (opts.handleWatchNodeRequest && scopedRequestPath.startsWith("/api/nodes/watch/")) { + requestStages.push({ + name: "watch-node", + run: () => opts.handleWatchNodeRequest?.(req, res) ?? false, + }); + } if (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) { requestStages.push({ name: "models", diff --git a/src/gateway/server-methods/device-pair-setup.test.ts b/src/gateway/server-methods/device-pair-setup.test.ts index 601501da260..2ae28bb19a3 100644 --- a/src/gateway/server-methods/device-pair-setup.test.ts +++ b/src/gateway/server-methods/device-pair-setup.test.ts @@ -164,6 +164,21 @@ describe("device.pair.setupCode", () => { expect(payload.setupCode).toBe("SETUP-CODE-XYZ"); }); + it("requests a node-only bootstrap profile for companion setup", async () => { + mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution); + mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ"); + + const { options } = createOptions({ includeQr: false, bootstrapProfile: "node" }); + await devicePairSetupHandlers["device.pair.setupCode"](options); + + expect(mocks.resolvePairingSetupFromConfig).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + bootstrapProfile: { roles: ["node"], scopes: [] }, + }), + ); + }); + it("omits an oversized QR but still returns the setup code", async () => { mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution); mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ"); diff --git a/src/gateway/server-methods/device-pair-setup.ts b/src/gateway/server-methods/device-pair-setup.ts index ab560fa35f2..f54c5b99b01 100644 --- a/src/gateway/server-methods/device-pair-setup.ts +++ b/src/gateway/server-methods/device-pair-setup.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { renderQrPngDataUrl } from "../../media/qr-image.js"; import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "../../pairing/setup-code.js"; import { runCommandWithTimeout } from "../../process/exec.js"; +import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../../shared/device-bootstrap-profile.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -49,6 +50,9 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = { env: process.env, publicUrl, preferRemoteUrl: params.preferRemoteUrl === true, + ...(params.bootstrapProfile === "node" + ? { bootstrapProfile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } + : {}), // Lets Tailscale serve/funnel URLs resolve, mirroring the `openclaw qr` CLI. runCommandWithTimeout: async (argv, runOpts) => await runCommandWithTimeout(argv, { timeoutMs: runOpts.timeoutMs }), diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 739b5117e42..630758ac55c 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -152,8 +152,11 @@ describe("createGatewayRequestContext", () => { socket: { close: vi.fn() }, }; const clients = new Set([target, unrelated]) as never; + const invalidateDeviceTransports = vi.fn(); - const context = createGatewayRequestContext(makeContextParams({ clients })); + const context = createGatewayRequestContext( + makeContextParams({ clients, invalidateDeviceTransports }), + ); context.invalidateClientsForDevice?.("device-1", { reason: "device-token-rotated" }); expect((target as { invalidated?: boolean }).invalidated).toBe(true); @@ -164,6 +167,9 @@ describe("createGatewayRequestContext", () => { expect((unrelated as { invalidated?: boolean }).invalidated).toBeUndefined(); expect(unrelated.socket.close).not.toHaveBeenCalled(); + expect(invalidateDeviceTransports).toHaveBeenCalledWith("device-1", { + reason: "device-token-rotated", + }); }); it("disconnectClientsForDevice also marks the invalidated flag before closing", () => { @@ -173,13 +179,17 @@ describe("createGatewayRequestContext", () => { socket: { close: vi.fn() }, }; const clients = new Set([target]) as never; + const disconnectDeviceTransports = vi.fn(); - const context = createGatewayRequestContext(makeContextParams({ clients })); + const context = createGatewayRequestContext( + makeContextParams({ clients, disconnectDeviceTransports }), + ); context.disconnectClientsForDevice?.("device-1"); expect((target as { invalidated?: boolean }).invalidated).toBe(true); expect((target as { invalidatedReason?: string }).invalidatedReason).toBe("device-removed"); expect(target.socket.close).toHaveBeenCalledWith(4001, "device removed"); + expect(disconnectDeviceTransports).toHaveBeenCalledWith("device-1", undefined); }); it("invalidateClientsForDevice filters by role when provided", () => { diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 3763b6c0914..2d666e0c0d8 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -36,6 +36,11 @@ export type GatewayRequestContextParams = { nodeUnsubscribeAll: GatewayRequestContext["nodeUnsubscribeAll"]; hasConnectedTalkNode: GatewayRequestContext["hasConnectedTalkNode"]; clients: Set; + invalidateDeviceTransports?: ( + deviceId: string, + opts?: { role?: string; reason?: string }, + ) => void; + disconnectDeviceTransports?: (deviceId: string, opts?: { role?: string }) => void; enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => void; nodeRegistry: GatewayRequestContext["nodeRegistry"]; terminalSessions?: GatewayRequestContext["terminalSessions"]; @@ -165,6 +170,7 @@ export function createGatewayRequestContext( gatewayClient.invalidated = true; gatewayClient.invalidatedReason = reason; } + params.invalidateDeviceTransports?.(deviceId, opts); }, disconnectClientsForDevice: (deviceId: string, opts?: { role?: string }) => { for (const gatewayClient of params.clients) { @@ -185,6 +191,7 @@ export function createGatewayRequestContext( /* ignore */ } } + params.disconnectDeviceTransports?.(deviceId, opts); }, disconnectClientsUsingSharedGatewayAuth: () => { disconnectAllSharedGatewayAuthClients(params.clients); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 4e5375378d7..4fe771cd6ff 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -101,6 +101,7 @@ export async function createGatewayRuntimeState(params: { logPlugins: ReturnType; getReadiness?: ReadinessChecker; isTerminalEnabled: () => boolean; + handleWatchNodeRequest?: (req: IncomingMessage, res: ServerResponse) => Promise; }): Promise<{ releasePluginRouteRegistry: () => void; httpServer: HttpServer; @@ -261,6 +262,7 @@ export async function createGatewayRuntimeState(params: { openResponsesEnabled: params.openResponsesEnabled, openResponsesConfig: params.openResponsesConfig, strictTransportSecurityHeader: params.strictTransportSecurityHeader, + handleWatchNodeRequest: params.handleWatchNodeRequest, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 6bcccace9e1..f275a7201b4 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; // Gateway server implementation builds runtime state, method registries, HTTP // and WebSocket surfaces, config reload hooks, and graceful restart/shutdown. import { monitorEventLoopDelay, performance } from "node:perf_hooks"; @@ -40,6 +41,7 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { readGatewayRestartHandoffSync } from "../infra/restart-handoff.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; +import { upsertPresence } from "../infra/system-presence.js"; import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js"; import { withDiagnosticPhase } from "../logging/diagnostic-phase.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; @@ -62,6 +64,7 @@ import { } from "../secrets/runtime-state.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { createLazyPromise } from "../shared/lazy-runtime.js"; +import { recordRemoteNodeInfo, removeRemoteNodeInfo } from "../skills/runtime/remote.js"; import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { resolveGatewayAuth } from "./auth.js"; import type { RestartRecoveryCandidate } from "./chat-abort.js"; @@ -99,6 +102,7 @@ import { createLazyGatewayCronState } from "./server-cron-lazy.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js"; import { createGatewayServerLiveState, type GatewayServerLiveState } from "./server-live-state.js"; import { GATEWAY_EVENTS } from "./server-methods-list.js"; +import { clearNodeWakeState } from "./server-methods/nodes-wake-state.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./server-methods/types.js"; import { setFallbackGatewayContextResolver } from "./server-plugins.js"; import type { GatewayPluginReloadResult } from "./server-reload-handlers.js"; @@ -119,6 +123,7 @@ import { refreshGatewayHealthSnapshot, } from "./server/health-state.js"; import { resolveHookClientIpConfig } from "./server/hook-client-ip-config.js"; +import { broadcastPresenceSnapshot } from "./server/presence-events.js"; import { createReadinessChecker } from "./server/readiness.js"; import { loadGatewayTlsRuntime } from "./server/tls.js"; import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js"; @@ -880,6 +885,9 @@ export async function startGatewayServer( }); log.info("starting HTTP server..."); let currentPluginRegistryGatewayContext: GatewayRequestContext | undefined; + const watchNodeRequestHandler: { + current?: (req: IncomingMessage, res: ServerResponse) => Promise; + } = {}; const { releasePluginRouteRegistry, httpServer, @@ -931,6 +939,8 @@ export async function startGatewayServer( logHooks, logPlugins, getReadiness, + handleWatchNodeRequest: async (req, res) => + (await watchNodeRequestHandler.current?.(req, res)) ?? false, }), ); const restartRecoveryCandidates = new Map(); @@ -948,6 +958,48 @@ export async function startGatewayServer( broadcastVoiceWakeChanged, hasTalkNodeConnected, } = createGatewayNodeSessionRuntime({ broadcast }); + const { createWatchNodeHttpRuntime } = await import("./watch-node-http.js"); + const watchNodeHttpRuntime = createWatchNodeHttpRuntime({ + nodeRegistry, + getConfig: getRuntimeConfig, + broadcast, + rateLimiter: authRateLimiter, + nodeReapprovalCoordinator, + onNodeConnected: (session) => { + upsertPresence(session.nodeId, { + host: session.displayName ?? session.clientId ?? session.nodeId, + ip: session.remoteIp, + version: session.version, + platform: session.platform, + deviceFamily: session.deviceFamily, + modelIdentifier: session.modelIdentifier, + mode: session.clientMode, + deviceId: session.nodeId, + roles: ["node"], + scopes: [], + instanceId: session.nodeId, + reason: "connect", + }); + incrementPresenceVersion(); + recordRemoteNodeInfo({ + nodeId: session.nodeId, + displayName: session.displayName, + platform: session.platform, + deviceFamily: session.deviceFamily, + commands: session.commands, + remoteIp: session.remoteIp, + }); + }, + onNodeDisconnected: (nodeId) => { + upsertPresence(nodeId, { reason: "disconnect" }); + broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion }); + removeRemoteNodeInfo(nodeId); + nodeUnsubscribeAll(nodeId); + clearNodeWakeState(nodeId); + }, + onError: (message, error) => log.warn(`${message}: ${String(error)}`), + }); + watchNodeRequestHandler.current = watchNodeHttpRuntime.handleRequest; const { TerminalSessionManager, DEFAULT_TERMINAL_DETACH_SECONDS } = await import("./terminal/session-manager.js"); // One PTY store per gateway. Emits each session's bytes only to the owning @@ -994,6 +1046,7 @@ export async function startGatewayServer( }; const runClosePrelude = async () => { markClosePreludeStarted(); + watchNodeHttpRuntime.close(); clearPluginMetadataLifecycleCaches(); const { runGatewayClosePrelude } = await loadGatewayCloseModule(); await runGatewayClosePrelude({ @@ -1466,6 +1519,8 @@ export async function startGatewayServer( nodeUnsubscribeAll, hasConnectedTalkNode: hasTalkNodeConnected, clients, + invalidateDeviceTransports: watchNodeHttpRuntime.invalidateSessionsForDevice, + disconnectDeviceTransports: watchNodeHttpRuntime.disconnectSessionsForDevice, enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => { enforceSharedGatewaySessionGenerationForConfigWrite({ state: sharedGatewaySessionGenerationState, diff --git a/src/gateway/server.ios-client-id.test.ts b/src/gateway/server.ios-client-id.test.ts index f2074300f1d..376757782d2 100644 --- a/src/gateway/server.ios-client-id.test.ts +++ b/src/gateway/server.ios-client-id.test.ts @@ -27,14 +27,15 @@ function makeConnectParams(clientId: string) { } describe("connect params client id validation", () => { - test.each([GATEWAY_CLIENT_IDS.IOS_APP, GATEWAY_CLIENT_IDS.ANDROID_APP])( - "accepts %s as a valid gateway client id", - (clientId) => { - const ok = validateConnectParams(makeConnectParams(clientId)); - expect(ok).toBe(true); - expect(validateConnectParams.errors ?? []).toHaveLength(0); - }, - ); + test.each([ + GATEWAY_CLIENT_IDS.IOS_APP, + GATEWAY_CLIENT_IDS.WATCHOS_APP, + GATEWAY_CLIENT_IDS.ANDROID_APP, + ])("accepts %s as a valid gateway client id", (clientId) => { + const ok = validateConnectParams(makeConnectParams(clientId)); + expect(ok).toBe(true); + expect(validateConnectParams.errors ?? []).toHaveLength(0); + }); test("rejects unknown client ids", () => { const ok = validateConnectParams(makeConnectParams("openclaw-mobile")); diff --git a/src/gateway/watch-node-http.test.ts b/src/gateway/watch-node-http.test.ts new file mode 100644 index 00000000000..fe68dbcf17e --- /dev/null +++ b/src/gateway/watch-node-http.test.ts @@ -0,0 +1,579 @@ +import { createServer, type Server } from "node:http"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from "../../packages/gateway-protocol/src/client-info.js"; +import { PROTOCOL_VERSION, type ConnectParams } from "../../packages/gateway-protocol/src/index.js"; +import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; +import { revokeDeviceToken } from "../infra/device-pairing.js"; +import { listNodePairing } from "../infra/node-pairing.js"; +import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; +import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; +import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; +import { buildDeviceAuthPayloadV3 } from "./device-auth.js"; +import { NodeRegistry, serializeEventPayload } from "./node-registry.js"; +import { createWatchNodeHttpRuntime, testing } from "./watch-node-http.js"; + +const tempDirs = createTrackedTempDirs(); +const servers: Server[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + await tempDirs.cleanup(); +}); + +function makeConnectParams(params: { + identity: ReturnType; + nonce: string; + bootstrapToken?: string; + deviceToken?: string; +}): ConnectParams { + const publicKey = publicKeyRawBase64UrlFromPem(params.identity.publicKeyPem); + const auth = params.deviceToken + ? { deviceToken: params.deviceToken } + : { bootstrapToken: params.bootstrapToken }; + const signedAt = Date.now(); + const client = { + id: GATEWAY_CLIENT_IDS.WATCHOS_APP, + displayName: "Test Watch", + version: "1.0.0", + platform: "watchOS 11.5.0", + deviceFamily: "Apple Watch", + mode: GATEWAY_CLIENT_MODES.NODE, + instanceId: "watch-test", + } as const; + const scopes: string[] = []; + const signaturePayload = buildDeviceAuthPayloadV3({ + deviceId: params.identity.deviceId, + clientId: client.id, + clientMode: client.mode, + role: "node", + scopes, + signedAtMs: signedAt, + token: params.deviceToken ?? params.bootstrapToken ?? null, + nonce: params.nonce, + platform: client.platform, + deviceFamily: client.deviceFamily, + }); + return { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client, + caps: [], + commands: ["device.info", "device.status", "system.notify"], + permissions: { notifications: true }, + role: "node", + scopes, + auth, + device: { + id: params.identity.deviceId, + publicKey, + signature: signDevicePayload(params.identity.privateKeyPem, signaturePayload), + signedAt, + nonce: params.nonce, + }, + } as ConnectParams; +} + +async function startRuntime( + baseDir: string, + options?: { rateLimiter?: AuthRateLimiter; abortConnectResponse?: boolean }, +) { + const nodeRegistry = new NodeRegistry(); + const broadcasts: Array<{ event: string; payload: unknown }> = []; + const connectedNodes: string[] = []; + const disconnectedNodes: Array<{ nodeId: string; reason: string }> = []; + const runtime = createWatchNodeHttpRuntime({ + nodeRegistry, + getConfig: () => ({}), + pairingBaseDir: baseDir, + broadcast: (event, payload) => broadcasts.push({ event, payload }), + onNodeConnected: (session) => connectedNodes.push(session.nodeId), + onNodeDisconnected: (nodeId, reason) => disconnectedNodes.push({ nodeId, reason }), + ...(options?.rateLimiter ? { rateLimiter: options.rateLimiter } : {}), + }); + let resolveConnectHandled: () => void = () => undefined; + const connectHandled = new Promise((resolve) => { + resolveConnectHandled = resolve; + }); + const server = createServer((req, res) => { + const isConnect = req.url === "/api/nodes/watch/connect"; + if (isConnect && options?.abortConnectResponse) { + res.end = (() => { + res.destroy(); + return res; + }) as typeof res.end; + } + void runtime + .handleRequest(req, res) + .then((handled) => { + if (!handled && !res.writableEnded) { + res.statusCode = 404; + res.end(); + } + }) + .finally(() => { + if (isConnect) { + resolveConnectHandled(); + } + }); + }); + servers.push(server); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected TCP server address"); + } + return { + nodeRegistry, + broadcasts, + connectedNodes, + disconnectedNodes, + runtime, + connectHandled, + baseUrl: `http://127.0.0.1:${address.port}/api/nodes/watch`, + }; +} + +async function readJson(response: Response): Promise> { + return (await response.json()) as Record; +} + +async function waitForLastConnectedMetadata(baseDir: string, nodeId: string): Promise { + await vi.waitFor(async () => { + const paired = (await listNodePairing(baseDir)).paired.find((entry) => entry.nodeId === nodeId); + expect(paired?.lastConnectedAtMs).toEqual(expect.any(Number)); + }); +} + +describe("watch node HTTP transport", () => { + it("accepts only the canonical bounded watch surface", async () => { + const baseDir = await tempDirs.make("openclaw-watch-node-surface-"); + const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json")); + const bounded = makeConnectParams({ identity, nonce: "nonce", bootstrapToken: "token" }); + expect(testing.isCanonicalWatchNode(bounded)).toBe(true); + expect( + testing.isCanonicalWatchNode({ + ...bounded, + permissions: { notifications: false }, + }), + ).toBe(true); + expect( + testing.isCanonicalWatchNode({ + ...bounded, + commands: [...(bounded.commands ?? []), "system.run"], + }), + ).toBe(false); + expect(testing.isCanonicalWatchNode({ ...bounded, caps: ["canvas"] })).toBe(false); + expect( + testing.isCanonicalWatchNode({ + ...bounded, + commands: [...(bounded.commands ?? []), "canvas.present"], + }), + ).toBe(false); + expect( + testing.isCanonicalWatchNode({ + ...bounded, + permissions: { ...bounded.permissions, canvas: true }, + }), + ).toBe(false); + expect( + testing.isCanonicalWatchNode({ + ...bounded, + client: { ...bounded.client, deviceFamily: "iPhone" }, + }), + ).toBe(false); + expect(testing.isCanonicalWatchNode({ ...bounded, minProtocol: 3, maxProtocol: 3 })).toBe( + false, + ); + expect(testing.isCanonicalWatchNode({ ...bounded, minProtocol: 4, maxProtocol: 3 })).toBe( + false, + ); + + const challenges = testing.createChallengeStore(); + const legitimate = challenges.issue("legitimate-client", 1_000); + for (let index = 0; index < 32; index += 1) { + challenges.issue("attacker", 1_000 + index); + } + expect(challenges.consume(legitimate.nonce, "legitimate-client", 2_000)).toBe(true); + }); + + it("requires an authenticated disconnect and emits one lifecycle teardown", async () => { + const baseDir = await tempDirs.make("openclaw-watch-node-disconnect-"); + const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json")); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const { nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } = + await startRuntime(baseDir); + + const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); + const connectResponse = await fetch(`${baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + makeConnectParams({ + identity, + nonce: String(challenge.nonce), + bootstrapToken: issued.token, + }), + ), + }); + expect(connectResponse.status).toBe(200); + const connected = await readJson(connectResponse); + const sessionToken = String(connected.sessionToken); + expect(connectedNodes).toEqual([identity.deviceId]); + + const unauthenticated = await fetch(`${baseUrl}/disconnect`, { method: "POST" }); + expect(unauthenticated.status).toBe(401); + expect(nodeRegistry.get(identity.deviceId)).toBeDefined(); + expect(disconnectedNodes).toEqual([]); + + const wrongMethod = await fetch(`${baseUrl}/disconnect`, { + headers: { authorization: `Bearer ${sessionToken}` }, + }); + expect(wrongMethod.status).toBe(405); + expect(nodeRegistry.get(identity.deviceId)).toBeDefined(); + + const disconnectResponse = await fetch(`${baseUrl}/disconnect`, { + method: "POST", + headers: { authorization: `Bearer ${sessionToken}` }, + }); + expect(disconnectResponse.status).toBe(200); + await expect(readJson(disconnectResponse)).resolves.toEqual({ ok: true }); + expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); + expect(disconnectedNodes).toEqual([ + { nodeId: identity.deviceId, reason: "watch disconnected" }, + ]); + + const repeatedDisconnect = await fetch(`${baseUrl}/disconnect`, { + method: "POST", + headers: { authorization: `Bearer ${sessionToken}` }, + }); + expect(repeatedDisconnect.status).toBe(401); + runtime.close(); + expect(disconnectedNodes).toHaveLength(1); + }); + + it("rejects empty shadow credentials without consuming the challenge", async () => { + const baseDir = await tempDirs.make("openclaw-watch-node-auth-fields-"); + const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json")); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const { baseUrl, runtime } = await startRuntime(baseDir); + + const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); + const connect = makeConnectParams({ + identity, + nonce: String(challenge.nonce), + bootstrapToken: issued.token, + }); + const shadowedResponse = await fetch(`${baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...connect, + auth: { ...connect.auth, token: "" }, + }), + }); + expect(shadowedResponse.status).toBe(401); + + const connectResponse = await fetch(`${baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(connect), + }); + expect(connectResponse.status).toBe(200); + await readJson(connectResponse); + await waitForLastConnectedMetadata(baseDir, identity.deviceId); + runtime.close(); + }); + + it("keeps challenge throttling after abort and resets it after completion", async () => { + const limiterConfig = { + maxAttempts: 1, + windowMs: 60_000, + lockoutMs: 60_000, + exemptLoopback: false, + pruneIntervalMs: 0, + }; + + const abortedBaseDir = await tempDirs.make("openclaw-watch-node-aborted-connect-"); + const abortedIdentity = loadOrCreateDeviceIdentity( + path.join(abortedBaseDir, "watch-identity.json"), + ); + const abortedBootstrap = await issueDeviceBootstrapToken({ + baseDir: abortedBaseDir, + profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const abortedLimiter = createAuthRateLimiter(limiterConfig); + try { + const abortedRuntime = await startRuntime(abortedBaseDir, { + rateLimiter: abortedLimiter, + abortConnectResponse: true, + }); + const challenge = await readJson(await fetch(`${abortedRuntime.baseUrl}/challenge`)); + await expect( + fetch(`${abortedRuntime.baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + makeConnectParams({ + identity: abortedIdentity, + nonce: String(challenge.nonce), + bootstrapToken: abortedBootstrap.token, + }), + ), + }), + ).rejects.toThrow(); + await abortedRuntime.connectHandled; + const stillLimited = await fetch(`${abortedRuntime.baseUrl}/challenge`); + expect(stillLimited.status).toBe(429); + abortedRuntime.runtime.close(); + } finally { + abortedLimiter.dispose(); + } + + const completedBaseDir = await tempDirs.make("openclaw-watch-node-completed-connect-"); + const completedIdentity = loadOrCreateDeviceIdentity( + path.join(completedBaseDir, "watch-identity.json"), + ); + const completedBootstrap = await issueDeviceBootstrapToken({ + baseDir: completedBaseDir, + profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const completedLimiter = createAuthRateLimiter(limiterConfig); + try { + const completedRuntime = await startRuntime(completedBaseDir, { + rateLimiter: completedLimiter, + }); + const challenge = await readJson(await fetch(`${completedRuntime.baseUrl}/challenge`)); + const connectResponse = await fetch(`${completedRuntime.baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + makeConnectParams({ + identity: completedIdentity, + nonce: String(challenge.nonce), + bootstrapToken: completedBootstrap.token, + }), + ), + }); + expect(connectResponse.status).toBe(200); + await readJson(connectResponse); + await completedRuntime.connectHandled; + await waitForLastConnectedMetadata(completedBaseDir, completedIdentity.deviceId); + const resetAfterCompletion = await fetch(`${completedRuntime.baseUrl}/challenge`); + expect(resetAfterCompletion.status).toBe(200); + completedRuntime.runtime.close(); + } finally { + completedLimiter.dispose(); + } + }); + + it("bootstraps, registers, polls an invoke, and accepts its result", async () => { + const baseDir = await tempDirs.make("openclaw-watch-node-http-"); + const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json")); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const { nodeRegistry, broadcasts, connectedNodes, disconnectedNodes, runtime, baseUrl } = + await startRuntime(baseDir); + + const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); + const connectResponse = await fetch(`${baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + makeConnectParams({ + identity, + nonce: String(challenge.nonce), + bootstrapToken: issued.token, + }), + ), + }); + expect(connectResponse.status).toBe(200); + const connected = await readJson(connectResponse); + expect(connected.sessionToken).toEqual(expect.any(String)); + expect(connected.deviceToken).toEqual(expect.any(String)); + expect(nodeRegistry.get(identity.deviceId)?.commands).toEqual([ + "device.info", + "device.status", + "system.notify", + ]); + expect(broadcasts.map((entry) => entry.event)).toContain("device.pair.resolved"); + expect(broadcasts.map((entry) => entry.event)).toContain("node.pair.resolved"); + expect(connectedNodes).toEqual([identity.deviceId]); + + const reconnectChallenge = await readJson(await fetch(`${baseUrl}/challenge`)); + const reconnectResponse = await fetch(`${baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + makeConnectParams({ + identity, + nonce: String(reconnectChallenge.nonce), + deviceToken: String(connected.deviceToken), + }), + ), + }); + expect(reconnectResponse.status).toBe(200); + const reconnected = await readJson(reconnectResponse); + expect(reconnected.deviceToken).toBe(connected.deviceToken); + expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId]); + expect(disconnectedNodes).toEqual([]); + const stalePollResponse = await fetch(`${baseUrl}/poll`, { + method: "POST", + headers: { authorization: `Bearer ${String(connected.sessionToken)}` }, + }); + expect(stalePollResponse.status).toBe(401); + + const invoke = nodeRegistry.invoke({ + nodeId: identity.deviceId, + command: "device.info", + timeoutMs: 2_000, + }); + const pollResponse = await fetch(`${baseUrl}/poll`, { + method: "POST", + headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` }, + }); + expect(pollResponse.status).toBe(200); + const polled = await readJson(pollResponse); + const event = polled.event as { event: string; payload: { id: string } }; + expect(event.event).toBe("node.invoke.request"); + + const resultResponse = await fetch(`${baseUrl}/result`, { + method: "POST", + headers: { + authorization: `Bearer ${String(reconnected.sessionToken)}`, + "content-type": "application/json", + }, + body: JSON.stringify({ id: event.payload.id, ok: true, payloadJSON: '{"model":"Watch"}' }), + }); + expect(resultResponse.status).toBe(200); + await expect(invoke).resolves.toMatchObject({ + ok: true, + payloadJSON: '{"model":"Watch"}', + }); + + const lateResultResponse = await fetch(`${baseUrl}/result`, { + method: "POST", + headers: { + authorization: `Bearer ${String(reconnected.sessionToken)}`, + "content-type": "application/json", + }, + body: JSON.stringify({ id: event.payload.id, ok: true }), + }); + expect(lateResultResponse.status).toBe(200); + await expect(readJson(lateResultResponse)).resolves.toEqual({ ok: true, ignored: true }); + await expect(nodeRegistry.checkConnectivity(identity.deviceId)).resolves.toEqual({ ok: true }); + + runtime.invalidateSessionsForDevice(identity.deviceId, { + role: "node", + reason: "device-token-revoked", + }); + await expect(nodeRegistry.checkConnectivity(identity.deviceId)).resolves.toEqual({ + ok: false, + error: { code: "NOT_CONNECTED", message: "device-token-revoked" }, + }); + runtime.disconnectSessionsForDevice(identity.deviceId, { role: "node" }); + expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); + expect(disconnectedNodes).toContainEqual({ + nodeId: identity.deviceId, + reason: "device-token-revoked", + }); + const invalidatedPollResponse = await fetch(`${baseUrl}/poll`, { + method: "POST", + headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` }, + }); + expect(invalidatedPollResponse.status).toBe(401); + + const revoked = await revokeDeviceToken({ + deviceId: identity.deviceId, + role: "node", + baseDir, + }); + expect(revoked.ok).toBe(true); + const replacementBootstrap = await issueDeviceBootstrapToken({ + baseDir, + profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const replacementChallenge = await readJson(await fetch(`${baseUrl}/challenge`)); + const replacementResponse = await fetch(`${baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + makeConnectParams({ + identity, + nonce: String(replacementChallenge.nonce), + bootstrapToken: replacementBootstrap.token, + }), + ), + }); + expect(replacementResponse.status).toBe(200); + const replacement = await readJson(replacementResponse); + expect(replacement.deviceToken).toEqual(expect.any(String)); + expect(replacement.deviceToken).not.toBe(connected.deviceToken); + expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId, identity.deviceId]); + + const replayChallenge = await readJson(await fetch(`${baseUrl}/challenge`)); + const replayResponse = await fetch(`${baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + makeConnectParams({ + identity, + nonce: String(replayChallenge.nonce), + bootstrapToken: replacementBootstrap.token, + }), + ), + }); + expect(replayResponse.status).toBe(401); + + const rawPayload = serializeEventPayload({ sequence: 1 }); + expect(rawPayload).not.toBeNull(); + expect(nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", rawPayload)).toBe( + true, + ); + const rawPollResponse = await fetch(`${baseUrl}/poll`, { + method: "POST", + headers: { authorization: `Bearer ${String(replacement.sessionToken)}` }, + }); + expect(rawPollResponse.status).toBe(200); + await expect(readJson(rawPollResponse)).resolves.toMatchObject({ + ok: true, + event: { event: "node.invoke.request", payload: { sequence: 1 } }, + }); + + const oversizedPayload = serializeEventPayload({ value: "x".repeat(70 * 1024) }); + expect(oversizedPayload).not.toBeNull(); + expect( + nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", oversizedPayload), + ).toBe(false); + expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); + expect(disconnectedNodes).toContainEqual({ + nodeId: identity.deviceId, + reason: "event payload too large", + }); + + runtime.close(); + expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); + }); +}); diff --git a/src/gateway/watch-node-http.ts b/src/gateway/watch-node-http.ts new file mode 100644 index 00000000000..99289e7e6ac --- /dev/null +++ b/src/gateway/watch-node-http.ts @@ -0,0 +1,1085 @@ +// watchOS direct-node transport. +// Apple Watch cannot use generic WebSockets on-device, so node events use bounded HTTPS polls. +import { randomBytes, randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from "../../packages/gateway-protocol/src/client-info.js"; +import { + formatValidationErrors, + PROTOCOL_VERSION, + validateConnectParams, + type ConnectParams, +} from "../../packages/gateway-protocol/src/index.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + getBoundDeviceBootstrapProfile, + redeemDeviceBootstrapTokenProfile, + restoreDeviceBootstrapToken, + revokeDeviceBootstrapToken, + verifyDeviceBootstrapToken, +} from "../infra/device-bootstrap.js"; +import { + deriveDeviceIdFromPublicKey, + normalizeDevicePublicKeyBase64Url, +} from "../infra/device-identity.js"; +import { + approveBootstrapDevicePairing, + ensureDeviceToken, + getPairedDevice, + requestDevicePairing, + verifyDeviceToken, +} from "../infra/device-pairing.js"; +import { + approveNodePairing, + beginNodePairingConnect, + finalizeNodePairingCleanupClaim, + releaseNodePairingCleanupClaim, + requestNodePairing, + updatePairedNodeMetadata, + type RequestNodePairingResult, +} from "../infra/node-pairing.js"; +import { isNodePairingSetupBootstrapProfile } from "../shared/device-bootstrap-profile.js"; +import { + AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, + AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE, + buildRateLimitIdentityKey, + type AuthRateLimiter, +} from "./auth-rate-limit.js"; +import { hasForwardedRequestHeaders } from "./auth.js"; +import { + readJsonBodyOrError, + sendInvalidRequest, + sendJson, + sendMethodNotAllowed, + sendRateLimited, + sendUnauthorized, +} from "./http-common.js"; +import { ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; +import { isLoopbackAddress, resolveRequestClientIp } from "./net.js"; +import { reconcileNodePairingOnConnect } from "./node-connect-reconcile.js"; +import type { NodeReapprovalCoordinator } from "./node-reapproval-coordinator.js"; +import type { + NodeConnectivityResult, + NodeEventTransport, + NodeRegistry, + NodeSession, + SerializedEventPayload, +} from "./node-registry.js"; +import { withSerializedRateLimitAttempt } from "./rate-limit-attempt-serialization.js"; +import type { GatewayBroadcastFn } from "./server-broadcast-types.js"; +import { resolveConnectAuthDecision } from "./server/ws-connection/auth-context.js"; +import { resolveDeviceSignaturePayloadVersion } from "./server/ws-connection/handshake-auth-helpers.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; + +const BASE_PATH = "/api/nodes/watch"; +const CONNECT_PATH = `${BASE_PATH}/connect`; +const CHALLENGE_PATH = `${BASE_PATH}/challenge`; +const DISCONNECT_PATH = `${BASE_PATH}/disconnect`; +const POLL_PATH = `${BASE_PATH}/poll`; +const RESULT_PATH = `${BASE_PATH}/result`; +const CHALLENGE_TTL_MS = 60_000; +const SIGNATURE_SKEW_MS = 2 * 60_000; +const POLL_TIMEOUT_MS = 20_000; +const SESSION_IDLE_MS = 75_000; +const MAX_BODY_BYTES = 64 * 1024; +const MAX_QUEUED_EVENT_BYTES = 64 * 1024; +const MAX_QUEUED_BYTES = 512 * 1024; +const MAX_QUEUED_EVENTS = 32; +const MAX_PENDING_CHALLENGES = 4_096; +const MAX_PENDING_CHALLENGES_PER_CLIENT = 8; +const WATCH_CAPS = new Set(); +const WATCH_COMMANDS = new Set(["device.info", "device.status", "system.notify"]); +const WATCH_PERMISSIONS = new Set(["notifications"]); + +type QueuedNodeEvent = { json: string; byteLength: number }; + +type PendingChallenge = { clientKey: string; expiresAtMs: number }; + +type ResponseLifecycle = { + completed: Promise; + isAborted: () => boolean; +}; + +type WatchNodeSession = { + token: string; + nodeId: string; + connId: string; + invalidatedReason?: string; + lastSeenAtMs: number; + expiresTimer: ReturnType; + queue: QueuedNodeEvent[]; + queuedBytes: number; + waiter?: { + res: ServerResponse; + timer: ReturnType; + }; +}; + +type WatchNodeHttpRuntimeOptions = { + nodeRegistry: NodeRegistry; + getConfig: () => OpenClawConfig; + broadcast: GatewayBroadcastFn; + rateLimiter?: AuthRateLimiter; + nodeReapprovalCoordinator?: NodeReapprovalCoordinator; + onNodeConnected?: (session: NodeSession) => void; + onNodeDisconnected?: (nodeId: string, reason: string) => void; + onError?: (message: string, error: unknown) => void; + pairingBaseDir?: string; + now?: () => number; +}; + +class WatchNodePairingRateLimitError extends Error { + constructor(readonly retryAfterMs: number) { + super("watch node pairing rate limited"); + } +} + +function normalizePath(req: IncomingMessage): string | null { + try { + return new URL(req.url ?? "/", "http://localhost").pathname; + } catch { + return null; + } +} + +function readBearerToken(req: IncomingMessage): string | null { + const header = req.headers.authorization?.trim() ?? ""; + const match = /^Bearer\s+(.+)$/i.exec(header); + return match?.[1]?.trim() || null; +} + +function resolveWatchClientAddress( + req: IncomingMessage, + config: OpenClawConfig, +): { clientIp?: string; rateLimitKey: string } { + const trustedProxies = config.gateway?.trustedProxies ?? []; + const clientIp = resolveRequestClientIp( + req, + trustedProxies, + config.gateway?.allowRealIpFallback === true, + ); + if (hasForwardedRequestHeaders(req) && isLoopbackAddress(clientIp)) { + // Untrusted loopback proxies must not inherit the limiter's localhost exemption. + return { + rateLimitKey: buildRateLimitIdentityKey("watch-proxy", req.socket.remoteAddress ?? "unknown"), + }; + } + return { + ...(clientIp ? { clientIp } : {}), + rateLimitKey: clientIp ?? buildRateLimitIdentityKey("watch-client", "unknown"), + }; +} + +function isStringRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trackResponseLifecycle(res: ServerResponse): ResponseLifecycle { + let aborted = false; + let settled = false; + let resolveCompleted: (completed: boolean) => void = () => undefined; + const completed = new Promise((resolve) => { + resolveCompleted = resolve; + }); + const settle = (value: boolean) => { + if (settled) { + return; + } + settled = true; + res.off("finish", onFinish); + res.off("close", onClose); + resolveCompleted(value); + }; + const onFinish = () => settle(true); + const onClose = () => { + aborted = !res.writableFinished; + settle(!aborted); + }; + res.once("finish", onFinish); + res.once("close", onClose); + return { completed, isAborted: () => aborted }; +} + +function hasOnlyBoundedWatchSurface(connect: ConnectParams): boolean { + const caps = Array.isArray(connect.caps) ? connect.caps : []; + const commands = Array.isArray(connect.commands) ? connect.commands : []; + const permissionEntries = Object.entries(connect.permissions ?? {}); + return ( + caps.every((cap) => WATCH_CAPS.has(cap)) && + commands.length > 0 && + commands.every((command) => WATCH_COMMANDS.has(command)) && + permissionEntries.every(([permission]) => WATCH_PERMISSIONS.has(permission)) + ); +} + +function isCanonicalWatchNode(connect: ConnectParams): boolean { + const platform = connect.client.platform.trim().toLowerCase(); + const family = connect.client.deviceFamily?.trim().toLowerCase(); + return ( + connect.minProtocol <= PROTOCOL_VERSION && + connect.maxProtocol >= PROTOCOL_VERSION && + connect.role === "node" && + (connect.scopes?.length ?? 0) === 0 && + connect.client.id === GATEWAY_CLIENT_IDS.WATCHOS_APP && + connect.client.mode === GATEWAY_CLIENT_MODES.NODE && + platform.startsWith("watchos") && + family === "apple watch" && + hasOnlyBoundedWatchSurface(connect) + ); +} + +function createChallengeStore() { + const challenges = new Map(); + + const pruneExpired = (current: number) => { + for (const [nonce, challenge] of challenges) { + if (challenge.expiresAtMs <= current) { + challenges.delete(nonce); + } + } + }; + + return { + issue: (clientKey: string, current: number) => { + pruneExpired(current); + const clientNonces = [...challenges.entries()].filter( + ([, challenge]) => challenge.clientKey === clientKey, + ); + while (clientNonces.length >= MAX_PENDING_CHALLENGES_PER_CLIENT) { + const oldest = clientNonces.shift(); + if (oldest) { + challenges.delete(oldest[0]); + } + } + while (challenges.size >= MAX_PENDING_CHALLENGES) { + const oldest = challenges.keys().next().value; + if (typeof oldest !== "string") { + break; + } + challenges.delete(oldest); + } + const nonce = randomBytes(24).toString("base64url"); + const expiresAtMs = current + CHALLENGE_TTL_MS; + challenges.set(nonce, { clientKey, expiresAtMs }); + return { nonce, expiresAtMs }; + }, + consume: (nonce: string, clientKey: string, current: number) => { + const challenge = challenges.get(nonce); + challenges.delete(nonce); + return Boolean( + challenge && challenge.clientKey === clientKey && challenge.expiresAtMs > current, + ); + }, + clear: () => challenges.clear(), + }; +} + +function broadcastPairingSuperseded( + broadcast: GatewayBroadcastFn, + result: RequestNodePairingResult, + now: number, +) { + for (const superseded of result.created ? (result.superseded ?? []) : []) { + broadcast( + "node.pair.resolved", + { + requestId: superseded.requestId, + nodeId: superseded.nodeId, + decision: "rejected", + ts: now, + }, + { dropIfSlow: true }, + ); + } +} + +/** Create the first-party watchOS node HTTP transport for one Gateway process. */ +export function createWatchNodeHttpRuntime(options: WatchNodeHttpRuntimeOptions): { + handleRequest: (req: IncomingMessage, res: ServerResponse) => Promise; + invalidateSessionsForDevice: ( + deviceId: string, + opts?: { role?: string; reason?: string }, + ) => void; + disconnectSessionsForDevice: (deviceId: string, opts?: { role?: string }) => void; + close: () => void; +} { + const now = options.now ?? Date.now; + const challenges = createChallengeStore(); + const sessionsByToken = new Map(); + const sessionsByNodeId = new Map(); + let closed = false; + + const closeSession = (session: WatchNodeSession, reason: string) => { + if (sessionsByToken.get(session.token) !== session) { + return; + } + sessionsByToken.delete(session.token); + if (sessionsByNodeId.get(session.nodeId) === session) { + sessionsByNodeId.delete(session.nodeId); + } + clearTimeout(session.expiresTimer); + if (session.waiter) { + clearTimeout(session.waiter.timer); + if (!session.waiter.res.writableEnded) { + sendJson(session.waiter.res, 401, { ok: false, reason }); + } + session.waiter = undefined; + } + const disconnectedNodeId = options.nodeRegistry.unregister(session.connId); + if (disconnectedNodeId) { + try { + options.onNodeDisconnected?.(disconnectedNodeId, reason); + } catch (error) { + options.onError?.("watch node disconnect cleanup failed", error); + } + } + }; + + const armExpiry = (session: WatchNodeSession) => { + clearTimeout(session.expiresTimer); + session.expiresTimer = setTimeout( + () => closeSession(session, "session expired"), + SESSION_IDLE_MS, + ); + session.expiresTimer.unref?.(); + }; + + const touchSession = (session: WatchNodeSession) => { + session.lastSeenAtMs = now(); + armExpiry(session); + }; + + const sendQueuedEvent = (res: ServerResponse, queued: QueuedNodeEvent): boolean => { + if (res.writableEnded) { + return false; + } + try { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(`{"ok":true,"event":${queued.json}}`); + return true; + } catch { + return false; + } + }; + + const enqueue = (session: WatchNodeSession, queued: QueuedNodeEvent | null): boolean => { + if (sessionsByToken.get(session.token) !== session || session.invalidatedReason) { + return false; + } + if (!queued || queued.byteLength > MAX_QUEUED_EVENT_BYTES) { + closeSession(session, "event payload too large"); + return false; + } + if (session.waiter) { + const waiter = session.waiter; + session.waiter = undefined; + clearTimeout(waiter.timer); + if (!sendQueuedEvent(waiter.res, queued)) { + closeSession(session, "event delivery failed"); + return false; + } + return true; + } + if ( + session.queue.length >= MAX_QUEUED_EVENTS || + session.queuedBytes + queued.byteLength > MAX_QUEUED_BYTES + ) { + closeSession(session, "event queue overflow"); + return false; + } + session.queue.push(queued); + session.queuedBytes += queued.byteLength; + return true; + }; + + const serializeEvent = (event: string, payload?: unknown): QueuedNodeEvent | null => { + try { + const json = JSON.stringify({ event, ...(payload === undefined ? {} : { payload }) }); + return { json, byteLength: Buffer.byteLength(json) }; + } catch { + return null; + } + }; + + const serializeRawEvent = ( + event: string, + payloadJSON?: SerializedEventPayload | null, + ): QueuedNodeEvent | null => { + const eventJSON = JSON.stringify(event); + if (!payloadJSON) { + const json = `{"event":${eventJSON}}`; + return { json, byteLength: Buffer.byteLength(json) }; + } + const prefix = `{"event":${eventJSON},"payload":`; + const byteLength = + Buffer.byteLength(prefix) + Buffer.byteLength(payloadJSON.json) + Buffer.byteLength("}"); + if (byteLength > MAX_QUEUED_EVENT_BYTES) { + return null; + } + return { json: `${prefix}${payloadJSON.json}}`, byteLength }; + }; + + const createTransport = (session: WatchNodeSession): NodeEventTransport => ({ + send: (event, payload) => enqueue(session, serializeEvent(event, payload)), + sendRaw: (event, payloadJSON: SerializedEventPayload | null | undefined) => + enqueue(session, serializeRawEvent(event, payloadJSON)), + checkConnectivity: async (): Promise => { + if (session.invalidatedReason) { + return { + ok: false, + error: { code: "NOT_CONNECTED", message: session.invalidatedReason }, + }; + } + return now() - session.lastSeenAtMs < SESSION_IDLE_MS + ? { ok: true } + : { ok: false, error: { code: "NOT_CONNECTED", message: "watch node poll expired" } }; + }, + }); + + const getSession = (req: IncomingMessage, res: ServerResponse): WatchNodeSession | null => { + const token = readBearerToken(req); + const session = token ? sessionsByToken.get(token) : undefined; + if (!session) { + sendUnauthorized(res); + return null; + } + if (session.invalidatedReason) { + closeSession(session, session.invalidatedReason); + sendUnauthorized(res); + return null; + } + touchSession(session); + return session; + }; + + const handleChallenge = (req: IncomingMessage, res: ServerResponse) => { + if ((req.method ?? "GET").toUpperCase() !== "GET") { + sendMethodNotAllowed(res, "GET"); + return; + } + const { rateLimitKey: clientKey } = resolveWatchClientAddress(req, options.getConfig()); + const rateLimit = options.rateLimiter?.check(clientKey, AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE); + if (rateLimit && !rateLimit.allowed) { + sendRateLimited(res, rateLimit.retryAfterMs); + return; + } + options.rateLimiter?.recordFailure(clientKey, AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE); + const challenge = challenges.issue(clientKey, now()); + res.setHeader("Cache-Control", "no-store"); + sendJson(res, 200, { ok: true, ...challenge }); + }; + + const handleConnect = async (req: IncomingMessage, res: ServerResponse) => { + if ((req.method ?? "").toUpperCase() !== "POST") { + sendMethodNotAllowed(res); + return; + } + const responseLifecycle = trackResponseLifecycle(res); + const body = await readJsonBodyOrError(req, res, MAX_BODY_BYTES); + if (body === undefined) { + return; + } + if (!validateConnectParams(body)) { + sendInvalidRequest( + res, + `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`, + ); + return; + } + const connect = body as ConnectParams; + if (!isCanonicalWatchNode(connect)) { + sendInvalidRequest(res, "unsupported watch node identity or capability surface"); + return; + } + const auth = connect.auth; + const bootstrapToken = auth?.bootstrapToken?.trim() || null; + const deviceToken = auth?.deviceToken?.trim() || null; + const expectedAuthField = bootstrapToken + ? "bootstrapToken" + : deviceToken + ? "deviceToken" + : null; + const authFields = Object.keys(auth ?? {}); + if ( + !expectedAuthField || + authFields.length !== 1 || + authFields[0] !== expectedAuthField || + !connect.device + ) { + sendUnauthorized(res); + return; + } + + const current = now(); + const { clientIp, rateLimitKey: clientKey } = resolveWatchClientAddress( + req, + options.getConfig(), + ); + if ( + !challenges.consume(connect.device.nonce, clientKey, current) || + Math.abs(current - connect.device.signedAt) > SIGNATURE_SKEW_MS + ) { + sendUnauthorized(res); + return; + } + const publicKey = normalizeDevicePublicKeyBase64Url(connect.device.publicKey); + const derivedDeviceId = publicKey ? deriveDeviceIdFromPublicKey(publicKey) : null; + if (!publicKey || !derivedDeviceId || derivedDeviceId !== connect.device.id) { + sendUnauthorized(res); + return; + } + const signatureVersion = resolveDeviceSignaturePayloadVersion({ + device: { ...connect.device, publicKey }, + connectParams: connect, + role: "node", + scopes: [], + signedAtMs: connect.device.signedAt, + nonce: connect.device.nonce, + }); + if (!signatureVersion) { + sendUnauthorized(res); + return; + } + + const authDecision = await resolveConnectAuthDecision({ + state: { + authResult: { ok: false, reason: "token_mismatch" }, + authOk: false, + authMethod: "token", + sharedAuthOk: false, + sharedAuthProvided: false, + ...(bootstrapToken ? { bootstrapTokenCandidate: bootstrapToken } : {}), + ...(deviceToken + ? { + deviceTokenCandidate: deviceToken, + deviceTokenCandidateSource: "explicit-device-token" as const, + } + : {}), + }, + hasDeviceIdentity: true, + deviceId: derivedDeviceId, + publicKey, + role: "node", + scopes: [], + rateLimiter: options.rateLimiter, + clientIp: clientKey, + verifyBootstrapToken: async (params) => + await verifyDeviceBootstrapToken({ ...params, baseDir: options.pairingBaseDir }), + verifyDeviceToken: async (params) => + await verifyDeviceToken({ ...params, baseDir: options.pairingBaseDir }), + }); + if (!authDecision.authOk) { + if (authDecision.authResult.rateLimited) { + sendRateLimited(res, authDecision.authResult.retryAfterMs ?? 0); + } else { + sendUnauthorized(res); + } + return; + } + + let issuedDeviceToken = deviceToken; + let setupBootstrapAccepted = false; + if (bootstrapToken) { + const existing = await getPairedDevice(derivedDeviceId, options.pairingBaseDir); + if (existing && existing.publicKey !== publicKey) { + sendUnauthorized(res); + return; + } + const profile = await getBoundDeviceBootstrapProfile({ + token: bootstrapToken, + deviceId: derivedDeviceId, + publicKey, + baseDir: options.pairingBaseDir, + }); + if (!profile || !isNodePairingSetupBootstrapProfile(profile)) { + sendUnauthorized(res); + return; + } + if (existing) { + issuedDeviceToken = + ( + await ensureDeviceToken({ + deviceId: derivedDeviceId, + role: "node", + scopes: [], + baseDir: options.pairingBaseDir, + }) + )?.token ?? null; + } + if (!issuedDeviceToken) { + const pairing = await requestDevicePairing( + { + deviceId: derivedDeviceId, + publicKey, + displayName: connect.client.displayName, + platform: connect.client.platform, + deviceFamily: connect.client.deviceFamily, + clientId: connect.client.id, + clientMode: connect.client.mode, + role: "node", + roles: ["node"], + scopes: [], + remoteIp: clientIp, + silent: true, + }, + options.pairingBaseDir, + ); + const approved = await approveBootstrapDevicePairing( + pairing.request.requestId, + profile, + options.pairingBaseDir, + ); + if (approved?.status !== "approved") { + sendUnauthorized(res); + return; + } + issuedDeviceToken = approved.device.tokens?.node?.token ?? null; + options.broadcast( + "device.pair.resolved", + { + requestId: pairing.request.requestId, + deviceId: derivedDeviceId, + decision: "approved", + ts: current, + }, + { dropIfSlow: true }, + ); + } + setupBootstrapAccepted = Boolean(issuedDeviceToken); + } else if (deviceToken) { + const paired = await getPairedDevice(derivedDeviceId, options.pairingBaseDir); + if (paired?.publicKey !== publicKey) { + sendUnauthorized(res); + return; + } + } + if (!issuedDeviceToken) { + sendUnauthorized(res); + return; + } + + const nodeSnapshot = await beginNodePairingConnect(derivedDeviceId, options.pairingBaseDir); + let cleanupClaim = nodeSnapshot.cleanupClaim; + try { + let reconciliation: Awaited>; + try { + reconciliation = await reconcileNodePairingOnConnect({ + cfg: options.getConfig(), + connectParams: connect, + pairedNode: nodeSnapshot.pairedNode, + reportedClientIp: clientIp, + requestPairing: async (input) => { + if (nodeSnapshot.pairedNode && options.nodeReapprovalCoordinator) { + return await options.nodeReapprovalCoordinator.request({ + input, + cleanupClaim, + baseDir: options.pairingBaseDir, + }); + } + if (!options.rateLimiter) { + return await requestNodePairing(input, options.pairingBaseDir); + } + return await withSerializedRateLimitAttempt({ + ip: clientKey, + scope: AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, + run: async () => { + const rateCheck = options.rateLimiter?.check( + clientKey, + AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, + ); + if (rateCheck && !rateCheck.allowed) { + throw new WatchNodePairingRateLimitError(rateCheck.retryAfterMs); + } + const result = await requestNodePairing(input, options.pairingBaseDir); + options.rateLimiter?.recordFailure(clientKey, AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING); + return result; + }, + }); + }, + }); + } catch (error) { + if (error instanceof WatchNodePairingRateLimitError) { + sendRateLimited(res, error.retryAfterMs); + return; + } + throw error; + } + if (reconciliation.pendingPairing) { + broadcastPairingSuperseded(options.broadcast, reconciliation.pendingPairing, current); + } + if ( + setupBootstrapAccepted && + !nodeSnapshot.pairedNode && + reconciliation.pendingPairing && + hasOnlyBoundedWatchSurface(connect) + ) { + const approved = await approveNodePairing( + reconciliation.pendingPairing.request.requestId, + { callerScopes: [ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE] }, + options.pairingBaseDir, + ); + if (approved && "node" in approved) { + options.broadcast( + "node.pair.resolved", + { + requestId: reconciliation.pendingPairing.request.requestId, + nodeId: derivedDeviceId, + decision: "approved", + ts: current, + }, + { dropIfSlow: true }, + ); + reconciliation = { + ...reconciliation, + effectiveCaps: reconciliation.declaredCaps, + effectiveCommands: reconciliation.declaredCommands, + effectivePermissions: reconciliation.declaredPermissions, + pendingPairing: undefined, + shouldClearPendingPairings: true, + }; + } + } + if (reconciliation.pendingPairing?.created) { + options.broadcast("node.pair.requested", reconciliation.pendingPairing.request, { + dropIfSlow: true, + }); + } + + let revokedBootstrapTokenRecord: + | Awaited>["record"] + | undefined; + if (closed || responseLifecycle.isAborted()) { + return; + } + if (bootstrapToken) { + const redemption = await redeemDeviceBootstrapTokenProfile({ + token: bootstrapToken, + role: "node", + scopes: [], + baseDir: options.pairingBaseDir, + }); + if (!redemption.recorded || !redemption.fullyRedeemed) { + sendUnauthorized(res); + return; + } + const revoked = await revokeDeviceBootstrapToken({ + token: bootstrapToken, + baseDir: options.pairingBaseDir, + }); + if (!revoked.removed || !revoked.record) { + sendUnauthorized(res); + return; + } + revokedBootstrapTokenRecord = revoked.record; + } + + // Device lifecycle mutations run asynchronously after marking current sessions. + // Reverify after every pairing await, then publish without another yield so a + // concurrent revoke either fails admission or sees this registered transport. + let finalTokenVerification: Awaited>; + try { + finalTokenVerification = await verifyDeviceToken({ + deviceId: derivedDeviceId, + token: issuedDeviceToken, + role: "node", + scopes: [], + baseDir: options.pairingBaseDir, + }); + } catch (error) { + if (revokedBootstrapTokenRecord) { + await restoreDeviceBootstrapToken({ + record: revokedBootstrapTokenRecord, + baseDir: options.pairingBaseDir, + }); + } + throw error; + } + if (!finalTokenVerification.ok) { + sendUnauthorized(res); + return; + } + if (closed || responseLifecycle.isAborted()) { + if (revokedBootstrapTokenRecord) { + await restoreDeviceBootstrapToken({ + record: revokedBootstrapTokenRecord, + baseDir: options.pairingBaseDir, + }); + } + return; + } + + const registeredConnect = connect as ConnectParams & { + declaredCaps?: string[]; + declaredCommands?: string[]; + declaredPermissions?: Record; + }; + registeredConnect.declaredCaps = reconciliation.declaredCaps; + registeredConnect.declaredCommands = reconciliation.declaredCommands; + registeredConnect.declaredPermissions = reconciliation.declaredPermissions; + registeredConnect.caps = reconciliation.effectiveCaps; + registeredConnect.commands = reconciliation.effectiveCommands; + registeredConnect.permissions = reconciliation.effectivePermissions; + + let session: WatchNodeSession | undefined; + try { + const previous = sessionsByNodeId.get(derivedDeviceId); + const connId = randomUUID(); + session = { + token: randomBytes(32).toString("base64url"), + nodeId: derivedDeviceId, + connId, + lastSeenAtMs: now(), + expiresTimer: setTimeout(() => undefined, SESSION_IDLE_MS), + queue: [], + queuedBytes: 0, + }; + const client: GatewayWsClient = { + socket: undefined as never, + connect: registeredConnect, + connId, + isDeviceTokenAuth: true, + usesSharedGatewayAuth: false, + clientIp, + }; + const nodeSession = options.nodeRegistry.registerTransport( + client, + { remoteIp: clientIp }, + createTransport(session), + ); + sessionsByToken.set(session.token, session); + sessionsByNodeId.set(session.nodeId, session); + armExpiry(session); + if (previous) { + // The new registry entry is current, so unregistering the superseded + // transport rejects its work without emitting a false offline lifecycle. + closeSession(previous, "replaced by a newer watch session"); + } + options.onNodeConnected?.(nodeSession); + sendJson(res, 200, { + ok: true, + sessionToken: session.token, + deviceToken: issuedDeviceToken, + nodeId: session.nodeId, + protocol: PROTOCOL_VERSION, + pollTimeoutMs: POLL_TIMEOUT_MS, + }); + const responseCompleted = await responseLifecycle.completed; + if (!responseCompleted) { + closeSession(session, "connect response aborted"); + if (revokedBootstrapTokenRecord) { + await restoreDeviceBootstrapToken({ + record: revokedBootstrapTokenRecord, + baseDir: options.pairingBaseDir, + }); + } + return; + } + options.rateLimiter?.reset(clientKey, AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE); + if (reconciliation.shouldClearPendingPairings && cleanupClaim) { + const claim = cleanupClaim; + cleanupClaim = undefined; + try { + const resolvedPairings = options.nodeReapprovalCoordinator + ? await options.nodeReapprovalCoordinator.finalizeCleanup(claim) + : await finalizeNodePairingCleanupClaim(claim); + const resolvedAt = now(); + for (const resolved of resolvedPairings) { + options.broadcast( + "node.pair.resolved", + { + requestId: resolved.requestId, + nodeId: resolved.nodeId, + decision: "rejected", + ts: resolvedAt, + }, + { dropIfSlow: true }, + ); + } + } catch (error) { + options.onError?.("watch node pending-pairing cleanup failed", error); + } + } + void updatePairedNodeMetadata( + session.nodeId, + { lastConnectedAtMs: nodeSession.connectedAtMs }, + options.pairingBaseDir, + ).catch((error: unknown) => + options.onError?.("watch node last-connect metadata update failed", error), + ); + } catch (error) { + if (session) { + closeSession(session, "connect failed"); + } + if (revokedBootstrapTokenRecord) { + await restoreDeviceBootstrapToken({ + record: revokedBootstrapTokenRecord, + baseDir: options.pairingBaseDir, + }); + } + throw error; + } + } finally { + if (cleanupClaim) { + await releaseNodePairingCleanupClaim(cleanupClaim); + } + } + }; + + const handlePoll = async (req: IncomingMessage, res: ServerResponse) => { + if ((req.method ?? "").toUpperCase() !== "POST") { + sendMethodNotAllowed(res); + return; + } + const session = getSession(req, res); + if (!session) { + return; + } + const queued = session.queue.shift(); + if (queued) { + session.queuedBytes -= queued.byteLength; + if (!sendQueuedEvent(res, queued)) { + closeSession(session, "event delivery failed"); + } + return; + } + if (session.waiter) { + clearTimeout(session.waiter.timer); + sendJson(session.waiter.res, 409, { ok: false, reason: "superseded poll" }); + } + const timer = setTimeout(() => { + if (session.waiter?.res !== res) { + return; + } + session.waiter = undefined; + if (!res.writableEnded) { + sendJson(res, 200, { ok: true, event: null }); + } + }, POLL_TIMEOUT_MS); + timer.unref?.(); + session.waiter = { res, timer }; + res.once("close", () => { + if (!res.writableEnded && session.waiter?.res === res) { + clearTimeout(session.waiter.timer); + session.waiter = undefined; + closeSession(session, "poll connection closed"); + } + }); + }; + + const handleDisconnect = (req: IncomingMessage, res: ServerResponse) => { + if ((req.method ?? "").toUpperCase() !== "POST") { + sendMethodNotAllowed(res); + return; + } + const session = getSession(req, res); + if (!session) { + return; + } + closeSession(session, "watch disconnected"); + sendJson(res, 200, { ok: true }); + }; + + const handleResult = async (req: IncomingMessage, res: ServerResponse) => { + if ((req.method ?? "").toUpperCase() !== "POST") { + sendMethodNotAllowed(res); + return; + } + const session = getSession(req, res); + if (!session) { + return; + } + const body = await readJsonBodyOrError(req, res, MAX_BODY_BYTES); + if (!isStringRecord(body) || typeof body.id !== "string" || typeof body.ok !== "boolean") { + sendInvalidRequest(res, "invalid node invoke result"); + return; + } + const error = isStringRecord(body.error) + ? { + ...(typeof body.error.code === "string" ? { code: body.error.code } : {}), + ...(typeof body.error.message === "string" ? { message: body.error.message } : {}), + } + : null; + const accepted = options.nodeRegistry.handleInvokeResult({ + id: body.id, + nodeId: session.nodeId, + connId: session.connId, + ok: body.ok, + payload: body.payload, + payloadJSON: typeof body.payloadJSON === "string" ? body.payloadJSON : null, + error, + }); + sendJson(res, 200, accepted ? { ok: true } : { ok: true, ignored: true }); + }; + + const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { + const path = normalizePath(req); + if (!path?.startsWith(`${BASE_PATH}/`)) { + return false; + } + if (closed) { + sendJson(res, 503, { ok: false, error: "gateway shutting down" }); + return true; + } + res.setHeader("Cache-Control", "no-store"); + switch (path) { + case CHALLENGE_PATH: + handleChallenge(req, res); + return true; + case CONNECT_PATH: + await handleConnect(req, res); + return true; + case DISCONNECT_PATH: + handleDisconnect(req, res); + return true; + case POLL_PATH: + await handlePoll(req, res); + return true; + case RESULT_PATH: + await handleResult(req, res); + return true; + default: + sendJson(res, 404, { ok: false, error: "not found" }); + return true; + } + }; + + return { + handleRequest, + invalidateSessionsForDevice: (deviceId, opts) => { + if (opts?.role && opts.role !== "node") { + return; + } + const session = sessionsByNodeId.get(deviceId); + if (session) { + // Match WebSocket invalidation: reject buffered work before the + // asynchronous transport teardown closes an outstanding long poll. + session.invalidatedReason = opts?.reason ?? "device-invalidated"; + } + }, + disconnectSessionsForDevice: (deviceId, opts) => { + if (opts?.role && opts.role !== "node") { + return; + } + const session = sessionsByNodeId.get(deviceId); + if (session) { + closeSession(session, session.invalidatedReason ?? "device removed"); + } + }, + close: () => { + closed = true; + for (const session of sessionsByToken.values()) { + closeSession(session, "gateway shutting down"); + } + challenges.clear(); + }, + }; +} + +export const testing = { + createChallengeStore, + hasOnlyBoundedWatchSurface, + isCanonicalWatchNode, +}; diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 6dae4e1a552..e8cee941557 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -113,6 +113,7 @@ describe("pairing setup code", () => { url?: string; urls?: string[]; urlSource?: string; + bootstrapProfile?: { roles: string[]; scopes: string[] }; }, ) { expect(resolved.ok).toBe(true); @@ -123,7 +124,7 @@ describe("pairing setup code", () => { expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({ baseDir: undefined, - profile: { + profile: params.bootstrapProfile ?? { roles: ["node", "operator"], scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }, @@ -155,6 +156,7 @@ describe("pairing setup code", () => { url: string; urls?: string[]; urlSource: string; + bootstrapProfile?: { roles: string[]; scopes: string[] }; }; runCommandWithTimeout?: ReturnType; expectedRunCommandCalls?: number; @@ -261,6 +263,23 @@ describe("pairing setup code", () => { }); }); + it("issues a node-only bootstrap profile for companion setup", async () => { + await expectResolvedSetupSuccessCase({ + config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }), + options: { + forceSecure: true, + publicUrl: "gateway.example.test:18789/setup", + bootstrapProfile: { roles: ["node"], scopes: [] }, + }, + expected: { + authLabel: "token", + url: "wss://gateway.example.test:18789", + urlSource: "plugins.entries.device-pair.config.publicUrl", + bootstrapProfile: { roles: ["node"], scopes: [] }, + }, + }); + }); + it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => { await expectResolvedSetupFailureCase({ config: { diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index ecb65460744..08ebe955f95 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -23,7 +23,10 @@ import { pickMatchingExternalInterfaceAddress, safeNetworkInterfaces, } from "../infra/network-interfaces.js"; -import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; +import { + PAIRING_SETUP_BOOTSTRAP_PROFILE, + type DeviceBootstrapProfileInput, +} from "../shared/device-bootstrap-profile.js"; import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; import { resolveTailnetHostWithRunner, @@ -55,6 +58,7 @@ export type ResolvePairingSetupOptions = { publicUrl?: string; preferRemoteUrl?: boolean; forceSecure?: boolean; + bootstrapProfile?: DeviceBootstrapProfileInput; pairingBaseDir?: string; runCommandWithTimeout?: PairingSetupCommandRunner; networkInterfaces?: () => ReturnType; @@ -435,7 +439,7 @@ export async function resolvePairingSetupFromConfig( bootstrapToken: ( await issueDeviceBootstrapToken({ baseDir: options.pairingBaseDir, - profile: PAIRING_SETUP_BOOTSTRAP_PROFILE, + profile: options.bootstrapProfile ?? PAIRING_SETUP_BOOTSTRAP_PROFILE, }) ).token, }, diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index 3f317f7caed..88110924933 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "vitest"; import { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, PAIRING_SETUP_BOOTSTRAP_PROFILE, + isNodePairingSetupBootstrapProfile, isPairingSetupBootstrapProfile, normalizeDeviceBootstrapHandoffProfile, resolveBootstrapProfileScopesForRole, @@ -76,6 +78,12 @@ describe("device bootstrap profile", () => { }); }); + test("node setup profile carries no operator access", () => { + expect(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ roles: ["node"], scopes: [] }); + expect(isNodePairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true); + expect(isPairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(false); + }); + test("recognizes only the current setup profile", () => { expect(isPairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true); expect( diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index a491824dea2..a2f5dbd5278 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -32,21 +32,37 @@ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], }; +/** Node-only setup profile for companions that never act as operators. */ +export const NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { + roles: ["node"], + scopes: [], +}; + +function matchesBootstrapProfile( + input: DeviceBootstrapProfileInput | undefined, + expected: DeviceBootstrapProfile, +): boolean { + const profile = normalizeDeviceBootstrapProfile(input); + return ( + profile.roles.length === expected.roles.length && + profile.scopes.length === expected.scopes.length && + profile.roles.every((role, index) => role === expected.roles[index]) && + profile.scopes.every((scope, index) => scope === expected.scopes[index]) + ); +} + /** Return whether an input exactly matches the current setup-code bootstrap profile. */ export function isPairingSetupBootstrapProfile( input: DeviceBootstrapProfileInput | undefined, ): boolean { - const profile = normalizeDeviceBootstrapProfile(input); - if (profile.roles.length !== PAIRING_SETUP_BOOTSTRAP_PROFILE.roles.length) { - return false; - } - if (profile.scopes.length !== PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes.length) { - return false; - } - return ( - profile.roles.every((role, index) => role === PAIRING_SETUP_BOOTSTRAP_PROFILE.roles[index]) && - profile.scopes.every((scope, index) => scope === PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes[index]) - ); + return matchesBootstrapProfile(input, PAIRING_SETUP_BOOTSTRAP_PROFILE); +} + +/** Return whether an input exactly matches the node-only companion setup profile. */ +export function isNodePairingSetupBootstrapProfile( + input: DeviceBootstrapProfileInput | undefined, +): boolean { + return matchesBootstrapProfile(input, NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE); } /** Resolve the subset of requested scopes a bootstrap profile may carry for one role. */