diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index b19cbf254868..5c6df64503fa 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -1667,7 +1667,7 @@ }, { "kind": "ui-call", - "line": 8980, + "line": 8982, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", @@ -3459,7 +3459,7 @@ }, { "kind": "ui-call", - "line": 93, + "line": 96, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs.", "surface": "android", @@ -3467,7 +3467,7 @@ }, { "kind": "ui-call", - "line": 98, + "line": 101, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access.", "surface": "android", @@ -3475,23 +3475,23 @@ }, { "kind": "conditional-branch", - "line": 253, + "line": 267, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", - "source": "${if (tls) \"https\" else \"http\"}://$displayHost", + "source": "${if (tls) \"https\" else \"http\"}://$displayHost$displayPath", "surface": "android", - "id": "native.android.ff5abe2418979715" + "id": "native.android.73109af9b97b61eb" }, { "kind": "conditional-branch", - "line": 255, + "line": 269, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", - "source": "${if (tls) \"https\" else \"http\"}://$displayHost:$port", + "source": "${if (tls) \"https\" else \"http\"}://$displayHost:$port$displayPath", "surface": "android", - "id": "native.android.28e58e1bd98e08ab" + "id": "native.android.6c4a0216a29d5921" }, { "kind": "ui-call", - "line": 322, + "line": 343, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Setup code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix", "surface": "android", @@ -3499,7 +3499,7 @@ }, { "kind": "ui-call", - "line": 328, + "line": 349, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "QR code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix", "surface": "android", @@ -3507,7 +3507,7 @@ }, { "kind": "ui-call", - "line": 334, + "line": 355, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "$remoteGatewaySecurityRule $remoteGatewaySecurityFix", "surface": "android", @@ -3515,7 +3515,7 @@ }, { "kind": "ui-call", - "line": 343, + "line": 364, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Setup code uses an IPv6 zone ID. Use an unscoped IPv6 address or a LAN hostname.", "surface": "android", @@ -3523,7 +3523,7 @@ }, { "kind": "ui-call", - "line": 345, + "line": 366, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "QR code uses an IPv6 zone ID. Use an unscoped IPv6 address or a LAN hostname.", "surface": "android", @@ -3531,7 +3531,7 @@ }, { "kind": "ui-call", - "line": 347, + "line": 368, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "IPv6 zone IDs are not supported. Use an unscoped IPv6 address or a LAN hostname.", "surface": "android", @@ -3539,7 +3539,7 @@ }, { "kind": "ui-call", - "line": 351, + "line": 372, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Setup code has invalid gateway URL.", "surface": "android", @@ -3547,7 +3547,7 @@ }, { "kind": "ui-call", - "line": 352, + "line": 373, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "QR code did not contain a valid setup code.", "surface": "android", @@ -3555,7 +3555,7 @@ }, { "kind": "ui-call", - "line": 353, + "line": 374, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Enter a valid manual endpoint to connect.", "surface": "android", @@ -3563,7 +3563,7 @@ }, { "kind": "ui-call", - "line": 493, + "line": 514, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Secure connection is required for this host.", "surface": "android", @@ -3571,7 +3571,7 @@ }, { "kind": "ui-call", - "line": 495, + "line": 516, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt", "source": "Use only on a trusted private network.", "surface": "android", @@ -21835,7 +21835,7 @@ }, { "kind": "ui-modifier", - "line": 156, + "line": 157, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Settings", "surface": "apple", @@ -21843,7 +21843,7 @@ }, { "kind": "ui-modifier", - "line": 264, + "line": 265, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Scan QR Code", "surface": "apple", @@ -21851,7 +21851,7 @@ }, { "kind": "ui-modifier", - "line": 286, + "line": 287, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset Onboarding?", "surface": "apple", @@ -21859,7 +21859,7 @@ }, { "kind": "ui-call", - "line": 290, + "line": 291, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset", "surface": "apple", @@ -21867,7 +21867,7 @@ }, { "kind": "ui-call", - "line": 298, + "line": 299, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This disconnects, clears saved gateway credentials, and reopens onboarding.", "surface": "apple", @@ -21875,7 +21875,7 @@ }, { "kind": "ui-modifier", - "line": 302, + "line": 303, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -21883,7 +21883,7 @@ }, { "kind": "ui-call", - "line": 311, + "line": 312, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "OK", "surface": "apple", @@ -21891,7 +21891,7 @@ }, { "kind": "ui-localized-call", - "line": 320, + "line": 321, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Forget %@?", "surface": "apple", @@ -21899,7 +21899,7 @@ }, { "kind": "ui-localized-call", - "line": 321, + "line": 322, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "gateway", "surface": "apple", @@ -21907,7 +21907,7 @@ }, { "kind": "ui-call", - "line": 336, + "line": 337, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Forget Gateway", "surface": "apple", @@ -21915,7 +21915,7 @@ }, { "kind": "ui-call", - "line": 342, + "line": 343, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Cancel", "surface": "apple", @@ -21923,7 +21923,7 @@ }, { "kind": "ui-localized-call", - "line": 350, + "line": 351, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This removes saved credentials, device access, TLS trust, and cached chats for this gateway.", "surface": "apple", @@ -21931,7 +21931,7 @@ }, { "kind": "ui-call", - "line": 404, + "line": 405, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Enable OpenClaw Hosted Push Relay?", "surface": "apple", @@ -21939,7 +21939,7 @@ }, { "kind": "ui-call", - "line": 418, + "line": 419, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Continue", "surface": "apple", @@ -21947,7 +21947,7 @@ }, { "kind": "ui-call", - "line": 426, + "line": 427, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Not Now", "surface": "apple", @@ -22123,7 +22123,7 @@ }, { "kind": "ui-localized-call", - "line": 329, + "line": 338, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "TLS", "surface": "apple", @@ -22131,7 +22131,7 @@ }, { "kind": "ui-localized-call", - "line": 329, + "line": 338, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "plain", "surface": "apple", @@ -22139,7 +22139,7 @@ }, { "kind": "ui-localized-call", - "line": 332, + "line": 341, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup link loaded for %@:%@ (%@). Tap Connect to apply.", "surface": "apple", @@ -22147,7 +22147,7 @@ }, { "kind": "ui-localized-call", - "line": 343, + "line": 352, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Paste a setup code to continue.", "surface": "apple", @@ -22155,7 +22155,7 @@ }, { "kind": "ui-localized-call", - "line": 358, + "line": 367, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup code not recognized or uses an insecure ws:// gateway URL.", "surface": "apple", @@ -22163,7 +22163,7 @@ }, { "kind": "ui-localized-call", - "line": 404, + "line": 414, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Opening QR scanner...", "surface": "apple", @@ -22171,7 +22171,7 @@ }, { "kind": "ui-localized-call", - "line": 410, + "line": 420, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "QR loaded. Closing scanner...", "surface": "apple", @@ -22179,7 +22179,7 @@ }, { "kind": "ui-localized-call", - "line": 440, + "line": 450, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Apple Review demo mode enabled.", "surface": "apple", @@ -22187,7 +22187,7 @@ }, { "kind": "ui-localized-call", - "line": 491, + "line": 501, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Failed: host required", "surface": "apple", @@ -22195,7 +22195,7 @@ }, { "kind": "ui-localized-call", - "line": 499, + "line": 509, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Failed: invalid port", "surface": "apple", @@ -22203,7 +22203,7 @@ }, { "kind": "ui-localized-call", - "line": 557, + "line": 568, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Tailscale is off on this device. Turn it on, then try again.", "surface": "apple", @@ -22211,7 +22211,7 @@ }, { "kind": "ui-localized-call", - "line": 934, + "line": 947, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Gateway", "surface": "apple", @@ -22219,7 +22219,7 @@ }, { "kind": "ui-localized-call", - "line": 935, + "line": 948, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "OpenClaw", "surface": "apple", @@ -22227,7 +22227,7 @@ }, { "kind": "ui-localized-call", - "line": 936, + "line": 949, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Apple Watch", "surface": "apple", @@ -22235,7 +22235,7 @@ }, { "kind": "ui-localized-call", - "line": 937, + "line": 950, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Approvals", "surface": "apple", @@ -22243,7 +22243,7 @@ }, { "kind": "ui-localized-call", - "line": 938, + "line": 951, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Permissions", "surface": "apple", @@ -22251,7 +22251,7 @@ }, { "kind": "ui-localized-call", - "line": 939, + "line": 952, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Channels", "surface": "apple", @@ -22259,7 +22259,7 @@ }, { "kind": "ui-localized-call", - "line": 940, + "line": 953, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Skills", "surface": "apple", @@ -22267,7 +22267,7 @@ }, { "kind": "ui-localized-call", - "line": 941, + "line": 954, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Voice & Talk", "surface": "apple", @@ -22275,7 +22275,7 @@ }, { "kind": "ui-localized-call", - "line": 942, + "line": 955, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Diagnostics", "surface": "apple", @@ -22283,7 +22283,7 @@ }, { "kind": "ui-localized-call", - "line": 943, + "line": 956, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Privacy", "surface": "apple", @@ -22291,7 +22291,7 @@ }, { "kind": "ui-localized-call", - "line": 945, + "line": 958, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Licenses", "surface": "apple", @@ -22299,7 +22299,7 @@ }, { "kind": "ui-localized-call", - "line": 946, + "line": 959, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "About", "surface": "apple", @@ -22307,7 +22307,7 @@ }, { "kind": "ui-localized-call", - "line": 953, + "line": 966, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Preparing one-time setup…", "surface": "apple", @@ -22315,7 +22315,7 @@ }, { "kind": "ui-localized-call", - "line": 958, + "line": 971, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup sent. Open OpenClaw on the watch to connect.", "surface": "apple", @@ -22323,7 +22323,7 @@ }, { "kind": "ui-localized-call", - "line": 960, + "line": 973, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup queued for the watch. Open OpenClaw before the code expires.", "surface": "apple", @@ -22331,7 +22331,7 @@ }, { "kind": "ui-localized-call", - "line": 1050, + "line": 1064, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect.", "surface": "apple", @@ -22339,7 +22339,7 @@ }, { "kind": "ui-localized-call", - "line": 1057, + "line": 1071, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Pairing required. Run /pair approve in your OpenClaw chat, then connect again.", "surface": "apple", @@ -22347,7 +22347,7 @@ }, { "kind": "ui-localized-call", - "line": 1060, + "line": 1074, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Secure handshake failed. Check Tailscale, then connect again.", "surface": "apple", @@ -22355,7 +22355,7 @@ }, { "kind": "ui-localized-call", - "line": 1069, + "line": 1083, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connection timed out. Make sure Tailscale is connected, then try again.", "surface": "apple", @@ -22363,7 +22363,7 @@ }, { "kind": "ui-localized-call", - "line": 1073, + "line": 1087, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connected, but some controls are restricted for nodes. This is expected.", "surface": "apple", @@ -22371,7 +22371,7 @@ }, { "kind": "ui-localized-call", - "line": 1080, + "line": 1094, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup code applied. Connecting...", "surface": "apple", @@ -22379,7 +22379,7 @@ }, { "kind": "ui-localized-call", - "line": 1081, + "line": 1095, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Checking gateway reachability...", "surface": "apple", @@ -22387,7 +22387,7 @@ }, { "kind": "ui-localized-call", - "line": 1082, + "line": 1096, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "QR loaded. Connecting to %@:%@...", "surface": "apple", @@ -22395,7 +22395,7 @@ }, { "kind": "ui-localized-call", - "line": 1145, + "line": 1159, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not loaded", "surface": "apple", @@ -22403,7 +22403,7 @@ }, { "kind": "ui-localized-call", - "line": 1148, + "line": 1162, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Configured", "surface": "apple", @@ -22411,7 +22411,7 @@ }, { "kind": "ui-localized-call", - "line": 1149, + "line": 1163, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not configured", "surface": "apple", @@ -22419,7 +22419,7 @@ }, { "kind": "ui-localized-call", - "line": 1156, + "line": 1170, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not active", "surface": "apple", @@ -22427,7 +22427,7 @@ }, { "kind": "ui-localized-call", - "line": 1192, + "line": 1206, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Apple Review demo mode", "surface": "apple", @@ -22435,7 +22435,7 @@ }, { "kind": "ui-localized-call", - "line": 1195, + "line": 1209, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connected", "surface": "apple", @@ -22443,7 +22443,7 @@ }, { "kind": "ui-localized-call", - "line": 1201, + "line": 1215, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "offline", "surface": "apple", @@ -22451,7 +22451,7 @@ }, { "kind": "ui-localized-call", - "line": 1201, + "line": 1215, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "online", "surface": "apple", @@ -22459,7 +22459,7 @@ }, { "kind": "ui-localized-call", - "line": 1219, + "line": 1233, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Live gateway requests are disabled in demo mode.", "surface": "apple", @@ -22467,7 +22467,7 @@ }, { "kind": "ui-localized-call", - "line": 1223, + "line": 1237, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Foreground approvals still appear while OpenClaw is connected.", "surface": "apple", @@ -22475,7 +22475,7 @@ }, { "kind": "ui-localized-call", - "line": 1226, + "line": 1240, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Gateway requests will appear here.", "surface": "apple", @@ -22483,7 +22483,7 @@ }, { "kind": "ui-localized-call", - "line": 1227, + "line": 1241, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connect to the gateway.", "surface": "apple", @@ -22491,7 +22491,7 @@ }, { "kind": "ui-localized-call", - "line": 1231, + "line": 1245, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Demo mode only", "surface": "apple", @@ -22499,7 +22499,7 @@ }, { "kind": "ui-localized-call", - "line": 1238, + "line": 1252, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "loaded", "surface": "apple", @@ -22507,7 +22507,7 @@ }, { "kind": "ui-localized-call", - "line": 1239, + "line": 1253, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "missing", "surface": "apple", @@ -22515,7 +22515,7 @@ }, { "kind": "ui-localized-call", - "line": 1248, + "line": 1262, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Waiting for gateway", "surface": "apple", @@ -22523,7 +22523,7 @@ }, { "kind": "ui-localized-call", - "line": 1265, + "line": 1279, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "1 waiting", "surface": "apple", @@ -22531,7 +22531,7 @@ }, { "kind": "ui-localized-call", - "line": 1268, + "line": 1282, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "%@ waiting", "surface": "apple", @@ -22539,7 +22539,7 @@ }, { "kind": "ui-localized-call", - "line": 1279, + "line": 1293, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Review gateway action", "surface": "apple", @@ -22547,7 +22547,7 @@ }, { "kind": "ui-localized-call", - "line": 1281, + "line": 1295, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Agent: %@", "surface": "apple", @@ -22555,7 +22555,7 @@ }, { "kind": "ui-localized-call", - "line": 1290, + "line": 1304, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Resolving", "surface": "apple", @@ -22563,7 +22563,7 @@ }, { "kind": "ui-localized-call", - "line": 1291, + "line": 1305, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "High", "surface": "apple", @@ -22571,7 +22571,7 @@ }, { "kind": "ui-localized-call", - "line": 1297, + "line": 1311, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Permission can be saved", "surface": "apple", @@ -22579,7 +22579,7 @@ }, { "kind": "ui-localized-call", - "line": 1298, + "line": 1312, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "One-time approval", "surface": "apple", @@ -22587,7 +22587,7 @@ }, { "kind": "ui-localized-call", - "line": 1301, + "line": 1315, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Medium", "surface": "apple", @@ -22595,7 +22595,7 @@ }, { "kind": "ui-localized-call", - "line": 1302, + "line": 1316, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Review", "surface": "apple", @@ -22603,7 +22603,7 @@ }, { "kind": "ui-localized-call", - "line": 1308, + "line": 1322, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Talk + Wake", "surface": "apple", @@ -22611,7 +22611,7 @@ }, { "kind": "ui-localized-call", - "line": 1309, + "line": 1323, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Talk on", "surface": "apple", @@ -22619,7 +22619,7 @@ }, { "kind": "ui-localized-call", - "line": 1310, + "line": 1324, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Wake on", "surface": "apple", @@ -22627,7 +22627,7 @@ }, { "kind": "ui-localized-call", - "line": 1311, + "line": 1325, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Off", "surface": "apple", @@ -22635,7 +22635,7 @@ }, { "kind": "ui-localized-call", - "line": 1315, + "line": 1329, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "demo", "surface": "apple", @@ -22643,7 +22643,7 @@ }, { "kind": "ui-localized-call", - "line": 1316, + "line": 1330, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "ready", "surface": "apple", @@ -22651,7 +22651,7 @@ }, { "kind": "ui-localized-call", - "line": 1317, + "line": 1331, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "check", "surface": "apple", @@ -22659,7 +22659,7 @@ }, { "kind": "ui-localized-call", - "line": 1318, + "line": 1332, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "partial", "surface": "apple", @@ -22667,7 +22667,7 @@ }, { "kind": "ui-localized-call", - "line": 1322, + "line": 1336, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "pending", "surface": "apple", @@ -22675,7 +22675,7 @@ }, { "kind": "ui-localized-call", - "line": 1324, + "line": 1338, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "pass", "surface": "apple", @@ -22683,7 +22683,7 @@ }, { "kind": "ui-localized-call", - "line": 1335, + "line": 1349, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Requesting iOS location permission…", "surface": "apple", @@ -22691,7 +22691,7 @@ }, { "kind": "ui-localized-call", - "line": 1401, + "line": 1415, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "This build uses OpenClaw's hosted push relay at %@ for notification delivery data.", "surface": "apple", @@ -22699,7 +22699,7 @@ }, { "kind": "ui-localized-call", - "line": 1405, + "line": 1419, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "This build is not configured to use OpenClaw's hosted push relay.", "surface": "apple", @@ -22707,7 +22707,7 @@ }, { "kind": "ui-localized-call", - "line": 1410, + "line": 1424, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Enabling this sends delivery data through OpenClaw's hosted push relay.", "surface": "apple", @@ -23595,7 +23595,7 @@ }, { "kind": "ui-call", - "line": 1343, + "line": 1344, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Auto-connect on launch", "surface": "apple", @@ -23603,7 +23603,7 @@ }, { "kind": "ui-call", - "line": 1344, + "line": 1345, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -23611,7 +23611,7 @@ }, { "kind": "ui-call", - "line": 1345, + "line": 1346, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Password", "surface": "apple", @@ -23619,7 +23619,7 @@ }, { "kind": "ui-call", - "line": 1350, + "line": 1351, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Custom Headers", "surface": "apple", @@ -23627,7 +23627,7 @@ }, { "kind": "ui-call", - "line": 1357, + "line": 1358, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reset Onboarding", "surface": "apple", @@ -23635,7 +23635,7 @@ }, { "kind": "ui-call", - "line": 1387, + "line": 1388, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Wake", "surface": "apple", @@ -23643,7 +23643,7 @@ }, { "kind": "ui-call", - "line": 1390, + "line": 1391, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Talk Mode", "surface": "apple", @@ -23651,7 +23651,7 @@ }, { "kind": "ui-call", - "line": 1398, + "line": 1399, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speech Language", "surface": "apple", @@ -23659,7 +23659,7 @@ }, { "kind": "ui-call", - "line": 1405, + "line": 1406, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speakerphone", "surface": "apple", @@ -23667,7 +23667,7 @@ }, { "kind": "ui-call", - "line": 1409, + "line": 1410, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Wake Words", "surface": "apple", @@ -23675,7 +23675,7 @@ }, { "kind": "ui-call", - "line": 1431, + "line": 1432, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice", "surface": "apple", @@ -23683,7 +23683,7 @@ }, { "kind": "ui-call", - "line": 1432, + "line": 1433, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Provider", "surface": "apple", @@ -23691,7 +23691,7 @@ }, { "kind": "ui-call", - "line": 1439, + "line": 1440, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Realtime Voice", "surface": "apple", @@ -23699,7 +23699,7 @@ }, { "kind": "ui-call", - "line": 1440, + "line": 1441, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Default", "surface": "apple", @@ -23707,7 +23707,7 @@ }, { "kind": "ui-call", - "line": 1447, + "line": 1448, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Mode", "surface": "apple", @@ -23715,7 +23715,7 @@ }, { "kind": "ui-call", - "line": 1450, + "line": 1451, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Voice", "surface": "apple", @@ -23723,7 +23723,7 @@ }, { "kind": "ui-call", - "line": 1454, + "line": 1455, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Last Voice Issue", "surface": "apple", @@ -23731,7 +23731,7 @@ }, { "kind": "ui-call", - "line": 1456, + "line": 1457, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Transport", "surface": "apple", @@ -23739,7 +23739,7 @@ }, { "kind": "ui-call", - "line": 1459, + "line": 1460, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "API Key", "surface": "apple", @@ -23747,7 +23747,7 @@ }, { "kind": "ui-call", - "line": 1466, + "line": 1467, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Show Talk Control", "surface": "apple", @@ -23755,7 +23755,7 @@ }, { "kind": "ui-call", - "line": 1467, + "line": 1468, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Share Instruction", "surface": "apple", @@ -23763,7 +23763,7 @@ }, { "kind": "ui-call", - "line": 1474, + "line": 1475, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Share Self-Test", "surface": "apple", @@ -23771,7 +23771,7 @@ }, { "kind": "ui-call", - "line": 1493, + "line": 1494, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Apple Health", "surface": "apple", @@ -23779,7 +23779,7 @@ }, { "kind": "ui-call", - "line": 1501, + "line": 1502, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Debug Logs", "surface": "apple", @@ -23787,7 +23787,7 @@ }, { "kind": "ui-call", - "line": 1504, + "line": 1505, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Debug Screen Status", "surface": "apple", @@ -23795,7 +23795,7 @@ }, { "kind": "ui-call", - "line": 1508, + "line": 1509, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Logs", "surface": "apple", @@ -23803,7 +23803,7 @@ }, { "kind": "ui-call", - "line": 1516, + "line": 1517, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device", "surface": "apple", @@ -23811,7 +23811,7 @@ }, { "kind": "ui-call", - "line": 1517, + "line": 1518, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device Name", "surface": "apple", @@ -23819,7 +23819,7 @@ }, { "kind": "ui-call", - "line": 1519, + "line": 1520, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Instance ID", "surface": "apple", @@ -23827,7 +23827,7 @@ }, { "kind": "ui-localized-call", - "line": 1543, + "line": 1544, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "On", "surface": "apple", @@ -23835,7 +23835,7 @@ }, { "kind": "ui-localized-call", - "line": 1544, + "line": 1545, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Off", "surface": "apple", @@ -25203,7 +25203,7 @@ }, { "kind": "ui-localized-call", - "line": 42, + "line": 46, "path": "apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift", "source": "Secure connection is required for this host.", "surface": "apple", @@ -25211,7 +25211,7 @@ }, { "kind": "ui-localized-call", - "line": 46, + "line": 50, "path": "apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift", "source": "Use only on a trusted private network.", "surface": "apple", @@ -25243,7 +25243,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1443, + "line": 1470, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Can't reach gateway at %1$@:%2$@. Verify Tailscale Serve is enabled and publishes this Gateway.", "surface": "apple", @@ -25251,7 +25251,7 @@ }, { "kind": "ui-localized-call", - "line": 1452, + "line": 1479, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Can't reach gateway at %1$@:%2$@. Check Tailscale or LAN.", "surface": "apple", @@ -25259,7 +25259,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1458, + "line": 1485, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "TLS fingerprint verification timed out for %1$@:%2$@. Secure endpoint was reached, but TLS did not finish in time.", "surface": "apple", @@ -25267,7 +25267,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1466, + "line": 1493, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "No secure gateway endpoint was detected at %1$@:%2$@. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "apple", @@ -25275,7 +25275,7 @@ }, { "kind": "ui-localized-call", - "line": 1476, + "line": 1503, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Could not read the TLS certificate from %1$@:%2$@.", "surface": "apple", @@ -25611,7 +25611,7 @@ }, { "kind": "conditional-branch", - "line": 634, + "line": 661, "path": "apps/ios/Sources/Gateway/GatewaySettingsStore.swift", "source": "\\(host):\\(port)", "surface": "apple", @@ -25619,7 +25619,7 @@ }, { "kind": "conditional-branch", - "line": 708, + "line": 736, "path": "apps/ios/Sources/Gateway/GatewaySettingsStore.swift", "source": "\\(legacy.host ?? \"\"):\\(legacy.port ?? 0)", "surface": "apple", @@ -26675,7 +26675,7 @@ }, { "kind": "conditional-branch", - "line": 105, + "line": 106, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway auth token is missing.", "surface": "apple", @@ -26683,7 +26683,7 @@ }, { "kind": "conditional-branch", - "line": 109, + "line": 110, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway rejected credentials.", "surface": "apple", @@ -26691,7 +26691,7 @@ }, { "kind": "conditional-branch", - "line": 113, + "line": 114, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Could not reach the gateway.", "surface": "apple", @@ -26699,7 +26699,7 @@ }, { "kind": "ui-modifier", - "line": 191, + "line": 192, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -26707,7 +26707,7 @@ }, { "kind": "ui-call", - "line": 197, + "line": 198, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "OK", "surface": "apple", @@ -26715,7 +26715,7 @@ }, { "kind": "ui-call", - "line": 302, + "line": 303, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan Setup Code", "surface": "apple", @@ -26723,7 +26723,7 @@ }, { "kind": "ui-call", - "line": 310, + "line": 311, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Cancel", "surface": "apple", @@ -26731,7 +26731,7 @@ }, { "kind": "ui-call", - "line": 317, + "line": 318, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Photos", "surface": "apple", @@ -26739,7 +26739,7 @@ }, { "kind": "ui-modifier", - "line": 358, + "line": 359, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back", "surface": "apple", @@ -26747,7 +26747,7 @@ }, { "kind": "ui-call", - "line": 366, + "line": 367, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Close", "surface": "apple", @@ -26755,7 +26755,7 @@ }, { "kind": "ui-modifier", - "line": 392, + "line": 393, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Dismiss Keyboard", "surface": "apple", @@ -26763,7 +26763,7 @@ }, { "kind": "ui-call", - "line": 443, + "line": 444, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Mode", "surface": "apple", @@ -26771,7 +26771,7 @@ }, { "kind": "ui-call", - "line": 444, + "line": 445, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery", "surface": "apple", @@ -26779,7 +26779,7 @@ }, { "kind": "ui-call", - "line": 446, + "line": 447, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Status", "surface": "apple", @@ -26787,7 +26787,7 @@ }, { "kind": "ui-call", - "line": 464, + "line": 465, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Choose a mode first.", "surface": "apple", @@ -26795,7 +26795,7 @@ }, { "kind": "ui-call", - "line": 469, + "line": 470, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back to Mode Selection", "surface": "apple", @@ -26803,7 +26803,7 @@ }, { "kind": "ui-named-argument", - "line": 513, + "line": 514, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Manual Fallback", "surface": "apple", @@ -26811,7 +26811,7 @@ }, { "kind": "ui-named-argument", - "line": 517, + "line": 518, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Domain Settings", "surface": "apple", @@ -26819,7 +26819,7 @@ }, { "kind": "ui-call", - "line": 528, + "line": 529, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer Local", "surface": "apple", @@ -26827,7 +26827,7 @@ }, { "kind": "ui-call", - "line": 531, + "line": 532, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.", "surface": "apple", @@ -26835,7 +26835,7 @@ }, { "kind": "ui-call", - "line": 559, + "line": 560, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway rejected credentials. Scan a fresh setup code or update token/password.", "surface": "apple", @@ -26843,7 +26843,7 @@ }, { "kind": "ui-call", - "line": 567, + "line": 568, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "OpenClaw is checking gateway and node access.", "surface": "apple", @@ -26851,7 +26851,7 @@ }, { "kind": "ui-call", - "line": 581, + "line": 582, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resume After Approval", "surface": "apple", @@ -26859,7 +26859,7 @@ }, { "kind": "ui-call", - "line": 587, + "line": 588, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Pairing Approval", "surface": "apple", @@ -26867,7 +26867,7 @@ }, { "kind": "ui-localized-call", - "line": 593, + "line": 594, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Request ID: %@", "surface": "apple", @@ -26875,7 +26875,7 @@ }, { "kind": "ui-localized-call", - "line": 596, + "line": 597, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Request ID: check `openclaw devices list`.", "surface": "apple", @@ -26883,7 +26883,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 600, + "line": 601, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Approve this device on the gateway.\n1) `%1$@`\n2) `/pair approve` in your OpenClaw chat\n%2$@\nOpenClaw will also retry automatically when you return to this app.", "surface": "apple", @@ -26891,7 +26891,7 @@ }, { "kind": "ui-call", - "line": 617, + "line": 618, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan Setup Code Again", "surface": "apple", @@ -26899,7 +26899,7 @@ }, { "kind": "ui-call", - "line": 630, + "line": 631, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Retry Connection", "surface": "apple", @@ -26907,7 +26907,7 @@ }, { "kind": "ui-call", - "line": 661, + "line": 662, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Enter setup code", "surface": "apple", @@ -26915,7 +26915,7 @@ }, { "kind": "ui-call", - "line": 677, + "line": 678, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Apply", "surface": "apple", @@ -26923,7 +26923,7 @@ }, { "kind": "ui-call", - "line": 695, + "line": 696, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Setup Code", "surface": "apple", @@ -26931,7 +26931,7 @@ }, { "kind": "ui-call", - "line": 698, + "line": 699, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use this if you have a setup code instead of scanning.", "surface": "apple", @@ -26939,7 +26939,7 @@ }, { "kind": "ui-call", - "line": 710, + "line": 711, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Host", "surface": "apple", @@ -26947,7 +26947,7 @@ }, { "kind": "ui-call", - "line": 711, + "line": 712, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Port", "surface": "apple", @@ -26955,7 +26955,7 @@ }, { "kind": "ui-call", - "line": 714, + "line": 715, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery Domain (optional)", "surface": "apple", @@ -26963,7 +26963,7 @@ }, { "kind": "ui-call", - "line": 719, + "line": 720, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -26971,7 +26971,7 @@ }, { "kind": "ui-call", - "line": 723, + "line": 724, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Password", "surface": "apple", @@ -26979,7 +26979,7 @@ }, { "kind": "ui-call", - "line": 753, + "line": 755, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Unencrypted", "surface": "apple", @@ -26987,7 +26987,7 @@ }, { "kind": "ui-call", - "line": 756, + "line": 758, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Secure (TLS)", "surface": "apple", @@ -26995,7 +26995,7 @@ }, { "kind": "ui-call", - "line": 760, + "line": 762, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connection security", "surface": "apple", @@ -27003,7 +27003,7 @@ }, { "kind": "ui-call", - "line": 830, + "line": 832, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connecting…", "surface": "apple", @@ -27011,7 +27011,7 @@ }, { "kind": "ui-call", - "line": 834, + "line": 836, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connect", "surface": "apple", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 92f979226b7c..9207b89edaac 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -842,6 +842,7 @@ class MainViewModel private constructor( host = config.host, port = config.port, tlsEnabled = config.tls, + contextPath = config.contextPath, ) val targetAlreadyPaired = prefs.gatewayRegistry.entries.value @@ -876,6 +877,7 @@ class MainViewModel private constructor( host = config.host, port = config.port, tls = config.tls, + contextPath = config.contextPath, ), ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index d44f071bf84f..16c0cfb1ddc5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -8530,6 +8530,7 @@ internal fun manualGatewayEndpoint(entry: GatewayRegistryEntry): GatewayEndpoint host = normalizedHost, port = normalizedPort, tlsEnabled = entry.tls, + contextPath = entry.contextPath, ) } @@ -8545,6 +8546,7 @@ internal fun gatewayRegistryEntry( host = endpoint.host, port = endpoint.port, tls = endpoint.tlsEnabled, + contextPath = endpoint.contextPath, lastConnectedAtMs = existing?.lastConnectedAtMs ?: 0L, ) } else { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt index fc11f3fc4834..55410ea4346d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt @@ -12,6 +12,7 @@ data class GatewayEndpoint( val canvasPort: Int? = null, val tlsEnabled: Boolean = false, val tlsFingerprintSha256: String? = null, + val contextPath: String = "", ) { companion object { /** Builds a stable manual endpoint key that survives display-name changes. */ @@ -19,14 +20,65 @@ data class GatewayEndpoint( host: String, port: Int, tlsEnabled: Boolean = false, - ): GatewayEndpoint = - GatewayEndpoint( - stableId = "manual|${host.lowercase()}|$port", + contextPath: String = "", + ): GatewayEndpoint { + val normalizedContextPath = normalizeGatewayContextPath(contextPath) + val stableIdPath = if (normalizedContextPath.isEmpty()) "" else "|$normalizedContextPath" + return GatewayEndpoint( + stableId = "manual|${host.lowercase()}|$port$stableIdPath", name = "$host:$port", host = host, port = port, tlsEnabled = tlsEnabled, tlsFingerprintSha256 = null, + contextPath = normalizedContextPath, ) + } } } + +internal fun normalizeGatewayContextPath(value: String?): String { + val path = value.orEmpty() + if (path.isEmpty() || path == "/") return "" + val prefixed = if (path.startsWith('/')) path else "/$path" + val encoded = StringBuilder(prefixed.length) + var index = 0 + while (index < prefixed.length) { + if ( + prefixed[index] == '%' && + index + 2 < prefixed.length && + prefixed[index + 1].isAsciiHexDigit() && + prefixed[index + 2].isAsciiHexDigit() + ) { + encoded.append(prefixed, index, index + 3) + index += 3 + continue + } + val codePoint = prefixed.codePointAt(index) + if (isGatewayPathCodePoint(codePoint)) { + encoded.appendCodePoint(codePoint) + } else { + for (byte in String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)) { + val value = byte.toInt() and 0xff + encoded.append('%') + encoded.append(HEX_DIGITS[value ushr 4]) + encoded.append(HEX_DIGITS[value and 0x0f]) + } + } + index += Character.charCount(codePoint) + } + return encoded.toString() +} + +private const val HEX_DIGITS = "0123456789ABCDEF" + +private fun Char.isAsciiHexDigit(): Boolean = this in '0'..'9' || this in 'A'..'F' || this in 'a'..'f' + +private fun isGatewayPathCodePoint(value: Int): Boolean = + value == '/'.code || + value == ':'.code || + value == '@'.code || + value in 'A'.code..'Z'.code || + value in 'a'.code..'z'.code || + value in '0'.code..'9'.code || + (value <= 0x7f && value.toChar() in "-._~!$&'()*+,;=") diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt index da64d5104dce..a9d6af4f8041 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt @@ -28,6 +28,7 @@ data class GatewayRegistryEntry( val port: Int? = null, val tls: Boolean = true, val lastConnectedAtMs: Long = 0L, + val contextPath: String = "", ) @Serializable @@ -83,6 +84,7 @@ class GatewayRegistryStore( stableId = stableId, name = entry.name.trim().ifEmpty { stableId }, host = entry.host?.trim()?.takeIf { it.isNotEmpty() }, + contextPath = normalizeGatewayContextPath(entry.contextPath), lastConnectedAtMs = if (entry.lastConnectedAtMs == 0L) { existing?.lastConnectedAtMs ?: 0L diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index fe3593b08734..9506f62e24d3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -2175,9 +2175,11 @@ internal fun buildGatewayWebSocketUrl( host: String, port: Int, useTls: Boolean, + contextPath: String = "", ): String { val scheme = if (useTls) "wss" else "ws" - return "$scheme://${formatGatewayAuthority(host, port)}" + val path = normalizeGatewayContextPath(contextPath) + return "$scheme://${formatGatewayAuthority(host, port)}$path" } /** Builds one gateway upgrade request without exposing proxy credentials to cleartext routes. */ @@ -2186,7 +2188,15 @@ internal fun buildGatewayWebSocketUpgradeRequest( tls: GatewayTlsParams?, customHeadersProvider: ((stableId: String) -> Map)?, ): Request { - val request = Request.Builder().url(buildGatewayWebSocketUrl(endpoint.host, endpoint.port, tls != null)) + val request = + Request.Builder().url( + buildGatewayWebSocketUrl( + endpoint.host, + endpoint.port, + tls != null, + endpoint.contextPath, + ), + ) if (tls == null) return request.build() // Read at connect time so edits apply on the next reconnect. Headers may contain service tokens diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 63f23454ba0b..667db7455310 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.ui import ai.openclaw.app.gateway.isLocalCleartextGatewayHost +import ai.openclaw.app.gateway.normalizeGatewayContextPath import ai.openclaw.app.i18n.NativeText import ai.openclaw.app.i18n.nativeString import ai.openclaw.app.i18n.nativeText @@ -20,6 +21,7 @@ internal data class GatewayEndpointConfig( val port: Int, val tls: Boolean, val displayUrl: String, + val contextPath: String = "", ) /** Effective transport shown by manual gateway forms before they connect. */ @@ -45,6 +47,7 @@ internal data class GatewayConnectConfig( val bootstrapToken: String, val token: String, val password: String, + val contextPath: String = "", ) /** How a connection attempt may update credentials already owned by the runtime. */ @@ -136,6 +139,7 @@ internal fun resolveGatewayConnectConfig( host = parsed.host, port = parsed.port, tls = parsed.tls, + contextPath = parsed.contextPath, bootstrapToken = setupBootstrapToken, token = sharedToken, password = sharedPassword, @@ -151,6 +155,7 @@ internal fun resolveGatewayConnectConfig( host = parsed.host, port = parsed.port, tls = parsed.tls, + contextPath = parsed.contextPath, bootstrapToken = bootstrapToken, token = token, password = password, @@ -206,7 +211,11 @@ internal fun resolveGatewayConnectPlan( return GatewayConnectPlan(config, action) } -private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean = host.equals(config.host, ignoreCase = true) && port == config.port && tls == config.tls +private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean = + host.equals(config.host, ignoreCase = true) && + port == config.port && + tls == config.tls && + contextPath == config.contextPath /** Parses an endpoint string and returns only the valid connection config. */ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config @@ -221,6 +230,9 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR runCatching { URI(normalized) } .getOrNull() ?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL) + if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) { + return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL) + } val host = uri.host ?.trim() @@ -247,22 +259,31 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR val defaultPort = if (tls) 443 else 18789 val displayPort = if (tls) 443 else 80 val port = gatewayPort(uri.port, defaultPort) ?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL) + val contextPath = normalizeGatewayContextPath(uri.rawPath) + val displayPath = contextPath val displayHost = if (host.contains(":")) "[$host]" else host val displayUrl = if (port == displayPort && defaultPort == displayPort) { - "${if (tls) "https" else "http"}://$displayHost" + "${if (tls) "https" else "http"}://$displayHost$displayPath" } else { - "${if (tls) "https" else "http"}://$displayHost:$port" + "${if (tls) "https" else "http"}://$displayHost:$port$displayPath" } return GatewayEndpointParseResult( - config = GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl), + config = + GatewayEndpointConfig( + host = host, + port = port, + tls = tls, + displayUrl = displayUrl, + contextPath = contextPath, + ), ) } /** Decodes base64url setup-code payloads produced by gateway onboarding. */ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { - val trimmed = rawInput.trim() + val trimmed = stripPairingSetupUrlPrefix(rawInput.trim()) if (trimmed.isEmpty()) return null val padded = @@ -512,3 +533,12 @@ private fun jsonField( val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() return value.ifEmpty { null } } + +private const val PAIRING_SETUP_URL_PREFIX = "oc-pair://" + +private fun stripPairingSetupUrlPrefix(raw: String): String = + if (raw.startsWith(PAIRING_SETUP_URL_PREFIX, ignoreCase = true)) { + raw.substring(PAIRING_SETUP_URL_PREFIX.length) + } else { + raw + } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt index 599298d927c3..59574e4d3d6c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt @@ -72,6 +72,38 @@ class GatewayRegistryStoreTest { assertEquals(first, second) } + @Test + fun roundTripPreservesManualGatewayContextPath() { + val (prefs, securePrefs) = freshPrefs() + val endpoint = + GatewayEndpoint.manual( + host = "gateway.example", + port = 443, + tlsEnabled = true, + contextPath = "/openclaw-gw", + ) + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry( + stableId = endpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = endpoint.name, + host = endpoint.host, + port = endpoint.port, + tls = endpoint.tlsEnabled, + contextPath = endpoint.contextPath, + ), + ) + + val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + + assertEquals( + "/openclaw-gw", + restored.entries.value + .single() + .contextPath, + ) + } + @Test fun failedRemovalCommitDoesNotPublishCandidateState() { val (_, securePrefs) = freshPrefs() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt index 4bdcb23a3647..c265cf0ecaec 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt @@ -21,6 +21,37 @@ class GatewaySessionInvokeTimeoutTest { assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("[::1]", 443, useTls = true)) } + @Test + fun buildGatewayWebSocketUrl_preservesAndEncodesContextPath() { + assertEquals( + "wss://gateway.example:443/openclaw%20gateway", + buildGatewayWebSocketUrl( + host = "gateway.example", + port = 443, + useTls = true, + contextPath = "/openclaw%20gateway", + ), + ) + assertEquals( + "wss://gateway.example:443/openclaw%2Fgateway", + buildGatewayWebSocketUrl( + host = "gateway.example", + port = 443, + useTls = true, + contextPath = "/openclaw%2Fgateway", + ), + ) + assertEquals( + "wss://gateway.example:443//openclaw", + buildGatewayWebSocketUrl( + host = "gateway.example", + port = 443, + useTls = true, + contextPath = "//openclaw", + ), + ) + } + @Test fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() { assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null)) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index 1ed9bc281427..795966366d3c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -109,6 +109,30 @@ class GatewayConfigResolverTest { ) } + @Test + fun parseGatewayEndpointPreservesDecodedContextPath() { + val parsed = parseGatewayEndpoint("wss://gateway.example/openclaw%20gateway") + + assertEquals("/openclaw%20gateway", parsed?.contextPath) + assertEquals("https://gateway.example/openclaw%20gateway", parsed?.displayUrl) + } + + @Test + fun parseGatewayEndpointPreservesEscapedPathDelimiter() { + val parsed = parseGatewayEndpoint("wss://gateway.example/openclaw%2Fgateway") + + assertEquals("/openclaw%2Fgateway", parsed?.contextPath) + assertEquals("https://gateway.example/openclaw%2Fgateway", parsed?.displayUrl) + } + + @Test + fun parseGatewayEndpointPreservesRepeatedLeadingPathSlashes() { + val parsed = parseGatewayEndpoint("wss://gateway.example//openclaw") + + assertEquals("//openclaw", parsed?.contextPath) + assertEquals("https://gateway.example//openclaw", parsed?.displayUrl) + } + @Test fun parseGatewayEndpointRejectsNonLoopbackCleartextWsUrls() { assertEndpointRejected("ws://gateway.example") @@ -375,6 +399,22 @@ class GatewayConfigResolverTest { assertEquals(GatewayEndpointValidationError.INVALID_URL, parsed.error) } + @Test + fun parseGatewayEndpointResultRejectsCredentialsQueriesAndFragments() { + val urls = + listOf( + "wss://user@gateway.example/openclaw-gw", + "wss://gateway.example/openclaw-gw?mode=setup", + "wss://gateway.example/openclaw-gw#fragment", + ) + + for (url in urls) { + val parsed = parseGatewayEndpointResult(url) + assertNull(url, parsed.config) + assertEquals(url, GatewayEndpointValidationError.INVALID_URL, parsed.error) + } + } + @Test fun parseGatewayEndpointResultAllowsPrivateLanCleartextGateway() { val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789") @@ -420,6 +460,17 @@ class GatewayConfigResolverTest { assertNull(decoded?.password) } + @Test + fun decodeGatewaySetupCodeAcceptsPairingUrlWrapper() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"Bootstrap-AbC123"}""") + + val decoded = decodeGatewaySetupCode("oc-pair://$setupCode") + + assertEquals("wss://gateway.example:18789", decoded?.url) + assertEquals("Bootstrap-AbC123", decoded?.bootstrapToken) + } + @Test fun manualTokenDetectsSetupCodePayloads() { val setupCode = @@ -450,6 +501,20 @@ class GatewayConfigResolverTest { assertEquals("", resolved?.password) } + @Test + fun resolveGatewayConnectConfigPreservesSetupContextPath() { + val resolved = + resolveConnectConfigFixture( + useSetupCode = true, + setupCode = setupCode("wss://gateway.example/openclaw-gw"), + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(443, resolved?.port) + assertEquals(true, resolved?.tls) + assertEquals("/openclaw-gw", resolved?.contextPath) + } + @Test fun resolveGatewayConnectConfigAcceptsQrJsonSetupCodePayload() { val setupCode = setupCode("wss://gateway.example:18789") @@ -630,9 +695,9 @@ class GatewayConfigResolverTest { val cases = listOf( "ws://gateway.local:18790" to true, - "http://192.168.1.20:18790/gateway?mode=manual" to true, + "http://192.168.1.20:18790/gateway" to true, "wss://gateway.example:8443" to false, - "https://gateway.example/gateway?mode=manual" to false, + "https://gateway.example/gateway" to false, "HTTPS://gateway.example:443" to false, "WS://GATEWAY.LOCAL.:18790" to true, "ws://[::1]:18790" to true, @@ -763,6 +828,9 @@ class GatewayConfigResolverTest { "gateway.local:18789#evil.example", "[::1]:18789?redirect=evil.example", "[::1]:18789#evil.example", + "wss://user@gateway.example/openclaw-gw", + "wss://gateway.example/openclaw-gw?mode=manual", + "wss://gateway.example/openclaw-gw#fragment", ) for (hostInput in hosts) { diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index 2c1ba0cdf379..4dcec1c6430f 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -56,6 +56,7 @@ struct SettingsProTab: View { @State var gatewayPassword = "" @State var gatewayCredentialFieldStableID: String? @State var manualGatewayPortText = "" + @State var manualGatewayContextPath: String? @State var setupStatusText: String? @State var setupAttemptID: UUID? @State var stagedGatewaySetupLink: GatewayConnectDeepLink? diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index 5731cccb7482..dc5105426bcf 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -214,6 +214,15 @@ extension SettingsProTab { func syncSettingsState() { self.refreshGatewayRegistry() self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : "" + let activeManual = GatewaySettingsStore.activeGatewayEntry() + if activeManual?.kind == .manual, + activeManual?.host?.caseInsensitiveCompare(self.manualGatewayHost) == .orderedSame, + activeManual?.port == self.manualGatewayPort + { + self.manualGatewayContextPath = activeManual?.contextPath + } else { + self.manualGatewayContextPath = nil + } self.selectedAgentPickerId = self.appModel.selectedAgentId ?? "" self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction() self.refreshLocationPermissionSummary() @@ -371,6 +380,7 @@ extension SettingsProTab { self.manualGatewayPort = link.port self.manualGatewayPortText = String(link.port) self.manualGatewayTLS = link.tls + self.manualGatewayContextPath = link.contextPath let instanceId = GatewaySettingsStore.currentInstanceID() let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) self.gatewayCredentialFieldStableID = setupAuth.targetStableID @@ -543,6 +553,7 @@ extension SettingsProTab { host: host, port: port, useTLS: self.manualGatewayTLS, + contextPath: self.manualGatewayContextPath, authOverride: authOverride) // The controller now owns this attempt's immutable override. A later retry must reload // durable state so a spent bootstrap token cannot be resurrected from the live view. @@ -830,7 +841,8 @@ extension SettingsProTab { guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil } return GatewayConnectionController.ManualAuthOverride.manualStableID( host: host, - port: port) + port: port, + contextPath: self.manualGatewayContextPath) } var gatewayCredentialTargetStableID: String? { @@ -879,6 +891,7 @@ extension SettingsProTab { get: { self.manualGatewayHost }, set: { value in let previousStableID = self.currentManualGatewayStableID + self.manualGatewayContextPath = nil self.manualGatewayHost = value if GatewayStableIdentifier.key(previousStableID) != GatewayStableIdentifier.key(self.currentManualGatewayStableID) @@ -968,6 +981,7 @@ extension SettingsProTab { get: { self.manualGatewayPortText }, set: { newValue in let previousStableID = self.currentManualGatewayStableID + self.manualGatewayContextPath = nil let filtered = newValue.filter(\.isNumber) self.manualGatewayPortText = filtered self.manualGatewayPort = Int(filtered) ?? 0 diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 7231baa0c67d..7ec64ca7fa74 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -1334,6 +1334,7 @@ extension SettingsProTab { get: { self.manualGatewayTransport.effectiveTLS }, set: { enabled in guard !self.manualGatewayTransport.requiresTLS else { return } + self.manualGatewayContextPath = nil self.manualGatewayTLS = enabled }) } diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift b/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift index 6731f5e86dfb..2302d6f0f803 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift @@ -16,13 +16,17 @@ struct GatewayManualTransportPresentation: Equatable { } extension GatewayConnectionController { - func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { - let scheme = useTLS ? "wss" : "ws" - var components = URLComponents() - components.scheme = scheme - components.host = host - components.port = port - return components.url + func buildGatewayURL( + host: String, + port: Int, + useTLS: Bool, + contextPath: String? = nil) -> URL? + { + GatewayConnectEndpoint( + host: host, + port: port, + tls: useTLS, + contextPath: contextPath).websocketURL } func resolveManualUseTLS(host: String, useTLS: Bool) -> Bool { @@ -51,8 +55,8 @@ extension GatewayConnectionController { helperText: helperText) } - func manualStableID(host: String, port: Int) -> String { - ManualAuthOverride.manualStableID(host: host, port: port) + func manualStableID(host: String, port: Int, contextPath: String? = nil) -> String { + ManualAuthOverride.manualStableID(host: host, port: port, contextPath: contextPath) } func makeConnectOptions( diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController+ManualAuth.swift b/apps/ios/Sources/Gateway/GatewayConnectionController+ManualAuth.swift index bfb54c339679..dae65f3c1225 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController+ManualAuth.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController+ManualAuth.swift @@ -189,8 +189,14 @@ extension GatewayConnectionController { suppressStoredDeviceAuth: pendingOverride.suppressStoredDeviceAuth) } - static func manualStableID(host: String, port: Int) -> String { - "manual|\(host.lowercased())|\(port)" + static func manualStableID(host: String, port: Int, contextPath: String? = nil) -> String { + let endpoint = GatewayConnectEndpoint( + host: host, + port: port, + tls: true, + contextPath: contextPath) + let pathSuffix = endpoint.contextPath.map { "|\($0)" } ?? "" + return "manual|\(host.lowercased())|\(port)\(pathSuffix)" } static func setupAuth(from link: GatewayConnectDeepLink) -> SetupAuth { @@ -198,7 +204,10 @@ extension GatewayConnectionController { token: link.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", bootstrapToken: link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", password: link.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - targetStableID: self.manualStableID(host: link.host, port: link.port)) + targetStableID: self.manualStableID( + host: link.host, + port: link.port, + contextPath: link.contextPath)) } } } diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 9631a1d0fc85..7fe34017799e 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -390,6 +390,7 @@ final class GatewayConnectionController { host: String, port: Int, useTLS: Bool, + contextPath: String? = nil, authOverride: ManualAuthOverride? = nil, forceReconnect: Bool = false) async { @@ -399,7 +400,10 @@ final class GatewayConnectionController { let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) guard let resolvedPort = Self.resolvedManualPort(host: host, port: port) else { return } - let stableID = self.manualStableID(host: host, port: resolvedPort) + let stableID = self.manualStableID( + host: host, + port: resolvedPort, + contextPath: contextPath) self.pendingConnectionStableID = stableID await self.waitForPendingForgetCleanup(stableID: stableID) guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return } @@ -422,7 +426,12 @@ final class GatewayConnectionController { : nil) let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) if resolvedUseTLS, stored == nil { - guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return } + guard let url = self.buildGatewayURL( + host: host, + port: resolvedPort, + useTLS: true, + contextPath: contextPath) + else { return } self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…") guard let probeResult = await self.probeTLSFingerprint( host: host, @@ -465,7 +474,8 @@ final class GatewayConnectionController { guard let url = self.buildGatewayURL( host: host, port: resolvedPort, - useTLS: tlsParams?.required == true) + useTLS: tlsParams?.required == true, + contextPath: contextPath) else { return } let registryEntry = GatewaySettingsStore.GatewayRegistryEntry( stableID: stableID, @@ -474,6 +484,7 @@ final class GatewayConnectionController { host: host, port: resolvedPort, useTLS: resolvedUseTLS && tlsParams != nil, + contextPath: contextPath, lastConnectedAtMs: nil) guard self.persistActiveGateway(registryEntry) else { return } self.didAutoConnect = true @@ -496,7 +507,12 @@ final class GatewayConnectionController { switch active.kind { case .manual: guard let host = active.host, let port = active.port else { return } - await self.connectManual(host: host, port: port, useTLS: active.useTLS, forceReconnect: true) + await self.connectManual( + host: host, + port: port, + useTLS: active.useTLS, + contextPath: active.contextPath, + forceReconnect: true) case .discovered: if let gateway = self.gateways.first(where: { GatewayStableIdentifier.matches($0.stableID, active.stableID) @@ -506,7 +522,12 @@ final class GatewayConnectionController { } guard let fallback = self.mostRecentlyConnectedManualGateway() else { return } guard let host = fallback.host, let port = fallback.port else { return } - await self.connectManual(host: host, port: port, useTLS: fallback.useTLS, forceReconnect: true) + await self.connectManual( + host: host, + port: port, + useTLS: fallback.useTLS, + contextPath: fallback.contextPath, + forceReconnect: true) } } @@ -533,6 +554,7 @@ final class GatewayConnectionController { host: host, port: port, useTLS: entry.useTLS, + contextPath: entry.contextPath, forceReconnect: true) return nil case .discovered: @@ -815,6 +837,9 @@ final class GatewayConnectionController { host: pending.isManual ? prompt.host : nil, port: pending.isManual ? prompt.port : nil, useTLS: true, + contextPath: pending.isManual + ? URLComponents(url: pending.url, resolvingAgainstBaseURL: false)?.percentEncodedPath + : nil, lastConnectedAtMs: nil) guard self.persistActiveGateway(registryEntry) else { _ = GatewayTLSStore.clearFingerprint(stableID: pending.stableID) @@ -1056,7 +1081,8 @@ extension GatewayConnectionController { guard let url = self.buildGatewayURL( host: host, port: port, - useTLS: tlsParams?.required == true) + useTLS: tlsParams?.required == true, + contextPath: active.contextPath) else { return false } let credentials = GatewaySettingsStore.loadGatewayCredentials( @@ -1261,7 +1287,8 @@ extension GatewayConnectionController { let url = self.buildGatewayURL( host: host, port: port, - useTLS: tls?.required == true) + useTLS: tls?.required == true, + contextPath: entry.contextPath) else { return nil } route = (url, tls) case .discovered: diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 88bb108fa330..0043d3391161 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -70,8 +70,29 @@ enum GatewaySettingsStore { var host: String? var port: Int? var useTLS: Bool + var contextPath: String? var lastConnectedAtMs: Int? + init( + stableID: String, + kind: Kind, + name: String, + host: String?, + port: Int?, + useTLS: Bool, + contextPath: String? = nil, + lastConnectedAtMs: Int?) + { + self.stableID = stableID + self.kind = kind + self.name = name + self.host = host + self.port = port + self.useTLS = useTLS + self.contextPath = contextPath + self.lastConnectedAtMs = lastConnectedAtMs + } + var id: GatewayStableIdentifier.Key { GatewayStableIdentifier.Key(self.stableID) } @@ -83,6 +104,7 @@ enum GatewaySettingsStore { lhs.host == rhs.host && lhs.port == rhs.port && lhs.useTLS == rhs.useTLS && + lhs.contextPath == rhs.contextPath && lhs.lastConnectedAtMs == rhs.lastConnectedAtMs } } @@ -628,6 +650,11 @@ enum GatewaySettingsStore { if entry.kind == .manual { let host = entry.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !host.isEmpty, let port = entry.port, (1...65535).contains(port) else { return nil } + let contextPath = GatewayConnectEndpoint( + host: host, + port: port, + tls: entry.useTLS, + contextPath: entry.contextPath).contextPath return GatewayRegistryEntry( stableID: stableID, kind: .manual, @@ -635,6 +662,7 @@ enum GatewaySettingsStore { host: host, port: port, useTLS: entry.useTLS, + contextPath: contextPath, lastConnectedAtMs: entry.lastConnectedAtMs) } return GatewayRegistryEntry( diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 28feb30f3ba5..17125962fab2 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -27,6 +27,7 @@ struct OnboardingWizardView: View { @State private var manualPort: Int = 18789 @State private var manualPortText: String = "18789" @State private var manualTLS: Bool = true + @State private var manualContextPath: String? @State private var gatewayToken: String = "" @State private var gatewayPassword: String = "" @State private var gatewayCredentialFieldStableID: String? @@ -743,6 +744,7 @@ extension OnboardingWizardView { get: { self.manualTransport.effectiveTLS }, set: { enabled in guard !self.manualTransport.requiresTLS else { return } + self.manualContextPath = nil self.manualTLS = enabled }) } @@ -979,6 +981,7 @@ extension OnboardingWizardView { self.manualPort = link.port self.manualPortText = String(link.port) self.manualTLS = link.tls + self.manualContextPath = link.contextPath let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) self.gatewayCredentialFieldStableID = setupAuth.targetStableID if setupAuth.hasBootstrapToken { @@ -1221,6 +1224,7 @@ extension OnboardingWizardView { self.manualHost = host self.manualPort = port self.manualTLS = active.useTLS + self.manualContextPath = active.contextPath } else { self.manualHost = "openclaw.local" self.manualPort = 18789 @@ -1280,7 +1284,8 @@ extension OnboardingWizardView { guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil } return GatewayConnectionController.ManualAuthOverride.manualStableID( host: host, - port: port) + port: port, + contextPath: self.manualContextPath) } private var gatewayCredentialTargetStableID: String? { @@ -1313,6 +1318,7 @@ extension OnboardingWizardView { get: { self.manualHost }, set: { value in let previousStableID = self.currentManualGatewayStableID + self.manualContextPath = nil self.manualHost = value if GatewayStableIdentifier.key(previousStableID) != GatewayStableIdentifier.key(self.currentManualGatewayStableID) @@ -1327,6 +1333,7 @@ extension OnboardingWizardView { get: { self.manualPortText }, set: { value in let previousStableID = self.currentManualGatewayStableID + self.manualContextPath = nil let digits = value.filter(\.isNumber) self.manualPortText = digits self.manualPort = min(Int(digits) ?? 0, 65535) @@ -1420,6 +1427,7 @@ extension OnboardingWizardView { private func applyModeDefaults(_ mode: OnboardingConnectionMode) { let previousStableID = self.currentManualGatewayStableID + self.manualContextPath = nil defer { if GatewayStableIdentifier.key(previousStableID) != GatewayStableIdentifier.key(self.currentManualGatewayStableID) @@ -1502,6 +1510,7 @@ extension OnboardingWizardView { host: host, port: port, useTLS: self.manualTLS, + contextPath: self.manualContextPath, authOverride: authOverride, forceReconnect: forceReconnect) // The controller now owns this attempt's immutable override. A later retry must reload diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 30480f89638b..c5e25158fd4f 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -7,6 +7,10 @@ import UIKit @testable import OpenClaw @testable import OpenClawKit +private func percentEncodedPath(of url: URL?) -> String? { + url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.percentEncodedPath } +} + @discardableResult private func saveActiveManualGateway( host: String, @@ -924,6 +928,46 @@ private func waitUntil( #expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == setupAuth.targetStableID) } + @Test @MainActor func `setup context path survives registry reconnect`() async throws { + let registryIsolation = GatewayRegistryTestIsolation() + defer { registryIsolation.restore() } + let instanceID = "ios-context-path-\(UUID().uuidString)" + let temporaryState = try TemporaryOpenClawState(instanceID: instanceID) + defer { temporaryState.restore() } + let link = GatewayConnectDeepLink( + host: "192.168.1.41", + port: 18789, + tls: false, + contextPath: "/openclaw%2Fgateway", + bootstrapToken: nil, + token: nil, + password: nil) + let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + await controller.connectManual( + host: link.host, + port: link.port, + useTLS: link.tls, + contextPath: link.contextPath, + authOverride: setupAuth.manualAuthOverride) + await waitUntil { appModel.activeGatewayConnectConfig != nil } + + #expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == "/openclaw%2Fgateway") + #expect(appModel.activeGatewayConnectConfig?.effectiveStableID == setupAuth.targetStableID) + let stored = try #require(GatewaySettingsStore.activeGatewayEntry()) + #expect(stored.contextPath == "/openclaw%2Fgateway") + + appModel.disconnectGateway() + await controller.connectActiveGateway() + await waitUntil { appModel.activeGatewayConnectConfig != nil } + + #expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == "/openclaw%2Fgateway") + #expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stored.stableID) + } + @Test @MainActor func `legacy auth preserves proven relay credentials and otherwise requires full re-pair`() throws { let registryIsolation = GatewayRegistryTestIsolation() defer { registryIsolation.restore() } @@ -2178,6 +2222,35 @@ private func waitUntil( #expect(!GatewaySettingsStore.loadGatewayRegistry().entries.contains { $0.stableID == stableID }) } + @Test @MainActor func `manual trust handoff persists its context path`() async throws { + let registryIsolation = GatewayRegistryTestIsolation() + defer { registryIsolation.restore() } + let host = "context-path-trust.example.com" + let contextPath = "/openclaw-gateway" + let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID( + host: host, + port: 443, + contextPath: contextPath) + defer { GatewayTLSStore.clearFingerprint(stableID: stableID) } + GatewayTLSStore.clearFingerprint(stableID: stableID) + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + let controller = makeTLSProbeController(appModel: appModel, fingerprint: "context-path-fingerprint") + + await controller.connectManual( + host: host, + port: 443, + useTLS: true, + contextPath: contextPath) + #expect(controller.pendingTrustPrompt?.stableID == stableID) + await controller.acceptPendingTrustPrompt() + await waitUntil { appModel.activeGatewayConnectConfig != nil } + + #expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == contextPath) + let stored = try #require(GatewaySettingsStore.activeGatewayEntry()) + #expect(stored.contextPath == contextPath) + } + @Test @MainActor func `forget gateway preserves another gateway pending trust handoff`() async { let registryIsolation = GatewayRegistryTestIsolation() defer { registryIsolation.restore() } diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index 5f1ad6c49534..c72520d77607 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -695,6 +695,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { host: "z.example.com", port: 443, useTLS: true, + contextPath: "/openclaw-gateway", lastConnectedAtMs: nil) let gatewayA = GatewaySettingsStore.GatewayRegistryEntry( stableID: "bonjour|alpha", @@ -716,6 +717,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { #expect(registry.connectedStableIDs == [gatewayB.stableID]) #expect(GatewaySettingsStore.connectedGatewayEntries().map(\.stableID) == [gatewayB.stableID]) #expect(registry.entries.last?.lastConnectedAtMs == 1234) + #expect(registry.entries.last?.contextPath == "/openclaw-gateway") #expect(GatewaySettingsStore.upsertGatewayRegistryEntry(gatewayA)) #expect(KeychainStore.loadString(service: gatewayService, account: "gateway-registry") == firstJSON) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 5cb0cb8c3185..2b7b25127029 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -4,6 +4,55 @@ private func defaultGatewayPort(tls: Bool) -> Int { tls ? 443 : 18789 } +private func normalizeGatewayContextPath(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + let path = value.hasPrefix("/") ? value : "/\(value)" + guard path != "/" else { return nil } + // Keep valid escapes such as %2F and %FF intact because decoding them can + // change segment boundaries or reject valid non-UTF-8 path octets. + let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "%?#")) + var encoded = "" + var index = path.startIndex + while index < path.endIndex { + if path[index] == "%" { + let first = path.index(after: index) + if first < path.endIndex { + let second = path.index(after: first) + if second < path.endIndex, + path[first].isHexDigit, + path[second].isHexDigit + { + let end = path.index(after: second) + encoded.append(contentsOf: path[index.. GatewayConnectDeepLink { @@ -96,6 +148,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { host: endpoint.host, port: endpoint.port, tls: endpoint.tls, + contextPath: endpoint.contextPath, bootstrapToken: self.bootstrapToken, token: self.token, password: self.password) @@ -144,8 +197,14 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { /// and `tls`. In both cases, the optional `bootstrapToken`, `token`, and `password` fields /// are also supported. public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? { - let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + var trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } + if trimmed.range( + of: self.pairingSetupURLPrefix, + options: [.anchored, .caseInsensitive]) != nil + { + trimmed = String(trimmed.dropFirst(self.pairingSetupURLPrefix.count)) + } if let link = decodeSetupPayload(from: Data(trimmed.utf8)) { return link } @@ -185,12 +244,17 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { } if let primary = links.first { let fallbacks = links.dropFirst().map { - GatewayConnectEndpoint(host: $0.host, port: $0.port, tls: $0.tls) + GatewayConnectEndpoint( + host: $0.host, + port: $0.port, + tls: $0.tls, + contextPath: $0.contextPath) } return GatewayConnectDeepLink( host: primary.host, port: primary.port, tls: primary.tls, + contextPath: primary.contextPath, bootstrapToken: primary.bootstrapToken, token: primary.token, password: primary.password, @@ -221,7 +285,11 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { password: String?) -> GatewayConnectDeepLink? { guard let parsed = URLComponents(string: urlString), - let hostname = parsed.host, !hostname.isEmpty + let hostname = parsed.host, !hostname.isEmpty, + parsed.user == nil, + parsed.password == nil, + parsed.query == nil, + parsed.fragment == nil else { return nil } let scheme = (parsed.scheme ?? "ws").lowercased() @@ -236,6 +304,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { host: hostname, port: parsed.port ?? defaultGatewayPort(tls: tls), tls: tls, + contextPath: parsed.percentEncodedPath, bootstrapToken: bootstrapToken, token: token, password: password) @@ -245,6 +314,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { host: String, port: Int, tls: Bool, + contextPath: String? = nil, bootstrapToken: String?, token: String?, password: String?) -> GatewayConnectDeepLink? @@ -253,6 +323,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { host: host, port: port, tls: tls, + contextPath: contextPath, bootstrapToken: bootstrapToken, token: token, password: password) @@ -285,14 +356,42 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { } public struct GatewayConnectEndpoint: Codable, Sendable, Equatable { + private enum CodingKeys: String, CodingKey { + case host + case port + case tls + case contextPath + } + public let host: String public let port: Int public let tls: Bool + public let contextPath: String? - public init(host: String, port: Int, tls: Bool) { + public init(host: String, port: Int, tls: Bool, contextPath: String? = nil) { self.host = host self.port = port self.tls = tls + self.contextPath = normalizeGatewayContextPath(contextPath) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.host = try container.decode(String.self, forKey: .host) + self.port = try container.decode(Int.self, forKey: .port) + self.tls = try container.decode(Bool.self, forKey: .tls) + self.contextPath = try normalizeGatewayContextPath( + container.decodeIfPresent(String.self, forKey: .contextPath)) + } + + public var websocketURL: URL? { + guard (1...65535).contains(self.port) else { return nil } + var components = URLComponents() + components.scheme = self.tls ? "wss" : "ws" + components.host = self.host + components.port = self.port + components.percentEncodedPath = self.contextPath ?? "" + return components.url } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index ef5b91843760..68cb49478c88 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -17890,6 +17890,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable { public let urlsource: String public let access: AnyCodable? public let accessdowngraded: Bool? + public let expiresatms: Int? public init( setupcode: String, @@ -17899,7 +17900,8 @@ public struct DevicePairSetupCodeResult: Codable, Sendable { auth: AnyCodable, urlsource: String, access: AnyCodable? = nil, - accessdowngraded: Bool? = nil) + accessdowngraded: Bool? = nil, + expiresatms: Int? = nil) { self.setupcode = setupcode self.qrdataurl = qrdataurl @@ -17909,6 +17911,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable { self.urlsource = urlsource self.access = access self.accessdowngraded = accessdowngraded + self.expiresatms = expiresatms } private enum CodingKeys: String, CodingKey { @@ -17920,6 +17923,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable { case urlsource = "urlSource" case access case accessdowngraded = "accessDowngraded" + case expiresatms = "expiresAtMs" } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index 9581aa62851a..ab510a825240 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -106,6 +106,47 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? { password: nil)) } + @Test func setupCodeAcceptsPairingURLWrapperWithoutLowercasingPayload() { + let payload = #"{"url":"wss://gateway.example:8443","bootstrapToken":"Bootstrap-AbC123"}"# + let code = setupCode(from: payload) + + #expect( + GatewayConnectDeepLink.fromSetupCode("oc-pair://\(code)") == + GatewayConnectDeepLink.fromSetupCode(code)) + } + + @Test func setupCodePreservesPrimaryGatewayContextPath() { + let payload = #"{"url":"wss://gateway.example/openclaw-gw","bootstrapToken":"tok"}"# + let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) + + #expect(link?.contextPath == "/openclaw-gw") + #expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw-gw") + } + + @Test func setupCodeDecodesGatewayContextPathExactlyOnce() { + let payload = #"{"url":"wss://gateway.example/openclaw%20gateway","bootstrapToken":"tok"}"# + let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) + + #expect(link?.contextPath == "/openclaw%20gateway") + #expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%20gateway") + } + + @Test func setupCodePreservesEscapedGatewayPathDelimiter() { + let payload = #"{"url":"wss://gateway.example/openclaw%2Fgateway","bootstrapToken":"tok"}"# + let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) + + #expect(link?.contextPath == "/openclaw%2Fgateway") + #expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%2Fgateway") + } + + @Test func setupCodePreservesNonUTF8GatewayPathOctet() { + let payload = #"{"url":"wss://gateway.example/openclaw%FFgateway","bootstrapToken":"tok"}"# + let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) + + #expect(link?.contextPath == "/openclaw%FFgateway") + #expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%FFgateway") + } + @Test func setupCodeAllowsPrivateLanWs() { let payload = #"{"url":"ws://192.168.1.20:18789","bootstrapToken":"tok"}"# #expect( @@ -131,17 +172,18 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? { } @Test func setupCodeParsesOrderedGatewayFallbacks() throws { - let payload = #"{"url":"ws://192.168.1.20:18789","urls":["ws://192.168.1.20:18789","wss://gateway.tailnet.ts.net:8443"],"bootstrapToken":"tok"}"# + let payload = #"{"url":"ws://192.168.1.20:18789/lan-gw","urls":["ws://192.168.1.20:18789/lan-gw","wss://gateway.tailnet.ts.net:8443/tailnet-gw"],"bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link?.connectionEndpoints == [ - .init(host: "192.168.1.20", port: 18789, tls: false), - .init(host: "gateway.tailnet.ts.net", port: 8443, tls: true), + .init(host: "192.168.1.20", port: 18789, tls: false, contextPath: "/lan-gw"), + .init(host: "gateway.tailnet.ts.net", port: 8443, tls: true, contextPath: "/tailnet-gw"), ]) #expect(try link?.selectingEndpoint(#require(link?.connectionEndpoints[1])) == .init( host: "gateway.tailnet.ts.net", port: 8443, tls: true, + contextPath: "/tailnet-gw", bootstrapToken: "tok", token: nil, password: nil)) @@ -154,9 +196,35 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? { GatewayConnectDeepLink.self, from: Data(payload.utf8)) + #expect(link.contextPath == nil) #expect(link.fallbackEndpoints.isEmpty) } + @Test func legacyEncodedFallbackEndpointDecodesWithoutContextPath() throws { + let payload = #"{"host":"gateway.example","port":443,"tls":true,"fallbackEndpoints":[{"host":"fallback.example","port":443,"tls":true}]}"# + + let link = try JSONDecoder().decode( + GatewayConnectDeepLink.self, + from: Data(payload.utf8)) + + #expect(link.fallbackEndpoints == [ + .init(host: "fallback.example", port: 443, tls: true), + ]) + } + + @Test func setupCodeRejectsGatewayURLMetadata() { + let urls = [ + "wss://user@gateway.example/openclaw-gw", + "wss://gateway.example/openclaw-gw?mode=setup", + "wss://gateway.example/openclaw-gw#fragment", + ] + + for url in urls { + let payload = #"{"url":"\#(url)","bootstrapToken":"tok"}"# + #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) + } + } + @Test func setupCodeDropsInsecureGatewayFallbacks() { let payload = #"{"url":"ws://attacker.example:18789","urls":["ws://attacker.example:18789","wss://gateway.tailnet.ts.net"],"bootstrapToken":"tok"}"# diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index b14cb45a6a19..8787aca39090 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"40f4454b9a60030b2a1ba050abaad8993e821cb84444365728dc3d12de86e1af","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"71bba45fd19e23bdcad65dc617c22dbdfc7b3c2957b7aec28fd470f4d98de9e1","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 6c5a518d1f2b..9c6df077a3de 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"c700bd1f9821574a3d0a20815d55fc8b3857d71cdbfeab0a9bfb394bb81fe745","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"e0f0d842ad89e78e3f40e545821fc39a3fccbdb43f592f8d298dae35b197f792","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 886771e7b69f..85adde0f6709 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"30538b51154ba0bdebf6bf02eefd16c73b7fd1043b074f3595db3a3088bfc98c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"970f98d008137e204aed058afc38196a2f4862ccfcaf0b1aeb35fc565060e80d","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 291a40c23610..ed881e464452 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"0c9c99f96d0c050db645580b2bb91405485423e2d7fd14031b101d4b44026537","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"a1f13d0608db5e34ac8a96d65522063ad164f04779905aa95263c6a95ddd8477","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 571e4e56af2b..e85c0df2e016 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"ad9dc515e2c9ed1c15397a9846c6212c56276c5dda24635be3f315312f077e43","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"f8c7d30e1606d19045fa79d6fad7713cdad0c0da719586fd083630c97caa7a48","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 6e63230bb6da..b2d74ee3c160 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"29089a2b47826afc64a85979f3606d4140aa272a77e90247dd92e65bca3a2dd8","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"7b9539b83b719a681f4ad601650bd4eda9d313e2cbc1d8f2caa18429e3204f6e","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 3c1e1c698bd6..162e94bb99e6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"1eda9bc8cdf2adff5b1c04f0d9eaafed8c448bccae1728ad7a6475040e48c960","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"0912d9c29be111d7899d426420841f63efd4b59f2c905d0b0f676522987a7435","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index f2423c7ed98a..e1c08b7db17e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"ca227941dce03110d71765ad1d2e8df89e30d09c19f664082d4e2a113937211b","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"dab3cc4ad5d01284c3458191168aad2eb829731334b5c2bc58909eed9df05a7f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index e984996cdaba..a2919073cd3e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"6de4593681bd8424e7334550612c4e1e9962e1a4dda50effc6890da7a4076fe5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"1aee60635c4552486cdd7a14e9767d39aef9ba90e38db8064a4bfa8d68ab8df3","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 8879d0effadc..a569656e1a2d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"225e30b4d18d4c77aee34623d9e69263835eddf690d0ad6d9dc1285e8f349d06","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"9e84fb5aa07d64518232ebf5d169f0a05d82789b1d4b3c2ade005a3c055921ad","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index c64cc8330183..afcc37c21b19 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"b156cd10587667ccf02b0fd7e066de0cf37cd219f0c07c8fb065c293e1f675de","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"12fcbb778c804d28f2cb15077026e97edc13250b55b8fe7934d4e50d1c184fe1","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 65f9fa9c219a..0699a9357986 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"1e566fe360e6b82b1d31ded008160ddbfbc97713d3d55f1b9f3ba641825d537c","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"805c19a024cee0370a81b3b8d60a88276b7f2ccfc3288e3082184e6d0503b4cc","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 0ae521065c36..65921fc6d0bf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"05d6916c82b9a5b512fd541e68a56040aee9a8a23019e6c70e8a62cdcee16716","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"90de46f1e0f51185b27f551eb1a90a5cba679927da65b45bccdd2adb88b7cff6","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 791f17163d87..edb314b2ad91 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"0d68fc98d2c75f2dc4d74670b8d902000c7af2945b65aeae2f783a8e705675a7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"169d46618a3fe5095c199ed12ef6bba91f64ec0cac95003dba9b61639c1b4c06","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 75abdbb72639..1b686c756917 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"34eb7210106298e9efc89a2891438dd05e198a1b6a7510f0c948c4f916b752ca","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"ffa80d2beeb2d3b6e3aff7520da7fa500b2b91d4466b636ea546096472d15e44","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 29e0e38c38df..837a9e8119aa 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"e573cfb3d9ee7c9f79aee3f425fd804953e9df34283196006033dea6ee302396","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"14e690663ce3426b199897c3e08bc8fcde45e3ef34d52acf8ae55d5e837eb738","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index e154f726fa75..58d0eaf5e3ab 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"baecdbe479ff6b3a7ae95d67ce3539d61acd424cea71692a04276972884ea372","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"886fac651c8ee8a85ea50cc883c475a966b66d91e7e65f01b08c995c20e8add7","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index c82c31d81ca9..9fe6bccb5fcd 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -139,7 +139,7 @@ creates a device pairing request that must be approved. Use an already connected Control UI session with `operator.admin` access: 1. Open the Control UI and go to **Settings → Devices**. -2. On the **Devices** page, click **Pair mobile device**. +2. On the **Devices** page, click **Pair device**. 3. Keep **Full access (recommended)**, or select **Limited access** to omit administrative Gateway controls. 4. Click **Create setup code**. diff --git a/docs/cli/node.md b/docs/cli/node.md index c81e3ae4e343..e4b4980be671 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -74,9 +74,18 @@ Disable it on the node if needed: openclaw node run --host --port 18789 ``` +Or paste a short-lived node setup link from the Control UI Devices page: + +```bash +openclaw node run --pair "oc-pair://" +``` + Options: - `--host `: Gateway WebSocket host (default: `127.0.0.1`) +- `--pair `: Read the Gateway endpoint, bootstrap token, TLS mode, + and optional certificate pin from a setup code or `oc-pair://` URL. Explicit + gateway flags override values from `--pair`. - `--port `: Gateway WebSocket port (default: `18789`) - `--context-path `: Gateway WebSocket context path (e.g. `/openclaw-gw`). Appended to the WebSocket URL. - `--tls`: Use TLS for the gateway connection @@ -87,6 +96,12 @@ Options: ## Gateway auth for node host +`--pair` uses a 10-minute single-use bootstrap token for the first connection. +After pairing, reconnects use the durable device credential. The setup link +does not pre-approve `system.run`; normal node approval and SSH verification +remain in force. `node install --pair` is intentionally unavailable because a +short-lived bearer setup link must not be persisted in service arguments. + `openclaw node run` and `openclaw node install` resolve gateway auth from config/env (no `--token`/`--password` flags on node commands): - `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first. diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index e2dc55e2d2f8..871f5c9034df 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -38,6 +38,28 @@ Pending requests expire automatically **5 minutes after the node's last retry** — an actively reconnecting node keeps its one pending request alive rather than generating a fresh request (and approval prompt) per attempt. +## One-paste node pairing + +In the Control UI Devices page, open the pairing dialog, choose **Node host**, +and copy the generated command to the device: + +```bash +openclaw node run --pair "oc-pair://" +``` + +The setup link carries the Gateway endpoint, a short-lived single-use bootstrap +token, and a TLS certificate pin when the Gateway directly serves a pinnable +leaf certificate. The bootstrap token expires after 10 minutes. Explicit +`--host`, `--port`, `--context-path`, `--tls`/`--no-tls`, and +`--tls-fingerprint` flags override values from `--pair`. + +The bootstrap token and resulting device credential are separate, like a +short-lived Tailscale auth key and the durable device identity it admits. +Revoking or expiring the setup link does not revoke the paired device; remove +the device separately when needed. The link never pre-approves `system.run` or +folder sync. Those operations still use pending approval or +[SSH-verified device auto-approval](#ssh-verified-device-auto-approval-default). + ## CLI workflow (headless friendly) ```bash diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 1e36f38c1bbd..cfc879195d0b 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -94,7 +94,21 @@ On the node machine: openclaw node run --host --port 18789 --display-name "Build Node" ``` -`node run` also accepts `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint `, and `--node-id` (override the legacy client instance ID; this does not reset pairing). On macOS, pass `--share-installed-apps` to advertise `device.apps`; sharing is off by default. Use `--no-share-installed-apps` to disable a previously saved opt-in. +For one-paste setup, create a **Node host** setup link from the Control UI +Devices page, then run its copyable command on the node machine: + +```bash +openclaw node run --pair "oc-pair://" +``` + +The link is single-use and expires after 10 minutes. It supplies the endpoint, +bootstrap token, TLS mode, and certificate pin when available. Explicit +gateway flags override the corresponding `--pair` values. Pairing does not +pre-approve command execution; the first `system.run` request still follows +the normal pending-approval or SSH-verification path. See +[Node pairing](/gateway/pairing#one-paste-node-pairing). + +`node run` also accepts `--pair`, `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint `, and `--node-id` (override the legacy client instance ID; this does not reset pairing). On macOS, pass `--share-installed-apps` to advertise `device.apps`; sharing is off by default. Use `--no-share-installed-apps` to disable a previously saved opt-in. ### Remote gateway via SSH tunnel (loopback bind) diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index c01329a72822..bc70546cfd7c 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -53,7 +53,7 @@ Gateway has not been configured yet, run `openclaw onboard` first so setup-code creation has a token or password auth path. 2. Open the [Control UI](/web/control-ui), select **Nodes**, and click - **Pair mobile device** on the **Devices** page. Full access is recommended + **Pair device** on the **Devices** page. Full access is recommended and selected by default; choose Limited access only when you want to omit administrative Gateway controls, then click **Create setup code**. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b7782a3e3eb6..6b10ae25cb2b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -98,7 +98,7 @@ An already paired administrator can create the iOS/Android connection QR without - Select **Devices**, then click **Pair mobile device** in the **Devices** card. + Select **Devices**, then click **Pair device** in the **Devices** card. In the OpenClaw mobile app, open **Settings** → **Gateway** and scan the QR code. You can copy and paste the setup code instead. diff --git a/packages/gateway-client/src/browser-device-auth.test.ts b/packages/gateway-client/src/browser-device-auth.test.ts index e19ed8dd46e5..0570dcccd9c3 100644 --- a/packages/gateway-client/src/browser-device-auth.test.ts +++ b/packages/gateway-client/src/browser-device-auth.test.ts @@ -119,21 +119,24 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => { expect(plan.device?.signedAt).toBe(123); }); - it("never persists bootstrap or shared-secret credentials", async () => { + it("uses only the preferred bootstrap credential and never persists it", async () => { + const sign = vi.fn(async () => "signature"); const store = vi.fn(); const lifecycle = new GatewayBrowserDeviceAuthLifecycle({ loadIdentity: async () => ({ deviceId: "device", publicKey: "public", - sign: async () => "signature", + sign, }), tokenStore: { load: () => null, store, clear: vi.fn() }, + nowMs: () => 123, }); const plan = await lifecycle.buildPlan({ client, role: "operator", defaultScopes: ["operator.read"], bootstrapScopes: ["operator.read", "operator.write"], + token: "test-shared-token", bootstrapToken: "test-bootstrap-token", password: "test-password", preferBootstrapToken: true, @@ -141,7 +144,12 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => { }); expect(plan.auth?.bootstrapToken).toBe("test-bootstrap-token"); - expect(plan.auth?.password).toBe("test-password"); + expect(plan.auth?.token).toBeUndefined(); + expect(plan.auth?.password).toBeUndefined(); + expect(plan.selectedAuth.signatureToken).toBe("test-bootstrap-token"); + expect(sign).toHaveBeenCalledWith( + "v3|device|openclaw-browser-copilot|ui|operator|operator.read,operator.write|123|test-bootstrap-token|nonce|chrome|extension", + ); await lifecycle.acceptHello({ auth: { role: "operator", scopes: [] } }, plan); expect(store).not.toHaveBeenCalled(); }); diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index 677e3b0b46b3..cd8a8a368b06 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -285,6 +285,7 @@ export type GatewayClientCloseInfo = { phase: "pre-hello" | "post-hello"; socketOpened: boolean; transportValidated: boolean; + connectRequestSent?: boolean; transientPreHelloCleanClose: boolean; connectError?: Error; }; @@ -337,6 +338,8 @@ export type GatewayClientOptions = { requestTimeoutMs?: number; token?: string; bootstrapToken?: string; + /** Prefer one setup credential for the first successful device-auth exchange. */ + preferBootstrapToken?: boolean; deviceToken?: string; password?: string; approvalRuntimeToken?: string; @@ -1037,6 +1040,13 @@ export class GatewayClient { env: this.opts.env, }); } + if (this.opts.preferBootstrapToken) { + // The setup credential is single-use; reconnects must use the stored device token. + this.opts.token = undefined; + this.opts.bootstrapToken = undefined; + this.opts.password = undefined; + this.opts.preferBootstrapToken = false; + } this.tickIntervalMs = typeof helloOk.policy?.tickIntervalMs === "number" ? helloOk.policy.tickIntervalMs : 30_000; if (reconnectWithCurrentNodeProtocol) { @@ -1219,6 +1229,7 @@ export class GatewayClient { phase: context.helloReceived ? "post-hello" : "pre-hello", socketOpened: context.socketOpened, transportValidated: this.transportValidated, + connectRequestSent: context.connectRequestSent, transientPreHelloCleanClose: !context.helloReceived && context.code === 1000 && context.reason === "", ...(context.connectFailure?.error ? { connectError: context.connectFailure.error } : {}), @@ -1336,6 +1347,7 @@ export class GatewayClient { return selectGatewayConnectAuth({ token: this.opts.token, bootstrapToken: this.opts.bootstrapToken, + preferBootstrapToken: this.opts.preferBootstrapToken, deviceToken: this.opts.deviceToken, password: this.opts.password, approvalRuntimeToken: this.approvalRuntimeTokenCompatibilityDisabled diff --git a/packages/gateway-client/src/connect-auth.ts b/packages/gateway-client/src/connect-auth.ts index cb00471033b6..2d4a78d1cc38 100644 --- a/packages/gateway-client/src/connect-auth.ts +++ b/packages/gateway-client/src/connect-auth.ts @@ -43,7 +43,11 @@ export function selectGatewayConnectAuth(params: { const storedToken = normalized(params.storedToken); const stored = { storedToken, storedScopes: params.storedScopes }; if (params.preferBootstrapToken && bootstrapToken) { - return { authBootstrapToken: bootstrapToken, authPassword, ...stored }; + return { + authBootstrapToken: bootstrapToken, + signatureToken: bootstrapToken, + ...stored, + }; } const useRetryToken = params.pendingDeviceTokenRetry === true && diff --git a/packages/gateway-protocol/src/schema/devices.ts b/packages/gateway-protocol/src/schema/devices.ts index 99be458c9195..f0e876f6670d 100644 --- a/packages/gateway-protocol/src/schema/devices.ts +++ b/packages/gateway-protocol/src/schema/devices.ts @@ -113,6 +113,7 @@ export const DevicePairSetupCodeResultSchema = closedObject({ Type.Union([Type.Literal("full"), Type.Literal("limited"), Type.Literal("node")]), ), accessDowngraded: Type.Optional(Type.Boolean()), + expiresAtMs: Type.Optional(Type.Integer({ minimum: 0 })), }); // Wire types derive directly from local schema consts so public d.ts graphs never diff --git a/src/cli/node-cli/gateway-options.test.ts b/src/cli/node-cli/gateway-options.test.ts new file mode 100644 index 000000000000..ecba3cf1f945 --- /dev/null +++ b/src/cli/node-cli/gateway-options.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { encodePairingSetupCode } from "../../pairing/setup-code.js"; +import { resolveNodeGatewayOptions, resolveNodePairGatewayOptions } from "./gateway-options.js"; + +describe("node gateway options", () => { + it("preserves ordered pairing endpoint candidates and pins only the direct endpoint", () => { + const pair = resolveNodePairGatewayOptions( + encodePairingSetupCode({ + url: "wss://192.168.1.20:8443/openclaw-gw", + urls: ["wss://192.168.1.20:8443/openclaw-gw", "wss://gateway.tailnet.example/tailnet-gw"], + bootstrapToken: "bootstrap-123", + tlsFingerprint: "sha256:direct-leaf", + }), + ); + + expect(resolveNodeGatewayOptions({}, null, pair).gatewayCandidates).toEqual([ + { + host: "192.168.1.20", + port: 8443, + contextPath: "/openclaw-gw", + tls: true, + tlsFingerprint: "sha256:direct-leaf", + }, + { + host: "gateway.tailnet.example", + port: 443, + contextPath: "/tailnet-gw", + tls: true, + }, + ]); + expect(resolveNodeGatewayOptions({}, null, pair).contextPath).toBe("/openclaw-gw"); + }); + + it("keeps origin-only pairing endpoints pathless", () => { + const pair = resolveNodePairGatewayOptions( + encodePairingSetupCode({ + url: "wss://gateway.example", + bootstrapToken: "bootstrap-123", + }), + ); + + expect(resolveNodeGatewayOptions({}, null, pair)).toMatchObject({ + contextPath: undefined, + gatewayCandidates: [{ host: "gateway.example", port: 443, tls: true }], + }); + }); + + it("collapses pairing candidates when an endpoint flag is explicit", () => { + const pair = resolveNodePairGatewayOptions( + encodePairingSetupCode({ + url: "ws://192.168.1.20:18789", + urls: ["ws://192.168.1.20:18789", "wss://gateway.tailnet.example"], + bootstrapToken: "bootstrap-123", + }), + ); + + expect(resolveNodeGatewayOptions({ host: "manual.example" }, null, pair)).toMatchObject({ + host: "manual.example", + gatewayCandidates: undefined, + }); + }); +}); diff --git a/src/cli/node-cli/gateway-options.ts b/src/cli/node-cli/gateway-options.ts index f4970240803d..e7d48040d4f8 100644 --- a/src/cli/node-cli/gateway-options.ts +++ b/src/cli/node-cli/gateway-options.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { NodeHostConfig } from "../../node-host/config.js"; +import type { NodeHostConfig, NodeHostGatewayConfig } from "../../node-host/config.js"; +import { decodePairingSetupCode } from "../../pairing/setup-code.js"; import { parsePort } from "../daemon-cli/shared.js"; type NodeGatewayOptions = { @@ -10,29 +11,84 @@ type NodeGatewayOptions = { tlsFingerprint?: string; }; +type NodePairGatewayOptions = { + host: string; + port: number; + contextPath?: string; + tls: boolean; + tlsFingerprint?: string; + bootstrapToken: string; + candidates: NodeHostGatewayConfig[]; +}; + +function gatewayConfigFromUrl(url: string, tlsFingerprint?: string): NodeHostGatewayConfig { + const parsed = new URL(url); + const tls = parsed.protocol === "wss:"; + return { + host: parsed.hostname, + port: parsed.port ? Number.parseInt(parsed.port, 10) : tls ? 443 : 80, + ...(parsed.pathname !== "/" ? { contextPath: parsed.pathname } : {}), + tls, + ...(tlsFingerprint ? { tlsFingerprint } : {}), + }; +} + +export function resolveNodePairGatewayOptions(input: string): NodePairGatewayOptions { + const payload = decodePairingSetupCode(input); + const candidates = (payload.urls ?? [payload.url]).map((url) => + gatewayConfigFromUrl(url, url === payload.url ? payload.tlsFingerprint : undefined), + ); + const primary = candidates[0]!; + return { + host: primary.host ?? "127.0.0.1", + port: primary.port ?? 18789, + ...(primary.contextPath ? { contextPath: primary.contextPath } : {}), + tls: primary.tls ?? false, + ...(primary.tlsFingerprint ? { tlsFingerprint: primary.tlsFingerprint } : {}), + bootstrapToken: payload.bootstrapToken, + candidates, + }; +} + export function resolveNodeGatewayOptions( options: NodeGatewayOptions, config: NodeHostConfig | null, + pair?: NodePairGatewayOptions, ) { - const savedHost = config?.gateway?.host || "127.0.0.1"; - const savedPort = config?.gateway?.port ?? 18789; - const host = normalizeOptionalString(options.host) || savedHost; - const port = options.port === undefined ? savedPort : parsePort(options.port); - const endpointChanged = host !== savedHost || (port !== null && port !== savedPort); + const baselineHost = pair?.host ?? config?.gateway?.host ?? "127.0.0.1"; + const baselinePort = pair?.port ?? config?.gateway?.port ?? 18789; + const host = normalizeOptionalString(options.host) || baselineHost; + const port = options.port === undefined ? baselinePort : parsePort(options.port); + const endpointChanged = host !== baselineHost || (port !== null && port !== baselinePort); + const baselineTlsFingerprint = pair?.tlsFingerprint ?? config?.gateway?.tlsFingerprint; + const baselineTls = pair?.tls ?? config?.gateway?.tls; const tlsFingerprint = options.tls === false ? undefined : (normalizeOptionalString(options.tlsFingerprint) ?? - (endpointChanged ? undefined : config?.gateway?.tlsFingerprint)); + (endpointChanged ? undefined : baselineTlsFingerprint)); const tls = typeof options.tls === "boolean" ? options.tls - : Boolean(tlsFingerprint) || (endpointChanged ? undefined : config?.gateway?.tls); + : Boolean(tlsFingerprint) || (endpointChanged ? undefined : baselineTls); const contextPath = normalizeOptionalString(options.contextPath) ?? (options.contextPath !== undefined || endpointChanged ? undefined - : config?.gateway?.contextPath); + : (pair?.contextPath ?? config?.gateway?.contextPath)); + const hasExplicitEndpoint = + options.host !== undefined || + options.port !== undefined || + options.contextPath !== undefined || + options.tls !== undefined || + options.tlsFingerprint !== undefined; - return { host, port, contextPath, tls, tlsFingerprint }; + return { + host, + port, + contextPath, + tls, + tlsFingerprint, + gatewayCandidates: pair && !hasExplicitEndpoint ? pair.candidates : undefined, + }; } diff --git a/src/cli/node-cli/register.test.ts b/src/cli/node-cli/register.test.ts index ce1eda947167..795b7e69e2ed 100644 --- a/src/cli/node-cli/register.test.ts +++ b/src/cli/node-cli/register.test.ts @@ -1,6 +1,7 @@ // Node CLI register tests cover node command registration and option wiring. import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { encodePairingSetupCode } from "../../pairing/setup-code.js"; import { registerNodeCli } from "./register.js"; type LoadNodeHostConfig = typeof import("../../node-host/config.js").loadNodeHostConfig; @@ -106,6 +107,86 @@ describe("registerNodeCli", () => { ); }); + it("derives the node endpoint, TLS pin, and bootstrap credential from --pair", async () => { + const setupCode = encodePairingSetupCode({ + url: "wss://gateway.example:8443/openclaw-gw", + bootstrapToken: "bootstrap-123", + tlsFingerprint: "sha256:pair-leaf", + }); + + await createProgram().parseAsync(["node", "run", "--pair", `oc-pair://${setupCode}`], { + from: "user", + }); + + expect(daemonMocks.runNodeHost).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayHost: "gateway.example", + gatewayPort: 8443, + gatewayContextPath: "/openclaw-gw", + gatewayTls: true, + gatewayTlsFingerprint: "sha256:pair-leaf", + gatewayCandidates: [ + { + host: "gateway.example", + port: 8443, + contextPath: "/openclaw-gw", + tls: true, + tlsFingerprint: "sha256:pair-leaf", + }, + ], + gatewayBootstrapToken: "bootstrap-123", + preferGatewayBootstrapToken: true, + }), + ); + }); + + it("lets explicit gateway flags override --pair values", async () => { + const setupCode = encodePairingSetupCode({ + url: "wss://paired.example:8443", + bootstrapToken: "bootstrap-123", + tlsFingerprint: "sha256:pair-leaf", + }); + + await createProgram().parseAsync( + [ + "node", + "run", + "--pair", + setupCode, + "--host", + "explicit.example", + "--port", + "19000", + "--tls-fingerprint", + "sha256:explicit-leaf", + ], + { from: "user" }, + ); + + expect(daemonMocks.runNodeHost).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayHost: "explicit.example", + gatewayPort: 19000, + gatewayTls: true, + gatewayTlsFingerprint: "sha256:explicit-leaf", + gatewayCandidates: undefined, + gatewayBootstrapToken: "bootstrap-123", + }), + ); + }); + + it("rejects an invalid --pair value before loading node state", async () => { + await createProgram().parseAsync(["node", "run", "--pair", "not-a-setup-code"], { + from: "user", + }); + + expect(daemonMocks.runNodeHost).not.toHaveBeenCalled(); + expect(daemonMocks.loadNodeHostConfig).not.toHaveBeenCalled(); + expect(daemonMocks.defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid pairing setup"), + ); + }); + it.each([ ["host", ["--host", "10.0.0.2"]], ["port", ["--port", "19001"]], diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index dce0bc4191e8..4816ee39b8a5 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -16,7 +16,7 @@ import { runNodeDaemonStop, runNodeDaemonUninstall, } from "./daemon.js"; -import { resolveNodeGatewayOptions } from "./gateway-options.js"; +import { resolveNodeGatewayOptions, resolveNodePairGatewayOptions } from "./gateway-options.js"; import { runNodeIdentityShow } from "./identity.js"; export function registerNodeCli(program: Command) { @@ -48,6 +48,10 @@ export function registerNodeCli(program: Command) { node .command("run") .description("Run the headless node host (foreground)") + .option( + "--pair ", + "Pair with a setup code or oc-pair URL; explicit gateway flags take precedence", + ) .option("--host ", "Gateway host") .option("--port ", "Gateway port") .option("--context-path ", "Gateway WebSocket context path (e.g. /openclaw-gw)") @@ -59,11 +63,17 @@ export function registerNodeCli(program: Command) { .option("--share-installed-apps", "Share installed macOS applications with the Gateway") .option("--no-share-installed-apps", "Disable installed application sharing") .action(async (opts) => { + let pair; + try { + pair = opts.pair ? resolveNodePairGatewayOptions(opts.pair) : undefined; + } catch (error) { + defaultRuntime.error(error instanceof Error ? error.message : String(error)); + defaultRuntime.exit(1); + return; + } const existing = await loadNodeHostConfig(); - const { host, port, contextPath, tls, tlsFingerprint } = resolveNodeGatewayOptions( - opts, - existing, - ); + const { host, port, contextPath, tls, tlsFingerprint, gatewayCandidates } = + resolveNodeGatewayOptions(opts, existing, pair); if (port === null) { defaultRuntime.error(formatInvalidPortOption("--port")); defaultRuntime.exit(1); @@ -80,6 +90,9 @@ export function registerNodeCli(program: Command) { gatewayTls: tls, gatewayTlsFingerprint: tlsFingerprint, gatewayContextPath: contextPath, + gatewayCandidates, + gatewayBootstrapToken: pair?.bootstrapToken, + preferGatewayBootstrapToken: pair !== undefined, nodeId: opts.nodeId, displayName: opts.displayName, installedAppsSharing: opts.shareInstalledApps, diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 6d5478056d8a..62bec3649b6d 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -159,6 +159,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url, bootstrapToken: "bootstrap-123", + expiresAtMs: 123, }); expect(runtime.log).toHaveBeenCalledWith(expected); } @@ -209,6 +210,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://127.0.0.1:18789", bootstrapToken: "bootstrap-123", + expiresAtMs: 123, }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(renderTerminal).not.toHaveBeenCalled(); @@ -290,6 +292,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://127.0.0.1:18789", bootstrapToken: "bootstrap-123", + expiresAtMs: 123, }); expect(renderTerminal).toHaveBeenCalledWith(expected, { small: true }); const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); @@ -495,6 +498,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", + expiresAtMs: 123, }); expect(runtime.log).toHaveBeenCalledWith(expected); const request = resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as @@ -557,6 +561,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", + expiresAtMs: 123, }); expect(runtime.log).toHaveBeenCalledWith(expected); }); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index adc9b900cb6e..29b808c82184 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { trimToUndefined } from "../gateway/credentials.js"; import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js"; +import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; import { renderQrTerminal } from "../media/qr-terminal.ts"; import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js"; import { runCommandWithTimeout } from "../process/exec.js"; @@ -220,6 +221,10 @@ export function registerQrCli(program: Command) { await runCommandWithTimeout(argv, { timeoutMs: runOpts.timeoutMs, }), + loadLocalTlsFingerprint: async () => { + const tls = await loadGatewayTlsRuntime(cfg.gateway?.tls); + return tls.enabled ? tls.fingerprintSha256 : undefined; + }, }); if (!resolved.ok) { diff --git a/src/cli/resume-cli.test.ts b/src/cli/resume-cli.test.ts index 5bbabc982a24..439a7154a45f 100644 --- a/src/cli/resume-cli.test.ts +++ b/src/cli/resume-cli.test.ts @@ -1,4 +1,9 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DeviceAuthTokenRecord } from "../../packages/gateway-client/src/client.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../packages/gateway-protocol/src/client-info.js"; import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; import { startMinimalRealGateway } from "../gateway/minimal-gateway.test-helpers.js"; import type { TuiSessionList } from "../tui/tui-backend.js"; @@ -218,4 +223,50 @@ describe("real Gateway session boundary", () => { }), ); }); + + it("retires the one-use bootstrap credential before a real-wire reconnect", async () => { + const { GatewayClient } = + await vi.importActual("../gateway/client.js"); + const authState: { value: DeviceAuthTokenRecord | null } = { value: null }; + const storeDeviceAuthToken = vi.fn(({ token, scopes }: { token: string; scopes: string[] }) => { + authState.value = { token, scopes }; + }); + let helloCount = 0; + const client = new GatewayClient({ + url: harness.url, + bootstrapToken: await harness.issueNodeBootstrapToken(), + preferBootstrapToken: true, + role: "node", + scopes: [], + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientVersion: "test", + platform: "test", + mode: GATEWAY_CLIENT_MODES.NODE, + deviceIdentity: harness.createDeviceIdentity("reconnect"), + hostDeps: { + loadDeviceAuthToken: () => authState.value, + storeDeviceAuthToken, + }, + onHelloOk: () => { + helloCount += 1; + }, + }); + client.start(); + try { + await vi.waitFor(() => expect(helloCount).toBe(1), { timeout: 5_000 }); + expect(storeDeviceAuthToken).toHaveBeenCalledOnce(); + expect(storeDeviceAuthToken).toHaveBeenCalledWith( + expect.objectContaining({ + token: expect.stringMatching(/\S/), + scopes: expect.any(Array), + }), + ); + expect(authState.value?.token).toBeTruthy(); + + await harness.restart(); + await vi.waitFor(() => expect(helloCount).toBe(2), { timeout: 5_000 }); + } finally { + await client.stopAndWait(); + } + }); }); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index d27a71674e46..d96a49c7a88b 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -125,6 +125,7 @@ function startStubGatewayClient() { phase: "pre-hello", socketOpened: true, transportValidated: true, + connectRequestSent: true, transientPreHelloCleanClose: true, }); lastClientOptions?.onHelloOk?.(makeStubGatewayHello()); @@ -133,12 +134,14 @@ function startStubGatewayClient() { phase: "pre-hello", socketOpened: true, transportValidated: true, + connectRequestSent: true, transientPreHelloCleanClose: true, }); lastClientOptions?.onClose?.(1000, "", { phase: "pre-hello", socketOpened: true, transportValidated: true, + connectRequestSent: true, transientPreHelloCleanClose: true, }); } else if (startMode === "connect-error") { diff --git a/src/gateway/client-callsites.guard.test.ts b/src/gateway/client-callsites.guard.test.ts index cbdff0114b62..7342512c91aa 100644 --- a/src/gateway/client-callsites.guard.test.ts +++ b/src/gateway/client-callsites.guard.test.ts @@ -16,7 +16,7 @@ const ALLOWED_GATEWAY_CLIENT_CALLSITES = new Set([ "src/gateway/gateway-cli-backend.live-helpers.ts", "src/gateway/operator-approvals-client.ts", "src/gateway/probe.ts", - "src/node-host/runner.ts", + "src/node-host/gateway-candidate-connection.ts", "src/tui/gateway-chat.ts", ]); diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index a2fee29aa28e..9c8227ed9b3d 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -745,6 +745,7 @@ describe("GatewayClient close handling", () => { phase: "pre-hello", socketOpened: false, transportValidated: false, + connectRequestSent: false, transientPreHelloCleanClose: false, }, ); @@ -768,6 +769,7 @@ describe("GatewayClient close handling", () => { phase: "pre-hello", socketOpened: false, transportValidated: false, + connectRequestSent: false, transientPreHelloCleanClose: false, }); client.stop(); @@ -785,6 +787,7 @@ describe("GatewayClient close handling", () => { phase: "pre-hello", socketOpened: false, transportValidated: false, + connectRequestSent: false, transientPreHelloCleanClose: false, }); client.stop(); @@ -813,6 +816,7 @@ describe("GatewayClient close handling", () => { phase: "pre-hello", socketOpened: true, transportValidated: false, + connectRequestSent: false, transientPreHelloCleanClose: false, }); client.stop(); @@ -831,6 +835,7 @@ describe("GatewayClient close handling", () => { phase: "pre-hello", socketOpened: false, transportValidated: false, + connectRequestSent: false, transientPreHelloCleanClose: false, }); expect(logDebugMock).toHaveBeenCalledWith( @@ -895,6 +900,7 @@ describe("GatewayClient close handling", () => { phase: "pre-hello", socketOpened: true, transportValidated: true, + connectRequestSent: true, transientPreHelloCleanClose: true, }); @@ -981,12 +987,14 @@ describe("GatewayClient close handling", () => { phase: "pre-hello", socketOpened: true, transportValidated: true, + connectRequestSent: true, transientPreHelloCleanClose: true, }); expect(onClose).toHaveBeenNthCalledWith(2, 1000, "", { phase: "pre-hello", socketOpened: true, transportValidated: true, + connectRequestSent: true, transientPreHelloCleanClose: true, }); expect(onConnectError).toHaveBeenCalledOnce(); @@ -1106,6 +1114,7 @@ describe("GatewayClient close handling", () => { phase: "pre-hello", socketOpened: false, transportValidated: false, + connectRequestSent: false, transientPreHelloCleanClose: false, }); client.stop(); @@ -1174,6 +1183,7 @@ describe("GatewayClient connect auth payload", () => { maxProtocol?: number; scopes?: string[]; client?: { + id?: string; mode?: string; platform?: string; }; @@ -2401,6 +2411,66 @@ describe("GatewayClient connect auth payload", () => { client.stop(); }); + it("emits only the signed bootstrap credential in a preferred node-host connect frame", () => { + loadDeviceAuthTokenMock.mockReturnValue({ token: "stale-device-token" }); + const signDevicePayload = vi.fn((_privateKeyPem: string, _payload: string) => "signature"); + const client = createClientWithIdentity("device-pairing-bootstrap", vi.fn(), { + token: "shared-token", + bootstrapToken: "bootstrap-token", + password: "shared-password", // pragma: allowlist secret + preferBootstrapToken: true, + role: "node", + mode: GATEWAY_CLIENT_MODES.NODE, + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + scopes: [], + hostDeps: { signDevicePayload }, + }); + + const { connect } = startClientAndConnect({ client }); + + expect(connect.params?.client).toMatchObject({ + id: GATEWAY_CLIENT_NAMES.NODE_HOST, + mode: GATEWAY_CLIENT_MODES.NODE, + }); + expect(connect.params?.auth).toEqual({ bootstrapToken: "bootstrap-token" }); + expect(signDevicePayload.mock.calls[0]?.[1]?.split("|")[7]).toBe("bootstrap-token"); + client.stop(); + }); + + it("prefers a paired bootstrap token once, then reconnects with stored device auth", async () => { + loadDeviceAuthTokenMock.mockReturnValue({ token: "stale-device-token" }); + const onHelloOk = vi.fn(); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + token: "shared-token", + bootstrapToken: "bootstrap-token", + password: "shared-password", // pragma: allowlist secret + preferBootstrapToken: true, + onHelloOk, + }); + + const { ws, connect } = startClientAndConnect({ client }); + expect(connectFrameFrom(ws)).toMatchObject({ bootstrapToken: "bootstrap-token" }); + expect(connectFrameFrom(ws).token).toBeUndefined(); + expect(connectFrameFrom(ws).deviceToken).toBeUndefined(); + + loadDeviceAuthTokenMock.mockReturnValue({ token: "issued-device-token" }); + emitHelloOk(ws, connect.id); + await waitForFast(() => expect(onHelloOk).toHaveBeenCalledOnce()); + ws.emitClose(1006, "socket lost"); + await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 }); + const reconnect = getLatestWs(); + reconnect.emitOpen(); + emitConnectChallenge(reconnect, "nonce-reconnect"); + expect(connectFrameFrom(reconnect)).toMatchObject({ + token: "issued-device-token", + deviceToken: "issued-device-token", + }); + expect(connectFrameFrom(reconnect).password).toBeUndefined(); + expect(connectFrameFrom(reconnect).bootstrapToken).toBeUndefined(); + client.stop(); + }); + it("prefers explicit deviceToken over stored device token", () => { loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token", @@ -2600,6 +2670,7 @@ describe("GatewayClient connect auth payload", () => { phase: "pre-hello", socketOpened: true, transportValidated: true, + connectRequestSent: true, transientPreHelloCleanClose: false, }); }); diff --git a/src/gateway/minimal-gateway.test-helpers.ts b/src/gateway/minimal-gateway.test-helpers.ts index 27434a8ed17e..2e641e01aaf4 100644 --- a/src/gateway/minimal-gateway.test-helpers.ts +++ b/src/gateway/minimal-gateway.test-helpers.ts @@ -84,8 +84,9 @@ export async function startMinimalRealGateway( visibility?: import("../config/sessions.js").SessionEntry["visibility"]; }> = [], ) { - const [bootstrap, profiles, sessionStore, testState] = await Promise.all([ + const [bootstrap, deviceIdentity, profiles, sessionStore, testState] = await Promise.all([ import("../infra/device-bootstrap.js"), + import("../infra/device-identity.js"), import("../shared/device-bootstrap-profile.js"), import("../config/sessions/session-accessor.sqlite-entry.js"), import("../test-utils/openclaw-test-state.js"), @@ -112,6 +113,23 @@ export async function startMinimalRealGateway( while (port === 18789) { port = await getFreePort(); } + const startServer = async () => { + const methods = await import("./server-methods.js"); + const original = methods.coreGatewayHandlers["sessions.list"]!; + methods.coreGatewayHandlers["sessions.list"] = async (options) => { + sessionListRequests.push(options.params as Record); + return await original(options); + }; + const gateway = await import("./server.js"); + return await gateway + .startGatewayServer(port, { + auth: { mode: "token", token }, + bind: "loopback", + controlUiEnabled: false, + sidecarStartup: "defer", + }) + .finally(() => (methods.coreGatewayHandlers["sessions.list"] = original)); + }; try { for (const session of sessions) { await sessionStore.upsertSessionEntryCore( @@ -123,21 +141,7 @@ export async function startMinimalRealGateway( { sessionId: session.key, updatedAt: Date.now(), visibility: session.visibility }, ); } - const methods = await import("./server-methods.js"); - const original = methods.coreGatewayHandlers["sessions.list"]!; - methods.coreGatewayHandlers["sessions.list"] = async (options) => { - sessionListRequests.push(options.params as Record); - return await original(options); - }; - const gateway = await import("./server.js"); - server = await gateway - .startGatewayServer(port, { - auth: { mode: "token", token }, - bind: "loopback", - controlUiEnabled: false, - sidecarStartup: "defer", - }) - .finally(() => (methods.coreGatewayHandlers["sessions.list"] = original)); + server = await startServer(); } catch (error) { await state.cleanup(); throw error; @@ -149,18 +153,31 @@ export async function startMinimalRealGateway( sessionListRequests, hellos, connectFailures, - connectBootstrap: async (mismatched = false) => { - const helpers = await import("./test-helpers.js"); - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - clients.push(ws); - const bootstrapToken = ( + issueNodeBootstrapToken: async () => + ( await bootstrap.issueDeviceBootstrapToken({ baseDir: state.stateDir, profile: profiles.NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, }) - ).token; + ).token, + createDeviceIdentity: (label: string) => + deviceIdentity.loadOrCreateDeviceIdentity({ + path: state.statePath(`device-${label}.sqlite`), + }), + restart: async () => { + await server!.close({ reason: "test reconnect", restartExpectedMs: 0 }); + server = await startServer(); + }, + connectBootstrap: async (mismatched = false) => { + const helpers = await import("./test-helpers.js"); + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + clients.push(ws); + const bootstrapToken = await bootstrap.issueDeviceBootstrapToken({ + baseDir: state.stateDir, + profile: profiles.NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); const response = await helpers.connectReq(ws, { - bootstrapToken, + bootstrapToken: bootstrapToken.token, ...(mismatched ? { deviceToken: "mismatched-device-token" } : {}), skipDefaultAuth: true, role: "node", diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index ad2366402ed2..2a8e6dec7ac8 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -103,6 +103,7 @@ class MockGatewayClient { phase: "pre-hello", socketOpened: gatewayClientState.socketOpened, transportValidated: gatewayClientState.transportValidated, + connectRequestSent: true, transientPreHelloCleanClose: false, }); } diff --git a/src/gateway/server-kernel-request-runtime.ts b/src/gateway/server-kernel-request-runtime.ts index 3f5c7f03cf42..c30e15196009 100644 --- a/src/gateway/server-kernel-request-runtime.ts +++ b/src/gateway/server-kernel-request-runtime.ts @@ -94,6 +94,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { pluginGatewayContext, getAttachedGatewayMethodRegistry, gatewayInstanceRuntimeRef, + gatewayTls, lifecycle, startupState, clearFallbackGatewayContextForServer, @@ -111,6 +112,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { runtimeState, sessionCompanion, getRuntimeConfig, + gatewayTlsFingerprint: gatewayTls.enabled ? gatewayTls.fingerprintSha256 : undefined, sessionObserver, getMcpAppSandboxPort, ensureSandboxHostPort, diff --git a/src/gateway/server-methods/device-pair-setup.test.ts b/src/gateway/server-methods/device-pair-setup.test.ts index 5e8ea0cd6826..c3a65464f6a1 100644 --- a/src/gateway/server-methods/device-pair-setup.test.ts +++ b/src/gateway/server-methods/device-pair-setup.test.ts @@ -43,6 +43,7 @@ function createOptions( respond, context: { getRuntimeConfig: vi.fn(() => config), + gatewayTlsFingerprint: "sha256:gateway-leaf", }, } as unknown as GatewayRequestHandlerOptions; return { options, respond }; @@ -59,6 +60,7 @@ const okResolution = { urlSource: "remote", access: "full" as const, accessDowngraded: false, + expiresAtMs: 123_456, }; describe("device.pair.setupCode", () => { @@ -95,9 +97,14 @@ describe("device.pair.setupCode", () => { auth: "token", urlSource: "remote", access: "full", + expiresAtMs: 123_456, }); // The bootstrap token only lives inside the (opaque) setup code, never as a field. expect(JSON.stringify(payload)).not.toContain("boot-123"); + expect(mocks.resolvePairingSetupFromConfig).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ localTlsFingerprint: "sha256:gateway-leaf" }), + ); }); it("reports when plaintext transport limits a requested full-access code", async () => { diff --git a/src/gateway/server-methods/device-pair-setup.ts b/src/gateway/server-methods/device-pair-setup.ts index 8c68721ee0a2..477ac1ad527e 100644 --- a/src/gateway/server-methods/device-pair-setup.ts +++ b/src/gateway/server-methods/device-pair-setup.ts @@ -53,6 +53,7 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = { env: process.env, publicUrl, preferRemoteUrl: params.preferRemoteUrl === true, + localTlsFingerprint: context.gatewayTlsFingerprint, ...(params.bootstrapProfile ? { bootstrapProfile: @@ -89,6 +90,7 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = { auth: resolved.authLabel, urlSource: requestPublicUrl ? "request.publicUrl" : resolved.urlSource, access: resolved.access, + expiresAtMs: resolved.expiresAtMs, ...(resolved.accessDowngraded ? { accessDowngraded: true } : {}), }, undefined, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 5b6ad1ffc114..309c286121d1 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -188,6 +188,8 @@ type GatewayKernelContext = { cron: GatewayCronServiceContract; cronStorePath: string; getRuntimeConfig: () => OpenClawConfig; + /** Prepared listener certificate pin; undefined when Gateway TLS is disabled. */ + gatewayTlsFingerprint?: string; sessionCompanion?: import("../session-companion.js").SessionCompanionService; sessionObserver?: SessionObserverService; resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution; diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 91e33b5f7d9f..f1372f4c19b5 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -31,6 +31,7 @@ type GatewayRequestContextParams = { "cronState" | "controlUiSessionPullRequests" | "sessionViewerPresence" >; getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"]; + gatewayTlsFingerprint?: GatewayRequestContext["gatewayTlsFingerprint"]; sessionCompanion: SessionCompanionService; sessionObserver: SessionObserverService; getMcpAppSandboxPort?: GatewayRequestContext["getMcpAppSandboxPort"]; @@ -173,6 +174,7 @@ export function createGatewayRequestContext( return params.runtimeState.cronState.storePath; }, getRuntimeConfig: params.getRuntimeConfig, + gatewayTlsFingerprint: params.gatewayTlsFingerprint, controlUiSessionPullRequests: params.runtimeState.controlUiSessionPullRequests, sessionViewerPresence: params.runtimeState.sessionViewerPresence, sessionCompanion: params.sessionCompanion, diff --git a/src/node-host/gateway-candidate-connection.test.ts b/src/node-host/gateway-candidate-connection.test.ts new file mode 100644 index 000000000000..4ed09ceb996e --- /dev/null +++ b/src/node-host/gateway-candidate-connection.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayClientOptions } from "../gateway/client.js"; +import { createNodeHostGatewayCandidateConnection } from "./gateway-candidate-connection.js"; + +const mocks = vi.hoisted(() => ({ + options: [] as GatewayClientOptions[], + clients: [] as Array<{ + request: ReturnType; + start: ReturnType; + stop: ReturnType; + updateNodeManifest: ReturnType; + }>, +})); + +vi.mock("../gateway/client.js", () => ({ + GatewayClient: function GatewayClient(options: GatewayClientOptions) { + const client = { + request: vi.fn(async () => ({ url: options.url })), + start: vi.fn(), + stop: vi.fn(), + updateNodeManifest: vi.fn(), + }; + mocks.options.push(options); + mocks.clients.push(client); + return client; + }, +})); + +const candidates = [ + { host: "192.168.1.20", port: 18789, contextPath: "/openclaw-gw", tls: false }, + { host: "gateway.tailnet.example", port: 443, tls: true }, +]; + +function createConnection() { + const callbacks = { + onEvent: vi.fn(), + onHelloOk: vi.fn(), + onConnectError: vi.fn(), + onReconnectPaused: vi.fn(), + onClose: vi.fn(), + onWinningCandidate: vi.fn(), + }; + return { + callbacks, + connection: createNodeHostGatewayCandidateConnection({ + candidates, + clientOptions: {}, + ...callbacks, + }), + }; +} + +describe("gateway candidate connection", () => { + beforeEach(() => { + mocks.options.length = 0; + mocks.clients.length = 0; + vi.clearAllMocks(); + }); + + it("rotates only before hello, fences stale callbacks, and forwards through the winner", async () => { + const { callbacks, connection } = createConnection(); + connection.start(); + + expect(mocks.options[0]?.url).toBe("ws://192.168.1.20:18789/openclaw-gw"); + expect(mocks.clients[0]?.start).toHaveBeenCalledOnce(); + mocks.options[0]?.onClose?.(1006, "transport unavailable", { + phase: "pre-hello", + socketOpened: false, + transportValidated: false, + connectRequestSent: false, + transientPreHelloCleanClose: false, + }); + await vi.waitFor(() => expect(mocks.clients).toHaveLength(2)); + + expect(mocks.clients[0]?.stop).toHaveBeenCalledOnce(); + expect(mocks.options[1]?.url).toBe("wss://gateway.tailnet.example:443"); + expect(mocks.clients[1]?.start).toHaveBeenCalledOnce(); + + mocks.options[0]?.onEvent?.({ type: "event", event: "stale" }); + mocks.options[0]?.onHelloOk?.({} as never); + mocks.options[0]?.onClose?.(1006, "stale close", { + phase: "pre-hello", + socketOpened: false, + transportValidated: false, + connectRequestSent: false, + transientPreHelloCleanClose: false, + }); + expect(callbacks.onEvent).not.toHaveBeenCalled(); + expect(callbacks.onHelloOk).not.toHaveBeenCalled(); + expect(callbacks.onWinningCandidate).not.toHaveBeenCalled(); + expect(mocks.clients).toHaveLength(2); + + const activeEvent = { type: "event", event: "active" } as const; + mocks.options[1]?.onEvent?.(activeEvent); + mocks.options[1]?.onHelloOk?.({} as never); + mocks.options[1]?.onHelloOk?.({} as never); + expect(callbacks.onEvent).toHaveBeenCalledWith(activeEvent); + expect(callbacks.onWinningCandidate).toHaveBeenCalledOnce(); + expect(callbacks.onWinningCandidate).toHaveBeenCalledWith(candidates[1]); + + await connection.request("node.test", { active: true }, undefined); + connection.updateNodeManifest({ caps: ["mcp"], commands: ["mcp.tools.call.v1"] }); + expect(mocks.clients[0]?.request).not.toHaveBeenCalled(); + expect(mocks.clients[1]?.request).toHaveBeenCalledWith( + "node.test", + { active: true }, + undefined, + ); + expect(mocks.clients[1]?.updateNodeManifest).toHaveBeenCalledWith({ + caps: ["mcp"], + commands: ["mcp.tools.call.v1"], + }); + }); + + it("does not rotate after the connect request was sent", async () => { + createConnection(); + + mocks.options[0]?.onClose?.(1008, "connect failed", { + phase: "pre-hello", + socketOpened: true, + transportValidated: true, + connectRequestSent: true, + transientPreHelloCleanClose: false, + }); + await Promise.resolve(); + + expect(mocks.clients).toHaveLength(1); + }); + + it("promotes a candidate after hello instead of replaying setup auth on another endpoint", async () => { + const { callbacks } = createConnection(); + + mocks.options[0]?.onHelloOk?.({} as never); + mocks.options[0]?.onClose?.(1006, "later reconnect transport failure", { + phase: "pre-hello", + socketOpened: false, + transportValidated: false, + connectRequestSent: false, + transientPreHelloCleanClose: false, + }); + await Promise.resolve(); + + expect(callbacks.onWinningCandidate).toHaveBeenCalledWith(candidates[0]); + expect(mocks.clients).toHaveLength(1); + }); + + it("carries a pre-hello manifest update into the next candidate", async () => { + const { connection } = createConnection(); + const manifest = { caps: ["mcp"], commands: ["mcp.tools.call.v1"] }; + + connection.updateNodeManifest(manifest); + mocks.options[0]?.onClose?.(1006, "transport unavailable", { + phase: "pre-hello", + socketOpened: false, + transportValidated: false, + connectRequestSent: false, + transientPreHelloCleanClose: false, + }); + await vi.waitFor(() => expect(mocks.clients).toHaveLength(2)); + + expect(mocks.clients[1]?.updateNodeManifest).toHaveBeenCalledWith(manifest); + }); + + it("does not create the queued candidate after stop", async () => { + const { connection } = createConnection(); + + mocks.options[0]?.onClose?.(1006, "transport unavailable", { + phase: "pre-hello", + socketOpened: false, + transportValidated: false, + connectRequestSent: false, + transientPreHelloCleanClose: false, + }); + connection.stop(); + await Promise.resolve(); + + expect(mocks.clients).toHaveLength(1); + }); +}); diff --git a/src/node-host/gateway-candidate-connection.ts b/src/node-host/gateway-candidate-connection.ts new file mode 100644 index 000000000000..df34108a29e9 --- /dev/null +++ b/src/node-host/gateway-candidate-connection.ts @@ -0,0 +1,152 @@ +import { + GatewayClient, + type GatewayClientCloseInfo, + type GatewayClientOptions, + type GatewayClientRequestOptions, + type GatewayReconnectPausedInfo, +} from "../gateway/client.js"; +import type { NodeHostGatewayConfig } from "./config.js"; + +type GatewayCandidateEvent = Parameters>[0]; +type GatewayCandidateHello = Parameters>[0]; + +type CandidateConnectionOptions = Omit< + GatewayClientOptions, + | "url" + | "tlsFingerprint" + | "onEvent" + | "onHelloOk" + | "onConnectError" + | "onReconnectPaused" + | "onClose" +>; + +type GatewayCandidateConnectionParams = { + candidates: readonly NodeHostGatewayConfig[]; + clientOptions: CandidateConnectionOptions; + onEvent: (event: GatewayCandidateEvent) => void; + onHelloOk: (hello: GatewayCandidateHello, url: string) => void; + onConnectError: (error: Error) => void; + onReconnectPaused: (info: GatewayReconnectPausedInfo) => void; + onClose: (code: number, reason: string, info?: GatewayClientCloseInfo) => void; + onWinningCandidate: (candidate: NodeHostGatewayConfig) => void; +}; + +function formatGatewayCandidateUrl(gateway: NodeHostGatewayConfig): string { + const host = gateway.host ?? "127.0.0.1"; + const urlHost = + host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host; + const port = gateway.port ?? 18789; + const scheme = gateway.tls ? "wss" : "ws"; + const contextPath = gateway.contextPath + ? gateway.contextPath.startsWith("/") + ? gateway.contextPath + : `/${gateway.contextPath}` + : ""; + return `${scheme}://${urlHost}:${port}${contextPath}`; +} + +function canTryNextGatewayCandidate(info: GatewayClientCloseInfo | undefined): boolean { + return info?.phase === "pre-hello" && info.connectRequestSent === false; +} + +export function createNodeHostGatewayCandidateConnection(params: GatewayCandidateConnectionParams) { + if (params.candidates.length === 0) { + throw new Error("node host gateway candidate list cannot be empty"); + } + + let currentCandidateIndex = 0; + let stopped = false; + let winnerSelected = params.candidates.length === 1; + let latestManifest: { caps: string[]; commands: string[] } | undefined; + let currentClient = createCandidateClient(currentCandidateIndex); + + function createCandidateClient(candidateIndex: number): GatewayClient { + const candidate = params.candidates[candidateIndex]; + if (!candidate) { + throw new Error(`node host gateway candidate ${candidateIndex} is unavailable`); + } + const url = formatGatewayCandidateUrl(candidate); + const candidateClient = new GatewayClient({ + ...params.clientOptions, + url, + tlsFingerprint: candidate.tlsFingerprint, + onEvent: (event) => { + if (currentCandidateIndex === candidateIndex) { + params.onEvent(event); + } + }, + onHelloOk: (hello) => { + if (currentCandidateIndex !== candidateIndex) { + return; + } + if (!winnerSelected) { + winnerSelected = true; + params.onWinningCandidate(candidate); + } + params.onHelloOk(hello, url); + }, + onConnectError: (error) => { + if (currentCandidateIndex === candidateIndex) { + params.onConnectError(error); + } + }, + onReconnectPaused: (info) => { + if (currentCandidateIndex === candidateIndex) { + params.onReconnectPaused(info); + } + }, + onClose: (code, reason, info) => { + if (currentCandidateIndex !== candidateIndex) { + return; + } + params.onClose(code, reason, info); + const nextCandidateIndex = candidateIndex + 1; + if ( + stopped || + // A successful hello redeems setup credentials and promotes this + // endpoint. Its own reconnect path owns durable device auth from here. + winnerSelected || + nextCandidateIndex >= params.candidates.length || + !canTryNextGatewayCandidate(info) + ) { + return; + } + currentCandidateIndex = nextCandidateIndex; + candidateClient.stop(); + queueMicrotask(() => { + if (stopped || currentCandidateIndex !== nextCandidateIndex) { + return; + } + currentClient = createCandidateClient(nextCandidateIndex); + currentClient.start(); + }); + }, + }); + if (latestManifest) { + candidateClient.updateNodeManifest(latestManifest); + } + return candidateClient; + } + + return { + start(): void { + currentClient.start(); + }, + stop(): void { + stopped = true; + currentClient.stop(); + }, + request>( + ...requestArgs: [method: string, params?: unknown, options?: GatewayClientRequestOptions] + ): Promise { + return currentClient.request(...requestArgs); + }, + updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void { + // Availability may change before the first hello. Every later candidate + // must start with the newest manifest rather than the constructor snapshot. + latestManifest = manifest; + currentClient.updateNodeManifest(manifest); + }, + }; +} diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index 6801e98d29d5..e796cdaf826f 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ capturedConfiguredGatewayConfigs: [] as Array<{ contextPath?: string }>, capturedGatewayClients: [] as Array<{ request: Mock<(method: string, params?: unknown) => Promise>; + start: ReturnType; stop: ReturnType; updateNodeManifest: ReturnType; }>, @@ -30,6 +31,9 @@ const mocks = vi.hoisted(() => ({ availabilityChanged: undefined as (() => void) | undefined, normalizedPath: null as string | null, resolvedExecutables: new Map(), + runtimeClient: undefined as + | { request: (method: string, params?: unknown) => Promise } + | undefined, closeMcpManager: vi.fn(async () => undefined), runStartupMigrations: vi.fn(async () => undefined), configureNodeHost: vi.fn(async (params: Parameters[0]) => { @@ -76,6 +80,7 @@ vi.mock("../gateway/client.js", async (importOriginal) => { GatewayClient: function GatewayClient(opts: GatewayClientOptions) { const client = { request: vi.fn(async () => ({})), + start: vi.fn(), stop: vi.fn(), updateNodeManifest: vi.fn(), }; @@ -171,7 +176,10 @@ vi.mock("./runtime.js", async (importOriginal) => { return { manifest: { caps: [], commands: [], pathEnv: process.env.PATH ?? "" }, initialInventory: { skills: [], pluginTools: [] }, - start: () => mocks.activeRuntime, + start: (params) => { + mocks.runtimeClient = params.client; + return mocks.activeRuntime; + }, }; }, }; @@ -207,6 +215,7 @@ describe("runNodeHost", () => { mocks.availabilityChanged = undefined; mocks.normalizedPath = null; mocks.resolvedExecutables.clear(); + mocks.runtimeClient = undefined; vi.clearAllMocks(); mocks.getRuntimeConfig.mockReturnValue({ gateway: { handshakeTimeoutMs: 1_000 }, @@ -246,6 +255,84 @@ describe("runNodeHost", () => { }, ); + it("passes a paired bootstrap credential with first-connect preference", async () => { + await expect( + runNodeHost({ + gatewayHost: "gateway.example", + gatewayPort: 443, + gatewayTls: true, + gatewayBootstrapToken: "bootstrap-123", + preferGatewayBootstrapToken: true, + }), + ).rejects.toThrow("event loop readiness timeout"); + + expect(lastCapturedOptions()).toMatchObject({ + bootstrapToken: "bootstrap-123", + preferBootstrapToken: true, + }); + expect(lastCapturedOptions()?.token).toBeUndefined(); + expect(mocks.resolveGatewayCredentialsWithSecretInputs).not.toHaveBeenCalled(); + }); + + it("persists the pairing candidate that completes the handshake", async () => { + mocks.useFakeRuntime = true; + mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({ + ready: true, + aborted: false, + elapsedMs: 0, + }); + const processOnceSpy = vi.spyOn(process, "once"); + const previousExitCode = process.exitCode; + try { + const running = runNodeHost({ + gatewayHost: "192.168.1.20", + gatewayPort: 18789, + gatewayBootstrapToken: "bootstrap-123", + preferGatewayBootstrapToken: true, + gatewayCandidates: [ + { host: "192.168.1.20", port: 18789, tls: false }, + { host: "gateway.tailnet.example", port: 443, tls: true }, + ], + }); + await vi.waitFor(() => expect(mocks.capturedGatewayClients).toHaveLength(1)); + + const firstOptions = mocks.capturedGatewayClientOptions[0]; + firstOptions?.onClose?.(1006, "transport unavailable", { + phase: "pre-hello", + socketOpened: false, + transportValidated: false, + connectRequestSent: false, + transientPreHelloCleanClose: false, + }); + await vi.waitFor(() => expect(mocks.capturedGatewayClients).toHaveLength(2)); + + expect(mocks.capturedGatewayClientOptions[1]?.url).toBe("wss://gateway.tailnet.example:443"); + + mocks.capturedGatewayClientOptions[1]?.onHelloOk?.({} as never); + await vi.waitFor(() => expect(mocks.configureNodeHost).toHaveBeenCalledTimes(2)); + expect(mocks.capturedConfiguredGatewayConfigs[1]).toEqual({ + host: "gateway.tailnet.example", + port: 443, + tls: true, + }); + + await vi.waitFor(() => + expect(processOnceSpy.mock.calls.some(([event]) => event === "SIGTERM")).toBe(true), + ); + const onSigterm = processOnceSpy.mock.calls.find(([event]) => event === "SIGTERM")?.[1]; + onSigterm?.("SIGTERM"); + await running; + } finally { + for (const [event, listener] of processOnceSpy.mock.calls) { + if ((event === "SIGINT" || event === "SIGTERM") && typeof listener === "function") { + process.off(event, listener); + } + } + process.exitCode = previousExitCode; + processOnceSpy.mockRestore(); + } + }); + it("routes invoke input, cancellation, and connection close to the runtime", async () => { mocks.useFakeRuntime = true; await expect(runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 })).rejects.toThrow( diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 98f79ef0b920..13b17c34c1a1 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -7,16 +7,13 @@ import { import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js"; -import { - GatewayClient, - GatewayClientRequestError, - type GatewayReconnectPausedInfo, -} from "../gateway/client.js"; +import { GatewayClientRequestError, type GatewayReconnectPausedInfo } from "../gateway/client.js"; import { resolveGatewayCredentialsWithSecretInputs } from "../gateway/credentials-secret-inputs.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { VERSION } from "../version.js"; import { configureNodeHost, type NodeHostGatewayConfig } from "./config.js"; +import { createNodeHostGatewayCandidateConnection } from "./gateway-candidate-connection.js"; import { coerceNodeInvokeCancelPayload, coerceNodeInvokeInputPayload, @@ -30,6 +27,9 @@ type NodeHostRunOptions = { gatewayPort: number; gatewayTls?: boolean; gatewayTlsFingerprint?: string; + gatewayCandidates?: NodeHostGatewayConfig[]; + gatewayBootstrapToken?: string; + preferGatewayBootstrapToken?: boolean; /** Optional WebSocket context path (e.g. "/openclaw-gw"). */ gatewayContextPath?: string; nodeId?: string; @@ -220,6 +220,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const nodeId = config.nodeId; const displayName = config.displayName ?? fallbackDisplayName; const gateway = config.gateway ?? plannedGateway; + const gatewayCandidates = opts.gatewayCandidates?.length ? opts.gatewayCandidates : [gateway]; const cfg = getRuntimeConfig(); const preparedRuntime = await prepareNodeHostRuntime({ @@ -228,22 +229,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { enableAgentRuns: true, installedAppsSharingEnabled: config.installedAppsSharing, }); - const { token, password } = await resolveNodeHostGatewayCredentials({ - config: cfg, - env: process.env, - }); + const { token, password } = opts.preferGatewayBootstrapToken + ? {} + : await resolveNodeHostGatewayCredentials({ + config: cfg, + env: process.env, + }); - const host = gateway.host ?? "127.0.0.1"; - const urlHost = - host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host; - const port = gateway.port ?? 18789; - const scheme = gateway.tls ? "wss" : "ws"; - const contextPath = gateway.contextPath - ? gateway.contextPath.startsWith("/") - ? gateway.contextPath - : `/${gateway.contextPath}` - : ""; - const url = `${scheme}://${urlHost}:${port}${contextPath}`; let inventory: NodeHostInventory = preparedRuntime.initialInventory; let gatewayHelloReceived = false; let gatewayConnectionGeneration = 0; @@ -451,27 +443,42 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { ); }; - const client = new GatewayClient({ - url, - token: token || undefined, - password: password || undefined, - instanceId: nodeId, - clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, - clientDisplayName: displayName, - clientVersion: VERSION, - platform: resolveNodeHostGatewayPlatform(process.platform), - deviceFamily: resolveNodeHostGatewayDeviceFamily(process.platform), - mode: GATEWAY_CLIENT_MODES.NODE, - role: "node", - scopes: [], - // Pair the built-in MCP command family up front. Server inventory is - // restart-scoped availability, not a capability upgrade requiring re-pairing. - caps: preparedRuntime.manifest.caps, - commands: preparedRuntime.manifest.commands, - pathEnv: preparedRuntime.manifest.pathEnv, - permissions: undefined, - deviceIdentity: loadOrCreateDeviceIdentity(), - tlsFingerprint: gateway.tlsFingerprint, + const persistWinningGateway = (winningGateway: NodeHostGatewayConfig) => { + void configureNodeHost({ + nodeId, + displayName, + fallbackDisplayName, + gateway: winningGateway, + installedAppsSharing: config.installedAppsSharing, + }).catch((error: unknown) => { + writeStderrLine(`node host gateway endpoint persistence failed: ${String(error)}`); + }); + }; + + const client = createNodeHostGatewayCandidateConnection({ + candidates: gatewayCandidates, + clientOptions: { + token: token || undefined, + bootstrapToken: opts.gatewayBootstrapToken, + preferBootstrapToken: opts.preferGatewayBootstrapToken, + password: password || undefined, + instanceId: nodeId, + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientDisplayName: displayName, + clientVersion: VERSION, + platform: resolveNodeHostGatewayPlatform(process.platform), + deviceFamily: resolveNodeHostGatewayDeviceFamily(process.platform), + mode: GATEWAY_CLIENT_MODES.NODE, + role: "node", + scopes: [], + // Pair the built-in MCP command family up front. Server inventory is + // restart-scoped availability, not a capability upgrade requiring re-pairing. + caps: preparedRuntime.manifest.caps, + commands: preparedRuntime.manifest.commands, + pathEnv: preparedRuntime.manifest.pathEnv, + permissions: undefined, + deviceIdentity: loadOrCreateDeviceIdentity(), + }, onEvent: (evt) => { if (evt.event === "node.invoke.cancel") { const payload = coerceNodeInvokeCancelPayload(evt.payload); @@ -491,12 +498,11 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { return; } const payload = coerceNodeInvokePayload(evt.payload); - if (!payload) { - return; + if (payload) { + void activeRuntime.invoke(payload); } - void activeRuntime.invoke(payload); }, - onHelloOk: (hello) => { + onHelloOk: (hello, url) => { writeStderrLine(`node host gateway connected: ${url}`); gatewayConnectionGeneration += 1; gatewayHelloReceived = true; @@ -505,9 +511,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { optionalPublicationStates = new Map(); publishInventory(); }, - onConnectError: (err) => { + onConnectError: (error) => { // keep retrying (handled by GatewayClient) - writeStderrLine(`node host gateway connect failed: ${err.message}`); + writeStderrLine(`node host gateway connect failed: ${error.message}`); }, onReconnectPaused: (info) => { handleNodeHostReconnectPaused(info, { @@ -524,6 +530,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { activeRuntime.cancelAll(); writeStderrLine(`node host gateway closed (${code}): ${reason}`); }, + onWinningCandidate: persistWinningGateway, }); const activeRuntime = preparedRuntime.start({ client, diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 873e1ab12b2f..4a305bc5d2bc 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -14,11 +14,41 @@ vi.mock("../infra/device-bootstrap.js", () => ({ })), })); -const { encodePairingSetupCode, resolvePairingSetupFromConfig } = await import("./setup-code.js"); +const { decodePairingSetupCode, encodePairingSetupCode, resolvePairingSetupFromConfig } = + await import("./setup-code.js"); const { issueDeviceBootstrapToken: issueDeviceBootstrapTokenMock } = await import("../infra/device-bootstrap.js"); describe("pairing setup code", () => { + it("round-trips bare and wrapped setup codes without normalizing payload case", () => { + const payload = { + url: "wss://gateway.example:8443/openclaw-gw", + bootstrapToken: "Bootstrap-AbC123", + tlsFingerprint: "sha256:AA:BB", + expiresAtMs: 20_000, + }; + const setupCode = encodePairingSetupCode(payload); + expect(setupCode).toMatch(/[A-Z]/u); + + expect(decodePairingSetupCode(setupCode, { nowMs: 10_000 })).toEqual(payload); + expect(decodePairingSetupCode(`oc-pair://${setupCode}`, { nowMs: 10_000 })).toEqual(payload); + }); + + it("rejects garbage and expired shipped payload shapes", () => { + expect(() => decodePairingSetupCode("not-json")).toThrow("Invalid pairing setup"); + const expired = encodePairingSetupCode({ + url: "wss://gateway.example", + bootstrapToken: "bootstrap-123", + expiresAtMs: 10_000, + }); + expect(() => decodePairingSetupCode(expired, { nowMs: 10_000 })).toThrow("expired"); + }); + + it("accepts older payloads without a TLS fingerprint or expiry", () => { + const payload = { url: "wss://gateway.example", bootstrapToken: "bootstrap-123" }; + expect(decodePairingSetupCode(encodePairingSetupCode(payload))).toEqual(payload); + }); + type ResolvedSetup = Awaited>; type ResolveSetupConfig = Parameters[0]; type ResolveSetupOptions = Parameters[1]; @@ -286,6 +316,20 @@ describe("pairing setup code", () => { }); }); + it("preserves context paths in fully qualified setup urls", async () => { + await expectResolvedSetupSuccessCase({ + config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }), + options: { + publicUrl: "wss://gateway.example.test:18789/openclaw-gw", + }, + expected: { + authLabel: "token", + url: "wss://gateway.example.test:18789/openclaw-gw", + urlSource: "plugins.entries.device-pair.config.publicUrl", + }, + }); + }); + it("issues a node-only bootstrap profile for companion setup", async () => { await expectResolvedSetupSuccessCase({ config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }), @@ -938,4 +982,35 @@ describe("pairing setup code", () => { expectedError: "Service MagicDNS could not be derived", }); }); + + it("pins the prepared leaf only for a direct TLS gateway URL", async () => { + const config = createCustomGatewayConfig({ mode: "token", token: "tok_123" }); + config.gateway = { ...config.gateway, tls: { enabled: true } }; + const direct = await resolvePairingSetupFromConfig(config, { + localTlsFingerprint: "sha256:direct-leaf", + }); + const proxied = await resolvePairingSetupFromConfig(config, { + publicUrl: "wss://proxy.example", + localTlsFingerprint: "sha256:direct-leaf", + }); + + expect(direct.ok && direct.payload.tlsFingerprint).toBe("sha256:direct-leaf"); + expect(proxied.ok && proxied.payload.tlsFingerprint).toBeUndefined(); + }); + + it("omits a configured remote TLS pin from a cleartext setup URL", async () => { + const config = createCustomGatewayConfig({ mode: "token", token: "tok_123" }); + config.gateway = { + ...config.gateway, + remote: { + url: "ws://127.0.0.1:18789", + tlsFingerprint: "sha256:stale-remote-leaf", + }, + }; + + const resolved = await resolvePairingSetupFromConfig(config, { preferRemoteUrl: true }); + + expect(resolved.ok).toBe(true); + expect(resolved.ok && resolved.payload.tlsFingerprint).toBeUndefined(); + }); }); diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index b99c638aa057..6725fbfcf54d 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -8,6 +8,7 @@ import { isRfc1918Ipv4Address, parseCanonicalIpAddress, } from "@openclaw/net-policy/ip"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -42,6 +43,8 @@ type PairingSetupPayload = { url: string; urls?: string[]; bootstrapToken: string; + expiresAtMs?: number; + tlsFingerprint?: string; }; type PairingSetupAccess = "full" | "limited" | "node"; @@ -68,6 +71,8 @@ type ResolvePairingSetupOptions = { pairingBaseDir?: string; runCommandWithTimeout?: PairingSetupCommandRunner; networkInterfaces?: () => ReturnType; + localTlsFingerprint?: string; + loadLocalTlsFingerprint?: () => Promise; }; type PairingSetupResolution = @@ -78,6 +83,7 @@ type PairingSetupResolution = urlSource: string; access: PairingSetupAccess; accessDowngraded: boolean; + expiresAtMs: number; } | { ok: false; @@ -239,7 +245,8 @@ function parseNormalizedGatewayUrl(raw: string): string | null { return null; } const port = parsed.port ? `:${parsed.port}` : ""; - return `${resolvedScheme}://${host}${port}`; + const contextPath = parsed.pathname === "/" ? "" : parsed.pathname; + return `${resolvedScheme}://${host}${port}${contextPath}`; } catch { return null; } @@ -409,6 +416,79 @@ export function encodePairingSetupCode(payload: PairingSetupPayload): string { return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } +const PAIRING_SETUP_URL_PREFIX = "oc-pair://"; +const PAIRING_SETUP_CODE_RE = /^[A-Za-z0-9_-]+$/u; + +/** Decode the current setup payload plus additive fields emitted by older pairing surfaces. */ +export function decodePairingSetupCode( + input: string, + options: { nowMs?: number } = {}, +): PairingSetupPayload { + const trimmed = input.trim(); + const setupCode = trimmed.toLowerCase().startsWith(PAIRING_SETUP_URL_PREFIX) + ? trimmed.slice(PAIRING_SETUP_URL_PREFIX.length) + : trimmed; + if (!setupCode || !PAIRING_SETUP_CODE_RE.test(setupCode)) { + throw new Error("Invalid pairing setup code or URL."); + } + + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(setupCode, "base64url").toString("utf8")); + } catch { + throw new Error("Invalid pairing setup code or URL."); + } + if (!isRecord(decoded)) { + throw new Error("Invalid pairing setup payload."); + } + + const url = normalizeOptionalString(decoded.url); + const bootstrapToken = normalizeOptionalString(decoded.bootstrapToken); + if (!url || !bootstrapToken || normalizeUrl(url, "ws") !== url) { + throw new Error("Invalid pairing setup payload."); + } + + let urls: string[] | undefined; + if (decoded.urls !== undefined) { + if ( + !Array.isArray(decoded.urls) || + decoded.urls.length === 0 || + decoded.urls.length > PAIRING_SETUP_MAX_URLS || + decoded.urls.some( + (candidate) => typeof candidate !== "string" || normalizeUrl(candidate, "ws") !== candidate, + ) + ) { + throw new Error("Invalid pairing setup payload."); + } + urls = decoded.urls; + } + + let expiresAtMs: number | undefined; + if (decoded.expiresAtMs !== undefined) { + const candidate = decoded.expiresAtMs; + if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate < 0) { + throw new Error("Invalid pairing setup payload."); + } + expiresAtMs = candidate; + if (candidate <= (options.nowMs ?? Date.now())) { + throw new Error("Pairing setup code has expired."); + } + } + + const tlsFingerprint = normalizeOptionalString(decoded.tlsFingerprint); + if (decoded.tlsFingerprint !== undefined && !tlsFingerprint) { + throw new Error("Invalid pairing setup payload."); + } + + return { + url, + ...(urls ? { urls } : {}), + bootstrapToken, + ...(expiresAtMs !== undefined ? { expiresAtMs } : {}), + ...(tlsFingerprint ? { tlsFingerprint } : {}), + }; +} + export async function resolvePairingSetupFromConfig( cfg: OpenClawConfig, options: ResolvePairingSetupOptions = {}, @@ -476,18 +556,28 @@ export async function resolvePairingSetupFromConfig( ? PAIRING_SETUP_BOOTSTRAP_PROFILE : requestedBootstrapProfile; + const issuedBootstrap = await issueDeviceBootstrapToken({ + baseDir: options.pairingBaseDir, + profile: issuedBootstrapProfile, + }); + const directGatewayTlsFingerprint = + urlResult.url.startsWith("wss://") && urlResult.source?.startsWith("gateway.bind=") + ? (normalizeOptionalString(options.localTlsFingerprint) ?? + (await options.loadLocalTlsFingerprint?.())) + : urlResult.url.startsWith("wss://") && urlResult.source === "gateway.remote.url" + ? normalizeOptionalString(cfgForAuth.gateway?.remote?.tlsFingerprint) + : undefined; + return { ok: true, payload: { url: urlResult.url, ...(uniqueUrls.length > 1 ? { urls: uniqueUrls } : {}), - bootstrapToken: ( - await issueDeviceBootstrapToken({ - baseDir: options.pairingBaseDir, - profile: issuedBootstrapProfile, - }) - ).token, + bootstrapToken: issuedBootstrap.token, + expiresAtMs: issuedBootstrap.expiresAtMs, + ...(directGatewayTlsFingerprint ? { tlsFingerprint: directGatewayTlsFingerprint } : {}), }, + expiresAtMs: issuedBootstrap.expiresAtMs, authLabel: authLabel.label, urlSource: urlResult.source ?? "unknown", access: resolvePairingSetupAccess(issuedBootstrapProfile), diff --git a/ui/src/app/app-host-pairing-access.test.ts b/ui/src/app/app-host-pairing-access.test.ts index df6fe250dd3c..3c4db1d3faa1 100644 --- a/ui/src/app/app-host-pairing-access.test.ts +++ b/ui/src/app/app-host-pairing-access.test.ts @@ -3,6 +3,7 @@ import { render, type TemplateResult } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { OpenClawDevicePairSetup } from "../pages/devices/view-pairing.ts"; import type { ApplicationRuntime } from "./bootstrap.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "./context.ts"; import "./app-host.ts"; @@ -23,7 +24,12 @@ function createPairingShell(params: { auth: PairingAuth | null; connected?: boolean; setupCode?: string; + expiresAtMs?: number; + approvalNowMs?: number; }) { + if (!customElements.get("openclaw-device-pair-setup")) { + customElements.define("openclaw-device-pair-setup", OpenClawDevicePairSetup); + } const snapshot: ApplicationGatewaySnapshot = { client: { request: vi.fn(async () => ({})) } as unknown as GatewayBrowserClient, phase: params.connected === false ? "stopped" : "connected", @@ -36,6 +42,30 @@ function createPairingShell(params: { lastErrorCode: null, }; const openDevicePairSetup = vi.fn(async () => undefined); + const overlaySnapshot = { + approvalQueue: [], + approvalErrors: new Map(), + approvalNowMs: params.approvalNowMs ?? 0, + approvalBusy: false, + devicePairSetupOpen: Boolean(params.setupCode), + devicePairSetupLoading: false, + devicePairSetupError: null, + devicePairSetup: params.setupCode + ? { + setupCode: params.setupCode, + gatewayUrl: "wss://gateway.example.test", + auth: "token", + urlSource: "test", + ...(params.expiresAtMs === undefined ? {} : { expiresAtMs: params.expiresAtMs }), + } + : null, + devicePairSetupAccess: "full", + devicePairPendingCount: 0, + updateAvailable: null, + updateRunning: false, + updateStatusBanner: null, + controlUiRefreshRequired: false, + }; const context = { basePath: "", gateway: { @@ -46,29 +76,7 @@ function createPairingShell(params: { snapshot: { navCollapsed: false, navWidth: 258, sidebarEntries: [], pinnedAgentIds: [] }, }, overlays: { - snapshot: { - approvalQueue: [], - approvalErrors: new Map(), - approvalNowMs: 0, - approvalBusy: false, - devicePairSetupOpen: Boolean(params.setupCode), - devicePairSetupLoading: false, - devicePairSetupError: null, - devicePairSetup: params.setupCode - ? { - setupCode: params.setupCode, - gatewayUrl: "wss://gateway.example.test", - auth: "token", - urlSource: "test", - } - : null, - devicePairSetupAccess: "full", - devicePairPendingCount: 0, - updateAvailable: null, - updateRunning: false, - updateStatusBanner: null, - controlUiRefreshRequired: false, - }, + snapshot: overlaySnapshot, openDevicePairSetup, }, config: { current: {} }, @@ -81,7 +89,13 @@ function createPairingShell(params: { theme: { mode: "system" }, } as unknown as ApplicationContext; const shell = document.createElement("openclaw-app-shell") as PairingShell; - shell.runtime = { context, router: {} } as ApplicationRuntime; + shell.runtime = { + context, + router: { + getState: () => ({ status: "idle", matches: [], pendingMatches: [] }), + subscribeSelector: () => () => undefined, + }, + } as unknown as ApplicationRuntime; const container = document.createElement("div"); const renderSidebar = () => { @@ -93,11 +107,13 @@ function createPairingShell(params: { return sidebar; }; - return { snapshot, openDevicePairSetup, renderSidebar, container }; + return { snapshot, overlaySnapshot, openDevicePairSetup, renderSidebar, container }; } -afterEach(() => { +afterEach(async () => { + vi.useRealTimers(); document.body.replaceChildren(); + await Promise.resolve(); vi.unstubAllGlobals(); vi.restoreAllMocks(); Reflect.deleteProperty(document, "execCommand"); @@ -161,12 +177,12 @@ describe("application shell pairing access", () => { auth: { role: "operator", scopes: ["operator.pairing"] }, setupCode: "pair-mobile-secret", }); + document.body.append(container); renderSidebar(); - const pairing = container.querySelector(".device-pair-setup"); - if (!pairing) { - throw new Error("Expected the application shell to render its mobile pairing dialog"); - } - document.body.append(pairing); + await vi.waitFor(() => + expect(container.querySelector(".device-pair-setup")).not.toBeNull(), + ); + const pairing = container.querySelector(".device-pair-setup")!; const button = pairing.querySelector(".device-pair-setup__actions button"); button?.click(); @@ -186,4 +202,31 @@ describe("application shell pairing access", () => { expect(button?.textContent?.trim()).toBe("Copy setup code"); expect(button?.getAttribute("aria-label")).toBe("Copy setup code"); }); + + it("expires a node setup link from the pairing clock, independently of approvals", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(4_000); + const { overlaySnapshot, container, renderSidebar } = createPairingShell({ + auth: { role: "operator", scopes: ["operator.pairing"] }, + setupCode: "pair-node-secret", + expiresAtMs: 5_000, + approvalNowMs: 50_000, + }); + document.body.append(container); + overlaySnapshot.devicePairSetupAccess = "node"; + + renderSidebar(); + await vi.waitFor(() => + expect(container.querySelector('[role="timer"]')?.textContent).toContain("0:01"), + ); + expect(container.querySelector(".device-pair-setup__command code")).not.toBeNull(); + + now.mockReturnValue(5_000); + renderSidebar(); + await vi.waitFor(() => + expect(container.querySelector('[role="timer"]')?.textContent?.toLowerCase()).toContain( + "expired", + ), + ); + expect(container.querySelector(".device-pair-setup__command code")).toBeNull(); + }); }); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index fce48efb0114..e38a624153e7 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -65,6 +65,7 @@ import { COMMAND_PALETTE_ELEMENT, CUSTODIAN_PANEL_ELEMENT, DESKTOP_PANEL_ELEMENT, + DEVICE_PAIR_SETUP_ELEMENT, EXEC_APPROVAL_ELEMENT, preloadOptionalElement, TERMINAL_PANEL_ELEMENT, @@ -135,6 +136,7 @@ class OpenClawShell readonly browserPanelElement = BROWSER_PANEL_ELEMENT; readonly desktopPanelElement = DESKTOP_PANEL_ELEMENT; readonly custodianPanelElement = CUSTODIAN_PANEL_ELEMENT; + readonly devicePairSetupElement = DEVICE_PAIR_SETUP_ELEMENT; readonly execApprovalElement = EXEC_APPROVAL_ELEMENT; @query("openclaw-command-palette") commandPalette: CommandPaletteElement | undefined; @query("openclaw-exec-approval") @@ -545,6 +547,9 @@ class OpenClawShell if ((context.overlays?.snapshot.approvalQueue.length ?? 0) > 0) { preloadOptionalElement(this, this.execApprovalElement); } + if (context.overlays?.snapshot.devicePairSetupOpen) { + preloadOptionalElement(this, this.devicePairSetupElement); + } const navState = { collapsed: this.nativeNavCollapsed(), width: context.navigation.snapshot.navWidth, diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 4ccee15ef81e..45a17945dde4 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -16,7 +16,6 @@ import { findUiSessionRow } from "../lib/sessions/route-navigation.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts"; -import { renderDevicePairSetup } from "../pages/devices/view-pairing.ts"; import type { NewSessionTarget } from "../pages/new-session/location.ts"; import { pluginTabKey, pluginTabRefFromSearch } from "../pages/plugin/route.ts"; import type { ShellRouteState } from "./app-host-route-state.ts"; @@ -54,6 +53,7 @@ export interface ShellViewHost { readonly commandPaletteElement: OptionalCustomElement; readonly custodianMinimizeRequestId: number; readonly desktopNavigationExpanded: boolean; + readonly devicePairSetupElement: OptionalCustomElement; readonly execApprovalElement: OptionalCustomElement; readonly nativeHistoryState: NativeHistoryState; readonly navDrawerOpen: boolean; @@ -544,25 +544,32 @@ export function renderApplicationShell(host: ShellViewHost) { }} >` : nothing} - ${renderDevicePairSetup({ - open: overlaySnapshot.devicePairSetupOpen, - loading: overlaySnapshot.devicePairSetupLoading, - error: overlaySnapshot.devicePairSetupError, - setup: overlaySnapshot.devicePairSetup, - access: overlaySnapshot.devicePairSetupAccess, - pendingCount: overlaySnapshot.devicePairPendingCount, - onRefresh: () => void context.overlays.refreshDevicePairSetup(), - onAccessChange: (access) => void context.overlays.setDevicePairSetupAccess(access), - onClose: () => context.overlays.closeDevicePairSetup(), - onManageDevices: () => { - context.overlays.closeDevicePairSetup(); - host.navigate("devices"); - }, - onGetApps: () => { - context.overlays.closeDevicePairSetup(); - host.navigate("apps"); - }, - })} + ${isOptionalElementDefined(host.devicePairSetupElement) + ? html` void context.overlays.refreshDevicePairSetup(), + onAccessChange: ( + access: Parameters[0], + ) => void context.overlays.setDevicePairSetupAccess(access), + onClose: () => context.overlays.closeDevicePairSetup(), + onManageDevices: () => { + context.overlays.closeDevicePairSetup(); + host.navigate("devices"); + }, + onGetApps: () => { + context.overlays.closeDevicePairSetup(); + host.navigate("apps"); + }, + }} + >` + : nothing} ${onboarding && activeRoute !== "custodian" ? html` import("../components/exec-approval.ts"), } satisfies OptionalCustomElement; +const DEVICE_PAIR_SETUP_TAG = "openclaw-device-pair-setup"; + +export const DEVICE_PAIR_SETUP_ELEMENT = { + tagName: DEVICE_PAIR_SETUP_TAG, + label: DEVICE_PAIR_SETUP_TAG, + loadModule: () => import("../pages/devices/view-pairing.ts"), +} satisfies OptionalCustomElement; + const hostElementLoads = new WeakMap>>(); export function isOptionalElementDefined(element: OptionalCustomElement): boolean { diff --git a/ui/src/app/overlays-types.ts b/ui/src/app/overlays-types.ts new file mode 100644 index 000000000000..395f5a945f65 --- /dev/null +++ b/ui/src/app/overlays-types.ts @@ -0,0 +1,41 @@ +import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; +import type { DevicePairSetup, DevicePairSetupAccess } from "../lib/device-pair-setup.ts"; +import type { DeviceAuthMigrationSnapshot } from "./device-auth-migration.ts"; +import type { ExecApprovalDecision, ExecApprovalRequest } from "./exec-approval.ts"; +import type { ApplicationStatusBanner } from "./update-overlay-helpers.ts"; + +export type ApplicationOverlaySnapshot = { + updateAvailable: UpdateAvailable | null; + updateSchedule: UpdateScheduleState | null; + heldUpdateCampaignId: string | null; + updateRunning: boolean; + updateReconciliationPending: boolean; + updateStatusBanner: ApplicationStatusBanner | null; + controlUiRefreshRequired: boolean; + approvalQueue: readonly ExecApprovalRequest[]; + approvalBusy: boolean; + approvalErrors: ReadonlyMap; + approvalNowMs: number; + devicePairSetupOpen: boolean; + devicePairSetupLoading: boolean; + devicePairSetupError: string | null; + devicePairSetup: DevicePairSetup | null; + devicePairSetupAccess: DevicePairSetupAccess; + devicePairPendingCount: number; + deviceAuthMigration: DeviceAuthMigrationSnapshot; +}; + +export type ApplicationOverlays = { + readonly snapshot: ApplicationOverlaySnapshot; + subscribe: (listener: (snapshot: ApplicationOverlaySnapshot) => void) => () => void; + refreshUpdateStatus: () => Promise; + runUpdate: () => Promise; + holdUpdate: () => Promise; + decideApproval: (decision: ExecApprovalDecision, approvalId?: string) => Promise; + openDevicePairSetup: () => Promise; + refreshDevicePairSetup: () => Promise; + setDevicePairSetupAccess: (access: DevicePairSetupAccess) => Promise; + closeDevicePairSetup: () => void; + secureThisBrowser: () => Promise; + dispose: () => void; +}; diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 2237609ba964..8630833379a4 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -3,7 +3,7 @@ import { type GatewayUpdateAvailableEventPayload, } from "../../../src/gateway/events.js"; import type { GatewayEventFrame } from "../api/gateway.ts"; -import type { UpdateAvailable, UpdateHoldResult, UpdateScheduleState } from "../api/types.ts"; +import type { UpdateHoldResult, UpdateScheduleState } from "../api/types.ts"; import { controlUiVersionDiffersFrom } from "../build-info.ts"; import { t } from "../i18n/index.ts"; import { @@ -13,8 +13,7 @@ import { readDevicePairSetupSnapshot, refreshDevicePairSetup as refreshDevicePairSetupState, setDevicePairSetupAccess as setPairAccess, - type DevicePairSetup, - type DevicePairSetupAccess, + syncDevicePairSetupCountdown, } from "../lib/device-pair-setup.ts"; import { createDeviceAuthMigrationLoader, @@ -28,12 +27,12 @@ import { parseApprovalRequestedEvent, parseExecApprovalResolved, resolveApprovalRequest, - type ExecApprovalDecision, type ExecApprovalPromptState, - type ExecApprovalRequest, } from "./exec-approval.ts"; import type { ApplicationGateway } from "./gateway.ts"; import { readGatewayOperatorAccess } from "./operator-access.ts"; +import type { ApplicationOverlays, ApplicationOverlaySnapshot } from "./overlays-types.ts"; +export type { ApplicationOverlays } from "./overlays-types.ts"; import { createOverlayApprovalRefresher, createOverlayPairingPendingCount, @@ -65,42 +64,6 @@ import { announceVerifiedUpdateInstall, } from "./update-success-notice.ts"; -type ApplicationOverlaySnapshot = { - updateAvailable: UpdateAvailable | null; - updateSchedule: UpdateScheduleState | null; - heldUpdateCampaignId: string | null; - updateRunning: boolean; - updateReconciliationPending: boolean; - updateStatusBanner: ApplicationStatusBanner | null; - controlUiRefreshRequired: boolean; - approvalQueue: readonly ExecApprovalRequest[]; - approvalBusy: boolean; - approvalErrors: ReadonlyMap; - approvalNowMs: number; - devicePairSetupOpen: boolean; - devicePairSetupLoading: boolean; - devicePairSetupError: string | null; - devicePairSetup: DevicePairSetup | null; - devicePairSetupAccess: DevicePairSetupAccess; - devicePairPendingCount: number; - deviceAuthMigration: import("./device-auth-migration.ts").DeviceAuthMigrationSnapshot; -}; - -export type ApplicationOverlays = { - readonly snapshot: ApplicationOverlaySnapshot; - subscribe: (listener: (snapshot: ApplicationOverlaySnapshot) => void) => () => void; - refreshUpdateStatus: () => Promise; - runUpdate: () => Promise; - holdUpdate: () => Promise; - decideApproval: (decision: ExecApprovalDecision, approvalId?: string) => Promise; - openDevicePairSetup: () => Promise; - refreshDevicePairSetup: () => Promise; - setDevicePairSetupAccess: (access: DevicePairSetupAccess) => Promise; - closeDevicePairSetup: () => void; - secureThisBrowser: () => Promise; - dispose: () => void; -}; - function isGatewayEvent(value: unknown): value is GatewayEventFrame { return Boolean(value && typeof value === "object" && "event" in value); } @@ -192,6 +155,7 @@ export function createApplicationOverlays( publish(); await operation; if (!disposed) { + syncDevicePairSetupCountdown(devicePairSetupState, publish); publish(); } }; diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index 7718a8616ddf..80bf81112cc2 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -1,6 +1,7 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "../api/gateway.ts"; import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; import { t } from "../i18n/index.ts"; +import { formatCountdown } from "../lib/format.ts"; import { readUpdateAvailableValue, readUpdateScheduleValue } from "./update-schedule-dto.ts"; export type ApplicationStatusBanner = { @@ -453,12 +454,6 @@ export function projectUpdateStatusResponse( }; } -function formatUpdateCountdown(deadlineMs: number, nowMs = Date.now()): string { - const totalSeconds = Math.max(0, Math.ceil((deadlineMs - nowMs) / 1_000)); - const minutes = Math.floor(totalSeconds / 60); - return `${minutes}:${String(totalSeconds % 60).padStart(2, "0")}`; -} - export function formatUpdateCampaignLabel( schedule: UpdateScheduleState | null | undefined, nowMs = Date.now(), @@ -469,7 +464,7 @@ export function formatUpdateCampaignLabel( } if (campaign.holdUntilMs !== undefined && campaign.holdUntilMs > nowMs) { return t("updates.campaign.held", { - time: formatUpdateCountdown(campaign.holdUntilMs, nowMs), + time: formatCountdown(campaign.holdUntilMs, nowMs), }); } if (campaign.state === "applying") { @@ -477,11 +472,11 @@ export function formatUpdateCampaignLabel( } if (campaign.state === "waiting-for-idle") { return t("updates.campaign.waitingForIdle", { - time: formatUpdateCountdown(campaign.forceAtMs, nowMs), + time: formatCountdown(campaign.forceAtMs, nowMs), }); } return t("updates.campaign.countdown", { - time: formatUpdateCountdown(campaign.applyAtMs ?? campaign.forceAtMs, nowMs), + time: formatCountdown(campaign.applyAtMs ?? campaign.forceAtMs, nowMs), }); } diff --git a/ui/src/components/exec-approval-card.ts b/ui/src/components/exec-approval-card.ts index d2409f305b8c..09401ad6539a 100644 --- a/ui/src/components/exec-approval-card.ts +++ b/ui/src/components/exec-approval-card.ts @@ -7,6 +7,7 @@ import type { ExecApprovalRequestPayload, } from "../app/exec-approval.ts"; import { t } from "../i18n/index.ts"; +import { formatCountdown } from "../lib/format.ts"; const DEFAULT_EXEC_APPROVAL_DECISIONS = [ "allow-once", @@ -24,14 +25,9 @@ type ExecApprovalCardProps = { onDecision: (approvalId: string, decision: ExecApprovalDecision) => void | Promise; }; -export function formatApprovalCountdown(expiresAtMs: number, nowMs: number): string { - const totalSeconds = Math.max(0, Math.ceil((expiresAtMs - nowMs) / 1_000)); - return `${String(Math.floor(totalSeconds / 60)).padStart(2, "0")}:${String(totalSeconds % 60).padStart(2, "0")}`; -} - export function approvalRemainingLabel(expiresAtMs: number, nowMs: number): string { return expiresAtMs > nowMs - ? t("execApproval.expiresIn", { time: formatApprovalCountdown(expiresAtMs, nowMs) }) + ? t("execApproval.expiresIn", { time: formatCountdown(expiresAtMs, nowMs, true) }) : t("execApproval.expired"); } diff --git a/ui/src/components/exec-approval.ts b/ui/src/components/exec-approval.ts index 4ca82da224ec..04fab4422544 100644 --- a/ui/src/components/exec-approval.ts +++ b/ui/src/components/exec-approval.ts @@ -5,12 +5,12 @@ import { property, query, state } from "lit/decorators.js"; import { modalApprovalQueue } from "../app/approval-presentation.ts"; import type { ExecApprovalDecision, ExecApprovalRequest } from "../app/exec-approval.ts"; import { t } from "../i18n/index.ts"; +import { formatCountdown } from "../lib/format.ts"; import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts"; import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; import { approvalRemainingLabel, approvalTitle, - formatApprovalCountdown, renderExecApprovalCard, resolveApprovalDecisions, } from "./exec-approval-card.ts"; @@ -47,7 +47,7 @@ function renderApprovalQueueList(params: { ${others.map((entry) => { const command = compactCommand(entry.request.command); const agent = entry.request.agentId?.trim() || "—"; - const countdown = formatApprovalCountdown(entry.expiresAtMs, params.nowMs); + const countdown = formatCountdown(entry.expiresAtMs, params.nowMs, true); return html` -

+ ${isNodeSetup + ? nothing + : html`

+ ${t("devices.pairing.noApp")} + +

`} + ${isNodeSetup + ? nothing + : html``}