diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index ae21d8a32831..4bf03018d4b0 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -731,7 +731,7 @@ }, { "kind": "ui-call", - "line": 195, + "line": 186, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.", "surface": "android", @@ -739,7 +739,7 @@ }, { "kind": "ui-call", - "line": 197, + "line": 188, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The Gateway still shows this approval as pending. Review it before trying again.", "surface": "android", @@ -747,7 +747,7 @@ }, { "kind": "ui-call", - "line": 199, + "line": 190, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approval details. Refresh and try again.", "surface": "android", @@ -755,7 +755,7 @@ }, { "kind": "ui-call", - "line": 201, + "line": 192, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approvals.", "surface": "android", @@ -763,7 +763,7 @@ }, { "kind": "ui-call", - "line": 203, + "line": 194, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not resolve approval. Refresh and try again.", "surface": "android", @@ -771,7 +771,7 @@ }, { "kind": "ui-call", - "line": 330, + "line": 321, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Device approved.", "surface": "android", @@ -779,7 +779,7 @@ }, { "kind": "ui-call", - "line": 331, + "line": 322, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pairing request rejected.", "surface": "android", @@ -787,7 +787,7 @@ }, { "kind": "ui-call", - "line": 332, + "line": 323, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Paired device removed.", "surface": "android", @@ -795,7 +795,7 @@ }, { "kind": "ui-call", - "line": 393, + "line": 384, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal applied.", "surface": "android", @@ -803,7 +803,7 @@ }, { "kind": "ui-call", - "line": 393, + "line": 384, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "apply", "surface": "android", @@ -811,7 +811,7 @@ }, { "kind": "ui-call", - "line": 394, + "line": 385, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal rejected.", "surface": "android", @@ -819,7 +819,7 @@ }, { "kind": "ui-call", - "line": 394, + "line": 385, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "reject", "surface": "android", @@ -827,7 +827,7 @@ }, { "kind": "ui-call", - "line": 395, + "line": 386, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal quarantined.", "surface": "android", @@ -835,7 +835,7 @@ }, { "kind": "ui-call", - "line": 395, + "line": 386, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "quarantine", "surface": "android", @@ -843,7 +843,7 @@ }, { "kind": "ui-call", - "line": 402, + "line": 393, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "unknown", "surface": "android", @@ -851,7 +851,7 @@ }, { "kind": "ui-call", - "line": 403, + "line": 394, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned status '$statusLabel' after ${action.verb}.", "surface": "android", @@ -859,7 +859,7 @@ }, { "kind": "ui-call", - "line": 411, + "line": 402, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not ${action.verb} Skill Workshop proposal.", "surface": "android", @@ -867,7 +867,7 @@ }, { "kind": "ui-call", - "line": 593, + "line": 584, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (node offline)", "surface": "android", @@ -875,7 +875,7 @@ }, { "kind": "ui-call", - "line": 594, + "line": 585, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator offline)", "surface": "android", @@ -883,7 +883,7 @@ }, { "kind": "ui-call", - "line": 596, + "line": 587, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting…", "surface": "android", @@ -891,7 +891,7 @@ }, { "kind": "ui-call", - "line": 604, + "line": 595, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator: $operator)", "surface": "android", @@ -899,7 +899,7 @@ }, { "kind": "ui-call", - "line": 2396, + "line": 2387, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation already has a queued run.", "surface": "android", @@ -907,7 +907,7 @@ }, { "kind": "ui-call", - "line": 2426, + "line": 2417, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation run queued.", "surface": "android", @@ -915,7 +915,7 @@ }, { "kind": "ui-call", - "line": 2426, + "line": 2417, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation started.", "surface": "android", @@ -923,7 +923,7 @@ }, { "kind": "ui-call", - "line": 2439, + "line": 2430, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway rejected the automation run.", "surface": "android", @@ -931,7 +931,7 @@ }, { "kind": "ui-call", - "line": 2470, + "line": 2461, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation enabled.", "surface": "android", @@ -939,7 +939,7 @@ }, { "kind": "ui-call", - "line": 2470, + "line": 2461, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation paused.", "surface": "android", @@ -947,7 +947,7 @@ }, { "kind": "ui-call", - "line": 2492, + "line": 2483, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation changed on the gateway. Review the latest version before saving again.", "surface": "android", @@ -955,7 +955,7 @@ }, { "kind": "ui-call", - "line": 2498, + "line": 2489, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation updated.", "surface": "android", @@ -963,7 +963,7 @@ }, { "kind": "ui-call", - "line": 2513, + "line": 2504, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation deleted.", "surface": "android", @@ -971,7 +971,7 @@ }, { "kind": "ui-call", - "line": 2728, + "line": 2719, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Node offline. Reconnect and retry.", "surface": "android", @@ -979,7 +979,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 2739, + "line": 2730, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Restore canvas now for session=$sessionKey source=$source. If existing A2UI state exists, replay it immediately. If not, create and render a compact mobile-friendly dashboard in Canvas.", "surface": "android", @@ -987,7 +987,7 @@ }, { "kind": "ui-call", - "line": 2760, + "line": 2751, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed to request restore. Tap to retry.", "surface": "android", @@ -995,7 +995,7 @@ }, { "kind": "ui-call", - "line": 2771, + "line": 2762, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No canvas update yet. Tap to retry.", "surface": "android", @@ -1003,7 +1003,7 @@ }, { "kind": "ui-call", - "line": 3880, + "line": 3869, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to a Gateway to save wake words", "surface": "android", @@ -1011,7 +1011,7 @@ }, { "kind": "ui-call", - "line": 3908, + "line": 3897, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Wake words saved", "surface": "android", @@ -1019,7 +1019,7 @@ }, { "kind": "ui-call", - "line": 3917, + "line": 3906, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not save wake words", "surface": "android", @@ -1027,7 +1027,7 @@ }, { "kind": "ui-call", - "line": 4630, + "line": 4619, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "android", @@ -1035,7 +1035,7 @@ }, { "kind": "ui-call", - "line": 4634, + "line": 4623, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -1043,7 +1043,7 @@ }, { "kind": "ui-call", - "line": 4638, + "line": 4627, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -1051,7 +1051,7 @@ }, { "kind": "ui-named-argument", - "line": 4688, + "line": 4677, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connecting…", "surface": "android", @@ -1059,7 +1059,7 @@ }, { "kind": "ui-named-argument", - "line": 4892, + "line": 4881, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -1067,7 +1067,7 @@ }, { "kind": "ui-call", - "line": 5705, + "line": 5694, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider catalog.", "surface": "android", @@ -1075,7 +1075,7 @@ }, { "kind": "ui-call", - "line": 5736, + "line": 5725, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Update your Gateway to view provider model config.", "surface": "android", @@ -1083,7 +1083,7 @@ }, { "kind": "ui-call", - "line": 5738, + "line": 5727, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider model config.", "surface": "android", @@ -1091,7 +1091,7 @@ }, { "kind": "ui-call", - "line": 5754, + "line": 5743, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Provider models loaded, but readiness is unavailable.", "surface": "android", @@ -1099,7 +1099,7 @@ }, { "kind": "ui-call", - "line": 5841, + "line": 5830, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automations.", "surface": "android", @@ -1107,7 +1107,7 @@ }, { "kind": "ui-call", - "line": 5927, + "line": 5916, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automations.", "surface": "android", @@ -1115,7 +1115,7 @@ }, { "kind": "ui-call", - "line": 5937, + "line": 5926, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned an invalid automation.", "surface": "android", @@ -1123,7 +1123,7 @@ }, { "kind": "ui-call", - "line": 5941, + "line": 5930, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation.", "surface": "android", @@ -1131,7 +1131,7 @@ }, { "kind": "ui-call", - "line": 5953, + "line": 5942, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automation run history.", "surface": "android", @@ -1139,7 +1139,7 @@ }, { "kind": "ui-call", - "line": 5984, + "line": 5973, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation run history.", "surface": "android", @@ -1147,7 +1147,7 @@ }, { "kind": "ui-call", - "line": 6001, + "line": 5990, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron changes require operator.admin access.", "surface": "android", @@ -1155,7 +1155,7 @@ }, { "kind": "ui-call", - "line": 6010, + "line": 5999, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to manage automations.", "surface": "android", @@ -1163,7 +1163,7 @@ }, { "kind": "ui-call", - "line": 6022, + "line": 6011, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Another cron action is still finishing.", "surface": "android", @@ -1171,7 +1171,7 @@ }, { "kind": "ui-call", - "line": 6069, + "line": 6058, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron action failed.", "surface": "android", @@ -1179,7 +1179,7 @@ }, { "kind": "ui-call", - "line": 6181, + "line": 6170, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load usage.", "surface": "android", @@ -1187,7 +1187,7 @@ }, { "kind": "ui-call", - "line": 6196, + "line": 6185, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load skills.", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "ui-call", - "line": 6216, + "line": 6205, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update skills.", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "ui-call", - "line": 6220, + "line": 6209, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to update skills.", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "conditional-branch", - "line": 6235, + "line": 6224, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not disable skill.", "surface": "android", @@ -1219,7 +1219,7 @@ }, { "kind": "conditional-branch", - "line": 6235, + "line": 6224, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not enable skill.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "ui-call", - "line": 6253, + "line": 6242, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to search ClawHub skills.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "ui-call", - "line": 6299, + "line": 6288, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not search ClawHub skills.", "surface": "android", @@ -1243,7 +1243,7 @@ }, { "kind": "ui-call", - "line": 6312, + "line": 6301, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect ClawHub skills.", "surface": "android", @@ -1251,7 +1251,7 @@ }, { "kind": "conditional-branch", - "line": 6345, + "line": 6334, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "ClawHub did not return an installable version for ${skill.slug}.", "surface": "android", @@ -1259,7 +1259,7 @@ }, { "kind": "ui-call", - "line": 6361, + "line": 6350, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load ClawHub details for ${skill.slug}.", "surface": "android", @@ -1267,7 +1267,7 @@ }, { "kind": "ui-call", - "line": 6377, + "line": 6366, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to install ClawHub skills.", "surface": "android", @@ -1275,7 +1275,7 @@ }, { "kind": "ui-call", - "line": 6393, + "line": 6382, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to install ClawHub skills.", "surface": "android", @@ -1283,7 +1283,7 @@ }, { "kind": "conditional-branch", - "line": 6483, + "line": 6472, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Installed $slug.", "surface": "android", @@ -1291,7 +1291,7 @@ }, { "kind": "ui-call", - "line": 6490, + "line": 6479, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not install ${slug} from ClawHub.", "surface": "android", @@ -1299,7 +1299,7 @@ }, { "kind": "ui-call", - "line": 6530, + "line": 6519, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to load Skill Workshop proposals.", "surface": "android", @@ -1307,7 +1307,7 @@ }, { "kind": "ui-call", - "line": 6570, + "line": 6559, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load Skill Workshop proposals.", "surface": "android", @@ -1315,7 +1315,7 @@ }, { "kind": "ui-call", - "line": 6590, + "line": 6579, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect Skill Workshop proposals.", "surface": "android", @@ -1323,7 +1323,7 @@ }, { "kind": "ui-call", - "line": 6642, + "line": 6631, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not inspect Skill Workshop proposal.", "surface": "android", @@ -1331,7 +1331,7 @@ }, { "kind": "ui-call", - "line": 6662, + "line": 6651, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Skill Workshop proposal actions require operator.admin scope.", "surface": "android", @@ -1339,7 +1339,7 @@ }, { "kind": "ui-call", - "line": 6667, + "line": 6656, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update Skill Workshop proposals.", "surface": "android", @@ -1347,7 +1347,7 @@ }, { "kind": "ui-call", - "line": 6857, + "line": 6846, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not verify the device pairing change. Refresh and try again.", "surface": "android", @@ -1355,7 +1355,7 @@ }, { "kind": "ui-named-argument", - "line": 6939, + "line": 6928, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected", "surface": "android", @@ -1363,7 +1363,7 @@ }, { "kind": "ui-call", - "line": 6969, + "line": 6958, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load nodes and devices.", "surface": "android", @@ -1371,7 +1371,7 @@ }, { "kind": "ui-call", - "line": 7619, + "line": 7608, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load channels.", "surface": "android", @@ -1379,7 +1379,7 @@ }, { "kind": "ui-call", - "line": 7637, + "line": 7626, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load dreaming.", "surface": "android", @@ -1387,7 +1387,7 @@ }, { "kind": "ui-call", - "line": 7652, + "line": 7641, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load gateway logs.", "surface": "android", @@ -1395,7 +1395,7 @@ }, { "kind": "ui-call", - "line": 8135, + "line": 8124, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "One time", "surface": "android", @@ -1403,7 +1403,7 @@ }, { "kind": "ui-call", - "line": 8144, + "line": 8133, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron", "surface": "android", @@ -1411,7 +1411,7 @@ }, { "kind": "ui-call", - "line": 8145, + "line": 8134, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Scheduled", "surface": "android", @@ -1419,7 +1419,7 @@ }, { "kind": "ui-call", - "line": 8153, + "line": 8142, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${days}d", "surface": "android", @@ -1427,7 +1427,7 @@ }, { "kind": "ui-call", - "line": 8154, + "line": 8143, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${hours}h", "surface": "android", @@ -1435,7 +1435,7 @@ }, { "kind": "ui-call", - "line": 8155, + "line": 8144, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${minutes}m", "surface": "android", @@ -1443,7 +1443,7 @@ }, { "kind": "ui-call", - "line": 8156, + "line": 8145, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Repeating", "surface": "android", @@ -1451,7 +1451,7 @@ }, { "kind": "ui-call", - "line": 8172, + "line": 8161, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No prompt", "surface": "android", @@ -1459,7 +1459,7 @@ }, { "kind": "ui-call", - "line": 8189, + "line": 8178, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway", "surface": "android", @@ -1467,7 +1467,7 @@ }, { "kind": "ui-call", - "line": 8197, + "line": 8186, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected to $gatewayLabel", "surface": "android", @@ -1475,7 +1475,7 @@ }, { "kind": "ui-call", - "line": 8198, + "line": 8187, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your agents are ready", "surface": "android", @@ -1483,7 +1483,7 @@ }, { "kind": "ui-call", - "line": 8200, + "line": 8189, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", "surface": "android", @@ -1491,7 +1491,7 @@ }, { "kind": "ui-call", - "line": 8204, + "line": 8193, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Selected on this phone", "surface": "android", @@ -1499,7 +1499,7 @@ }, { "kind": "ui-call", - "line": 8207, + "line": 8196, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The overview refreshes on reconnect and when this screen opens.", "surface": "android", @@ -1507,7 +1507,7 @@ }, { "kind": "ui-call", - "line": 8212, + "line": 8201, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting", "surface": "android", @@ -1515,7 +1515,7 @@ }, { "kind": "ui-call", - "line": 8213, + "line": 8202, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "OpenClaw is syncing back up", "surface": "android", @@ -1523,7 +1523,7 @@ }, { "kind": "ui-call", - "line": 8215, + "line": 8204, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", "surface": "android", @@ -1531,7 +1531,7 @@ }, { "kind": "ui-call", - "line": 8219, + "line": 8208, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway session in progress", "surface": "android", @@ -1539,7 +1539,7 @@ }, { "kind": "ui-call", - "line": 8222, + "line": 8211, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "If the gateway is reachable, reconnect should complete without intervention.", "surface": "android", @@ -1547,7 +1547,7 @@ }, { "kind": "ui-call", - "line": 8227, + "line": 8216, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -1555,7 +1555,7 @@ }, { "kind": "ui-call", - "line": 8228, + "line": 8217, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your phone stays quiet until it is needed", "surface": "android", @@ -1563,7 +1563,7 @@ }, { "kind": "ui-call", - "line": 8230, + "line": 8219, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", "surface": "android", @@ -1571,7 +1571,7 @@ }, { "kind": "ui-call", - "line": 8234, + "line": 8223, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to load your agents", "surface": "android", @@ -1579,7 +1579,7 @@ }, { "kind": "ui-call", - "line": 8237, + "line": 8226, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", "surface": "android", @@ -1587,7 +1587,7 @@ }, { "kind": "ui-call", - "line": 8269, + "line": 8258, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Main", "surface": "android", @@ -1595,7 +1595,7 @@ }, { "kind": "ui-call", - "line": 8284, + "line": 8273, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Active on this phone", "surface": "android", @@ -1603,7 +1603,7 @@ }, { "kind": "ui-call", - "line": 8285, + "line": 8274, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Default agent", "surface": "android", @@ -1611,7 +1611,7 @@ }, { "kind": "ui-call", - "line": 8286, + "line": 8275, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Ready", "surface": "android", @@ -1619,7 +1619,7 @@ }, { "kind": "ui-call", - "line": 8941, + "line": 8930, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", @@ -1915,7 +1915,7 @@ }, { "kind": "ui-call", - "line": 1055, + "line": 1039, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Accept", "surface": "android", @@ -1923,7 +1923,7 @@ }, { "kind": "conditional-branch", - "line": 1961, + "line": 1849, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Connecting…", "surface": "android", @@ -1931,7 +1931,7 @@ }, { "kind": "conditional-branch", - "line": 1961, + "line": 1849, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Reconnecting…", "surface": "android", @@ -14155,7 +14155,7 @@ }, { "kind": "ui-call", - "line": 1062, + "line": 1019, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Start failed: $message", "surface": "android", @@ -14163,7 +14163,7 @@ }, { "kind": "ui-call", - "line": 1159, + "line": 1115, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Connecting…", "surface": "android", @@ -14171,7 +14171,7 @@ }, { "kind": "ui-call", - "line": 1230, + "line": 1186, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Speech recognizer unavailable", "surface": "android", @@ -14179,7 +14179,7 @@ }, { "kind": "ui-call", - "line": 1262, + "line": 1218, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Off", "surface": "android", @@ -14187,7 +14187,7 @@ }, { "kind": "ui-call", - "line": 1265, + "line": 1221, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Talk failed: Realtime provider closed unexpectedly.", "surface": "android", @@ -14195,7 +14195,7 @@ }, { "kind": "ui-call", - "line": 1270, + "line": 1226, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Talk failed: Realtime provider closed: $reason", "surface": "android", @@ -14203,7 +14203,7 @@ }, { "kind": "ui-call", - "line": 2398, + "line": 2351, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Thinking…", "surface": "android", @@ -14211,7 +14211,7 @@ }, { "kind": "ui-call", - "line": 2415, + "line": 2368, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Gateway not connected", "surface": "android", @@ -14219,7 +14219,7 @@ }, { "kind": "ui-call", - "line": 2429, + "line": 2381, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Aborted", "surface": "android", @@ -14227,7 +14227,7 @@ }, { "kind": "ui-call", - "line": 2429, + "line": 2381, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Chat error", "surface": "android", @@ -14235,7 +14235,7 @@ }, { "kind": "ui-call", - "line": 2446, + "line": 2398, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "No reply", "surface": "android", @@ -14243,7 +14243,7 @@ }, { "kind": "ui-call", - "line": 2462, + "line": 2414, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Talk failed: $message", "surface": "android", @@ -14251,7 +14251,7 @@ }, { "kind": "ui-call", - "line": 2578, + "line": 2524, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Ready", "surface": "android", @@ -14259,7 +14259,7 @@ }, { "kind": "ui-call", - "line": 2777, + "line": 2723, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Generating voice…", "surface": "android", @@ -14267,7 +14267,7 @@ }, { "kind": "ui-call", - "line": 2805, + "line": 2751, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Speak failed: $message", "surface": "android", @@ -14275,7 +14275,7 @@ }, { "kind": "ui-call", - "line": 2954, + "line": 2900, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Speaking…", "surface": "android", @@ -14283,7 +14283,7 @@ }, { "kind": "ui-call", - "line": 3292, + "line": 3238, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Microphone permission required", "surface": "android", @@ -14291,7 +14291,7 @@ }, { "kind": "ui-call", - "line": 3298, + "line": 3244, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Audio error", "surface": "android", @@ -14299,7 +14299,7 @@ }, { "kind": "ui-call", - "line": 3299, + "line": 3245, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Client error", "surface": "android", @@ -14307,7 +14307,7 @@ }, { "kind": "ui-call", - "line": 3300, + "line": 3246, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Network error", "surface": "android", @@ -14315,7 +14315,7 @@ }, { "kind": "ui-call", - "line": 3301, + "line": 3247, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Network timeout", "surface": "android", @@ -14323,7 +14323,7 @@ }, { "kind": "ui-call", - "line": 3304, + "line": 3250, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Listening", "surface": "android", @@ -14331,7 +14331,7 @@ }, { "kind": "ui-call", - "line": 3304, + "line": 3250, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Listening (PTT)", "surface": "android", @@ -14339,7 +14339,7 @@ }, { "kind": "ui-call", - "line": 3305, + "line": 3251, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Recognizer busy", "surface": "android", @@ -14347,7 +14347,7 @@ }, { "kind": "ui-call", - "line": 3306, + "line": 3252, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Server error", "surface": "android", @@ -14355,7 +14355,7 @@ }, { "kind": "ui-call", - "line": 3307, + "line": 3253, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Speech error ($error)", "surface": "android", 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 df028496123f..b493dd21bef8 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 @@ -84,7 +84,6 @@ import ai.openclaw.app.node.LocationCaptureManager import ai.openclaw.app.node.LocationHandler import ai.openclaw.app.node.MobileUiHandler import ai.openclaw.app.node.MotionHandler -import ai.openclaw.app.node.NodeInvokeSessionKeyEnvelope import ai.openclaw.app.node.NodePresenceAliveBeacon import ai.openclaw.app.node.NotificationsHandler import ai.openclaw.app.node.PhotosHandler @@ -95,7 +94,6 @@ import ai.openclaw.app.node.SystemHandler import ai.openclaw.app.node.TalkHandler import ai.openclaw.app.node.asObjectOrNull import ai.openclaw.app.node.asStringOrNull -import ai.openclaw.app.node.currentNodeInvokeSessionKeyEnvelope import ai.openclaw.app.node.invokeErrorFromThrowable import ai.openclaw.app.node.parseHexColorArgb import ai.openclaw.app.node.readAndroidPermissionSnapshot @@ -111,7 +109,6 @@ import ai.openclaw.app.voice.TalkAudioPlayer import ai.openclaw.app.voice.TalkModeManager import ai.openclaw.app.voice.TalkPttOnceStart import ai.openclaw.app.voice.TalkPttStopPayload -import ai.openclaw.app.voice.TalkSessionKeyEnvelope import ai.openclaw.app.voice.VoiceConversationEntry import ai.openclaw.app.voice.VoiceConversationRole import ai.openclaw.app.voice.VoiceWakeManager @@ -186,12 +183,6 @@ private const val CRON_JOBS_SNAPSHOT_MAX_ATTEMPTS = 3 private const val OperatorAdminScope = "operator.admin" private const val OperatorPairingScope = "operator.pairing" -private fun NodeInvokeSessionKeyEnvelope.toTalkSessionKeyEnvelope(): TalkSessionKeyEnvelope = - when (this) { - NodeInvokeSessionKeyEnvelope.Legacy -> TalkSessionKeyEnvelope.Legacy - is NodeInvokeSessionKeyEnvelope.Authoritative -> TalkSessionKeyEnvelope.Authoritative(sessionKey) - } - private fun execApprovalOutcomeUnknownMessage(): String = nativeText("Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.").source private fun execApprovalStillPendingMessage(): String = nativeText("The Gateway still shows this approval as pending. Review it before trying again.").source @@ -1058,13 +1049,13 @@ class NodeRuntime private constructor( systemHandler = systemHandler, talkHandler = object : TalkHandler { - override suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttStart(currentNodeInvokeSessionKeyEnvelope().toTalkSessionKeyEnvelope()) + override suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttStart() override suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttStop() override suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttCancel() - override suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttOnce(currentNodeInvokeSessionKeyEnvelope().toTalkSessionKeyEnvelope()) + override suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult = handleTalkPttOnce() }, photosHandler = photosHandler, contactsHandler = contactsHandler, @@ -1762,7 +1753,7 @@ class NodeRuntime private constructor( }, onEvent = ::handleNodeGatewayEvent, onInvoke = { req -> - invokeDispatcher.handleInvoke(req) + invokeDispatcher.handleInvoke(req.command, req.paramsJson) }, onTlsFingerprint = { stableId, fingerprint -> prefs.saveGatewayTlsFingerprint(stableId, fingerprint) @@ -3558,7 +3549,7 @@ class NodeRuntime private constructor( setVoiceCaptureMode(if (value) VoiceCaptureMode.TalkMode else VoiceCaptureMode.Off) } - private suspend fun handleTalkPttStart(sessionKeyEnvelope: TalkSessionKeyEnvelope): GatewaySession.InvokeResult = + private suspend fun handleTalkPttStart(): GatewaySession.InvokeResult = runTalkPttCommand { talkMode.finishingPushToTalkCaptureId?.let { return@runTalkPttCommand GatewaySession.InvokeResult.error( @@ -3577,7 +3568,6 @@ class NodeRuntime private constructor( val started = talkMode.beginPushToTalk( allowNewCapture = true, - sessionKeyEnvelope = sessionKeyEnvelope, canStartCapture = { _isForeground.value && voiceLifecycleEpoch.get() == lifecycleEpoch && @@ -3603,7 +3593,7 @@ class NodeRuntime private constructor( GatewaySession.InvokeResult.ok(payload.toJson()) } - private suspend fun handleTalkPttOnce(sessionKeyEnvelope: TalkSessionKeyEnvelope): GatewaySession.InvokeResult = + private suspend fun handleTalkPttOnce(): GatewaySession.InvokeResult = runTalkPttCommand { currentTalkPttOnceBusy()?.let { busy -> return@runTalkPttCommand GatewaySession.InvokeResult.ok(busy.payload.toJson()) @@ -3618,7 +3608,6 @@ class NodeRuntime private constructor( ) { ownershipEpoch -> val started = talkMode.beginPushToTalkOnce( - sessionKeyEnvelope = sessionKeyEnvelope, canStartCapture = { _isForeground.value && voiceLifecycleEpoch.get() == lifecycleEpoch && diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index b0d2af5ff6f2..7578c6a48f09 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -72,7 +72,6 @@ data class GatewayNodeInvokeRequest( val paramsJson: String? = null, val timeoutMs: Long? = null, val idempotencyKey: String? = null, - val sessionKey: JsonElement? = null, ) @Serializable @@ -379,7 +378,6 @@ enum class GatewayMethod( NodeDescribe("node.describe"), NodePluginSurfaceRefresh("node.pluginSurface.refresh"), NodePluginToolsUpdate("node.pluginTools.update"), - NodeProtocolFeaturesUpdate("node.protocolFeatures.update"), NodeSkillsUpdate("node.skills.update"), NodePendingDrain("node.pending.drain"), NodePendingEnqueue("node.pending.enqueue"), 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 033028a2b0e4..4ab2db3cfe11 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 @@ -284,8 +284,6 @@ class GatewaySession( private companion object { // Keep connect timeout above observed gateway unauthorized close on lower-end devices. private const val CONNECT_RPC_TIMEOUT_MS = 12_000L - private const val NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE = - "node-invoke-session-key-envelope-v1" } /** @@ -297,8 +295,6 @@ class GatewaySession( val command: String, val paramsJson: String?, val timeoutMs: Long?, - val sessionKey: String?, - val hasSessionKeyEnvelope: Boolean, ) data class InvokeResult( @@ -900,11 +896,6 @@ class GatewaySession( val error: ErrorShape?, ) - private data class PendingRequest( - val deferred: CompletableDeferred, - val onResponse: ((RpcResponse) -> Unit)?, - ) - private data class TicketedMediaRequest( val url: String, val headers: Map, @@ -927,11 +918,6 @@ class GatewaySession( CLOSED, } - private enum class NodeInvokeSessionEnvelopeMode { - AUTHORITATIVE, - LEGACY, - } - private inner class Connection( val endpoint: GatewayEndpoint, private val token: String?, @@ -946,7 +932,6 @@ class GatewaySession( private val connectDeferred = CompletableDeferred() private val closedDeferred = CompletableDeferred() private val connectChallengeDeferred = CompletableDeferred() - private val nodeInvokeSessionEnvelopeMode = CompletableDeferred() private val terminalCallbackClaimed = AtomicBoolean(false) private val connectResponseAccepted = AtomicBoolean(false) @@ -966,7 +951,7 @@ class GatewaySession( private val incomingMessages = Channel(Channel.UNLIMITED) // RPC waiters belong to this socket generation. Closing it must not touch a replacement connection. - private val pending = ConcurrentHashMap() + private val pending = ConcurrentHashMap>() private val pendingLock = Any() private val messagePumpJob = @@ -1002,11 +987,10 @@ class GatewaySession( method: String, params: JsonElement?, timeoutMs: Long, - onResponse: ((RpcResponse) -> Unit)? = null, ): RpcResponse { val id = UUID.randomUUID().toString() if (method == "connect") connectRequestId = id - val deferred = registerPending(id, onResponse).deferred + val deferred = registerPending(id) try { sendJson(buildRequestFrame(id = id, method = method, params = params)) return withTimeout(timeoutMs) { deferred.await() } @@ -1130,7 +1114,7 @@ class GatewaySession( onError: (ErrorShape) -> Unit, ) { val id = UUID.randomUUID().toString() - val deferred = registerPending(id).deferred + val deferred = registerPending(id) try { sendJson(buildRequestFrame(id = id, method = method, params = params)) } catch (err: Throwable) { @@ -1165,20 +1149,16 @@ class GatewaySession( } } - private fun registerPending( - id: String, - onResponse: ((RpcResponse) -> Unit)? = null, - ): PendingRequest { + private fun registerPending(id: String): CompletableDeferred { val deferred = CompletableDeferred() - val request = PendingRequest(deferred = deferred, onResponse = onResponse) // Registration and the close drain are one lifecycle decision; no waiter may slip between them. synchronized(pendingLock) { if (state.get() == ConnectionState.CLOSED) { throw GatewayRequestNotEnqueued("Gateway closed") } - pending[id] = request + pending[id] = deferred } - return request + return deferred } suspend fun sendJson(obj: JsonObject) { @@ -1384,67 +1364,8 @@ class GatewaySession( } val connected = parseConnectSuccess(res, identity.deviceId, selectedAuth.authSource) connectDeferred.complete(connected) - startNodeInvokeSessionEnvelopeNegotiation() } - private fun startNodeInvokeSessionEnvelopeNegotiation() { - if (options.role != "node" || onInvoke == null) { - nodeInvokeSessionEnvelopeMode.complete(NodeInvokeSessionEnvelopeMode.LEGACY) - return - } - connectionScope.launch { - val mode = - try { - val response = - request( - GatewayMethod.NodeProtocolFeaturesUpdate.rawValue, - buildJsonObject { - put( - "features", - JsonArray( - listOf(JsonPrimitive(NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE)), - ), - ) - }, - timeoutMs = 15_000, - onResponse = { response -> - nodeInvokeSessionEnvelopeMode.complete( - resolveNodeInvokeSessionEnvelopeMode(response), - ) - }, - ) - resolveNodeInvokeSessionEnvelopeMode(response) - } catch (err: TimeoutCancellationException) { - Log.w(loggerTag, "node protocol feature publish timed out") - NodeInvokeSessionEnvelopeMode.AUTHORITATIVE - } catch (err: CancellationException) { - nodeInvokeSessionEnvelopeMode.cancel(err) - throw err - } catch (err: Throwable) { - Log.w( - loggerTag, - "node protocol feature publish failed: ${err.message ?: err::class.java.simpleName}", - ) - NodeInvokeSessionEnvelopeMode.AUTHORITATIVE - } - nodeInvokeSessionEnvelopeMode.complete(mode) - } - } - - private fun isUnsupportedNodeProtocolFeaturesUpdate(response: RpcResponse): Boolean { - val error = response.error ?: return false - return !response.ok && - error.code == "INVALID_REQUEST" && - error.message == "unknown method: node.protocolFeatures.update" - } - - private fun resolveNodeInvokeSessionEnvelopeMode(response: RpcResponse): NodeInvokeSessionEnvelopeMode = - if (isUnsupportedNodeProtocolFeaturesUpdate(response)) { - NodeInvokeSessionEnvelopeMode.LEGACY - } else { - NodeInvokeSessionEnvelopeMode.AUTHORITATIVE - } - private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean { if (authSource != GatewayConnectAuthSource.BOOTSTRAP_TOKEN) return false if (isLocalCleartextGatewayHost(endpoint.host)) return true @@ -1703,9 +1624,10 @@ class GatewaySession( private suspend fun handleMessage(text: String) { val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return val frameType = frame["type"].asStringOrNull() - // The transport closes its input only after accepting preceding frames. Drain all - // connection-owned responses so a completed RPC wins over the following close. - if (state.get() == ConnectionState.CLOSED && frameType != "res") { + if ( + state.get() == ConnectionState.CLOSED && + (frameType != "res" || frame["id"].asStringOrNull() != connectRequestId) + ) { return } when (frameType) { @@ -1753,12 +1675,7 @@ class GatewaySession( } ErrorShape(wireError.code, wireError.message, details) } - val rpcResponse = RpcResponse(id, response.ok, payloadJson, error) - pending.remove(id)?.let { request -> - // Response observers run in socket receive order before the next event is admitted. - request.onResponse?.invoke(rpcResponse) - request.deferred.complete(rpcResponse) - } + pending.remove(id)?.complete(RpcResponse(id, response.ok, payloadJson, error)) } private fun handleEvent(frame: JsonObject) { @@ -1808,31 +1725,18 @@ class GatewaySession( } private fun handleInvokeEvent(payloadJson: String) { - val receivedAtMs = SystemClock.elapsedRealtime() - val payloadObject = - runCatching { json.parseToJsonElement(payloadJson).asObjectOrNull() }.getOrNull() ?: return val payload = runCatching { - json.decodeFromJsonElement(GatewayNodeInvokeRequest.serializer(), payloadObject) + json.decodeFromString(GatewayNodeInvokeRequest.serializer(), payloadJson) }.getOrNull() ?: return // Older gateways sent structured `params`; keep accepting that shipped wire shape while // generated models follow the canonical `paramsJSON` schema. val paramsJson = payload.paramsJson - ?: payloadObject["params"]?.let { value -> if (value is JsonNull) null else value.toString() } - val hasWireSessionKey = payloadObject.containsKey("sessionKey") - // An omitted envelope is only authoritative after this socket has finished - // publishing the feature. Do not reinterpret already-received legacy frames. - val envelopeNegotiationCompleteAtReceipt = nodeInvokeSessionEnvelopeMode.isCompleted + ?: runCatching { + json.parseToJsonElement(payloadJson).asObjectOrNull()?.get("params") + }.getOrNull()?.let { value -> if (value is JsonNull) null else value.toString() } connectionScope.launch { - val envelopeMode = - when { - hasWireSessionKey -> NodeInvokeSessionEnvelopeMode.AUTHORITATIVE - !envelopeNegotiationCompleteAtReceipt -> NodeInvokeSessionEnvelopeMode.LEGACY - else -> nodeInvokeSessionEnvelopeMode.await() - } - val hasSessionKeyEnvelope = - hasWireSessionKey || envelopeMode == NodeInvokeSessionEnvelopeMode.AUTHORITATIVE val request = InvokeRequest( id = payload.id, @@ -1840,38 +1744,24 @@ class GatewaySession( command = payload.command, paramsJson = paramsJson, timeoutMs = payload.timeoutMs, - sessionKey = - payload.sessionKey - .asStringOrNull() - ?.trim() - ?.takeIf { it.isNotEmpty() }, - hasSessionKeyEnvelope = hasSessionKeyEnvelope, ) - val result = executeInvokeRequest(request, receivedAtMs) + val result = executeInvokeRequest(request) sendInvokeResult(payload.id, payload.nodeId, result, payload.timeoutMs) } } - private suspend fun executeInvokeRequest( - request: InvokeRequest, - receivedAtMs: Long, - ): InvokeResult { + private suspend fun executeInvokeRequest(request: InvokeRequest): InvokeResult { val handler = onInvoke ?: return InvokeResult.error("UNAVAILABLE", "invoke handler missing") return try { val timeoutMs = resolveInvokeExecutionTimeoutMs(request.timeoutMs) if (timeoutMs == null) { handler(request) } else { - val elapsedMs = (SystemClock.elapsedRealtime() - receivedAtMs).coerceAtLeast(0L) - if (elapsedMs >= timeoutMs) { - return InvokeResult.error("TIMEOUT", "node invoke timed out") - } - val remainingTimeoutMs = timeoutMs - elapsedMs // Keep the deadline owner separate so a blocking handler cannot delay the timeout result. // Cancellation still reaches cooperative handlers; late results are never sent. val handlerTask = connectionScope.async { handler(request) } try { - withTimeoutOrNull(remainingTimeoutMs) { handlerTask.await() } + withTimeoutOrNull(timeoutMs) { handlerTask.await() } ?: run { handlerTask.cancel(CancellationException("node invoke timed out")) InvokeResult.error("TIMEOUT", "node invoke timed out") @@ -1935,9 +1825,7 @@ class GatewaySession( pending.values.toList().also { pending.clear() } } for (waiter in waiters) { - waiter.deferred.completeExceptionally( - GatewayRequestOutcomeUnknown("Gateway disconnected before response"), - ) + waiter.completeExceptionally(GatewayRequestOutcomeUnknown("Gateway disconnected before response")) } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt index 355adb11639c..95f45df0121c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt @@ -15,30 +15,8 @@ import ai.openclaw.app.protocol.OpenClawNotificationsCommand import ai.openclaw.app.protocol.OpenClawSmsCommand import ai.openclaw.app.protocol.OpenClawSystemCommand import ai.openclaw.app.protocol.OpenClawTalkCommand -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.CoroutineContext - -internal sealed interface NodeInvokeSessionKeyEnvelope { - data object Legacy : NodeInvokeSessionKeyEnvelope - - data class Authoritative( - val sessionKey: String?, - ) : NodeInvokeSessionKeyEnvelope -} - -private class NodeInvokeExecutionContext( - val sessionKeyEnvelope: NodeInvokeSessionKeyEnvelope, -) : AbstractCoroutineContextElement(NodeInvokeExecutionContext) { - companion object Key : CoroutineContext.Key -} - -internal suspend fun currentNodeInvokeSessionKeyEnvelope(): NodeInvokeSessionKeyEnvelope = - currentCoroutineContext()[NodeInvokeExecutionContext]?.sessionKeyEnvelope - ?: NodeInvokeSessionKeyEnvelope.Legacy /** Runtime state for SMS search, split so permission prompts are not reported as hard unavailability. */ internal enum class SmsSearchAvailabilityReason { @@ -122,19 +100,6 @@ class InvokeDispatcher( private val canvasCommandMutex = Mutex() /** Dispatches one gateway node.invoke command after foreground and availability gates pass. */ - suspend fun handleInvoke(request: GatewaySession.InvokeRequest): GatewaySession.InvokeResult { - val sessionKeyEnvelope = - if (request.hasSessionKeyEnvelope) { - NodeInvokeSessionKeyEnvelope.Authoritative(request.sessionKey) - } else { - NodeInvokeSessionKeyEnvelope.Legacy - } - return withContext(NodeInvokeExecutionContext(sessionKeyEnvelope)) { - handleInvoke(request.command, request.paramsJson) - } - } - - /** Dispatches one command for direct native callers that have no gateway attribution envelope. */ suspend fun handleInvoke( command: String, paramsJson: String?, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index e56bae799c3d..cdc9de6649b8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -112,26 +112,6 @@ internal sealed interface TalkPttOnceStart { ) : TalkPttOnceStart } -internal sealed interface TalkSessionKeyEnvelope { - data object Legacy : TalkSessionKeyEnvelope - - data class Authoritative( - val sessionKey: String?, - ) : TalkSessionKeyEnvelope -} - -internal fun resolveTalkChatSessionKey( - envelope: TalkSessionKeyEnvelope, - deviceSessionKey: String, -): String = - when (envelope) { - TalkSessionKeyEnvelope.Legacy -> deviceSessionKey.ifBlank { "main" } - is TalkSessionKeyEnvelope.Authoritative -> - // chat.send requires a non-empty routing key. An explicit null clears the - // device-selected session and uses the Gateway's canonical main route. - envelope.sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: "main" - } - internal suspend fun requestPhoneRealtimeSessionWithLanguageFallback( language: String?, request: suspend (language: String?) -> String, @@ -240,7 +220,6 @@ class TalkModeManager internal constructor( private val realtimeCaptureDispatcher: CoroutineDispatcher = Dispatchers.IO, private val realtimePlaybackDispatcher: CoroutineDispatcher = Dispatchers.IO, private val realtimeMarkAcknowledger: (suspend (sessionId: String, markName: String) -> Unit)? = null, - private val requestGatewayOverride: (suspend (method: String, paramsJson: String?, timeoutMs: Long) -> String)? = null, ) { companion object { private const val tag = "TalkMode" @@ -334,7 +313,6 @@ class TalkModeManager internal constructor( private var pttAutoStopEnabled = false private var pttTimeoutJob: Job? = null private var pttCompletion: CompletableDeferred? = null - private var pttSessionKeyEnvelope: TalkSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy private var pttRecognitionRung: PushToTalkRecognitionRung? = null private var pttReleaseCompletion: CompletableDeferred? = null private val pttFinalSegments = mutableListOf() @@ -494,7 +472,6 @@ class TalkModeManager internal constructor( paramsJson: String?, timeoutMs: Long = 15_000, ): String { - requestGatewayOverride?.let { return it(method, paramsJson, timeoutMs) } val gatewayId = gatewayStableId()?.trim()?.takeIf { it.isNotEmpty() } return if (gatewayId == null) { session.request(method, paramsJson, timeoutMs) @@ -527,21 +504,9 @@ class TalkModeManager internal constructor( suspend fun beginPushToTalk( allowNewCapture: Boolean, canStartCapture: () -> Boolean = { true }, - ): TalkPttStartPayload = - beginPushToTalk( - allowNewCapture = allowNewCapture, - sessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy, - canStartCapture = canStartCapture, - ) - - internal suspend fun beginPushToTalk( - allowNewCapture: Boolean, - sessionKeyEnvelope: TalkSessionKeyEnvelope, - canStartCapture: () -> Boolean = { true }, ): TalkPttStartPayload = startPushToTalk( allowNewCapture = allowNewCapture, - sessionKeyEnvelope = sessionKeyEnvelope, canStartCapture = canStartCapture, completion = null, ).payload @@ -561,7 +526,6 @@ class TalkModeManager internal constructor( private data class ClearedPushToTalkCapture( val transcript: String, val completion: CompletableDeferred?, - val sessionKeyEnvelope: TalkSessionKeyEnvelope, ) private data class RealtimeCapturePause( @@ -581,7 +545,6 @@ class TalkModeManager internal constructor( private suspend fun startPushToTalk( allowNewCapture: Boolean, - sessionKeyEnvelope: TalkSessionKeyEnvelope, canStartCapture: () -> Boolean, completion: CompletableDeferred?, autoStopAfterMs: Long? = null, @@ -659,9 +622,6 @@ class TalkModeManager internal constructor( lastHeardAtMs = null activePttCaptureId = captureId pttCompletion = completion - // Bind routing to the capture owner. Later UI selection or retry invokes - // must not rebound this turn to another session. - pttSessionKeyEnvelope = sessionKeyEnvelope try { // PTT owns the microphone until its turn finishes. Waiting here prevents // SpeechRecognizer from racing the realtime AudioRecord teardown. @@ -691,7 +651,6 @@ class TalkModeManager internal constructor( clearListenWatchdog() activePttCaptureId = null pttCompletion = null - pttSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy completion?.cancel() resumeRealtimeCaptureAfterPushToTalk(captureId) setStatus(if (_isEnabled.value) nativeText("Listening") else nativeText("Ready")) @@ -757,7 +716,7 @@ class TalkModeManager internal constructor( // finally still resumes capture when the scope cancels this job. gatewayWorkScope.launch(start = CoroutineStart.LAZY) { try { - finalizeTranscript(transcript, cleared.sessionKeyEnvelope) + finalizeTranscript(transcript) } finally { withContext(NonCancellable + Dispatchers.Main) { resumeRealtimeCaptureAfterPushToTalk(captureId) @@ -822,7 +781,6 @@ class TalkModeManager internal constructor( /** Starts a bounded one-shot PTT turn that auto-stops on silence or timeout. */ internal suspend fun beginPushToTalkOnce( maxDurationMs: Long = 12_000L, - sessionKeyEnvelope: TalkSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy, canStartCapture: () -> Boolean = { true }, ): TalkPttOnceStart { val busyCaptureId = activePttCaptureId ?: finishingPttCaptureId @@ -841,7 +799,6 @@ class TalkModeManager internal constructor( val start = startPushToTalk( allowNewCapture = true, - sessionKeyEnvelope = sessionKeyEnvelope, canStartCapture = canStartCapture, completion = completion, autoStopAfterMs = maxDurationMs, @@ -1072,7 +1029,6 @@ class TalkModeManager internal constructor( finalizeInFlight = false listeningMode = false activePttCaptureId = null - pttSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy synchronized(finishingPttLock) { finishingPttJob?.cancel() } @@ -2382,17 +2338,14 @@ class TalkModeManager internal constructor( finalizeInFlight = true gatewayWorkScope.launch { try { - finalizeTranscript(transcript, TalkSessionKeyEnvelope.Legacy) + finalizeTranscript(transcript) } finally { finalizeInFlight = false } } } - private suspend fun finalizeTranscript( - transcript: String, - sessionKeyEnvelope: TalkSessionKeyEnvelope, - ) { + private suspend fun finalizeTranscript(transcript: String) { listeningMode = false _isListening.value = false setStatus(nativeText("Thinking…"), awaitingAgent = true) @@ -2420,9 +2373,8 @@ class TalkModeManager internal constructor( try { val startedAt = System.currentTimeMillis().toDouble() / 1000.0 - val chatSessionKey = resolveTalkChatSessionKey(sessionKeyEnvelope, mainSessionKey) - Log.d(tag, "chat.send start sessionKey=$chatSessionKey chars=${prompt.length}") - val ack = sendChat(prompt, chatSessionKey) + Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}") + val ack = sendChat(prompt, session) val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id") Log.d(tag, "chat.send ok runId=$runId status=${ack.status}") if (ack.isTerminalFailure) { @@ -2534,12 +2486,10 @@ class TalkModeManager internal constructor( if (activePttCaptureId != captureId) return null val transcript = PushToTalkTranscriptMerger.merge(pttFinalSegments, pttLivePartial) val completion = pttCompletion - val sessionKeyEnvelope = pttSessionKeyEnvelope pttTimeoutJob?.cancel() pttTimeoutJob = null pttAutoStopEnabled = false pttCompletion = null - pttSessionKeyEnvelope = TalkSessionKeyEnvelope.Legacy pttReleaseCompletion?.cancel() pttReleaseCompletion = null activePttCaptureId = null @@ -2555,11 +2505,7 @@ class TalkModeManager internal constructor( lastTranscript = "" lastHeardAtMs = null _inputLevel.value = 0f - return ClearedPushToTalkCapture( - transcript = transcript, - completion = completion, - sessionKeyEnvelope = sessionKeyEnvelope, - ) + return ClearedPushToTalkCapture(transcript = transcript, completion = completion) } private fun finishPushToTalk( @@ -2615,13 +2561,13 @@ class TalkModeManager internal constructor( private suspend fun sendChat( message: String, - sessionKey: String, + session: GatewaySession, ): ChatSendAck { val runId = UUID.randomUUID().toString() armPendingRun(runId) val params = buildJsonObject { - put("sessionKey", JsonPrimitive(sessionKey)) + put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" })) put("message", JsonPrimitive(message)) put("thinking", JsonPrimitive("low")) put("timeoutMs", JsonPrimitive(30_000)) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index b5b141eb5612..af4d53768d8f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -91,12 +90,6 @@ private data class InvokeScenarioResult( val resultParams: JsonObject, ) -private enum class ProtocolFeaturesResponse { - Supported, - Unsupported, - Failed, -} - @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GatewaySessionInvokeTest { @@ -962,13 +955,11 @@ class GatewaySessionInvokeTest { fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { val handshakeOrigin = AtomicReference(null) - val protocolFeatures = AtomicReference(null) val result = runInvokeScenario( invokeEventFrame = """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""", onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) }, - onProtocolFeaturesUpdate = protocolFeatures::set, ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } @@ -977,18 +968,6 @@ class GatewaySessionInvokeTest { assertEquals("node-1", result.request.nodeId) assertEquals("debug.ping", result.request.command) assertEquals("""{"ping":"pong"}""", result.request.paramsJson) - assertTrue(result.request.hasSessionKeyEnvelope) - assertNull(result.request.sessionKey) - assertEquals( - "node-invoke-session-key-envelope-v1", - protocolFeatures - .get() - ?.get("features") - ?.let { it as? JsonArray } - ?.single() - ?.jsonPrimitive - ?.content, - ) assertNull(handshakeOrigin.get()) assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content) assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content) @@ -1010,266 +989,6 @@ class GatewaySessionInvokeTest { ) } - @Test - fun nodeInvokeRequest_preservesSessionKeyEnvelopeValue() = - runBlocking { - val result = - runInvokeScenario( - invokeEventFrame = - """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-session","nodeId":"node-1","command":"debug.ping","sessionKey":"agent:main:main"}}""", - ) { - GatewaySession.InvokeResult.ok(null) - } - - assertTrue(result.request.hasSessionKeyEnvelope) - assertEquals("agent:main:main", result.request.sessionKey) - } - - @Test - fun nodeInvokeRequest_preservesExplicitSessionKeyClear() = - runBlocking { - val result = - runInvokeScenario( - invokeEventFrame = - """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-clear","nodeId":"node-1","command":"debug.ping","sessionKey":null}}""", - ) { - GatewaySession.InvokeResult.ok(null) - } - - assertTrue(result.request.hasSessionKeyEnvelope) - assertNull(result.request.sessionKey) - } - - @Test - fun nodeInvokeRequest_omitsSessionKeyEnvelopeOnlyForConfirmedLegacyGateway() = - runBlocking { - val result = - runInvokeScenario( - invokeEventFrame = - """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-legacy","nodeId":"node-1","command":"debug.ping"}}""", - protocolFeaturesResponse = ProtocolFeaturesResponse.Unsupported, - ) { - GatewaySession.InvokeResult.ok(null) - } - - assertFalse(result.request.hasSessionKeyEnvelope) - assertNull(result.request.sessionKey) - } - - @Test - fun nodeInvokeRequest_failsClosedWhenProtocolFeaturePublishFails() = - runBlocking { - val result = - runInvokeScenario( - invokeEventFrame = - """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-failed","nodeId":"node-1","command":"debug.ping"}}""", - protocolFeaturesResponse = ProtocolFeaturesResponse.Failed, - ) { - GatewaySession.InvokeResult.ok(null) - } - - assertTrue(result.request.hasSessionKeyEnvelope) - assertNull(result.request.sessionKey) - } - - @Test - fun nodeInvokeRequest_preservesLegacyEnvelopeForFrameReceivedBeforeNegotiationCompletes() = - runBlocking { - val json = testJson() - val connected = CompletableDeferred() - val invokeRequest = CompletableDeferred() - val invokeResultParams = CompletableDeferred() - val lastDisconnect = AtomicReference("") - val invokeSentAtNanos = AtomicReference() - val server = - startGatewayServer(json) { webSocket, id, method, frame -> - when (method) { - "connect" -> { - webSocket.send(connectResponseFrame(id)) - invokeSentAtNanos.set(System.nanoTime()) - webSocket.send( - """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-pre-negotiation","nodeId":"node-1","command":"debug.ping","timeoutMs":500}}""", - ) - } - "node.protocolFeatures.update" -> { - Thread { - Thread.sleep(2_000) - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") - }.apply { - isDaemon = true - start() - } - } - "node.invoke.result" -> { - invokeResultParams.complete(frame["params"]?.jsonObject ?: JsonObject(emptyMap())) - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") - } - } - } - val harness = - createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { request -> - invokeRequest.complete(request) - GatewaySession.InvokeResult.ok(null) - } - - try { - connectNodeSession(harness.session, server.port) - awaitConnectedOrThrow(connected, lastDisconnect, server) - val request = withTimeout(1_000) { invokeRequest.await() } - val result = withTimeout(1_000) { invokeResultParams.await() } - - assertFalse(request.hasSessionKeyEnvelope) - assertNull(request.sessionKey) - assertTrue( - TimeUnit.NANOSECONDS.toMillis( - System.nanoTime() - checkNotNull(invokeSentAtNanos.get()), - ) < 1_000, - ) - assertEquals(true, result["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) - } finally { - shutdownHarness(harness, server) - } - } - - @Test - fun nodeInvokeRequest_usesAuthoritativeEnvelopeImmediatelyAfterNegotiationResponse() = - runBlocking { - val json = testJson() - val connected = CompletableDeferred() - val invokeRequest = CompletableDeferred() - val lastDisconnect = AtomicReference("") - val server = - startGatewayServer(json) { webSocket, id, method, _ -> - when (method) { - "connect" -> webSocket.send(connectResponseFrame(id)) - "node.protocolFeatures.update" -> { - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") - webSocket.send( - """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-post-negotiation","nodeId":"node-1","command":"debug.ping"}}""", - ) - } - "node.invoke.result" -> - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") - } - } - val harness = - createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { request -> - invokeRequest.complete(request) - GatewaySession.InvokeResult.ok(null) - } - - try { - connectNodeSession(harness.session, server.port) - awaitConnectedOrThrow(connected, lastDisconnect, server) - val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() } - - assertTrue(request.hasSessionKeyEnvelope) - assertNull(request.sessionKey) - } finally { - shutdownHarness(harness, server) - } - } - - @Test - fun nodeInvokeRequest_explicitEnvelopeBypassesProtocolNegotiation() = - runBlocking { - val json = testJson() - val connected = CompletableDeferred() - val invokeRequest = CompletableDeferred() - val lastDisconnect = AtomicReference("") - val server = - startGatewayServer(json) { webSocket, id, method, _ -> - when (method) { - "connect" -> { - webSocket.send(connectResponseFrame(id)) - webSocket.send( - """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-explicit","nodeId":"node-1","command":"debug.ping","timeoutMs":50,"sessionKey":"agent:main:main"}}""", - ) - } - "node.protocolFeatures.update" -> { - Thread.sleep(150) - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") - } - "node.invoke.result" -> - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") - } - } - val harness = - createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { request -> - invokeRequest.complete(request) - GatewaySession.InvokeResult.ok(null) - } - - try { - connectNodeSession(harness.session, server.port) - awaitConnectedOrThrow(connected, lastDisconnect, server) - val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() } - - assertTrue(request.hasSessionKeyEnvelope) - assertEquals("agent:main:main", request.sessionKey) - } finally { - shutdownHarness(harness, server) - } - } - - @Test - fun nodeInvokeRequest_resetsLegacyNegotiationAfterReconnect() = - runBlocking { - val json = testJson() - val connected = CompletableDeferred() - val requests = Channel(capacity = 2) - val lastDisconnect = AtomicReference("") - - fun startServer(protocolFeaturesResponse: ProtocolFeaturesResponse): MockWebServer = - startGatewayServer(json) { webSocket, id, method, _ -> - when (method) { - "connect" -> webSocket.send(connectResponseFrame(id)) - "node.protocolFeatures.update" -> { - val response = - when (protocolFeaturesResponse) { - ProtocolFeaturesResponse.Supported -> - """{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""" - ProtocolFeaturesResponse.Unsupported -> - """{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unknown method: node.protocolFeatures.update"}}""" - ProtocolFeaturesResponse.Failed -> - """{"type":"res","id":"$id","ok":false,"error":{"code":"UNAVAILABLE","message":"temporary failure"}}""" - } - webSocket.send(response) - webSocket.send( - """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-$protocolFeaturesResponse","nodeId":"node-1","command":"debug.ping"}}""", - ) - } - "node.invoke.result" -> - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") - } - } - val legacyServer = startServer(ProtocolFeaturesResponse.Unsupported) - val currentServer = startServer(ProtocolFeaturesResponse.Supported) - val harness = - createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) { request -> - requests.send(request) - GatewaySession.InvokeResult.ok(null) - } - - try { - connectNodeSession(harness.session, legacyServer.port) - awaitConnectedOrThrow(connected, lastDisconnect, legacyServer) - val legacy = withTimeout(TEST_TIMEOUT_MS) { requests.receive() } - assertFalse(legacy.hasSessionKeyEnvelope) - - harness.session.disconnectAndJoin() - connectNodeSession(harness.session, currentServer.port) - val current = withTimeout(TEST_TIMEOUT_MS) { requests.receive() } - assertTrue(current.hasSessionKeyEnvelope) - assertNull(current.sessionKey) - } finally { - harness.session.disconnect() - harness.sessionJob.cancelAndJoin() - legacyServer.shutdown() - currentServer.shutdown() - } - } - @Test fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking { @@ -1463,8 +1182,6 @@ class GatewaySessionInvokeTest { """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-cancelled","nodeId":"node-1","command":"camera.snap","timeoutMs":5000}}""", ) } - "node.protocolFeatures.update" -> - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") "node.invoke.result" -> invokeResult.complete(Unit) } } @@ -1771,8 +1488,6 @@ class GatewaySessionInvokeTest { private suspend fun runInvokeScenario( invokeEventFrame: String, onHandshake: ((RecordedRequest) -> Unit)? = null, - protocolFeaturesResponse: ProtocolFeaturesResponse = ProtocolFeaturesResponse.Supported, - onProtocolFeaturesUpdate: ((JsonObject) -> Unit)? = null, afterResult: suspend (InvokeScenarioResult) -> Unit = {}, onInvoke: suspend (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult, ): InvokeScenarioResult { @@ -1787,19 +1502,8 @@ class GatewaySessionInvokeTest { onHandshake = onHandshake, ) { webSocket, id, method, frame -> when (method) { - "connect" -> webSocket.send(connectResponseFrame(id)) - "node.protocolFeatures.update" -> { - onProtocolFeaturesUpdate?.invoke(frame["params"]?.jsonObject ?: JsonObject(emptyMap())) - val response = - when (protocolFeaturesResponse) { - ProtocolFeaturesResponse.Supported -> - """{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""" - ProtocolFeaturesResponse.Unsupported -> - """{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unknown method: node.protocolFeatures.update"}}""" - ProtocolFeaturesResponse.Failed -> - """{"type":"res","id":"$id","ok":false,"error":{"code":"UNAVAILABLE","message":"temporary failure"}}""" - } - webSocket.send(response) + "connect" -> { + webSocket.send(connectResponseFrame(id)) webSocket.send(invokeEventFrame) } "node.invoke.result" -> { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt index ffd44e4b196f..2aca809d369f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt @@ -165,11 +165,10 @@ class GatewaySessionReconnectTest { val unexpectedRequest = CompletableDeferred() val server = startGatewayServer(json = json) { webSocket, id, method -> - when (method) { - "connect" -> webSocket.send(connectResponseFrame(id)) - "node.protocolFeatures.update" -> - webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") - else -> unexpectedRequest.complete(Unit) + if (method == "connect") { + webSocket.send(connectResponseFrame(id)) + } else { + unexpectedRequest.complete(Unit) } } val harness = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt index d77508a16f90..017fbbcded1e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt @@ -272,56 +272,6 @@ class InvokeDispatcherTest { ) } - @Test - fun handleInvoke_preservesGatewaySessionEnvelopeInsideHandlers() = - runTest { - val talk = InvokeDispatcherFakeTalkHandler() - val dispatcher = newDispatcher(talkHandler = talk) - - dispatcher.handleInvoke( - GatewaySession.InvokeRequest( - id = "attributed", - nodeId = "node-1", - command = OpenClawTalkCommand.PttOnce.rawValue, - paramsJson = null, - timeoutMs = null, - sessionKey = "agent:main:main", - hasSessionKeyEnvelope = true, - ), - ) - dispatcher.handleInvoke( - GatewaySession.InvokeRequest( - id = "cleared", - nodeId = "node-1", - command = OpenClawTalkCommand.PttOnce.rawValue, - paramsJson = null, - timeoutMs = null, - sessionKey = null, - hasSessionKeyEnvelope = true, - ), - ) - dispatcher.handleInvoke( - GatewaySession.InvokeRequest( - id = "legacy", - nodeId = "node-1", - command = OpenClawTalkCommand.PttOnce.rawValue, - paramsJson = null, - timeoutMs = null, - sessionKey = null, - hasSessionKeyEnvelope = false, - ), - ) - - assertEquals( - listOf( - NodeInvokeSessionKeyEnvelope.Authoritative("agent:main:main"), - NodeInvokeSessionKeyEnvelope.Authoritative(null), - NodeInvokeSessionKeyEnvelope.Legacy, - ), - talk.sessionKeyEnvelopes, - ) - } - @Test fun handleInvoke_blocksTalkOnceButLeavesPttStartToRuntimeStateGateWhenBackgrounded() = runTest { @@ -507,30 +457,24 @@ private class InvokeDispatcherFakeSystemNotificationPoster : SystemNotificationP private class InvokeDispatcherFakeTalkHandler : TalkHandler { val calls = mutableListOf() - val sessionKeyEnvelopes = mutableListOf() - - private suspend fun record(call: String) { - calls.add(call) - sessionKeyEnvelopes.add(currentNodeInvokeSessionKeyEnvelope()) - } override suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult { - record("start") + calls.add("start") return GatewaySession.InvokeResult.ok("""{"captureId":"start"}""") } override suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult { - record("stop") + calls.add("stop") return GatewaySession.InvokeResult.ok("""{"status":"stop"}""") } override suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult { - record("cancel") + calls.add("cancel") return GatewaySession.InvokeResult.ok("""{"status":"cancel"}""") } override suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult { - record("once") + calls.add("once") return GatewaySession.InvokeResult.ok("""{"status":"once"}""") } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt index d293b4117f5f..3831d54ff30a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt @@ -37,9 +37,6 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -226,47 +223,6 @@ class TalkModeManagerTest { assertFalse(completion.isCompleted) } - @Test - fun pushToTalkChatSendUsesCaptureOwnedSessionEnvelope() = - runTest { - val cases = - listOf( - TalkSessionKeyEnvelope.Authoritative("agent:main:admitted") to "agent:main:admitted", - TalkSessionKeyEnvelope.Authoritative(null) to "main", - TalkSessionKeyEnvelope.Legacy to "device-selected", - ) - - for ((envelope, expectedSessionKey) in cases) { - val sentParams = CompletableDeferred() - val manager = - createManager( - scope = this, - requestGateway = { method, paramsJson, _ -> - if (method == "chat.send") { - sentParams.complete(requireNotNull(paramsJson)) - throw IllegalStateException("captured chat.send") - } - error("unexpected gateway request: $method") - }, - ) - manager.setMainSessionKey("device-selected") - setPrivateField(manager, "configLoaded", true) - setPrivateField(manager, "activePttCaptureId", "capture-$expectedSessionKey") - setPrivateField(manager, "pttSessionKeyEnvelope", envelope) - @Suppress("UNCHECKED_CAST") - (readPrivateField(manager, "pttFinalSegments") as MutableList) += "send this" - - withMain(dispatcher = Dispatchers.Unconfined, cleanup = manager::stopAllCapture) { - val result = manager.endPushToTalk("capture-$expectedSessionKey") - assertEquals("queued", result.status) - advanceUntilIdle() - } - - val params = Json.parseToJsonElement(sentParams.await()).jsonObject - assertEquals(expectedSessionKey, params.getValue("sessionKey").jsonPrimitive.content) - } - } - @Test fun cancelledOneShotWaitCleansItsCapture() = runTest { @@ -1121,7 +1077,6 @@ class TalkModeManagerTest { realtimeCaptureDispatcher: CoroutineDispatcher = Dispatchers.IO, realtimePlaybackDispatcher: CoroutineDispatcher = Dispatchers.IO, realtimeMarkAcknowledger: (suspend (String, String) -> Unit)? = null, - requestGateway: (suspend (String, String?, Long) -> String)? = null, ): TalkModeManager { val app = RuntimeEnvironment.getApplication() val session = @@ -1144,7 +1099,6 @@ class TalkModeManagerTest { realtimeCaptureDispatcher = realtimeCaptureDispatcher, realtimePlaybackDispatcher = realtimePlaybackDispatcher, realtimeMarkAcknowledger = realtimeMarkAcknowledger, - requestGatewayOverride = requestGateway, ) } diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift index 9060a72b6b2b..6234cb6310fc 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift @@ -1,6 +1,6 @@ import Darwin import Foundation -@_spi(AgentExecutionAttribution) import OpenClawKit +import OpenClawKit import OSLog extension Notification.Name { @@ -118,8 +118,7 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable { } func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse { - let sessionKeyEnvelope = GatewayNodeInvokeContext.sessionKeyEnvelope - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in self.queue.async { guard self.process?.isRunning == true, self.manifest != nil else { continuation.resume(returning: Self.unavailableResponse( @@ -135,18 +134,12 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable { } self.invokeContinuations[request.id] = continuation do { - var workerRequest: [String: Any] = [ + let workerRequest: [String: Any] = [ "id": request.id, "nodeId": request.nodeId ?? "", "command": request.command, "paramsJSON": request.paramsJSON ?? NSNull(), ] - switch sessionKeyEnvelope { - case .legacy: - break - case let .authoritative(sessionKey): - workerRequest["sessionKey"] = sessionKey ?? NSNull() - } try self.enqueueWriteLocked([ "type": "invoke", "request": workerRequest, diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift index 833442cfa2c0..c345db3c6567 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift @@ -92,14 +92,12 @@ struct DashboardWindowSmokeTests { @Test func `dashboard window controller shows and closes`() throws { let url = try #require(URL(string: "http://127.0.0.1:18789/control/#token=device-token")) - let windowAutosaveName = "OpenClawDashboardWindow-Test-\(UUID().uuidString)" let controller = DashboardWindowController( url: url, auth: DashboardWindowAuth( gatewayUrl: "ws://127.0.0.1:18789/control/", token: "device-token", - password: nil), - windowAutosaveName: windowAutosaveName) + password: nil)) controller.show() #expect(controller.window?.styleMask.contains(.titled) == true) #expect(controller.window?.styleMask.contains(.closable) == true) @@ -117,7 +115,7 @@ struct DashboardWindowSmokeTests { #expect(controller.window?.toolbar?.isVisible == true) #expect((controller.window?.frame.width ?? 0) >= DashboardWindowLayout.windowMinSize.width) #expect((controller.window?.frame.height ?? 0) >= DashboardWindowLayout.windowMinSize.height) - #expect(controller.window?.frameAutosaveName == windowAutosaveName) + #expect(controller.window?.frameAutosaveName == DashboardWindowLayout.windowFrameAutosaveName) controller.closeDashboard() } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift index d61a22a23a6b..a9e72d39de16 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift @@ -1,5 +1,5 @@ import Foundation -@_spi(AgentExecutionAttribution) import OpenClawKit +import OpenClawKit import Testing @testable import OpenClaw @@ -238,47 +238,6 @@ struct MacNodeHostWorkerTests { await worker.stop() } - @Test func `worker forwards only negotiated session envelopes`() async throws { - let worker = MacNodeHostWorker(session: GatewayNodeSession()) - let script = """ - printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}' - IFS= read -r attributed - printf '%s' "$attributed" | grep -q '"sessionKey":"agent:main:main"' || exit 40 - printf '%s\\n' '{"type":"invoke-result","result":{"id":"attributed","ok":true}}' - IFS= read -r cleared - printf '%s' "$cleared" | grep -q '"sessionKey":null' || exit 41 - printf '%s\\n' '{"type":"invoke-result","result":{"id":"cleared","ok":true}}' - IFS= read -r legacy - if printf '%s' "$legacy" | grep -q '"sessionKey"'; then exit 42; fi - printf '%s\\n' '{"type":"invoke-result","result":{"id":"legacy","ok":true}}' - while IFS= read -r line; do :; done - """ - - _ = try await worker.start(command: ["/bin/sh", "-c", script]) - let attributed = await GatewayNodeInvokeContext.$sessionKeyEnvelope.withValue( - .authoritative("agent:main:main")) - { - await worker.invoke(BridgeInvokeRequest( - id: "attributed", - command: "system.run")) - } - let cleared = await GatewayNodeInvokeContext.$sessionKeyEnvelope.withValue(.authoritative(nil)) { - await worker.invoke(BridgeInvokeRequest( - id: "cleared", - command: "system.run")) - } - let legacy = await GatewayNodeInvokeContext.$sessionKeyEnvelope.withValue(.legacy) { - await worker.invoke(BridgeInvokeRequest( - id: "legacy", - command: "system.run")) - } - - #expect(attributed.ok) - #expect(cleared.ok) - #expect(legacy.ok) - await worker.stop() - } - @Test func `worker forwards terminal input and cancellation frames`() async throws { let worker = MacNodeHostWorker(session: GatewayNodeSession()) let script = """ diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index 15c5e119cb1b..f5191860ed23 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -402,7 +402,6 @@ struct MacNodeModeCoordinatorTests { "nodeId": "test-node", "command": "computer.act", "paramsJSON": "{}", - "sessionKey": NSNull(), "timeoutMs": 0, ], ]) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift index 5c289562f947..a551899608c9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift @@ -1,16 +1,5 @@ import Foundation -@_spi(AgentExecutionAttribution) -public enum GatewayNodeInvokeSessionKeyEnvelope: Equatable, Sendable { - case legacy - case authoritative(String?) -} - -@_spi(AgentExecutionAttribution) -public enum GatewayNodeInvokeContext { - @TaskLocal public static var sessionKeyEnvelope: GatewayNodeInvokeSessionKeyEnvelope = .legacy -} - public struct BridgeInvokeRequest: Codable, Sendable { public let type: String public let id: String diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 7a609c7dfe31..1301815409e5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -6,6 +6,28 @@ import OSLog /// Avoid ambiguity with the app's own AnyCodable type. private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable +private func gatewayErrorDetails(_ error: ErrorShape?) -> [String: ProtoAnyCodable] { + var details: [String: ProtoAnyCodable] = [:] + if let nested = error?.details?.value as? [String: ProtoAnyCodable] { + details.merge(nested) { _, nestedValue in nestedValue } + } + if let error { + if details["code"] == nil { + details["code"] = ProtoAnyCodable(error.code) + } else { + details["errorCode"] = ProtoAnyCodable(error.code) + } + details["message"] = ProtoAnyCodable(error.message) + if let retryable = error.retryable { + details["retryable"] = ProtoAnyCodable(retryable) + } + if let retryAfterMs = error.retryafterms { + details["retryAfterMs"] = ProtoAnyCodable(retryAfterMs) + } + } + return details +} + extension String { fileprivate var nilIfEmpty: String? { self.isEmpty ? nil : self @@ -13,10 +35,22 @@ extension String { } public actor GatewayChannelActor { + nonisolated static func resolveRequestTimeoutMs(_ timeoutMs: Double?, defaultMs: Double) -> Double? { + timeoutMs == 0 ? nil : (timeoutMs ?? defaultMs) + } + + nonisolated static func minimumProtocolVersion(role: String, clientMode: String) -> Int { + // Node RPC frames stayed compatible across v3/v4. Operator chat surfaces require v4. + if role == "node", clientMode == "node" { + return GATEWAY_MIN_NODE_PROTOCOL_VERSION + } + return GATEWAY_MIN_PROTOCOL_VERSION + } + private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") private var task: WebSocketTaskBox? private var activeConnectAttemptID: UUID? - var pending: [String: PendingRequest] = [:] + var pending: [String: CheckedContinuation] = [:] private var connected = false private var connectAttemptTask: Task? /// Socket ownership epoch. Every callback and send stays bound to the task @@ -1089,10 +1123,8 @@ extension GatewayChannelActor { switch frame { case let .res(res): let id = res.id - if let request = pending.removeValue(forKey: id) { - // Keep response observers ahead of the next socket frame. - await request.onResponse?(res) - request.continuation.resume(returning: .res(res)) + if let waiter = pending.removeValue(forKey: id) { + waiter.resume(returning: .res(res)) } case let .event(evt): if evt.event == "connect.challenge" { return } @@ -1351,8 +1383,7 @@ extension GatewayChannelActor { params: params, timeoutMs: timeoutMs, task: task, - connectionGeneration: connectionGeneration, - onResponse: nil) + connectionGeneration: connectionGeneration) } /// Sends a request only on an already-connected physical socket. Unlike @@ -1372,28 +1403,7 @@ extension GatewayChannelActor { params: params, timeoutMs: timeoutMs, task: task, - connectionGeneration: expectedGeneration, - onResponse: nil) - } - - func request( - method: String, - params: [String: AnyCodable]?, - timeoutMs: Double? = nil, - ifCurrentConnectionGeneration expectedGeneration: UInt64, - onResponse: @escaping @Sendable (ResponseFrame) async -> Void) async throws -> Data - { - guard self.isConnected(connectionGeneration: expectedGeneration), - let task = self.task, - task.state == .running - else { throw CancellationError() } - return try await self.request( - method: method, - params: params, - timeoutMs: timeoutMs, - task: task, - connectionGeneration: expectedGeneration, - onResponse: onResponse) + connectionGeneration: expectedGeneration) } /// The generation is usable as a lease only while its socket is live. @@ -1410,8 +1420,7 @@ extension GatewayChannelActor { params: [String: AnyCodable]?, timeoutMs: Double?, task: WebSocketTaskBox, - connectionGeneration: UInt64, - onResponse: (@Sendable (ResponseFrame) async -> Void)?) async throws -> Data + connectionGeneration: UInt64) async throws -> Data { // Zero leaves terminal-operation deadlines to the Gateway owner. let effectiveTimeout = Self.resolveRequestTimeoutMs(timeoutMs, defaultMs: self.defaultRequestTimeoutMs) @@ -1426,9 +1435,7 @@ extension GatewayChannelActor { cont.resume(throwing: CancellationError()) return } - self.pending[payload.id] = PendingRequest( - continuation: cont, - onResponse: onResponse) + self.pending[payload.id] = cont if let effectiveTimeout { Task { [weak self] in guard let self else { return } @@ -1613,23 +1620,23 @@ extension GatewayChannelActor { private func failPending(_ error: Error) async { let waiters = self.pending self.pending.removeAll() - for (_, request) in waiters { - request.continuation.resume(throwing: error) + for (_, waiter) in waiters { + waiter.resume(throwing: error) } } private func timeoutRequest(id: String, timeoutMs: Double) async { - guard let request = self.pending.removeValue(forKey: id) else { return } + guard let waiter = self.pending.removeValue(forKey: id) else { return } let err = NSError( domain: "Gateway", code: 5, userInfo: [NSLocalizedDescriptionKey: "gateway request timed out after \(Int(timeoutMs))ms"]) - request.continuation.resume(throwing: err) + waiter.resume(throwing: err) } private func cancelRequest(id: String) { - guard let request = self.pending.removeValue(forKey: id) else { return } - request.continuation.resume(throwing: CancellationError()) + guard let waiter = self.pending.removeValue(forKey: id) else { return } + waiter.resume(throwing: CancellationError()) } private func cancelConnectWaiter(id: UUID) { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift index e403ebcc9be1..a1fad4fb51aa 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift @@ -1,6 +1,5 @@ import CryptoKit import Foundation -import OpenClawProtocol func gatewayIntValue(_ value: Any?) -> Int? { if let value = value as? Int { @@ -25,28 +24,6 @@ func gatewayIntValue(_ value: Any?) -> Int? { return nil } -func gatewayErrorDetails(_ error: ErrorShape?) -> [String: OpenClawProtocol.AnyCodable] { - var details: [String: OpenClawProtocol.AnyCodable] = [:] - if let nested = error?.details?.value as? [String: OpenClawProtocol.AnyCodable] { - details.merge(nested) { _, nestedValue in nestedValue } - } - if let error { - if details["code"] == nil { - details["code"] = OpenClawProtocol.AnyCodable(error.code) - } else { - details["errorCode"] = OpenClawProtocol.AnyCodable(error.code) - } - details["message"] = OpenClawProtocol.AnyCodable(error.message) - if let retryable = error.retryable { - details["retryable"] = OpenClawProtocol.AnyCodable(retryable) - } - if let retryAfterMs = error.retryafterms { - details["retryAfterMs"] = OpenClawProtocol.AnyCodable(retryAfterMs) - } - } - return details -} - /// Bridges task cancellation into the request continuation without racing send. final class GatewayRequestCancellationGate: @unchecked Sendable { private let lock = NSLock() @@ -66,18 +43,6 @@ final class GatewayRequestCancellationGate: @unchecked Sendable { } extension GatewayChannelActor { - nonisolated static func resolveRequestTimeoutMs(_ timeoutMs: Double?, defaultMs: Double) -> Double? { - timeoutMs == 0 ? nil : (timeoutMs ?? defaultMs) - } - - nonisolated static func minimumProtocolVersion(role: String, clientMode: String) -> Int { - // Node RPC frames stayed compatible across v3/v4. Operator chat surfaces require v4. - if role == "node", clientMode == "node" { - return GATEWAY_MIN_NODE_PROTOCOL_VERSION - } - return GATEWAY_MIN_PROTOCOL_VERSION - } - enum ConnectChallengeError: Error { case invalid case timeout @@ -92,11 +57,6 @@ extension GatewayChannelActor { "operator.pairing", ] - struct PendingRequest { - let continuation: CheckedContinuation - let onResponse: (@Sendable (ResponseFrame) async -> Void)? - } - struct SelectedConnectAuth { let authToken: String? let authBootstrapToken: String? diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession+ComputerReceipts.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession+ComputerReceipts.swift deleted file mode 100644 index 991e3b4ac310..000000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession+ComputerReceipts.swift +++ /dev/null @@ -1,482 +0,0 @@ -import CryptoKit -import Foundation - -struct NodeInvokeRequestPayload: Codable { - var id: String - var nodeId: String - var command: String - var paramsJSON: String? - var timeoutMs: Int? - var idempotencyKey: String? - var sessionKey: String? - var hasSessionKeyEnvelope: Bool - - private enum CodingKeys: String, CodingKey { - case id - case nodeId - case command - case paramsJSON - case timeoutMs - case idempotencyKey - case sessionKey - } - - init( - id: String, - nodeId: String, - command: String, - paramsJSON: String?, - timeoutMs: Int?, - idempotencyKey: String?, - sessionKey: String? = nil, - hasSessionKeyEnvelope: Bool = false) - { - self.id = id - self.nodeId = nodeId - self.command = command - self.paramsJSON = paramsJSON - self.timeoutMs = timeoutMs - self.idempotencyKey = idempotencyKey - self.sessionKey = sessionKey - self.hasSessionKeyEnvelope = hasSessionKeyEnvelope - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(String.self, forKey: .id) - self.nodeId = try container.decode(String.self, forKey: .nodeId) - self.command = try container.decode(String.self, forKey: .command) - self.paramsJSON = try container.decodeIfPresent(String.self, forKey: .paramsJSON) - self.timeoutMs = try container.decodeIfPresent(Int.self, forKey: .timeoutMs) - self.idempotencyKey = try container.decodeIfPresent(String.self, forKey: .idempotencyKey) - self.hasSessionKeyEnvelope = container.contains(.sessionKey) - let sessionKey = try container.decodeIfPresent(String.self, forKey: .sessionKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) - self.sessionKey = sessionKey?.isEmpty == false ? sessionKey : nil - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.id, forKey: .id) - try container.encode(self.nodeId, forKey: .nodeId) - try container.encode(self.command, forKey: .command) - try container.encodeIfPresent(self.paramsJSON, forKey: .paramsJSON) - try container.encodeIfPresent(self.timeoutMs, forKey: .timeoutMs) - try container.encodeIfPresent(self.idempotencyKey, forKey: .idempotencyKey) - if self.hasSessionKeyEnvelope { - try container.encode(self.sessionKey, forKey: .sessionKey) - } - } -} - -enum NodeInvokeSessionEnvelopeMode: Equatable, Sendable { - case authoritative - case legacy -} - -struct NodeInvokeRequestContext: Sendable { - let envelopeMode: NodeInvokeSessionEnvelopeMode - let route: GatewayNodeSessionRoute - let receiptScope: String - let channel: GatewayChannelActor - let socketGeneration: UInt64 - let receivedAt: ContinuousClock.Instant -} - -enum ComputerInvokeReceiptState { - case inFlight(Task) - case completed(BridgeInvokeResponse) - - var isCompleted: Bool { - if case .completed = self { - return true - } - return false - } -} - -struct ComputerInvokeReceipt { - let id: UUID - let fingerprint: String - var state: ComputerInvokeReceiptState - var operationStarted: Bool - var operationSettled: Bool -} - -struct ComputerInvokeReceiptKey: Hashable { - let receiptScopeBytes: [UInt8] - let idempotencyKeyBytes: [UInt8] - - init(receiptScope: String, idempotencyKey: String) { - self.receiptScopeBytes = Array(receiptScope.utf8) - self.idempotencyKeyBytes = Array(idempotencyKey.utf8) - } -} - -extension GatewayNodeSession { - static func staleRouteInvokeResponse(requestId: String) -> BridgeInvokeResponse { - BridgeInvokeResponse( - id: requestId, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: self.staleRouteInvokeMessage)) - } - - func invokeWithComputerReceipt( - requestPayload: NodeInvokeRequestPayload, - request: BridgeInvokeRequest, - timeoutMs: Int?, - receiptScope: String, - onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async - -> BridgeInvokeResponse - { - let timeout = timeoutMs.map { min(max(0, $0), Self.maxInvokeTimeoutMs) } - ?? Self.defaultInvokeTimeoutMs - let deadline = timeout > 0 - ? ContinuousClock.now.advanced(by: .milliseconds(timeout)) - : nil - return await self.invokeWithComputerReceipt( - requestPayload: requestPayload, - request: request, - deadline: deadline, - receiptScope: receiptScope, - onInvoke: onInvoke, - retryStaleJoinedReceipt: true) - } - - private func invokeWithComputerReceipt( - requestPayload: NodeInvokeRequestPayload, - request: BridgeInvokeRequest, - deadline: ContinuousClock.Instant?, - receiptScope: String, - onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse, - retryStaleJoinedReceipt: Bool) async - -> BridgeInvokeResponse - { - let idempotencyKey = requestPayload.idempotencyKey? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard requestPayload.command == "computer.act", !idempotencyKey.isEmpty else { - return await Self.invokeWithComputerDeadline( - request: request, - deadline: deadline, - onInvoke: onInvoke) - } - - let receiptKey = ComputerInvokeReceiptKey( - receiptScope: receiptScope, - idempotencyKey: idempotencyKey) - let fingerprint = Self.computerInvokeFingerprint(requestPayload) - if let receipt = computerInvokeReceipts[receiptKey] { - guard receipt.fingerprint == fingerprint else { - return BridgeInvokeResponse( - id: request.id, - ok: false, - error: OpenClawNodeError( - code: .invalidRequest, - message: "INVALID_REQUEST: computer.act idempotency key reused with different parameters")) - } - #if DEBUG - self.computerInvokeReceiptJoinCounts[receipt.id, default: 0] += 1 - #endif - let response = switch receipt.state { - case let .inFlight(task): - // A duplicate joins the shared side effect but keeps its own deadline. - // Timing out this wait must not cancel the original receipt task. - await Self.invokeWithComputerDeadline( - request: request, - deadline: deadline, - onInvoke: { _ in await task.value }) - case let .completed(response): response - } - self.discardRetryableComputerInvokeReceipt( - key: receiptKey, - receiptID: receipt.id, - fingerprint: fingerprint, - response: response) - if retryStaleJoinedReceipt, Self.isStaleRouteInvokeResponse(response) { - // A reconnect retry can join the old route's in-flight receipt. - // Once that receipt proves it never dispatched, retry exactly once - // with this request's route-bound invoke closure. - return await self.invokeWithComputerReceipt( - requestPayload: requestPayload, - request: request, - deadline: deadline, - receiptScope: receiptScope, - onInvoke: onInvoke, - retryStaleJoinedReceipt: false) - } - return Self.rebindInvokeResponse(response, requestId: request.id) - } - - guard self.makeComputerInvokeReceiptCapacity() else { - return BridgeInvokeResponse( - id: request.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "UNAVAILABLE: computer.act receipt capacity exhausted")) - } - - let receiptID = UUID() - let task = Task { [self] in - await Self.invokeWithComputerDeadline( - request: request, - deadline: deadline, - onInvoke: onInvoke, - onOperationStarted: { [weak self] in - await self?.markComputerInvokeOperationStarted( - key: receiptKey, - receiptID: receiptID, - fingerprint: fingerprint) - }, - onOperationSettled: { [weak self] in - await self?.markComputerInvokeOperationSettled( - key: receiptKey, - receiptID: receiptID, - fingerprint: fingerprint) - }) - } - self.computerInvokeReceipts[receiptKey] = ComputerInvokeReceipt( - id: receiptID, - fingerprint: fingerprint, - state: .inFlight(task), - operationStarted: false, - operationSettled: false) - self.computerInvokeReceiptOrder.append(receiptKey) - let response = await task.value - if Self.isStaleRouteInvokeResponse(response) { - self.discardRetryableComputerInvokeReceipt( - key: receiptKey, - receiptID: receiptID, - fingerprint: fingerprint, - response: response) - } else if let receipt = self.computerInvokeReceipts[receiptKey], - receipt.id == receiptID, - receipt.fingerprint == fingerprint - { - if receipt.operationStarted { - self.computerInvokeReceipts[receiptKey]?.state = .completed(response) - } else { - // No side effect could have started, so a retry must get a fresh - // receipt instead of inheriting this pre-dispatch timeout. - self.discardComputerInvokeReceipt( - key: receiptKey, - receiptID: receiptID, - fingerprint: fingerprint) - } - } - return Self.rebindInvokeResponse(response, requestId: request.id) - } - - private static func invokeWithComputerDeadline( - request: BridgeInvokeRequest, - deadline: ContinuousClock.Instant?, - onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse, - onOperationStarted: (@Sendable () async -> Void)? = nil, - onOperationSettled: (@Sendable () async -> Void)? = nil) async -> BridgeInvokeResponse - { - guard let deadline else { - return await invokeWithTimeout( - request: request, - timeoutMs: 0, - onInvoke: onInvoke, - onOperationStarted: onOperationStarted, - onOperationSettled: onOperationSettled) - } - let remaining = ContinuousClock.now.duration(to: deadline) - guard remaining > .zero else { - await onOperationSettled?() - return invokeTimeoutResponse(requestId: request.id) - } - let components = remaining.components - let remainingMs = max( - 1, - Int(components.seconds) * 1000 + - Int(components.attoseconds / 1_000_000_000_000_000)) - return await invokeWithTimeout( - request: request, - timeoutMs: remainingMs, - onInvoke: onInvoke, - onOperationStarted: onOperationStarted, - onOperationSettled: onOperationSettled) - } - - #if DEBUG - // periphery:ignore - package tests exercise receipt dedupe around the private invoke path. - func invokeComputerWithReceiptForTesting( - requestId: String, - paramsJSON: String, - idempotencyKey: String, - receiptScope: String, - timeoutMs: Int = 0, - onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async - -> BridgeInvokeResponse - { - let payload = NodeInvokeRequestPayload( - id: requestId, - nodeId: "test-node", - command: "computer.act", - paramsJSON: paramsJSON, - timeoutMs: timeoutMs, - idempotencyKey: idempotencyKey) - return await self.invokeWithComputerReceipt( - requestPayload: payload, - request: BridgeInvokeRequest( - id: requestId, - command: "computer.act", - paramsJSON: paramsJSON, - nodeId: "test-node"), - timeoutMs: timeoutMs, - receiptScope: receiptScope, - onInvoke: onInvoke) - } - - // periphery:ignore - package tests exercise retry after expiry before native dispatch. - func invokeComputerWithExpiredReceiptForTesting( - requestId: String, - paramsJSON: String, - idempotencyKey: String, - receiptScope: String, - onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async - -> BridgeInvokeResponse - { - let payload = NodeInvokeRequestPayload( - id: requestId, - nodeId: "test-node", - command: "computer.act", - paramsJSON: paramsJSON, - timeoutMs: 1, - idempotencyKey: idempotencyKey) - return await self.invokeWithComputerReceipt( - requestPayload: payload, - request: BridgeInvokeRequest( - id: requestId, - command: "computer.act", - paramsJSON: paramsJSON, - nodeId: "test-node"), - deadline: ContinuousClock.now.advanced(by: .milliseconds(-1)), - receiptScope: receiptScope, - onInvoke: onInvoke, - retryStaleJoinedReceipt: true) - } - - // periphery:ignore - package tests assert receipt joining without exposing the receipt store. - func computerReceiptJoinCountForTesting( - idempotencyKey: String, - receiptScope: String) -> Int - { - let receiptKey = ComputerInvokeReceiptKey( - receiptScope: receiptScope, - idempotencyKey: idempotencyKey) - guard let receiptID = self.computerInvokeReceipts[receiptKey]?.id else { return 0 } - return self.computerInvokeReceiptJoinCounts[receiptID] ?? 0 - } - #endif - - private static func computerInvokeFingerprint(_ request: NodeInvokeRequestPayload) -> String { - // Negotiation may turn an omitted legacy envelope into an explicit null. - // Both mean unattributed; only a non-empty session key changes ownership. - let sessionEnvelope = request.sessionKey.flatMap { - $0.isEmpty ? nil : "attributed:" + $0 - } ?? "unattributed" - let value = [ - request.nodeId, - request.command, - request.paramsJSON ?? "", - sessionEnvelope, - ].joined(separator: "\u{0}") - return SHA256.hash(data: Data(value.utf8)) - .map { String(format: "%02x", $0) } - .joined() - } - - private static func rebindInvokeResponse( - _ response: BridgeInvokeResponse, - requestId: String) -> BridgeInvokeResponse - { - BridgeInvokeResponse( - type: response.type, - id: requestId, - ok: response.ok, - payload: response.payload, - payloadJSON: response.payloadJSON, - error: response.error) - } - - private static func isStaleRouteInvokeResponse(_ response: BridgeInvokeResponse) -> Bool { - response.ok == false && - response.error?.code == .unavailable && - response.error?.message == self.staleRouteInvokeMessage - } - - private func discardRetryableComputerInvokeReceipt( - key: ComputerInvokeReceiptKey, - receiptID: UUID, - fingerprint: String, - response: BridgeInvokeResponse) - { - guard Self.isStaleRouteInvokeResponse(response), - self.computerInvokeReceipts[key]?.id == receiptID, - self.computerInvokeReceipts[key]?.fingerprint == fingerprint - else { return } - self.discardComputerInvokeReceipt( - key: key, - receiptID: receiptID, - fingerprint: fingerprint) - } - - private func discardComputerInvokeReceipt( - key: ComputerInvokeReceiptKey, - receiptID: UUID, - fingerprint: String) - { - guard self.computerInvokeReceipts[key]?.id == receiptID, - self.computerInvokeReceipts[key]?.fingerprint == fingerprint - else { return } - self.computerInvokeReceipts.removeValue(forKey: key) - self.computerInvokeReceiptOrder.removeAll { $0 == key } - #if DEBUG - self.computerInvokeReceiptJoinCounts.removeValue(forKey: receiptID) - #endif - } - - private func markComputerInvokeOperationStarted( - key: ComputerInvokeReceiptKey, - receiptID: UUID, - fingerprint: String) - { - guard self.computerInvokeReceipts[key]?.id == receiptID, - self.computerInvokeReceipts[key]?.fingerprint == fingerprint - else { return } - self.computerInvokeReceipts[key]?.operationStarted = true - } - - private func markComputerInvokeOperationSettled( - key: ComputerInvokeReceiptKey, - receiptID: UUID, - fingerprint: String) - { - guard self.computerInvokeReceipts[key]?.id == receiptID, - self.computerInvokeReceipts[key]?.fingerprint == fingerprint - else { return } - self.computerInvokeReceipts[key]?.operationSettled = true - } - - private func makeComputerInvokeReceiptCapacity() -> Bool { - while self.computerInvokeReceipts.count >= Self.computerInvokeReceiptLimit { - guard let completedIndex = computerInvokeReceiptOrder.firstIndex(where: { key in - guard let receipt = self.computerInvokeReceipts[key] else { return false } - return receipt.state.isCompleted && receipt.operationSettled - }) else { return false } - let evictedKey = self.computerInvokeReceiptOrder.remove(at: completedIndex) - let evictedReceipt = self.computerInvokeReceipts.removeValue(forKey: evictedKey) - #if DEBUG - if let receiptID = evictedReceipt?.id { - self.computerInvokeReceiptJoinCounts.removeValue(forKey: receiptID) - } - #endif - } - return true - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession+InvokeTimeout.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession+InvokeTimeout.swift index 987f36ddad4c..f3207f39d046 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession+InvokeTimeout.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession+InvokeTimeout.swift @@ -6,7 +6,6 @@ extension GatewayNodeSession { request: BridgeInvokeRequest, timeoutMs: Int?, onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse, - onOperationStarted: (@Sendable () async -> Void)? = nil, onOperationSettled: (@Sendable () async -> Void)? = nil) async -> BridgeInvokeResponse { let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway") @@ -16,7 +15,6 @@ extension GatewayNodeSession { } return Self.defaultInvokeTimeoutMs }() - await onOperationStarted?() guard timeout > 0 else { let response = await onInvoke(request) await onOperationSettled?() @@ -73,20 +71,16 @@ extension GatewayNodeSession { } guard !Task.isCancelled else { return } timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)") - latch.resume(Self.invokeTimeoutResponse(requestId: request.id)) + latch.resume(BridgeInvokeResponse( + id: request.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "node invoke timed out"))) } } timeoutLogger .info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") return response } - - static func invokeTimeoutResponse(requestId: String) -> BridgeInvokeResponse { - BridgeInvokeResponse( - id: requestId, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "node invoke timed out")) - } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 74e4a9570448..feb8bcde692d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -1,7 +1,17 @@ +import CryptoKit import Foundation import OpenClawProtocol import OSLog +private struct NodeInvokeRequestPayload: Codable { + var id: String + var nodeId: String + var command: String + var paramsJSON: String? + var timeoutMs: Int? + var idempotencyKey: String? +} + private struct NodeInvokeCancelPayload: Codable { var invokeId: String } @@ -69,27 +79,47 @@ public struct GatewayNodeSessionCredentials: Sendable, Equatable { public actor GatewayNodeSession { @TaskLocal private static var executingLifecycleCallbackID: UUID? private static let pluginSurfaceRefreshTimeoutMs = 8000.0 - private static let nodeInvokeSessionKeyEnvelopeProtocolFeature = - "node-invoke-session-key-envelope-v1" - static let staleRouteInvokeMessage = "UNAVAILABLE: node route changed before dispatch" + private static let staleRouteInvokeMessage = "UNAVAILABLE: node route changed before dispatch" + private enum ComputerInvokeReceiptState { + case inFlight(Task) + case completed(BridgeInvokeResponse) + + var isCompleted: Bool { + if case .completed = self { + return true + } + return false + } + } + + private struct ComputerInvokeReceipt { + let id: UUID + let fingerprint: String + var state: ComputerInvokeReceiptState + var operationSettled: Bool + } private struct ConnectOptionsKey: Equatable { let normalizedInputs: String let deviceAuthGatewayIDBytes: [UInt8]? } + private struct ComputerInvokeReceiptKey: Hashable { + let receiptScopeBytes: [UInt8] + let idempotencyKeyBytes: [UInt8] + + init(receiptScope: String, idempotencyKey: String) { + self.receiptScopeBytes = Array(receiptScope.utf8) + self.idempotencyKeyBytes = Array(idempotencyKey.utf8) + } + } + private struct ActiveInvoke { let admissionGeneration: UInt64 let task: Task } - private enum InvokeTimeoutBudget { - case disabled - case expired - case remaining(Int) - } - private struct LifecycleCallbackBarrier { let id: UUID let task: Task @@ -100,7 +130,7 @@ public actor GatewayNodeSession { private let encoder = JSONEncoder() static let defaultInvokeTimeoutMs = 30000 static let maxInvokeTimeoutMs = Int(Int32.max) - static let computerInvokeReceiptLimit = 256 + private static let computerInvokeReceiptLimit = 256 private var channel: GatewayChannelActor? private var activeURL: URL? private var activeCredentials: GatewayNodeSessionCredentials? @@ -130,20 +160,16 @@ public actor GatewayNodeSession { private var serverMethods: Set? private var serverCapabilities: Set? private var mainSessionKey: String? - private var nodeInvokeSessionEnvelopeMode: Task? - private var nodeInvokeSessionEnvelopeModeResolved = false - private var nodeInvokeSessionEnvelopeNegotiationGeneration: UInt64 = 0 - private var nodeInvokeControlDispatch: Task? private var snapshotWaiters: [UUID: CheckedContinuation] = [:] private var snapshotReadyWaiters: [CheckedContinuation] = [] // `computer.act` is not safe to repeat after a response is lost. Keep recent // in-flight/results on the long-lived node session so a channel reconnect can // replay the receipt without posting input twice. App restart intentionally // remains a wider durable-storage boundary. - var computerInvokeReceipts: [ComputerInvokeReceiptKey: ComputerInvokeReceipt] = [:] - var computerInvokeReceiptOrder: [ComputerInvokeReceiptKey] = [] + private var computerInvokeReceipts: [ComputerInvokeReceiptKey: ComputerInvokeReceipt] = [:] + private var computerInvokeReceiptOrder: [ComputerInvokeReceiptKey] = [] #if DEBUG - var computerInvokeReceiptJoinCounts: [UUID: Int] = [:] + private var computerInvokeReceiptJoinCounts: [UUID: Int] = [:] #endif private struct ServerEventSubscriber { @@ -881,10 +907,6 @@ extension GatewayNodeSession { } self.hasEverConnected = true self.markSnapshotReceived() - self.startNodeInvokeSessionEnvelopeNegotiation( - channelGeneration: channelGeneration, - admissionGeneration: admissionGeneration, - socketGeneration: socketGeneration) await self.notifyConnectedIfNeeded( admissionGeneration: admissionGeneration) case let .event(evt): @@ -900,106 +922,7 @@ extension GatewayNodeSession { } } - private func startNodeInvokeSessionEnvelopeNegotiation( - channelGeneration: UInt64, - admissionGeneration: UInt64, - socketGeneration: UInt64) - { - self.nodeInvokeSessionEnvelopeMode?.cancel() - self.nodeInvokeSessionEnvelopeModeResolved = false - self.nodeInvokeSessionEnvelopeNegotiationGeneration &+= 1 - let negotiationGeneration = self.nodeInvokeSessionEnvelopeNegotiationGeneration - guard self.connectOptions?.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "node", - let channel - else { - self.nodeInvokeSessionEnvelopeMode = Task { .legacy } - self.nodeInvokeSessionEnvelopeModeResolved = true - return - } - self.nodeInvokeSessionEnvelopeMode = Task { [weak self] in - guard let self else { return .authoritative } - let mode = await self.negotiateNodeInvokeSessionEnvelope( - channel: channel, - channelGeneration: channelGeneration, - admissionGeneration: admissionGeneration, - socketGeneration: socketGeneration, - negotiationGeneration: negotiationGeneration) - await self.markNodeInvokeSessionEnvelopeModeResolved( - channel: channel, - channelGeneration: channelGeneration, - admissionGeneration: admissionGeneration, - negotiationGeneration: negotiationGeneration) - return mode - } - } - - private func negotiateNodeInvokeSessionEnvelope( - channel: GatewayChannelActor, - channelGeneration: UInt64, - admissionGeneration: UInt64, - socketGeneration: UInt64, - negotiationGeneration: UInt64) async -> NodeInvokeSessionEnvelopeMode - { - do { - _ = try await channel.request( - method: "node.protocolFeatures.update", - params: [ - "features": AnyCodable([ - Self.nodeInvokeSessionKeyEnvelopeProtocolFeature, - ]), - ], - timeoutMs: 15000, - ifCurrentConnectionGeneration: socketGeneration, - onResponse: { [weak self] _ in - guard let self else { return } - await self.markNodeInvokeSessionEnvelopeModeResolved( - channel: channel, - channelGeneration: channelGeneration, - admissionGeneration: admissionGeneration, - negotiationGeneration: negotiationGeneration) - }) - guard self.channel === channel, - self.channelGeneration == channelGeneration, - self.admissionGeneration == admissionGeneration - else { return .authoritative } - return .authoritative - } catch let error as GatewayResponseError - where error.code == "INVALID_REQUEST" && - error.message == "unknown method: node.protocolFeatures.update" - { - return .legacy - } catch is CancellationError { - return .authoritative - } catch { - self.logger.error( - "node protocol feature publish failed: \(error.localizedDescription, privacy: .public)") - // Only an exact unknown-method response enables the nested legacy field. - // Ambiguous failures stay fail-closed for the lifetime of this socket. - return .authoritative - } - } - - private func markNodeInvokeSessionEnvelopeModeResolved( - channel: GatewayChannelActor, - channelGeneration: UInt64, - admissionGeneration: UInt64, - negotiationGeneration: UInt64) - { - guard !Task.isCancelled, - self.channel === channel, - self.channelGeneration == channelGeneration, - self.admissionGeneration == admissionGeneration, - self.nodeInvokeSessionEnvelopeNegotiationGeneration == negotiationGeneration - else { return } - self.nodeInvokeSessionEnvelopeModeResolved = true - } - private func resetConnectionState() { - self.nodeInvokeSessionEnvelopeMode?.cancel() - self.nodeInvokeControlDispatch?.cancel() - self.nodeInvokeSessionEnvelopeMode = nil - self.nodeInvokeSessionEnvelopeModeResolved = false - self.nodeInvokeControlDispatch = nil self.hasNotifiedConnected = false self.snapshotReceived = false self.serverMethods = nil @@ -1193,89 +1116,6 @@ extension GatewayNodeSession { socketGeneration: UInt64) async { self.broadcastServerEvent(evt) - if evt.event == "node.invoke.request" || - evt.event == "node.invoke.input" || - evt.event == "node.invoke.cancel" - { - self.enqueueNodeInvokeEvent( - evt, - channel: channel, - channelGeneration: channelGeneration, - admissionGeneration: admissionGeneration, - socketGeneration: socketGeneration) - } - } - - private func enqueueNodeInvokeEvent( - _ evt: EventFrame, - channel: GatewayChannelActor, - channelGeneration: UInt64, - admissionGeneration: UInt64, - socketGeneration: UInt64) - { - let receivedAt = ContinuousClock.now - if evt.event == "node.invoke.input" || evt.event == "node.invoke.cancel" { - // MacNodeModeCoordinator is the only production control consumer. Its worker - // buffers controls by invoke id until the invoke frame is registered. - let previous = self.nodeInvokeControlDispatch - let dispatch = Task { [weak self] in - await previous?.value - guard !Task.isCancelled, let self else { return } - await self.handleAdmittedNodeInvokeEvent( - evt, - mode: .authoritative, - channel: channel, - channelGeneration: channelGeneration, - admissionGeneration: admissionGeneration, - socketGeneration: socketGeneration, - receivedAt: receivedAt) - } - self.nodeInvokeControlDispatch = dispatch - return - } - let decodedRequest = evt.payload.flatMap { try? self.decodeInvokeRequest(from: $0) } - let hasWireSessionKey = decodedRequest?.hasSessionKeyEnvelope == true - // Omission becomes authoritative only after feature publication completes. - // Frames already received during negotiation retain legacy semantics. - let envelopeNegotiationResolvedAtReceipt = self.nodeInvokeSessionEnvelopeModeResolved - let envelopeModeTask = - hasWireSessionKey - ? Task { .authoritative } - : self.nodeInvokeSessionEnvelopeMode ?? Task { .authoritative } - Task { [weak self] in - let mode: NodeInvokeSessionEnvelopeMode = if hasWireSessionKey { - .authoritative - } else if !envelopeNegotiationResolvedAtReceipt { - .legacy - } else { - await envelopeModeTask.value - } - guard !Task.isCancelled, let self else { return } - await self.handleAdmittedNodeInvokeEvent( - evt, - mode: mode, - channel: channel, - channelGeneration: channelGeneration, - admissionGeneration: admissionGeneration, - socketGeneration: socketGeneration, - receivedAt: receivedAt) - } - } - - private func handleAdmittedNodeInvokeEvent( - _ evt: EventFrame, - mode: NodeInvokeSessionEnvelopeMode, - channel: GatewayChannelActor, - channelGeneration: UInt64, - admissionGeneration: UInt64, - socketGeneration: UInt64, - receivedAt: ContinuousClock.Instant) async - { - guard self.channelGeneration == channelGeneration, - self.admissionGeneration == admissionGeneration, - self.activeSocketGeneration == socketGeneration, - self.channel === channel - else { return } if evt.event == "node.invoke.input" { guard let payload = evt.payload, let onInvokeInput else { return } do { @@ -1313,20 +1153,17 @@ extension GatewayNodeSession { channelGeneration: channelGeneration, admissionGeneration: admissionGeneration, socketGeneration: socketGeneration) - let context = NodeInvokeRequestContext( - envelopeMode: mode, - route: route, - receiptScope: self.computerInvokeReceiptScope(), - channel: channel, - socketGeneration: socketGeneration, - receivedAt: receivedAt) + let receiptScope = self.computerInvokeReceiptScope() // GatewayChannel waits for push handling before it rearms receive. Run device work // separately so a long invoke cannot starve heartbeats or later node requests. Task.detached { [weak self] in await self?.handleInvokeRequest( request: request, onInvoke: onInvoke, - context: context) + route: route, + receiptScope: receiptScope, + channel: channel, + socketGeneration: socketGeneration) } } catch { self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") @@ -1336,15 +1173,13 @@ extension GatewayNodeSession { private func handleInvokeRequest( request: NodeInvokeRequestPayload, onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse, - context: NodeInvokeRequestContext) async + route: GatewayNodeSessionRoute, + receiptScope: String, + channel: GatewayChannelActor, + socketGeneration: UInt64) async { - var request = request - if context.envelopeMode == .authoritative, !request.hasSessionKeyEnvelope { - request.hasSessionKeyEnvelope = true - request.sessionKey = nil - } - guard self.isCurrentRoute(context.route), - self.channel === context.channel + guard self.isCurrentRoute(route), + self.channel === channel else { return } // Lifecycle cleanup gates owner readiness. Reject while it is suspended instead of // holding the Gateway request until timeout; the replacement route stays fail-closed. @@ -1358,8 +1193,8 @@ extension GatewayNodeSession { error: OpenClawNodeError( code: .unavailable, message: "UNAVAILABLE: node lifecycle transition in progress")), - channel: context.channel, - socketGeneration: context.socketGeneration) + channel: channel, + socketGeneration: socketGeneration) return } self.logger.info("node invoke executing id=\(request.id, privacy: .public)") @@ -1368,52 +1203,33 @@ extension GatewayNodeSession { command: request.command, paramsJSON: request.paramsJSON, nodeId: request.nodeId) - let sessionKeyEnvelope = Self.sessionKeyEnvelope(request: request) let routeBoundInvoke: @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse = { [weak self] req in - // Timeout and receipt helpers may dispatch detached tasks. Rebind the immutable - // envelope at the final owner callback so those hops cannot erase attribution. - await GatewayNodeInvokeContext.$sessionKeyEnvelope.withValue(sessionKeyEnvelope) { - guard let self else { - return Self.staleRouteInvokeResponse(requestId: req.id) - } - return await self.invokeIfCurrentRoute( - req, - expectedRoute: context.route, - onInvoke: onInvoke) + guard let self else { + return Self.staleRouteInvokeResponse(requestId: req.id) } + return await self.invokeIfCurrentRoute( + req, + expectedRoute: route, + onInvoke: onInvoke) } - let timeoutMs: Int - switch Self.invokeTimeoutBudget(timeoutMs: request.timeoutMs, receivedAt: context.receivedAt) { - case .disabled: - timeoutMs = 0 - case .expired: - await self.sendInvokeResult( - request: request, - response: Self.invokeTimeoutResponse(requestId: request.id), - channel: context.channel, - socketGeneration: context.socketGeneration) - return - case let .remaining(remaining): - timeoutMs = remaining - } - let response = await self.invokeWithComputerReceipt( + let response = await invokeWithComputerReceipt( requestPayload: request, request: bridgeRequest, - timeoutMs: timeoutMs, - receiptScope: context.receiptScope, + timeoutMs: request.timeoutMs, + receiptScope: receiptScope, onInvoke: routeBoundInvoke) // Invoke output belongs to the requesting channel. A target switch while the device // command is running must discard it instead of disclosing it to the replacement. - guard self.isCurrentRoute(context.route), - self.channel === context.channel + guard self.isCurrentRoute(route), + self.channel === channel else { return } self.logger.info( "node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") await self.sendInvokeResult( request: request, response: response, - channel: context.channel, - socketGeneration: context.socketGeneration) + channel: channel, + socketGeneration: socketGeneration) } func invokeIfCurrentRoute( @@ -1451,35 +1267,6 @@ extension GatewayNodeSession { route.admissionGeneration == self.admissionGeneration } - private static func sessionKeyEnvelope( - request: NodeInvokeRequestPayload) -> GatewayNodeInvokeSessionKeyEnvelope - { - if request.hasSessionKeyEnvelope { - return .authoritative(request.sessionKey) - } - return .legacy - } - - private static func invokeTimeoutBudget( - timeoutMs: Int?, - receivedAt: ContinuousClock.Instant) -> InvokeTimeoutBudget - { - let timeout = timeoutMs.map { min(max(0, $0), Self.maxInvokeTimeoutMs) } - ?? Self.defaultInvokeTimeoutMs - guard timeout > 0 else { return .disabled } - let duration = receivedAt.duration(to: ContinuousClock.now) - let components = duration.components - guard components.seconds >= 0 else { return .remaining(timeout) } - if components.seconds > Int64(timeout / 1000) { - return .expired - } - let elapsedMs = - Int(components.seconds) * 1000 + - Int(components.attoseconds / 1_000_000_000_000_000) - guard elapsedMs < timeout else { return .expired } - return .remaining(timeout - elapsedMs) - } - private func admitSocketGeneration(_ socketGeneration: UInt64) -> Bool { if let lastRetiredSocketGeneration, socketGeneration <= lastRetiredSocketGeneration @@ -1593,6 +1380,159 @@ extension GatewayNodeSession { } } + private static func staleRouteInvokeResponse(requestId: String) -> BridgeInvokeResponse { + BridgeInvokeResponse( + id: requestId, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: self.staleRouteInvokeMessage)) + } + + private func invokeWithComputerReceipt( + requestPayload: NodeInvokeRequestPayload, + request: BridgeInvokeRequest, + timeoutMs: Int?, + receiptScope: String, + onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse, + retryStaleJoinedReceipt: Bool = true) async + -> BridgeInvokeResponse + { + let idempotencyKey = requestPayload.idempotencyKey? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard requestPayload.command == "computer.act", !idempotencyKey.isEmpty else { + return await Self.invokeWithTimeout( + request: request, + timeoutMs: timeoutMs, + onInvoke: onInvoke) + } + + let receiptKey = ComputerInvokeReceiptKey( + receiptScope: receiptScope, + idempotencyKey: idempotencyKey) + let fingerprint = Self.computerInvokeFingerprint(requestPayload) + if let receipt = computerInvokeReceipts[receiptKey] { + guard receipt.fingerprint == fingerprint else { + return BridgeInvokeResponse( + id: request.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "INVALID_REQUEST: computer.act idempotency key reused with different parameters")) + } + #if DEBUG + self.computerInvokeReceiptJoinCounts[receipt.id, default: 0] += 1 + #endif + let response = switch receipt.state { + case let .inFlight(task): await task.value + case let .completed(response): response + } + self.discardRetryableComputerInvokeReceipt( + key: receiptKey, + receiptID: receipt.id, + fingerprint: fingerprint, + response: response) + if retryStaleJoinedReceipt, Self.isStaleRouteInvokeResponse(response) { + // A reconnect retry can join the old route's in-flight receipt. + // Once that receipt proves it never dispatched, retry exactly once + // with this request's route-bound invoke closure. + return await self.invokeWithComputerReceipt( + requestPayload: requestPayload, + request: request, + timeoutMs: timeoutMs, + receiptScope: receiptScope, + onInvoke: onInvoke, + retryStaleJoinedReceipt: false) + } + return Self.rebindInvokeResponse(response, requestId: request.id) + } + + guard self.makeComputerInvokeReceiptCapacity() else { + return BridgeInvokeResponse( + id: request.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "UNAVAILABLE: computer.act receipt capacity exhausted")) + } + + let receiptID = UUID() + let task = Task { [self] in + await Self.invokeWithTimeout( + request: request, + timeoutMs: timeoutMs, + onInvoke: onInvoke, + onOperationSettled: { [weak self] in + await self?.markComputerInvokeOperationSettled( + key: receiptKey, + receiptID: receiptID, + fingerprint: fingerprint) + }) + } + self.computerInvokeReceipts[receiptKey] = ComputerInvokeReceipt( + id: receiptID, + fingerprint: fingerprint, + state: .inFlight(task), + operationSettled: false) + self.computerInvokeReceiptOrder.append(receiptKey) + let response = await task.value + if Self.isStaleRouteInvokeResponse(response) { + self.discardRetryableComputerInvokeReceipt( + key: receiptKey, + receiptID: receiptID, + fingerprint: fingerprint, + response: response) + } else if self.computerInvokeReceipts[receiptKey]?.id == receiptID, + self.computerInvokeReceipts[receiptKey]?.fingerprint == fingerprint + { + self.computerInvokeReceipts[receiptKey]?.state = .completed(response) + } + return Self.rebindInvokeResponse(response, requestId: request.id) + } + + #if DEBUG + // periphery:ignore - package tests exercise receipt dedupe around the private invoke path. + func invokeComputerWithReceiptForTesting( + requestId: String, + paramsJSON: String, + idempotencyKey: String, + receiptScope: String, + timeoutMs: Int = 0, + onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async + -> BridgeInvokeResponse + { + let payload = NodeInvokeRequestPayload( + id: requestId, + nodeId: "test-node", + command: "computer.act", + paramsJSON: paramsJSON, + timeoutMs: timeoutMs, + idempotencyKey: idempotencyKey) + return await self.invokeWithComputerReceipt( + requestPayload: payload, + request: BridgeInvokeRequest( + id: requestId, + command: "computer.act", + paramsJSON: paramsJSON, + nodeId: "test-node"), + timeoutMs: timeoutMs, + receiptScope: receiptScope, + onInvoke: onInvoke) + } + + // periphery:ignore - package tests assert receipt joining without exposing the receipt store. + func computerReceiptJoinCountForTesting( + idempotencyKey: String, + receiptScope: String) -> Int + { + let receiptKey = ComputerInvokeReceiptKey( + receiptScope: receiptScope, + idempotencyKey: idempotencyKey) + guard let receiptID = self.computerInvokeReceipts[receiptKey]?.id else { return 0 } + return self.computerInvokeReceiptJoinCounts[receiptID] ?? 0 + } + #endif + private func computerInvokeReceiptScope() -> String { if let gatewayID = self.connectOptions?.deviceAuthGatewayID, !gatewayID.isEmpty @@ -1602,6 +1542,77 @@ extension GatewayNodeSession { return "url:\(self.activeURL?.absoluteString ?? "unknown")" } + private static func computerInvokeFingerprint(_ request: NodeInvokeRequestPayload) -> String { + let value = [request.nodeId, request.command, request.paramsJSON ?? ""].joined(separator: "\u{0}") + return SHA256.hash(data: Data(value.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + + private static func rebindInvokeResponse( + _ response: BridgeInvokeResponse, + requestId: String) -> BridgeInvokeResponse + { + BridgeInvokeResponse( + type: response.type, + id: requestId, + ok: response.ok, + payload: response.payload, + payloadJSON: response.payloadJSON, + error: response.error) + } + + private static func isStaleRouteInvokeResponse(_ response: BridgeInvokeResponse) -> Bool { + response.ok == false && + response.error?.code == .unavailable && + response.error?.message == self.staleRouteInvokeMessage + } + + private func discardRetryableComputerInvokeReceipt( + key: ComputerInvokeReceiptKey, + receiptID: UUID, + fingerprint: String, + response: BridgeInvokeResponse) + { + guard Self.isStaleRouteInvokeResponse(response), + self.computerInvokeReceipts[key]?.id == receiptID, + self.computerInvokeReceipts[key]?.fingerprint == fingerprint + else { return } + self.computerInvokeReceipts.removeValue(forKey: key) + self.computerInvokeReceiptOrder.removeAll { $0 == key } + #if DEBUG + self.computerInvokeReceiptJoinCounts.removeValue(forKey: receiptID) + #endif + } + + private func markComputerInvokeOperationSettled( + key: ComputerInvokeReceiptKey, + receiptID: UUID, + fingerprint: String) + { + guard self.computerInvokeReceipts[key]?.id == receiptID, + self.computerInvokeReceipts[key]?.fingerprint == fingerprint + else { return } + self.computerInvokeReceipts[key]?.operationSettled = true + } + + private func makeComputerInvokeReceiptCapacity() -> Bool { + while self.computerInvokeReceipts.count >= Self.computerInvokeReceiptLimit { + guard let completedIndex = computerInvokeReceiptOrder.firstIndex(where: { key in + guard let receipt = self.computerInvokeReceipts[key] else { return false } + return receipt.state.isCompleted && receipt.operationSettled + }) else { return false } + let evictedKey = self.computerInvokeReceiptOrder.remove(at: completedIndex) + let evictedReceipt = self.computerInvokeReceipts.removeValue(forKey: evictedKey) + #if DEBUG + if let receiptID = evictedReceipt?.id { + self.computerInvokeReceiptJoinCounts.removeValue(forKey: receiptID) + } + #endif + } + return true + } + private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload { try self.decodeEventPayload(from: payload) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 91f609f47a23..2a9aee841408 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3384,20 +3384,6 @@ public struct NodeSkillsUpdateParams: Codable, Sendable { } } -public struct NodeProtocolFeaturesUpdateParams: Codable, Sendable { - public let features: [String] - - public init( - features: [String]) - { - self.features = features - } - - private enum CodingKeys: String, CodingKey { - case features - } -} - public struct NodePendingAckParams: Codable, Sendable { public let ids: [String] @@ -3569,7 +3555,6 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { public let paramsjson: String? public let timeoutms: Int? public let idempotencykey: String? - public let sessionkey: AnyCodable? public init( id: String, @@ -3577,8 +3562,7 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { command: String, paramsjson: String? = nil, timeoutms: Int? = nil, - idempotencykey: String? = nil, - sessionkey: AnyCodable? = nil) + idempotencykey: String? = nil) { self.id = id self.nodeid = nodeid @@ -3586,7 +3570,6 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { self.paramsjson = paramsjson self.timeoutms = timeoutms self.idempotencykey = idempotencykey - self.sessionkey = sessionkey } private enum CodingKeys: String, CodingKey { @@ -3596,31 +3579,6 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { case paramsjson = "paramsJSON" case timeoutms = "timeoutMs" case idempotencykey = "idempotencyKey" - case sessionkey = "sessionKey" - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(String.self, forKey: .id) - self.nodeid = try container.decode(String.self, forKey: .nodeid) - self.command = try container.decode(String.self, forKey: .command) - self.paramsjson = try container.decodeIfPresent(String.self, forKey: .paramsjson) - self.timeoutms = try container.decodeIfPresent(Int.self, forKey: .timeoutms) - self.idempotencykey = try container.decodeIfPresent(String.self, forKey: .idempotencykey) - self.sessionkey = container.contains(.sessionkey) - ? try container.decode(AnyCodable.self, forKey: .sessionkey) - : nil - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(nodeid, forKey: .nodeid) - try container.encode(command, forKey: .command) - try container.encodeIfPresent(paramsjson, forKey: .paramsjson) - try container.encodeIfPresent(timeoutms, forKey: .timeoutms) - try container.encodeIfPresent(idempotencykey, forKey: .idempotencykey) - try container.encodeIfPresent(sessionkey, forKey: .sessionkey) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift index 4842cc5ee687..79d2a92fc1c5 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayModelsCompatibilityTests.swift @@ -180,27 +180,4 @@ struct GatewayModelsCompatibilityTests { #expect(decodedCleared.modelvalue?.value is NSNull) #expect(reencodedCleared["model"] is NSNull) } - - @Test - func `node invoke session envelope preserves omitted null and value states`() throws { - let omitted = try JSONDecoder().decode( - NodeInvokeRequestEvent.self, - from: Data(#"{"id":"invoke-1","nodeId":"node-1","command":"debug.ping"}"#.utf8)) - let cleared = try JSONDecoder().decode( - NodeInvokeRequestEvent.self, - from: Data( - #"{"id":"invoke-2","nodeId":"node-1","command":"debug.ping","sessionKey":null}"#.utf8)) - let attributed = try JSONDecoder().decode( - NodeInvokeRequestEvent.self, - from: Data( - #"{"id":"invoke-3","nodeId":"node-1","command":"debug.ping","sessionKey":"agent:main:main"}"#.utf8)) - let reencodedCleared = try #require( - JSONSerialization.jsonObject(with: JSONEncoder().encode(cleared)) - as? [String: Any]) - - #expect(omitted.sessionkey == nil) - #expect(cleared.sessionkey?.value is NSNull) - #expect(attributed.sessionkey?.value as? String == "agent:main:main") - #expect(reencodedCleared["sessionKey"] is NSNull) - } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index a3442e52201a..c5056ac7d09d 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -1,7 +1,7 @@ import Foundation import OpenClawProtocol import Testing -@_spi(AgentExecutionAttribution) @testable import OpenClawKit +@testable import OpenClawKit extension NSLock { fileprivate func withLock(_ body: () -> T) -> T { @@ -36,18 +36,6 @@ private actor StringCapture { } } -private actor SessionKeyEnvelopeCapture { - private var values: [GatewayNodeInvokeSessionKeyEnvelope] = [] - - func append(_ value: GatewayNodeInvokeSessionKeyEnvelope) { - self.values.append(value) - } - - func all() -> [GatewayNodeInvokeSessionKeyEnvelope] { - self.values - } -} - /// Delivers a pong asynchronously, well before the deadline, so a cancelled deadline /// task racing the gate would surface as a spurious timeout. private final class DelayedPongWebSocketTask: WebSocketTasking, @unchecked Sendable { @@ -204,10 +192,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda private let helloSessionDefaults: [String: Any]? private let helloDelayNanoseconds: UInt64 private let connectError: [String: Any]? - private let protocolFeaturesError: [String: Any]? - private let protocolFeaturesAutoResponse: Bool - private let protocolFeaturesResponseDelay: Duration - private let protocolFeaturesPostResponseInvoke: Bool private let cancelGate: FirstCancelGate? private var _state: URLSessionTask.State = .suspended private var connectRequestId: String? @@ -225,10 +209,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda helloSessionDefaults: [String: Any]? = nil, helloDelayNanoseconds: UInt64 = 0, connectError: [String: Any]? = nil, - protocolFeaturesError: [String: Any]? = nil, - protocolFeaturesAutoResponse: Bool = true, - protocolFeaturesResponseDelay: Duration = .zero, - protocolFeaturesPostResponseInvoke: Bool = false, cancelGate: FirstCancelGate? = nil) { self.helloAuth = helloAuth @@ -236,10 +216,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda self.helloSessionDefaults = helloSessionDefaults self.helloDelayNanoseconds = helloDelayNanoseconds self.connectError = connectError - self.protocolFeaturesError = protocolFeaturesError - self.protocolFeaturesAutoResponse = protocolFeaturesAutoResponse - self.protocolFeaturesResponseDelay = protocolFeaturesResponseDelay - self.protocolFeaturesPostResponseInvoke = protocolFeaturesPostResponseInvoke self.cancelGate = cancelGate } @@ -281,25 +257,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda self.sentRequestMethods.append(method) self.sentRequestPayloads.append(obj) } - if method == "node.protocolFeatures.update", - self.protocolFeaturesAutoResponse, - let id = obj["id"] as? String - { - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(for: self.protocolFeaturesResponseDelay) - if let protocolFeaturesError = self.protocolFeaturesError { - self.emitError(id: id, error: protocolFeaturesError) - } else { - self.emitResponse(id: id, payload: ["ok": true]) - } - if self.protocolFeaturesPostResponseInvoke { - self.emitInvokeRequest( - id: "post-negotiation", - command: "mcp.tools.call.v1") - } - } - } guard method == "connect", let id = obj["id"] as? String else { return } let params = obj["params"] as? [String: Any] let auth = (params?["auth"] as? [String: Any]) ?? [:] @@ -394,23 +351,25 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda self.emitInbound(.failure(URLError(.networkConnectionLost))) } + func emitInvokeRequest(id: String, command: String, idempotencyKey: String? = nil) { + self.emitInvokeRequest( + id: id, + command: command, + paramsJSON: "{}", + idempotencyKey: idempotencyKey) + } + func emitInvokeRequest( id: String, command: String, - paramsJSON: String? = "{}", - idempotencyKey: String? = nil, - includeSessionKey: Bool = false, - sessionKey: String? = nil, - timeoutMs: Int? = nil) + paramsJSON: String?, + idempotencyKey: String? = nil) { self.emitInbound(.success(.data(Self.invokeRequestData( id: id, command: command, paramsJSON: paramsJSON, - idempotencyKey: idempotencyKey, - includeSessionKey: includeSessionKey, - sessionKey: sessionKey, - timeoutMs: timeoutMs)))) + idempotencyKey: idempotencyKey)))) } func emitResponse(id: String, payload: [String: Any]) { @@ -424,17 +383,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda self.emitInbound(.success(.data(data))) } - func emitError(id: String, error: [String: Any]) { - let frame: [String: Any] = [ - "type": "res", - "id": id, - "ok": false, - "error": error, - ] - let data = (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() - self.emitInbound(.success(.data(data))) - } - private func emitInbound(_ result: ReceiveResult) { let handler = self.lock.withLock { () -> (@Sendable (ReceiveResult) -> Void)? in guard let handler = self.pendingReceiveHandler else { @@ -529,10 +477,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda id: String, command: String, paramsJSON: String?, - idempotencyKey: String?, - includeSessionKey: Bool, - sessionKey: String?, - timeoutMs: Int?) -> Data + idempotencyKey: String?) -> Data { var payload: [String: Any] = [ "id": id, @@ -543,12 +488,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda if let idempotencyKey { payload["idempotencyKey"] = idempotencyKey } - if includeSessionKey { - payload["sessionKey"] = sessionKey ?? NSNull() - } - if let timeoutMs { - payload["timeoutMs"] = timeoutMs - } let frame: [String: Any] = [ "type": "event", "event": "node.invoke.request", @@ -567,10 +506,6 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, GatewayTLS private let helloSessionDefaults: [String: Any]? private let helloDelayNanoseconds: UInt64 private let connectError: [String: Any]? - private let protocolFeaturesError: [String: Any]? - private let protocolFeaturesAutoResponse: Bool - private let protocolFeaturesResponseDelay: Duration - private let protocolFeaturesPostResponseInvoke: Bool private let cancelGate: FirstCancelGate? let effectiveTLSFingerprintSHA256: String? private var tasks: [FakeGatewayWebSocketTask] = [] @@ -583,10 +518,6 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, GatewayTLS helloSessionDefaults: [String: Any]? = nil, helloDelayNanoseconds: UInt64 = 0, connectError: [String: Any]? = nil, - protocolFeaturesError: [String: Any]? = nil, - protocolFeaturesAutoResponse: Bool = true, - protocolFeaturesResponseDelay: Duration = .zero, - protocolFeaturesPostResponseInvoke: Bool = false, cancelGate: FirstCancelGate? = nil, effectiveTLSFingerprintSHA256: String? = nil) { @@ -595,10 +526,6 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, GatewayTLS self.helloSessionDefaults = helloSessionDefaults self.helloDelayNanoseconds = helloDelayNanoseconds self.connectError = connectError - self.protocolFeaturesError = protocolFeaturesError - self.protocolFeaturesAutoResponse = protocolFeaturesAutoResponse - self.protocolFeaturesResponseDelay = protocolFeaturesResponseDelay - self.protocolFeaturesPostResponseInvoke = protocolFeaturesPostResponseInvoke self.cancelGate = cancelGate self.effectiveTLSFingerprintSHA256 = effectiveTLSFingerprintSHA256 } @@ -629,10 +556,6 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, GatewayTLS helloSessionDefaults: self.helloSessionDefaults, helloDelayNanoseconds: self.helloDelayNanoseconds, connectError: self.connectError, - protocolFeaturesError: self.protocolFeaturesError, - protocolFeaturesAutoResponse: self.protocolFeaturesAutoResponse, - protocolFeaturesResponseDelay: self.protocolFeaturesResponseDelay, - protocolFeaturesPostResponseInvoke: self.protocolFeaturesPostResponseInvoke, cancelGate: self.cancelGate) self.tasks.append(task) return WebSocketTaskBox(task: task) @@ -859,29 +782,6 @@ private func nodeInvokePush(id: String, command: String) -> GatewayPush { stateversion: nil)) } -private func nodeInvokeInputPush(id: String, seq: Int, payloadJSON: String) -> GatewayPush { - .event(EventFrame( - type: "event", - event: "node.invoke.input", - payload: AnyCodable([ - "id": AnyCodable(id), - "nodeId": AnyCodable("test-node"), - "seq": AnyCodable(seq), - "payloadJSON": AnyCodable(payloadJSON), - ]), - seq: nil, - stateversion: nil)) -} - -private func nodeInvokeCancelPush(id: String) -> GatewayPush { - .event(EventFrame( - type: "event", - event: "node.invoke.cancel", - payload: AnyCodable(["invokeId": AnyCodable(id)]), - seq: nil, - stateversion: nil)) -} - @Suite(.serialized) struct GatewayNodeSessionTests { @Test func `operator canvas refresh uses the operator surface method`() async throws { @@ -915,9 +815,7 @@ struct GatewayNodeSessionTests { @Test func `canvas surface refresh is shared across callers with different timeouts`() async throws { let expectedFingerprint = String(repeating: "ab", count: 32) - let session = FakeGatewayWebSocketSession( - protocolFeaturesAutoResponse: false, - effectiveTLSFingerprintSHA256: expectedFingerprint) + let session = FakeGatewayWebSocketSession(effectiveTLSFingerprintSHA256: expectedFingerprint) let gateway = GatewayNodeSession() let options = nodeConnectOptions(caps: ["canvas"], clientId: "openclaw-macos", clientDisplayName: "macOS Test") @@ -926,20 +824,14 @@ struct GatewayNodeSessionTests { async let first = gateway.refreshCanvasHostUrl(replacing: nil) async let second = gateway.refreshCanvasHostUrl(timeoutSeconds: 1) async let third = gateway.refreshPluginSurfaceUrl(surface: "canvas", timeoutSeconds: 2) - try await waitUntil("protocol feature and surface refresh sent") { - guard let task = session.latestTask() else { return false } - return task.sentRequestCount(method: "node.protocolFeatures.update") == 1 && - task.sentRequestCount(method: "node.pluginSurface.refresh") == 1 + try await waitUntil("single surface refresh sent") { + session.latestTask()?.sentRequestCount(method: "node.pluginSurface.refresh") == 1 } let task = try #require(session.latestTask()) - let featureRequest = try #require(task.sentRequests(method: "node.protocolFeatures.update").first) - let surfaceRequest = try #require(task.sentRequests(method: "node.pluginSurface.refresh").first) - - try task.emitResponse( - id: #require(featureRequest["id"] as? String), - payload: ["ok": true]) - try task.emitResponse( - id: #require(surfaceRequest["id"] as? String), + let request = try #require(task.sentRequests(method: "node.pluginSurface.refresh").first) + let requestID = try #require(request["id"] as? String) + task.emitResponse( + id: requestID, payload: [ "surface": "canvas", "pluginSurfaceUrls": [ @@ -1139,8 +1031,7 @@ struct GatewayNodeSessionTests { } @Test func `node invoke input and cancellation reach route callbacks`() async throws { - let session = FakeGatewayWebSocketSession( - protocolFeaturesResponseDelay: .seconds(1)) + let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let probe = NodeInvokeControlProbe() let options = nodeConnectOptions( @@ -1157,36 +1048,30 @@ struct GatewayNodeSessionTests { onInvokeCancel: { invokeId in await probe.recordCancellation(invokeId) }) await gateway._test_handlePush( - nodeInvokePush(id: "blocked", command: "codex.terminal.resume.v1"), + .event(EventFrame( + type: "event", + event: "node.invoke.input", + payload: AnyCodable([ + "id": AnyCodable("terminal-1"), + "nodeId": AnyCodable("test-node"), + "seq": AnyCodable(3), + "payloadJSON": AnyCodable(#"{"data":"hello"}"#), + ]), + seq: nil, + stateversion: nil)), socketGeneration: 1) await gateway._test_handlePush( - nodeInvokeInputPush( - id: "terminal-1", - seq: 3, - payloadJSON: #"{"data":"hello"}"#), - socketGeneration: 1) - await gateway._test_handlePush( - nodeInvokeCancelPush(id: "terminal-1"), - socketGeneration: 1) - await gateway._test_handlePush( - nodeInvokeInputPush( - id: "blocked", - seq: 4, - payloadJSON: #"{"data":"queued"}"#), - socketGeneration: 1) - await gateway._test_handlePush( - nodeInvokeCancelPush(id: "blocked"), + .event(EventFrame( + type: "event", + event: "node.invoke.cancel", + payload: AnyCodable(["invokeId": AnyCodable("terminal-1")]), + seq: nil, + stateversion: nil)), socketGeneration: 1) - try await waitUntil("invoke controls bypass protocol negotiation", timeoutSeconds: 0.2) { - let values = await probe.values() - return values.0.count == 2 && values.1.count == 2 - } let values = await probe.values() - #expect(values.0.contains(#"terminal-1:3:{"data":"hello"}"#)) - #expect(values.0.contains(#"blocked:4:{"data":"queued"}"#)) - #expect(values.1.contains("terminal-1")) - #expect(values.1.contains("blocked")) + #expect(values.0 == [#"terminal-1:3:{"data":"hello"}"#]) + #expect(values.1 == ["terminal-1"]) await gateway.disconnect() } @@ -2386,396 +2271,6 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } - @Test - func `node invoke negotiation carries authoritative session envelopes`() async throws { - let session = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false) - let gateway = GatewayNodeSession() - let capture = SessionKeyEnvelopeCapture() - let options = GatewayConnectOptions( - role: "node", - scopes: [], - caps: ["mcp"], - commands: ["mcp.tools.call.v1"], - permissions: [:], - clientId: "openclaw-macos", - clientMode: "node", - clientDisplayName: "macOS Test", - includeDeviceIdentity: false) - - try await gateway.connect( - url: #require(URL(string: "ws://example.invalid")), - credentials: .init(), - connectOptions: options, - sessionBox: WebSocketSessionBox(session: session), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { request in - await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope) - return BridgeInvokeResponse(id: request.id, ok: true) - }) - let task = try #require(session.latestTask()) - try await waitUntil("protocol feature publication") { - task.sentRequestCount(method: "node.protocolFeatures.update") == 1 - } - let publication = try #require(task.sentRequests(method: "node.protocolFeatures.update").first) - let publicationParams = try #require(publication["params"] as? [String: Any]) - #expect(publicationParams["features"] as? [String] == [ - "node-invoke-session-key-envelope-v1", - ]) - - try task.emitResponse( - id: #require(publication["id"] as? String), - payload: ["ok": true]) - task.emitInvokeRequest( - id: "attributed", - command: "mcp.tools.call.v1", - paramsJSON: "{}", - includeSessionKey: true, - sessionKey: "agent:main:main") - try await waitUntil("attributed envelope delivered") { - await capture.all().count == 1 - } - - try await waitUntil("receive loop ready for explicit clear") { - task.hasPendingReceiveHandler() - } - task.emitInvokeRequest( - id: "unattributed", - command: "mcp.tools.call.v1", - paramsJSON: "{}", - includeSessionKey: true, - sessionKey: nil) - try await waitUntil("explicit clear delivered") { - await capture.all().count == 2 - } - - #expect(await capture.all() == [ - .authoritative("agent:main:main"), - .authoritative(nil), - ]) - await gateway.disconnect() - } - - @Test - func `node invoke receipt timeout dispatch preserves authoritative session envelopes`() async throws { - let session = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false) - let gateway = GatewayNodeSession() - let capture = SessionKeyEnvelopeCapture() - let options = nodeConnectOptions( - caps: ["computer"], - commands: ["computer.act"], - clientId: "openclaw-macos", - clientDisplayName: "macOS Test") - - try await gateway.connectForTest( - testURL("ws://example.invalid"), - options: options, - session: session, - onInvoke: { request in - await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope) - return BridgeInvokeResponse(id: request.id, ok: true) - }) - let task = try #require(session.latestTask()) - try await waitUntil("protocol feature publication") { - task.sentRequestCount(method: "node.protocolFeatures.update") == 1 && - task.hasPendingReceiveHandler() - } - let publication = try #require(task.sentRequests(method: "node.protocolFeatures.update").first) - - try task.emitResponse( - id: #require(publication["id"] as? String), - payload: ["ok": true]) - task.emitInvokeRequest( - id: "computer-attributed", - command: "computer.act", - paramsJSON: #"{"action":"type","text":"one"}"#, - idempotencyKey: "computer.act:v1:attributed", - includeSessionKey: true, - sessionKey: "agent:main:main", - timeoutMs: 1000) - try await waitUntil("attributed computer invoke completed") { - task.sentRequestCount(method: "node.invoke.result") == 1 - } - try await waitUntil("receive loop ready for cleared computer invoke") { - task.hasPendingReceiveHandler() - } - task.emitInvokeRequest( - id: "computer-cleared", - command: "computer.act", - paramsJSON: #"{"action":"type","text":"two"}"#, - idempotencyKey: "computer.act:v1:cleared", - includeSessionKey: true, - sessionKey: nil, - timeoutMs: 1000) - try await waitUntil("cleared computer invoke completed") { - task.sentRequestCount(method: "node.invoke.result") == 2 - } - - #expect(await capture.all() == [ - .authoritative("agent:main:main"), - .authoritative(nil), - ]) - await gateway.disconnect() - } - - @Test - func `node invoke negotiation resets legacy fallback after reconnect`() async throws { - let legacySession = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false) - let currentSession = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false) - let gateway = GatewayNodeSession() - let capture = SessionKeyEnvelopeCapture() - let options = GatewayConnectOptions( - role: "node", - scopes: [], - caps: ["mcp"], - commands: ["mcp.tools.call.v1"], - permissions: [:], - clientId: "openclaw-macos", - clientMode: "node", - clientDisplayName: "macOS Test", - includeDeviceIdentity: false) - let onInvoke: @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse = { request in - await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope) - return BridgeInvokeResponse(id: request.id, ok: true) - } - - try await gateway.connect( - url: #require(URL(string: "ws://legacy.invalid")), - credentials: .init(), - connectOptions: options, - sessionBox: WebSocketSessionBox(session: legacySession), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: onInvoke) - let legacyTask = try #require(legacySession.latestTask()) - try await waitUntil("legacy negotiation completed") { - legacyTask.sentRequestCount(method: "node.protocolFeatures.update") == 1 && - legacyTask.hasPendingReceiveHandler() - } - let legacyPublication = try #require( - legacyTask.sentRequests(method: "node.protocolFeatures.update").first) - try legacyTask.emitError( - id: #require(legacyPublication["id"] as? String), - error: [ - "code": "INVALID_REQUEST", - "message": "unknown method: node.protocolFeatures.update", - ]) - legacyTask.emitInvokeRequest(id: "legacy", command: "mcp.tools.call.v1") - try await waitUntil("legacy envelope delivered") { - await capture.all().count == 1 - } - - try await gateway.connect( - url: #require(URL(string: "ws://current.invalid")), - credentials: .init(), - connectOptions: options, - sessionBox: WebSocketSessionBox(session: currentSession), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: onInvoke) - let currentTask = try #require(currentSession.latestTask()) - try await waitUntil("current negotiation completed") { - currentTask.sentRequestCount(method: "node.protocolFeatures.update") == 1 && - currentTask.hasPendingReceiveHandler() - } - let currentPublication = try #require( - currentTask.sentRequests(method: "node.protocolFeatures.update").first) - try currentTask.emitResponse( - id: #require(currentPublication["id"] as? String), - payload: ["ok": true]) - currentTask.emitInvokeRequest(id: "current", command: "mcp.tools.call.v1") - try await waitUntil("current envelope delivered") { - await capture.all().count == 2 - } - - #expect(await capture.all() == [ - .legacy, - .authoritative(nil), - ]) - await gateway.disconnect() - } - - @Test - func `node invoke negotiation fails closed on non compatibility errors`() async throws { - let session = FakeGatewayWebSocketSession(protocolFeaturesAutoResponse: false) - let gateway = GatewayNodeSession() - let capture = SessionKeyEnvelopeCapture() - let options = GatewayConnectOptions( - role: "node", - scopes: [], - caps: ["mcp"], - commands: ["mcp.tools.call.v1"], - permissions: [:], - clientId: "openclaw-macos", - clientMode: "node", - clientDisplayName: "macOS Test", - includeDeviceIdentity: false) - - try await gateway.connect( - url: #require(URL(string: "ws://example.invalid")), - credentials: .init(), - connectOptions: options, - sessionBox: WebSocketSessionBox(session: session), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { request in - await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope) - return BridgeInvokeResponse(id: request.id, ok: true) - }) - let task = try #require(session.latestTask()) - try await waitUntil("failed negotiation completed") { - task.sentRequestCount(method: "node.protocolFeatures.update") == 1 && - task.hasPendingReceiveHandler() - } - let publication = try #require(task.sentRequests(method: "node.protocolFeatures.update").first) - try task.emitError( - id: #require(publication["id"] as? String), - error: [ - "code": "UNAVAILABLE", - "message": "temporary failure", - ]) - task.emitInvokeRequest(id: "fail-closed", command: "mcp.tools.call.v1") - try await waitUntil("fail-closed envelope delivered") { - await capture.all().count == 1 - } - - #expect(await capture.all() == [.authoritative(nil)]) - await gateway.disconnect() - } - - @Test - func `node invoke received before negotiation preserves legacy envelope`() async throws { - let session = FakeGatewayWebSocketSession( - protocolFeaturesResponseDelay: .seconds(2)) - let gateway = GatewayNodeSession() - let capture = SessionKeyEnvelopeCapture() - let options = GatewayConnectOptions( - role: "node", - scopes: [], - caps: ["mcp"], - commands: ["mcp.tools.call.v1"], - permissions: [:], - clientId: "openclaw-macos", - clientMode: "node", - clientDisplayName: "macOS Test", - includeDeviceIdentity: false) - - try await gateway.connect( - url: #require(URL(string: "ws://example.invalid")), - credentials: .init(), - connectOptions: options, - sessionBox: WebSocketSessionBox(session: session), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { request in - await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope) - return BridgeInvokeResponse(id: request.id, ok: true) - }) - let task = try #require(session.latestTask()) - try await waitUntil("protocol feature publication") { - task.sentRequestCount(method: "node.protocolFeatures.update") == 1 && - task.hasPendingReceiveHandler() - } - task.emitInvokeRequest( - id: "pre-negotiation", - command: "mcp.tools.call.v1", - paramsJSON: "{}", - timeoutMs: 500) - try await waitUntil("pre-negotiation invoke result", timeoutSeconds: 1) { - task.sentRequestCount(method: "node.invoke.result") == 1 - } - - #expect(await capture.all() == [.legacy]) - let result = try #require(task.sentRequests(method: "node.invoke.result").first) - let params = try #require(result["params"] as? [String: Any]) - #expect(params["ok"] as? Bool == true) - await gateway.disconnect() - } - - @Test - func `node invoke immediately after negotiation response is authoritative`() async throws { - let session = FakeGatewayWebSocketSession( - protocolFeaturesPostResponseInvoke: true) - let gateway = GatewayNodeSession() - let capture = SessionKeyEnvelopeCapture() - let options = GatewayConnectOptions( - role: "node", - scopes: [], - caps: ["mcp"], - commands: ["mcp.tools.call.v1"], - permissions: [:], - clientId: "openclaw-macos", - clientMode: "node", - clientDisplayName: "macOS Test", - includeDeviceIdentity: false) - - try await gateway.connect( - url: #require(URL(string: "ws://example.invalid")), - credentials: .init(), - connectOptions: options, - sessionBox: WebSocketSessionBox(session: session), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { request in - await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope) - return BridgeInvokeResponse(id: request.id, ok: true) - }) - try await waitUntil("post-negotiation invoke delivered") { - await capture.all().count == 1 - } - - #expect(await capture.all() == [.authoritative(nil)]) - await gateway.disconnect() - } - - @Test - func `explicit node invoke envelope bypasses protocol negotiation`() async throws { - let session = FakeGatewayWebSocketSession( - protocolFeaturesResponseDelay: .seconds(1)) - let gateway = GatewayNodeSession() - let capture = SessionKeyEnvelopeCapture() - let options = GatewayConnectOptions( - role: "node", - scopes: [], - caps: ["mcp"], - commands: ["mcp.tools.call.v1"], - permissions: [:], - clientId: "openclaw-macos", - clientMode: "node", - clientDisplayName: "macOS Test", - includeDeviceIdentity: false) - - try await gateway.connect( - url: #require(URL(string: "ws://example.invalid")), - credentials: .init(), - connectOptions: options, - sessionBox: WebSocketSessionBox(session: session), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { request in - await capture.append(GatewayNodeInvokeContext.sessionKeyEnvelope) - return BridgeInvokeResponse(id: request.id, ok: true) - }) - let task = try #require(session.latestTask()) - try await waitUntil("protocol feature publication") { - task.sentRequestCount(method: "node.protocolFeatures.update") == 1 && - task.hasPendingReceiveHandler() - } - task.emitInvokeRequest( - id: "explicit", - command: "mcp.tools.call.v1", - paramsJSON: "{}", - includeSessionKey: true, - sessionKey: "agent:main:main", - timeoutMs: 100) - try await waitUntil("explicit invoke bypass", timeoutSeconds: 0.2) { - await capture.all().count == 1 - } - - #expect(await capture.all() == [.authoritative("agent:main:main")]) - await gateway.disconnect() - } - @Test func `node invoke result preserves structured worker payload`() async throws { let session = FakeGatewayWebSocketSession() @@ -2871,9 +2366,7 @@ struct GatewayNodeSessionTests { id: "computer-completed-replay", command: "computer.act", paramsJSON: paramsJSON, - idempotencyKey: idempotencyKey, - includeSessionKey: true, - sessionKey: nil) + idempotencyKey: idempotencyKey) try await waitUntil("completed computer receipt returned after reconnect") { replayTask.sentRequestCount(method: "node.invoke.result") == 1 } @@ -2900,73 +2393,6 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } - @Test - func `duplicate computer receipt applies its timeout without cancelling the shared invoke`() async throws { - let gateway = GatewayNodeSession() - let probe = ComputerInvokeProbe() - let paramsJSON = #"{"action":"type","text":"hello"}"# - let key = "computer.act:v1:duplicate-timeout" - let scope = "gateway:duplicate-timeout" - - let original = Task { - await gateway.invokeComputerWithReceiptForTesting( - requestId: "original", - paramsJSON: paramsJSON, - idempotencyKey: key, - receiptScope: scope, - onInvoke: { request in await probe.execute(request) }) - } - try await waitUntil("original computer invoke started") { - await probe.count() == 1 - } - - let duplicate = await gateway.invokeComputerWithReceiptForTesting( - requestId: "duplicate", - paramsJSON: paramsJSON, - idempotencyKey: key, - receiptScope: scope, - timeoutMs: 10, - onInvoke: { request in await probe.execute(request) }) - - #expect(!duplicate.ok) - #expect(duplicate.id == "duplicate") - #expect(duplicate.error?.message == "node invoke timed out") - #expect(await probe.count() == 1) - - await probe.release() - #expect(await original.value.ok) - #expect(await probe.count() == 1) - } - - @Test - func `computer receipt timeout before dispatch remains retryable`() async { - let gateway = GatewayNodeSession() - let probe = ComputerInvokeProbe() - await probe.release() - let paramsJSON = #"{"action":"type","text":"hello"}"# - let key = "computer.act:v1:pre-dispatch-timeout" - let scope = "gateway:pre-dispatch-timeout" - - let timedOut = await gateway.invokeComputerWithExpiredReceiptForTesting( - requestId: "expired", - paramsJSON: paramsJSON, - idempotencyKey: key, - receiptScope: scope, - onInvoke: { request in await probe.execute(request) }) - #expect(!timedOut.ok) - #expect(timedOut.error?.message == "node invoke timed out") - #expect(await probe.count() == 0) - - let retry = await gateway.invokeComputerWithReceiptForTesting( - requestId: "retry", - paramsJSON: paramsJSON, - idempotencyKey: key, - receiptScope: scope, - onInvoke: { request in await probe.execute(request) }) - #expect(retry.ok) - #expect(await probe.count() == 1) - } - @Test func `computer invoke receipts isolate canonically equivalent gateway owners`() async { let gateway = GatewayNodeSession() @@ -3055,99 +2481,6 @@ struct GatewayNodeSessionTests { #expect(await freshProbe.count() == 1) } - @Test - func `stale receipt retry dispatches exactly once without a deadline`() async throws { - let gateway = GatewayNodeSession() - let staleGate = AsyncGate() - let freshProbe = ComputerInvokeProbe() - let paramsJSON = #"{"action":"type","text":"hello"}"# - let key = "computer.act:v1:stale-no-deadline" - let scope = "gateway:stale-no-deadline" - let stale = Task { - await gateway.invokeComputerWithReceiptForTesting( - requestId: "stale", - paramsJSON: paramsJSON, - idempotencyKey: key, - receiptScope: scope, - onInvoke: { request in - await staleGate.wait() - return GatewayNodeSession.staleRouteInvokeResponse(requestId: request.id) - }) - } - try await waitUntil("stale receipt without deadline is in flight") { - await staleGate.hasStarted() - } - - let replay = Task { - await gateway.invokeComputerWithReceiptForTesting( - requestId: "replay", - paramsJSON: paramsJSON, - idempotencyKey: key, - receiptScope: scope, - onInvoke: { request in await freshProbe.execute(request) }) - } - try await waitUntil("no-deadline replay joined the stale receipt") { - await gateway.computerReceiptJoinCountForTesting( - idempotencyKey: key, - receiptScope: scope) == 1 - } - await staleGate.release() - try await waitUntil("no-deadline retry dispatched once") { - await freshProbe.count() == 1 - } - await freshProbe.release() - - #expect(await stale.value.ok == false) - #expect(await replay.value.ok) - #expect(await freshProbe.count() == 1) - } - - @Test - func `stale receipt retry preserves the original invoke deadline`() async throws { - let gateway = GatewayNodeSession() - let staleGate = AsyncGate() - let freshProbe = ComputerInvokeProbe() - let paramsJSON = #"{"action":"type","text":"hello"}"# - let key = "computer.act:v1:stale-deadline" - let scope = "gateway:stale-deadline" - let stale = Task { - await gateway.invokeComputerWithReceiptForTesting( - requestId: "stale", - paramsJSON: paramsJSON, - idempotencyKey: key, - receiptScope: scope, - onInvoke: { request in - await staleGate.wait() - return GatewayNodeSession.staleRouteInvokeResponse(requestId: request.id) - }) - } - try await waitUntil("stale deadline receipt is in flight") { - await staleGate.hasStarted() - } - - let replay = Task { - await gateway.invokeComputerWithReceiptForTesting( - requestId: "replay", - paramsJSON: paramsJSON, - idempotencyKey: key, - receiptScope: scope, - timeoutMs: 100, - onInvoke: { request in await freshProbe.execute(request) }) - } - try await waitUntil("deadline replay joined the stale receipt") { - await gateway.computerReceiptJoinCountForTesting( - idempotencyKey: key, - receiptScope: scope) == 1 - } - await staleGate.release() - let response = await replay.value - #expect(!response.ok) - #expect(response.error?.message == "node invoke timed out") - await freshProbe.release() - #expect(await stale.value.ok == false) - #expect(await freshProbe.count() <= 1) - } - @Test func `timed out computer receipt stays non evictable until operation settles`() async throws { let gateway = GatewayNodeSession() diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 808e36e834f6..b9871bdd4e78 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -13,7 +13,6 @@ import { validateModelsListParams, validateModelsProbeParams, validateNodePluginToolsUpdateParams, - validateNodeProtocolFeaturesUpdateParams, validateNodeSkillsUpdateParams, validateNodePresenceActivityPayload, validateSessionsListParams, @@ -292,24 +291,6 @@ describe("lazy protocol validators", () => { ]); }); - it("validates bounded transient node protocol features", () => { - expect( - validateNodeProtocolFeaturesUpdateParams({ - features: [protocol.NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE], - }), - ).toBe(true); - expect( - validateNodeProtocolFeaturesUpdateParams({ - features: ["duplicate", "duplicate"], - }), - ).toBe(false); - expect( - validateNodeProtocolFeaturesUpdateParams({ - features: ["x".repeat(129)], - }), - ).toBe(false); - }); - it("accepts selected-agent scope on chat send, history, and abort params", () => { expectAccepted(validateChatHistoryParams, [ { diff --git a/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts b/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts index a25c36068c66..07801f7ae41b 100644 --- a/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts +++ b/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts @@ -182,18 +182,14 @@ describe("native Gateway protocol levels", () => { }); it("uses the min constant for native connect compatibility ranges", async () => { - const swiftSupportPath = - "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannelSupport.swift"; - const swiftSupport = await readRepoFile(swiftSupportPath); + const swiftChannelPath = "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift"; + const swiftChannel = await readRepoFile(swiftChannelPath); assertPattern( - swiftSupport, - swiftSupportPath, + swiftChannel, + swiftChannelPath, /if role == "node", clientMode == "node" \{\s+return GATEWAY_MIN_NODE_PROTOCOL_VERSION\s+\}\s+return GATEWAY_MIN_PROTOCOL_VERSION/, "node connections must use the node compatibility floor without changing operator clients.", ); - - const swiftChannelPath = "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift"; - const swiftChannel = await readRepoFile(swiftChannelPath); assertPattern( swiftChannel, swiftChannelPath, diff --git a/packages/gateway-protocol/src/schema-export-registry.ts b/packages/gateway-protocol/src/schema-export-registry.ts index fef3df19f6e5..4e6af9e35988 100644 --- a/packages/gateway-protocol/src/schema-export-registry.ts +++ b/packages/gateway-protocol/src/schema-export-registry.ts @@ -117,8 +117,6 @@ export { NodePluginToolsUpdateParamsSchema, NodeSkillDescriptorSchema, NodeSkillsUpdateParamsSchema, - NodeProtocolFeaturesUpdateParamsSchema, - NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE, NodePendingAckParamsSchema, NodeInvokeParamsSchema, NodeInvokeInputEventSchema, diff --git a/packages/gateway-protocol/src/schema/nodes.test.ts b/packages/gateway-protocol/src/schema/nodes.test.ts index 3caa764aa91d..5735413cc91a 100644 --- a/packages/gateway-protocol/src/schema/nodes.test.ts +++ b/packages/gateway-protocol/src/schema/nodes.test.ts @@ -1,26 +1,7 @@ -import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { validateNodeInvokeProgressParams } from "../index.js"; -import { NodeInvokeRequestEventSchema } from "./nodes.js"; describe("node protocol schemas", () => { - it("accepts gateway-owned session attribution on node invoke requests", () => { - const request = { - id: "invoke-1", - nodeId: "node-1", - command: "system.run", - paramsJSON: JSON.stringify({ command: ["echo", "ok"] }), - timeoutMs: 30_000, - idempotencyKey: "request-1", - sessionKey: "agent:main:main", - }; - - expect(Value.Check(NodeInvokeRequestEventSchema, request)).toBe(true); - expect(Value.Check(NodeInvokeRequestEventSchema, { ...request, sessionKey: null })).toBe(true); - expect(Value.Check(NodeInvokeRequestEventSchema, { ...request, sessionKey: "" })).toBe(false); - expect(Value.Check(NodeInvokeRequestEventSchema, { ...request, extra: true })).toBe(false); - }); - it("accepts bounded progress chunks and rejects extra fields", () => { expect( validateNodeInvokeProgressParams({ diff --git a/packages/gateway-protocol/src/schema/nodes.ts b/packages/gateway-protocol/src/schema/nodes.ts index 928d3d6bbff5..9fcb6d7fc06b 100644 --- a/packages/gateway-protocol/src/schema/nodes.ts +++ b/packages/gateway-protocol/src/schema/nodes.ts @@ -129,19 +129,6 @@ export const NodeSkillsUpdateParamsSchema = closedObject({ export type NodeSkillDescriptor = Static; export type NodeSkillsUpdateParams = Static; -export const NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE = - "node-invoke-session-key-envelope-v1"; -const NodeProtocolFeatureSchema = Type.String({ minLength: 1, maxLength: 128 }); - -/** Replaces transient protocol features supported by this node connection. */ -export const NodeProtocolFeaturesUpdateParamsSchema = closedObject({ - features: Type.Array(NodeProtocolFeatureSchema, { maxItems: 32, uniqueItems: true }), -}); - -export type NodeProtocolFeaturesUpdateParams = Static< - typeof NodeProtocolFeaturesUpdateParamsSchema ->; - /** Acknowledges queued node work that the node has consumed. */ export const NodePendingAckParamsSchema = closedObject({ ids: Type.Array(NonEmptyString, { minItems: 1 }), @@ -245,8 +232,6 @@ export const NodeInvokeRequestEventSchema = closedObject({ paramsJSON: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), idempotencyKey: Type.Optional(NonEmptyString), - // Presence marks Gateway-owned attribution; null means intentionally unattributed. - sessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), }); /** Ordered input frame sent by the gateway to one long-lived node invoke. */ diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-nodes.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-nodes.ts index baaa32e43842..733d23454fad 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-nodes.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-nodes.ts @@ -13,7 +13,6 @@ export const NodeProtocolSchemas = { NodePluginToolsUpdateParams: nodes.NodePluginToolsUpdateParamsSchema, NodeSkillDescriptor: nodes.NodeSkillDescriptorSchema, NodeSkillsUpdateParams: nodes.NodeSkillsUpdateParamsSchema, - NodeProtocolFeaturesUpdateParams: nodes.NodeProtocolFeaturesUpdateParamsSchema, NodePendingAckParams: nodes.NodePendingAckParamsSchema, NodeDescribeParams: nodes.NodeDescribeParamsSchema, ...nodeInvoke.NodeInvokeProtocolSchemas, diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index e19cecb35622..f2f382368d13 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -140,9 +140,6 @@ export const validateNodeRenameParams = compile(S.NodeRenameParamsSchema); export const validateNodeListParams = compile(S.NodeListParamsSchema); export const validateNodePluginToolsUpdateParams = compile(S.NodePluginToolsUpdateParamsSchema); export const validateNodeSkillsUpdateParams = compile(S.NodeSkillsUpdateParamsSchema); -export const validateNodeProtocolFeaturesUpdateParams = compile( - S.NodeProtocolFeaturesUpdateParamsSchema, -); export const validateEnvironmentsCreateParams = compile(S.EnvironmentsCreateParamsSchema); export const validateEnvironmentsDestroyParams = compile(S.EnvironmentsDestroyParamsSchema); export const validateEnvironmentsListParams = compile(S.EnvironmentsListParamsSchema); diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index a648b4def5a3..341a28e6c365 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -452,17 +452,14 @@ function emitStructCustomCodable( props: Record, required: Set, ): string { - const preservesExplicitNull = (key: string) => - (name === "AgentsUpdateParams" && key === "model") || - (name === "NodeInvokeRequestEvent" && key === "sessionKey"); - if (!Object.keys(props).some(preservesExplicitNull)) { + if (name !== "AgentsUpdateParams" || !props.model) { return ""; } const decodedProperties = Object.entries(props).map(([key, propSchema]) => { const propName = swiftStoredPropertyName(name, key); - if (preservesExplicitNull(key)) { + if (key === "model") { // decodeIfPresent collapses an explicit JSON null into nil. Presence-aware decoding - // preserves Gateway distinctions between clearing and omitting nullable fields. + // preserves the Gateway patch distinction between clearing and omitting the model. return ` self.${propName} = container.contains(.${propName})\n ? try container.decode(AnyCodable.self, forKey: .${propName})\n : nil`; } if (required.has(key)) { diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index 0bf7bd3b00a3..a9b3133b6ada 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -384,7 +384,6 @@ export function buildNodeSystemRunInvoke(params: { // the node program timer. Without this the Gateway falls back to a fixed 30s // pending-invoke timer and discards a later node result as `ignored`. timeoutMs: params.target.invokeDeadlineMs, - sessionKey: params.sessionKey, params: { command: params.command, rawCommand: params.rawCommand, @@ -467,7 +466,6 @@ export async function prepareNodeSystemRun(params: { { nodeId: params.target.nodeId, command: "system.run.prepare", - sessionKey: params.request.sessionKey, params: { command: params.target.argv, rawCommand: params.request.command, @@ -484,26 +482,14 @@ export async function prepareNodeSystemRun(params: { if (!prepared) { throw new Error("invalid system.run.prepare response"); } - const { - agentId: _preparedAgentId, - sessionKey: _preparedSessionKey, - ...preparedPlan - } = prepared.plan; - // The node may normalize execution details, but it cannot redefine the - // Gateway-owned agent or session that requested preparation. - const plan: SystemRunApprovalPlan = { - ...preparedPlan, - agentId: params.request.agentId ?? null, - sessionKey: params.request.sessionKey ?? null, - }; return { - plan, - argv: plan.argv, - rawCommand: plan.commandText, - transportRawCommand: plan.commandText, - cwd: plan.cwd ?? params.request.workdir, - agentId: params.request.agentId, - sessionKey: params.request.sessionKey, + plan: prepared.plan, + argv: prepared.plan.argv, + rawCommand: prepared.plan.commandText, + transportRawCommand: prepared.plan.commandText, + cwd: prepared.plan.cwd ?? params.request.workdir, + agentId: prepared.plan.agentId ?? params.request.agentId, + sessionKey: prepared.plan.sessionKey ?? params.request.sessionKey, ...(prepared.execPolicy ? { execPolicy: prepared.execPolicy } : {}), allowAlwaysCoverage: prepared.allowAlwaysCoverage, }; diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 37c5f9e94b3b..a8a75567a54d 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -95,11 +95,6 @@ const preparedPlan = vi.hoisted(() => ({ sha256: "abc123", }, })); -const gatewayBoundPreparedPlan = { - ...preparedPlan, - agentId: "requested-agent", - sessionKey: "requested-session", -}; const nodeCommandMarker = vi.hoisted(() => "=node-command:test"); const exactCommandMarker = (commandText: string): string => `=command:${crypto.createHash("sha256").update(commandText).digest("hex").slice(0, 16)}`; @@ -361,7 +356,6 @@ function createNodeHostRequest( type MockNodeInvokeParams = { command?: string; timeoutMs?: number; - sessionKey?: string; params?: Record; }; @@ -1176,7 +1170,7 @@ describe("executeNodeHostCommand", () => { expect(result.details?.status).toBe("approval-pending"); expect(requireRegisteredApprovalRequest()).toMatchObject({ - systemRunPlan: gatewayBoundPreparedPlan, + systemRunPlan: preparedPlan, toolCallId: "tool-node", }); @@ -1188,15 +1182,11 @@ describe("executeNodeHostCommand", () => { expect(call.options.timeoutMs).toBe(40_000); expect(call.params?.timeoutMs).toBe(35_000); expect(call.callOptions).toEqual({ scopes: ["operator.write", "operator.approvals"] }); - expect(requireGatewayCommand("system.run.prepare").params?.sessionKey).toBe( - "requested-session", - ); - expect(call.params?.sessionKey).toBe("requested-session"); const runParams = requireRunParams(call); expect(runParams.approved).toBe(true); expect(runParams.approvalDecision).toBe("allow-once"); expect(runParams.approvalSource).toBeUndefined(); - expect(runParams.systemRunPlan).toEqual(gatewayBoundPreparedPlan); + expect(runParams.systemRunPlan).toEqual(preparedPlan); expect(runParams.timeoutMs).toBe(30_000); expect(runParams.turnSourceChannel).toBe("telegram"); expect(runParams.turnSourceTo).toBe("telegram:12345"); @@ -1205,38 +1195,6 @@ describe("executeNodeHostCommand", () => { expect(resolveExecHostApprovalContextMock).toHaveBeenCalledTimes(2); }); - it("clears node-prepared attribution when the gateway request has none", async () => { - resolveExecHostApprovalContextMock.mockReturnValue({ - approvals: { allowlist: [], file: { version: 1, agents: {} } }, - hostSecurity: "full", - hostAsk: "always", - askFallback: "deny", - }); - - const result = await executeNodeHostCommand( - createNodeHostRequest({ - agentId: undefined, - sessionKey: undefined, - }), - ); - - expect(result.details?.status).toBe("approval-pending"); - expect(requireRegisteredApprovalRequest()).toMatchObject({ - systemRunPlan: { - agentId: null, - sessionKey: null, - }, - }); - - await vi.waitFor(() => { - expect(callGatewayToolMock).toHaveBeenCalledTimes(3); - }); - expect(requireRunParams(requireGatewayCall(2)).systemRunPlan).toMatchObject({ - agentId: null, - sessionKey: null, - }); - }); - it("forwards cancellation without removing detached node approval scopes", async () => { const abortController = new AbortController(); resolveExecHostApprovalContextMock.mockReturnValue({ @@ -2003,8 +1961,8 @@ describe("executeNodeHostCommand", () => { command: "rm -rf /tmp/work", argv: ["rm", "-rf", "/tmp/work"], agent: { - id: "requested-agent", - sessionKey: "requested-session", + id: "prepared-agent", + sessionKey: "prepared-session", }, }), ); @@ -2097,7 +2055,7 @@ describe("executeNodeHostCommand", () => { expect(autoReviewer).not.toHaveBeenCalled(); expect(createAndRegisterDefaultExecApprovalRequestMock).not.toHaveBeenCalled(); expect(resolveExecApprovalsFromFileMock).toHaveBeenCalledWith( - expect.objectContaining({ agentId: "requested-agent" }), + expect.objectContaining({ agentId: "prepared-agent" }), ); expectSystemRunInvoke({ invokeDeadlineMs: 35_000, invokeWaitMs: 40_000, runTimeoutMs: 30_000 }); }); @@ -3708,12 +3666,7 @@ describe("executeNodeHostCommand", () => { FOO: "bar", }); expect(requireGatewayCommand("system.run.prepare").params?.params?.cwd).toBe("/tmp/work"); - expect(requireGatewayCommand("system.run.prepare").params?.sessionKey).toBe( - "requested-session", - ); - const runCall = requireGatewayCommand("system.run"); - expect(runCall.params?.sessionKey).toBe("requested-session"); - const runParams = requireRunParams(runCall); + const runParams = requireRunParams(requireGatewayCommand("system.run")); expect(runParams.env).toEqual({ FOO: "bar" }); expect(runParams.cwd).toBe("/tmp/work"); const evalEnvs = evaluateShellAllowlistMock.mock.calls.map( @@ -3734,7 +3687,6 @@ describe("executeNodeHostCommand", () => { const call = requireGatewayCall(0); expect(call.options.timeoutMs).toBe(40_000); expect(call.params?.timeoutMs).toBe(35_000); - expect(call.params?.sessionKey).toBe("requested-session"); const runParams = requireRunParams(call); expect(runParams.command).toEqual(["/bin/sh", "-lc", "bun ./script.ts"]); expect(runParams.rawCommand).toBe("bun ./script.ts"); diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 88c303fcc659..821ef1b508db 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -1681,10 +1681,7 @@ describe("exec approvals", () => { expect(params.approved).toBeUndefined(); expect(params.approvalDecision).toBeUndefined(); expect(params.approvalSource).toBe("ask-fallback"); - expect(params.systemRunPlan).toStrictEqual({ - ...preparedPlan, - agentId: "main", - }); + expect(params.systemRunPlan).toStrictEqual(preparedPlan); expect(params.runId).toBeTypeOf("string"); }); diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 1ec0eef2643a..c12e4193a734 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -74,7 +74,6 @@ const CURRENT_TRAIN_METHODS = [ "cron.scratch.get", "cron.scratch.set", "memory.search", - "node.protocolFeatures.update", "skills.proposals.evaluate", "skills.proposals.events.list", "hooks.status", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 233ebf9c5bec..4a835d87335f 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -285,7 +285,6 @@ const CORE_GATEWAY_METHOD_SPECS = [ ["node.describe", "nodes", "operator.read", "<=2026.7"], ["node.pluginSurface.refresh", "nodes", "node", "<=2026.7"], ["node.pluginTools.update", "nodes", "node", "<=2026.7"], - ["node.protocolFeatures.update", "nodes", "node", "2026.7"], ["node.skills.update", "nodes", "node", "<=2026.7"], ["node.pending.drain", "nodes-pending", "node", "<=2026.7"], ["node.pending.enqueue", "nodes-pending", "operator.write", "<=2026.7"], diff --git a/src/gateway/node-agent-cli-runtime.test.ts b/src/gateway/node-agent-cli-runtime.test.ts index dc82fcd768c0..71969c05328d 100644 --- a/src/gateway/node-agent-cli-runtime.test.ts +++ b/src/gateway/node-agent-cli-runtime.test.ts @@ -89,28 +89,4 @@ describe("invokeNodeClaudeCliRun", () => { }), ); }); - - it("forwards admitted session attribution to the envelope and legacy command params", async () => { - mocks.isNodeCommandAllowed.mockReturnValue({ ok: true }); - mocks.invoke.mockResolvedValue({ ok: true }); - - await invokeNodeClaudeCliRun({ - nodeId: "node-1", - argv: ["-p"], - stdin: "hello", - sessionKey: "agent:main:claude", - timeoutMs: 10_000, - idleTimeoutMs: 1_000, - onProgress: () => {}, - }); - - expect(mocks.invoke).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: "agent:main:claude", - params: expect.objectContaining({ - sessionKey: "agent:main:claude", - }), - }), - ); - }); }); diff --git a/src/gateway/node-agent-cli-runtime.ts b/src/gateway/node-agent-cli-runtime.ts index d058564ae23d..104dcfc06405 100644 --- a/src/gateway/node-agent-cli-runtime.ts +++ b/src/gateway/node-agent-cli-runtime.ts @@ -63,7 +63,6 @@ export async function invokeNodeClaudeCliRun(params: { expectedConnId: node.connId, ...(node.pairingGeneration ? { expectedPairingGeneration: node.pairingGeneration } : {}), command: NODE_AGENT_CLI_CLAUDE_RUN_COMMAND, - ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), params: { argv: params.argv, stdin: params.stdin, diff --git a/src/gateway/node-invoke-plugin-policy.test.ts b/src/gateway/node-invoke-plugin-policy.test.ts index 7ce8de93e803..dd21530f77ed 100644 --- a/src/gateway/node-invoke-plugin-policy.test.ts +++ b/src/gateway/node-invoke-plugin-policy.test.ts @@ -274,32 +274,6 @@ describe("applyPluginNodeInvokePolicy", () => { }); }); - it("keeps gateway session attribution authoritative over nested policy params", async () => { - setDangerousDemoCommandRegistry([ - createDemoPolicy((ctx: OpenClawPluginNodeInvokePolicyContext) => ctx.invokeNode()), - ]); - const { context, invoke } = createContext(); - const sessionKey = "agent:main:main"; - const forgedParams = { ...DEMO_PARAMS, sessionKey: "agent:attacker:forged" }; - - const result = await applyPluginNodeInvokePolicy({ - context, - client: null, - nodeSession: createNodeSession(), - command: DEMO_COMMAND, - params: forgedParams, - sessionKey, - }); - - expect(result).toMatchObject({ ok: true }); - expect(invoke).toHaveBeenCalledWith( - expect.objectContaining({ - params: forgedParams, - sessionKey, - }), - ); - }); - it.each([5_000, 0])( "bounds plugin timeout override %i by the remaining invocation deadline", async (overrideTimeoutMs) => { diff --git a/src/gateway/node-invoke-plugin-policy.ts b/src/gateway/node-invoke-plugin-policy.ts index 91a67352da0b..260bf787c25e 100644 --- a/src/gateway/node-invoke-plugin-policy.ts +++ b/src/gateway/node-invoke-plugin-policy.ts @@ -182,7 +182,6 @@ export async function applyPluginNodeInvokePolicy(params: { nodeSession: NodeSession; command: string; params: unknown; - sessionKey?: string; turnSource?: { channel?: unknown; to?: unknown; @@ -294,7 +293,6 @@ export async function applyPluginNodeInvokePolicy(params: { timeoutMs, ...(params.signal ? { signal: params.signal } : {}), idempotencyKey: override.idempotencyKey ?? params.idempotencyKey, - ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), onDispatchReady: () => { // Only the registry knows that the transport send succeeded. Preserve // pre-send failures as retry-safe while making later failures ambiguous. diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index 4488e72912f9..75e290df6944 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -9,7 +9,6 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { GATEWAY_CLIENT_IDS } from "../../packages/gateway-protocol/src/client-info.js"; -import { NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE } from "../../packages/gateway-protocol/src/schema/nodes.js"; import { getCurrentActiveNodeContext, setActiveNodeContext } from "../infra/active-node-context.js"; import { onDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; @@ -1294,178 +1293,6 @@ describe("gateway/node-registry", () => { await expect(invoke).resolves.toMatchObject({ ok: true }); }); - it("binds gateway-owned attribution into params for legacy nodes", async () => { - const registry = createNodeRegistry(); - const frames = registerNode(registry); - const invoke = registry.invoke({ - nodeId: "node-1", - command: "debug.ping", - params: { - value: "ok", - sessionKey: "agent:attacker:forged", - }, - sessionKey: "agent:main:main", - timeoutMs: 0, - }); - const request = JSON.parse(frames[0] ?? "{}") as { - payload?: { id?: string; paramsJSON?: string | null; sessionKey?: string | null }; - }; - - expect(request.payload?.sessionKey).toBe("agent:main:main"); - expect(JSON.parse(request.payload?.paramsJSON ?? "{}")).toEqual({ - value: "ok", - sessionKey: "agent:main:main", - }); - expect( - registry.handleInvokeResult({ - id: request.payload?.id ?? "", - nodeId: "node-1", - connId: "conn-1", - ok: true, - }), - ).toBe(true); - await expect(invoke).resolves.toMatchObject({ ok: true }); - }); - - it("keeps non-empty attribution for legacy commands without an object params carrier", async () => { - const registry = createNodeRegistry(); - const frames = registerNode(registry); - const invoke = registry.invoke({ - nodeId: "node-1", - command: "debug.ping", - params: "ping", - sessionKey: "agent:main:main", - timeoutMs: 0, - }); - const request = JSON.parse(frames[0] ?? "{}") as { - payload?: { id?: string; paramsJSON?: string | null; sessionKey?: string | null }; - }; - - expect(request.payload?.sessionKey).toBe("agent:main:main"); - expect(JSON.parse(request.payload?.paramsJSON ?? "null")).toBe("ping"); - expect( - registry.handleInvokeResult({ - id: request.payload?.id ?? "", - nodeId: "node-1", - connId: "conn-1", - ok: true, - }), - ).toBe(true); - await expect(invoke).resolves.toMatchObject({ ok: true }); - }); - - it("clears nested attribution for unattributed legacy node invokes", async () => { - const registry = createNodeRegistry(); - const frames = registerNode(registry); - const invoke = registry.invoke({ - nodeId: "node-1", - command: "debug.ping", - params: { - value: "ok", - sessionKey: "agent:attacker:forged", - }, - timeoutMs: 0, - }); - const request = JSON.parse(frames[0] ?? "{}") as { - payload?: { id?: string; paramsJSON?: string | null; sessionKey?: string | null }; - }; - - expect(request.payload).not.toHaveProperty("sessionKey"); - expect(JSON.parse(request.payload?.paramsJSON ?? "{}")).toEqual({ value: "ok" }); - expect( - registry.handleInvokeResult({ - id: request.payload?.id ?? "", - nodeId: "node-1", - connId: "conn-1", - ok: true, - }), - ).toBe(true); - await expect(invoke).resolves.toMatchObject({ ok: true }); - }); - - it("omits an unattributed session envelope until the node negotiates explicit clears", async () => { - const registry = createNodeRegistry(); - const frames = registerNode(registry); - const invoke = registry.invoke({ - nodeId: "node-1", - command: "debug.ping", - timeoutMs: 0, - }); - const request = JSON.parse(frames[0] ?? "{}") as { - payload?: { id?: string; sessionKey?: string | null }; - }; - - expect(request.payload).not.toHaveProperty("sessionKey"); - expect( - registry.handleInvokeResult({ - id: request.payload?.id ?? "", - nodeId: "node-1", - connId: "conn-1", - ok: true, - }), - ).toBe(true); - await expect(invoke).resolves.toMatchObject({ ok: true }); - }); - - it("emits explicit null for negotiated unattributed node invokes", async () => { - const registry = createNodeRegistry(); - const frames = registerNode(registry); - registry.updateProtocolFeatures("node-1", "conn-1", [ - NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE, - ]); - const invoke = registry.invoke({ - nodeId: "node-1", - command: "debug.ping", - timeoutMs: 0, - }); - const request = JSON.parse(frames[0] ?? "{}") as { - payload?: { id?: string; sessionKey?: string | null }; - }; - - expect(request.payload?.sessionKey).toBeNull(); - expect( - registry.handleInvokeResult({ - id: request.payload?.id ?? "", - nodeId: "node-1", - connId: "conn-1", - ok: true, - }), - ).toBe(true); - await expect(invoke).resolves.toMatchObject({ ok: true }); - }); - - it("does not let a stale connection negotiate features for its replacement", async () => { - const registry = createNodeRegistry(); - registerNodeSession(registry, makeClient("conn-old", "node-1"), {}); - const frames: string[] = []; - registerNodeSession(registry, makeClient("conn-new", "node-1", frames), {}); - - expect( - registry.updateProtocolFeatures("node-1", "conn-old", [ - NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE, - ]), - ).toBeNull(); - const invoke = registry.invoke({ - nodeId: "node-1", - command: "debug.ping", - timeoutMs: 0, - }); - const request = JSON.parse(frames[0] ?? "{}") as { - payload?: { id?: string; sessionKey?: string | null }; - }; - - expect(request.payload).not.toHaveProperty("sessionKey"); - expect( - registry.handleInvokeResult({ - id: request.payload?.id ?? "", - nodeId: "node-1", - connId: "conn-new", - ok: true, - }), - ).toBe(true); - await expect(invoke).resolves.toMatchObject({ ok: true }); - }); - it("returns a structured result when a zero-timeout invoke disconnects", async () => { const registry = createNodeRegistry(); registerNode(registry); diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index eae270e5f8d5..417614ccd132 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -15,7 +15,6 @@ import type { NodePluginToolDescriptor, NodeSkillDescriptor, } from "../../packages/gateway-protocol/src/schema/nodes.js"; -import { NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE } from "../../packages/gateway-protocol/src/schema/nodes.js"; import { setActiveNodeContext } from "../infra/active-node-context.js"; import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js"; import type { NodePairingBinding } from "../infra/node-pairing-state.js"; @@ -144,30 +143,6 @@ function normalizeSystemRunInvokeParams(params: { command: string; params?: unkn return normalized; } -/** Bind legacy nested attribution to the Gateway-owned session before dispatch. */ -function bindNodeInvokeSessionKey(params: unknown, sessionKey: string | undefined): unknown { - if (!params || typeof params !== "object" || Array.isArray(params)) { - return params; - } - const bound = { ...(params as Record) }; - if (sessionKey) { - bound.sessionKey = sessionKey; - } else { - delete bound.sessionKey; - } - if ( - bound.systemRunPlan && - typeof bound.systemRunPlan === "object" && - !Array.isArray(bound.systemRunPlan) - ) { - bound.systemRunPlan = { - ...(bound.systemRunPlan as Record), - sessionKey: sessionKey ?? null, - }; - } - return bound; -} - /** Result payload returned from node.invoke. */ export type NodeInvokeResult = { ok: boolean; @@ -277,7 +252,6 @@ export class NodeRegistry { private nodesById = new Map(); private nodesByConn = new Map(); private eventTransportsByConn = new Map(); - private protocolFeaturesByConn = new Map>(); private pendingInvokes = new Map(); private invokeStreams = new NodeInvokeStreamController({ pendingInvokes: this.pendingInvokes, @@ -547,7 +521,6 @@ export class NodeRegistry { const replacesPresence = previousSession?.lastActiveAtMs !== undefined; this.nodesById.set(nodeId, session); this.nodesByConn.set(client.connId, nodeId); - this.protocolFeaturesByConn.set(client.connId, new Set()); if (previousSession && previousSession.connId !== client.connId) { // Install the replacement first so retiring its old invokes cannot // remove the new session or publish a false offline transition. @@ -591,7 +564,6 @@ export class NodeRegistry { } this.nodesByConn.delete(connId); this.eventTransportsByConn.delete(connId); - this.protocolFeaturesByConn.delete(connId); const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId; if (unregistersCurrentNode) { const hadPresence = this.nodesById.get(nodeId)?.lastActiveAtMs !== undefined; @@ -987,19 +959,6 @@ export class NodeRegistry { }); return node; } - - updateProtocolFeatures( - nodeId: string, - connId: string | undefined, - features: readonly string[], - ): NodeSession | null { - const node = this.nodesById.get(nodeId); - if (!node || node.connId !== connId) { - return null; - } - this.protocolFeaturesByConn.set(node.connId, new Set(features)); - return node; - } updateSurface( nodeId: string, surface: { @@ -1180,19 +1139,12 @@ export class NodeRegistry { } } const requestId = randomUUID(); - const sessionKey = normalizeString(params.sessionKey) || undefined; - const invokeParams = bindNodeInvokeSessionKey( - normalizeSystemRunInvokeParams({ - command: params.command, - params: params.params, - }), - sessionKey, - ); + const invokeParams = normalizeSystemRunInvokeParams({ + command: params.command, + params: params.params, + }); // Keep node and Gateway on the same timer-safe value; zero disables both deadlines. const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 30_000, 0); - const supportsSessionKeyEnvelope = this.protocolFeaturesByConn - .get(node.connId) - ?.has(NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE); const payload = { id: requestId, nodeId: params.nodeId, @@ -1201,13 +1153,7 @@ export class NodeRegistry { "params" in params && invokeParams !== undefined ? JSON.stringify(invokeParams) : null, timeoutMs, idempotencyKey: params.idempotencyKey, - // Object params carry a canonical nested binding for legacy nodes. Keep the - // additive non-empty envelope fallback for commands without an object carrier. - ...(supportsSessionKeyEnvelope - ? { sessionKey: sessionKey ?? null } - : sessionKey - ? { sessionKey } - : {}), + sessionKey: normalizeString(params.sessionKey) || undefined, }; const systemRunEvent = resolvePendingSystemRunEvent({ command: params.command, diff --git a/src/gateway/server-methods/nodes.invoke-abort.test.ts b/src/gateway/server-methods/nodes.invoke-abort.test.ts index 5a893b9eb3f2..c85472a3e4b5 100644 --- a/src/gateway/server-methods/nodes.invoke-abort.test.ts +++ b/src/gateway/server-methods/nodes.invoke-abort.test.ts @@ -57,11 +57,7 @@ afterEach(() => { resetNodeWakeStateForTest(); }); -function startNodeInvoke(options: { - invoke: ReturnType; - signal?: AbortSignal; - requestParams?: Record; -}) { +function startNodeInvoke(options: { invoke: ReturnType; signal?: AbortSignal }) { const respond = vi.fn(); const handler = nodeInvokeHandlers["node.invoke"]; if (!handler) { @@ -75,7 +71,6 @@ function startNodeInvoke(options: { params: { model: "node-local:small", prompt: "answer locally" }, timeoutMs: 10_000, idempotencyKey: "paired-inference-idempotency-key", - ...options.requestParams, }, client: null, isWebchatConnect: () => false, @@ -95,28 +90,6 @@ function startNodeInvoke(options: { } describe("node.invoke caller cancellation", () => { - it("forwards the normalized gateway session key through plugin policy", async () => { - const invoke = vi.fn(async () => ({ ok: true, payload: { response: "node-only inference" } })); - const sessionKey = "agent:main:main"; - const { invocation } = startNodeInvoke({ - invoke, - requestParams: { - sessionKey, - params: { - model: "node-local:small", - prompt: "answer locally", - sessionKey: "agent:attacker:forged", - }, - }, - }); - - await invocation; - - expect(mocks.applyPluginNodeInvokePolicy).toHaveBeenCalledWith( - expect.objectContaining({ sessionKey }), - ); - }); - it("cancels paired-node work without breaking pairing lifecycle identity", async () => { const controller = new AbortController(); const invoke = vi.fn( diff --git a/src/gateway/server-methods/nodes.invoke.ts b/src/gateway/server-methods/nodes.invoke.ts index 1e514a533bff..8277db2a7814 100644 --- a/src/gateway/server-methods/nodes.invoke.ts +++ b/src/gateway/server-methods/nodes.invoke.ts @@ -465,7 +465,6 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = { nodeSession, command, params: forwardedParams.params, - ...(sessionKey ? { sessionKey } : {}), turnSource: { channel: p.turnSourceChannel, to: p.turnSourceTo, diff --git a/src/gateway/server-methods/nodes.read.ts b/src/gateway/server-methods/nodes.read.ts index 535786079405..0b1ee7af73d5 100644 --- a/src/gateway/server-methods/nodes.read.ts +++ b/src/gateway/server-methods/nodes.read.ts @@ -5,7 +5,6 @@ import { validateNodeDescribeParams, validateNodeListParams, validateNodePluginToolsUpdateParams, - validateNodeProtocolFeaturesUpdateParams, validateNodeSkillsUpdateParams, } from "../../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -327,33 +326,6 @@ export const nodeReadHandlers: GatewayRequestHandlers = { } respond(true, { nodeId, tools: updated.nodePluginTools }, undefined); }, - "node.protocolFeatures.update": async ({ params, respond, client, context }) => { - if (!validateNodeProtocolFeaturesUpdateParams(params)) { - respondInvalidParams({ - respond, - method: "node.protocolFeatures.update", - validator: validateNodeProtocolFeaturesUpdateParams, - }); - return; - } - const nodeId = normalizeOptionalString( - client?.connect?.device?.id ?? client?.connect?.client?.id, - ); - if (!nodeId) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); - return; - } - const updated = context.nodeRegistry.updateProtocolFeatures( - nodeId, - client?.connId, - params.features, - ); - if (!updated) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId")); - return; - } - respond(true, { nodeId }, undefined); - }, "node.skills.update": async ({ params, respond, client, context }) => { if (!validateNodeSkillsUpdateParams(params)) { respondInvalidParams({ diff --git a/src/gateway/server-methods/nodes.test.ts b/src/gateway/server-methods/nodes.test.ts index afba136fbf85..6ed3fcbb0d46 100644 --- a/src/gateway/server-methods/nodes.test.ts +++ b/src/gateway/server-methods/nodes.test.ts @@ -126,7 +126,6 @@ function createContext() { getActiveNode: vi.fn(), updateSurface: vi.fn(), updateNodeSkills: vi.fn(), - updateProtocolFeatures: vi.fn(), }, }; } @@ -197,34 +196,6 @@ describe("nodeHandlers node.skills.update", () => { }); }); -describe("nodeHandlers node.protocolFeatures.update", () => { - it("stores transient features on the calling node connection", async () => { - const features = ["node-invoke-session-key-envelope-v1"]; - const { context, opts } = createOptions( - { features }, - { - client: { - connId: "conn-1", - connect: { device: { id: "node-1" }, client: { id: "node-client" } }, - } as never, - }, - ); - context.nodeRegistry.updateProtocolFeatures.mockReturnValue({ nodeId: "node-1" }); - - await expectDefined( - nodeHandlers["node.protocolFeatures.update"], - 'nodeHandlers["node.protocolFeatures.update"] test invariant', - )(opts); - - expect(context.nodeRegistry.updateProtocolFeatures).toHaveBeenCalledWith( - "node-1", - "conn-1", - features, - ); - expect(opts.respond).toHaveBeenCalledWith(true, { nodeId: "node-1" }, undefined); - }); -}); - async function pairAndroidNodeDevice(stateDir: string, nodeId: string): Promise { const pending = await requestDevicePairing( { diff --git a/src/node-host/invoke-agent-cli-claude-handler.ts b/src/node-host/invoke-agent-cli-claude-handler.ts index 82696fc8d32a..e51a71ad7301 100644 --- a/src/node-host/invoke-agent-cli-claude-handler.ts +++ b/src/node-host/invoke-agent-cli-claude-handler.ts @@ -122,16 +122,6 @@ export async function handleClaudeCliNodeInvoke(params: { await params.deps.sendInvalidRequestResult(params.client, params.frame, error); return; } - if (Object.hasOwn(params.frame, "sessionKey")) { - const sessionKey = params.frame.sessionKey ?? null; - const requestWithoutSessionKey = { ...request }; - delete requestWithoutSessionKey.sessionKey; - request = { - ...requestWithoutSessionKey, - ...(sessionKey ? { sessionKey } : {}), - ...(request.systemRunPlan ? { systemRunPlan: { ...request.systemRunPlan, sessionKey } } : {}), - }; - } const approvalCommand = [claudePath, ...request.argv]; const preparedApproval = buildSystemRunApprovalPlan({ command: approvalCommand, diff --git a/src/node-host/invoke-agent-cli-claude.test.ts b/src/node-host/invoke-agent-cli-claude.test.ts index 77eb3d3e70eb..545e99c23ab3 100644 --- a/src/node-host/invoke-agent-cli-claude.test.ts +++ b/src/node-host/invoke-agent-cli-claude.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { NodeHostClient } from "./client.js"; -import type { NodeHostInvokeRuntime } from "./invoke-agent-cli-claude-handler.js"; import { decodeClaudeCliNodeRunParams } from "./invoke-agent-cli-claude-params.js"; import { runClaudeCliNodeCommand } from "./invoke-agent-cli-claude.js"; import { handleInvoke, type NodeInvokeRequestPayload } from "./invoke.js"; @@ -227,46 +226,6 @@ describe("Claude CLI node command", () => { }); }); - it("clears nested Claude run attribution from an explicit Gateway envelope", async () => { - const executable = await executableScript("process.exit(0);"); - const calls: Array<{ method: string; params: unknown }> = []; - const handleSystemRun = vi.fn( - async (_options: Parameters>[0]) => - undefined, - ); - const invokeFrame = frame({ - argv: ["-p"], - sessionKey: "agent:forged:request", - systemRunPlan: { - argv: [executable, "-p"], - cwd: null, - commandText: `${executable} -p`, - agentId: null, - sessionKey: "agent:forged:plan", - }, - idleTimeoutMs: 1_000, - timeoutMs: 2_000, - }); - invokeFrame.sessionKey = null; - - await handleInvoke(invokeFrame, client(calls), { current: async () => [] }, undefined, { - claudePath: executable, - handleSystemRun: handleSystemRun as never, - }); - - expect(handleSystemRun).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - systemRunPlan: expect.objectContaining({ sessionKey: null }), - }), - }), - ); - const runParams = handleSystemRun.mock.calls[0]?.[0]?.params as - | { sessionKey?: string } - | undefined; - expect(runParams).not.toHaveProperty("sessionKey"); - }); - it("converts forwarded OAuth into a child-only descriptor after approval", async () => { const executable = await executableScript(` const fs = require("node:fs"); diff --git a/src/node-host/invoke-payload.test.ts b/src/node-host/invoke-payload.test.ts index 0fea2f948ffb..975eb45a8ce1 100644 --- a/src/node-host/invoke-payload.test.ts +++ b/src/node-host/invoke-payload.test.ts @@ -1,48 +1,5 @@ import { describe, expect, it } from "vitest"; -import { coerceNodeInvokeInputPayload, coerceNodeInvokePayload } from "./invoke-payload.js"; - -describe("coerceNodeInvokePayload", () => { - it("preserves normalized gateway-owned session attribution", () => { - expect( - coerceNodeInvokePayload({ - id: "invoke-1", - nodeId: "node-1", - command: "system.run", - sessionKey: " agent:main:main ", - }), - ).toEqual({ - id: "invoke-1", - nodeId: "node-1", - command: "system.run", - paramsJSON: null, - timeoutMs: null, - idempotencyKey: null, - sessionKey: "agent:main:main", - }); - }); - - it("distinguishes a missing legacy envelope from an explicit clear", () => { - expect( - coerceNodeInvokePayload({ id: "i", nodeId: "n", command: "system.run" }), - ).not.toHaveProperty("sessionKey"); - expect( - coerceNodeInvokePayload({ - id: "i", - nodeId: "n", - command: "system.run", - sessionKey: null, - }), - ).toMatchObject({ sessionKey: null }); - expect( - coerceNodeInvokePayload({ - id: "i", - nodeId: "n", - command: "system.run", - sessionKey: " ", - }), - ).toMatchObject({ sessionKey: null }); - }); -}); +import { coerceNodeInvokeInputPayload } from "./invoke-payload.js"; describe("coerceNodeInvokeInputPayload", () => { it("accepts a bounded well-formed input payload", () => { diff --git a/src/node-host/invoke-payload.ts b/src/node-host/invoke-payload.ts index 196bbcde58c2..e9bdcaadd1cd 100644 --- a/src/node-host/invoke-payload.ts +++ b/src/node-host/invoke-payload.ts @@ -21,7 +21,6 @@ export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayl : null; const timeoutMs = typeof obj.timeoutMs === "number" ? obj.timeoutMs : null; const idempotencyKey = typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null; - const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() || null : null; return { id, nodeId, @@ -29,7 +28,6 @@ export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayl paramsJSON, timeoutMs, idempotencyKey, - ...(Object.hasOwn(obj, "sessionKey") ? { sessionKey } : {}), }; } diff --git a/src/node-host/invoke.test.ts b/src/node-host/invoke.test.ts index 94a5daa1fe17..27629c79f749 100644 --- a/src/node-host/invoke.test.ts +++ b/src/node-host/invoke.test.ts @@ -912,57 +912,4 @@ describe("node host invoke", () => { allowlistRules: [], }); }); - - it("clears nested prepare correlation when the Gateway envelope is unattributed", async () => { - const request = vi.fn().mockResolvedValue(null); - - await handleInvoke( - { - id: "invoke-unattributed-prepare", - nodeId: "node-1", - command: "system.run.prepare", - sessionKey: null, - paramsJSON: JSON.stringify({ - command: ["echo", "ok"], - sessionKey: "agent:forged:prepare", - }), - }, - { request } as unknown as GatewayClient, - { current: async () => [] }, - ); - - const result = request.mock.calls.find(([method]) => method === "node.invoke.result")?.[1] as { - payloadJSON?: string; - }; - const payload = JSON.parse(result.payloadJSON ?? "{}") as { - plan?: { sessionKey?: string | null }; - }; - expect(payload.plan?.sessionKey).toBeNull(); - }); - - it("preserves nested prepare correlation from a legacy Gateway", async () => { - const request = vi.fn().mockResolvedValue(null); - - await handleInvoke( - { - id: "invoke-legacy-prepare", - nodeId: "node-1", - command: "system.run.prepare", - paramsJSON: JSON.stringify({ - command: ["echo", "ok"], - sessionKey: "agent:legacy:prepare", - }), - }, - { request } as unknown as GatewayClient, - { current: async () => [] }, - ); - - const result = request.mock.calls.find(([method]) => method === "node.invoke.result")?.[1] as { - payloadJSON?: string; - }; - const payload = JSON.parse(result.payloadJSON ?? "{}") as { - plan?: { sessionKey?: string | null }; - }; - expect(payload.plan?.sessionKey).toBe("agent:legacy:prepare"); - }); }); diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index 896d9cbd6023..7b897dcd0624 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -133,29 +133,6 @@ function resolveNodeSkillCwdParam(params: T, nodeId return resolved ? { ...params, cwd: resolved } : params; } -function bindNodeInvokeSessionKey< - T extends { - sessionKey?: unknown; - systemRunPlan?: SystemRunParams["systemRunPlan"]; - }, ->(params: T, frame: NodeInvokeRequestPayload): T { - if (!Object.hasOwn(frame, "sessionKey")) { - return params; - } - const sessionKey = frame.sessionKey ?? null; - // The Gateway envelope owns run correlation. Nested command params are - // caller-controlled and must not mint or retain a different session binding. - const systemRunPlan = - params.systemRunPlan === undefined || params.systemRunPlan === null - ? params.systemRunPlan - : { ...params.systemRunPlan, sessionKey: sessionKey ?? null }; - return { - ...params, - sessionKey, - ...(systemRunPlan !== undefined ? { systemRunPlan } : {}), - }; -} - function buildEnvOverrideRejectionMessage(params: { rejectedOverrideBlockedKeys: string[]; rejectedOverrideInvalidKeys: string[]; @@ -769,12 +746,11 @@ async function dispatchInvoke( } try { const { pluginCommandIo: io, pluginCommandContext: context } = runtime; - const hasSessionKeyEnvelope = Object.hasOwn(frame, "sessionKey"); const invokeContext = - context && (hasSessionKeyEnvelope || runtime.signal) + context && (frame.sessionKey || runtime.signal) ? { ...context, - ...(hasSessionKeyEnvelope ? { sessionKey: frame.sessionKey ?? undefined } : {}), + ...(frame.sessionKey ? { sessionKey: frame.sessionKey } : {}), ...(runtime.signal ? { signal: runtime.signal } : {}), } : context; @@ -790,12 +766,9 @@ async function dispatchInvoke( if (command === "system.run.prepare") { try { - const params = bindNodeInvokeSessionKey( - resolveNodeSkillCwdParam( - decodeParams(frame.paramsJSON), - frame.nodeId, - ), - frame, + const params = resolveNodeSkillCwdParam( + decodeParams(frame.paramsJSON), + frame.nodeId, ); const prepared = buildSystemRunApprovalPlan(params); if (!prepared.ok) { @@ -852,9 +825,9 @@ async function dispatchInvoke( let params: SystemRunParams; try { - params = bindNodeInvokeSessionKey( - resolveNodeSkillCwdParam(decodeParams(frame.paramsJSON), frame.nodeId), - frame, + params = resolveNodeSkillCwdParam( + decodeParams(frame.paramsJSON), + frame.nodeId, ); } catch (err) { await sendInvalidRequestResult(client, frame, err); diff --git a/src/node-host/runner.session-envelope.test.ts b/src/node-host/runner.session-envelope.test.ts deleted file mode 100644 index 1c94315f6927..000000000000 --- a/src/node-host/runner.session-envelope.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { GatewayClientRequestError, type GatewayClientOptions } from "../gateway/client.js"; -import type { configureNodeHost } from "./config.js"; -import type { NodeInvokeRequestPayload } from "./invoke-types.js"; -import { runNodeHost } from "./runner.js"; - -const mocks = vi.hoisted(() => ({ - capturedGatewayClientOptions: [] as GatewayClientOptions[], - capturedGatewayClients: [] as Array<{ - request: ReturnType Promise>>; - stop: ReturnType; - updateNodeManifest: ReturnType; - }>, - activeRuntime: { - invoke: vi.fn(async (_payload: NodeInvokeRequestPayload) => {}), - handleInput: vi.fn(), - cancel: vi.fn(), - cancelAll: vi.fn(), - close: vi.fn(async () => {}), - }, - configureNodeHost: vi.fn(async (params: Parameters[0]) => ({ - version: 1 as const, - nodeId: params.nodeId?.trim() || "node-test", - displayName: params.displayName?.trim() || params.fallbackDisplayName, - gateway: params.gateway, - })), - getRuntimeConfig: vi.fn(() => ({ - gateway: { handshakeTimeoutMs: 1_000 }, - })), - startGatewayClientWhenEventLoopReady: vi.fn(async () => ({ - ready: false, - aborted: false, - elapsedMs: 0, - })), -})); - -vi.mock("../config/config.js", () => ({ - getRuntimeConfig: mocks.getRuntimeConfig, -})); - -vi.mock("../gateway/client-start-readiness.js", () => ({ - startGatewayClientWhenEventLoopReady: mocks.startGatewayClientWhenEventLoopReady, -})); - -vi.mock("../gateway/client.js", () => ({ - GatewayClientRequestError: class MockGatewayClientRequestError extends Error { - readonly gatewayCode: string; - - constructor(params: { code: string; message: string }) { - super(params.message); - this.gatewayCode = params.code; - } - }, - GatewayClient: function GatewayClient(opts: GatewayClientOptions) { - const client = { - request: vi.fn<(method: string, params?: unknown) => Promise>(async () => ({})), - stop: vi.fn(), - updateNodeManifest: vi.fn(), - }; - mocks.capturedGatewayClientOptions.push(opts); - mocks.capturedGatewayClients.push(client); - return client; - }, -})); - -vi.mock("../gateway/credentials-secret-inputs.js", () => ({ - resolveGatewayCredentialsWithSecretInputs: vi.fn(async () => ({})), -})); - -vi.mock("../infra/device-identity.js", () => ({ - loadOrCreateDeviceIdentity: vi.fn(() => ({ - id: "device-test", - publicKey: "public-key-test", - privateKey: "private-key-test", - })), -})); - -vi.mock("../infra/machine-name.js", () => ({ - getMachineDisplayName: vi.fn(async () => "test-node"), -})); - -vi.mock("../infra/executable-path.js", () => ({ - resolveExecutableFromPathEnv: vi.fn(() => null), -})); - -vi.mock("../infra/path-env.js", () => ({ - ensureOpenClawCliOnPath: vi.fn(), -})); - -vi.mock("./config.js", () => ({ - configureNodeHost: mocks.configureNodeHost, -})); - -vi.mock("./plugin-node-host.js", () => ({ - ensureNodeHostPluginRegistry: vi.fn(async () => undefined), - listRegisteredNodeHostCapsAndCommands: vi.fn(() => ({ - commands: [], - caps: [], - nodePluginTools: [], - })), - watchRegisteredNodeHostCommandAvailability: vi.fn(() => () => undefined), -})); - -vi.mock("./mcp.js", () => ({ - startNodeHostMcpManager: vi.fn(async () => ({ - configuredServerCount: 0, - descriptors: [], - callMcpTool: vi.fn(), - close: vi.fn(async () => undefined), - })), -})); - -vi.mock("./skills.js", () => ({ - scanNodeHostedSkills: vi.fn(() => []), -})); - -vi.mock("./startup-state-migrations.js", () => ({ - runStartupMigrations: vi.fn(async () => undefined), -})); - -vi.mock("./runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - prepareNodeHostRuntime: async () => ({ - manifest: { caps: [], commands: [], pathEnv: process.env.PATH ?? "" }, - initialInventory: { skills: [], pluginTools: [] }, - start: () => mocks.activeRuntime, - }), - }; -}); - -function hello(options: GatewayClientOptions | undefined) { - options?.onHelloOk?.({ - protocol: 1, - features: { methods: [], events: [] }, - } as unknown as Parameters>[0]); -} - -function deferNegotiation( - client: (typeof mocks.capturedGatewayClients)[number] | undefined, -): () => void { - let resolveNegotiation: (() => void) | undefined; - client?.request.mockImplementation((method: string) => { - if (method === "node.protocolFeatures.update") { - return new Promise((resolve) => { - resolveNegotiation = () => resolve({}); - }); - } - return Promise.resolve({}); - }); - return () => resolveNegotiation?.(); -} - -async function waitForProtocolFeaturesNegotiation( - client: (typeof mocks.capturedGatewayClients)[number] | undefined, - expectedCount = 1, -): Promise { - await vi.waitFor(() => { - expect( - client?.request.mock.calls.filter(([method]) => method === "node.protocolFeatures.update"), - ).toHaveLength(expectedCount); - }); - await new Promise((resolve) => { - setImmediate(resolve); - }); -} - -async function startFakeNodeHost() { - await expect(runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 })).rejects.toThrow( - "event loop readiness timeout", - ); - return { - options: mocks.capturedGatewayClientOptions[0], - client: mocks.capturedGatewayClients[0], - }; -} - -describe("node-host session envelope negotiation", () => { - beforeEach(() => { - mocks.capturedGatewayClientOptions.length = 0; - mocks.capturedGatewayClients.length = 0; - vi.clearAllMocks(); - mocks.getRuntimeConfig.mockReturnValue({ - gateway: { handshakeTimeoutMs: 1_000 }, - }); - }); - - it("preserves legacy semantics for invokes received before negotiation completes", async () => { - const { options, client } = await startFakeNodeHost(); - const resolveNegotiation = deferNegotiation(client); - - hello(options); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-negotiating", - nodeId: "node-1", - command: "system.run", - paramsJSON: '{"sessionKey":"nested-session"}', - }, - }); - options?.onEvent?.({ - type: "event", - event: "node.invoke.input", - payload: { - id: "invoke-negotiating", - nodeId: "node-1", - seq: 1, - payloadJSON: '{"kind":"data"}', - }, - }); - await vi.waitFor(() => { - expect(mocks.activeRuntime.invoke).toHaveBeenCalledOnce(); - expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith( - "invoke-negotiating", - 1, - '{"kind":"data"}', - ); - }); - const invokePayload = mocks.activeRuntime.invoke.mock.calls[0]?.[0]; - expect(invokePayload).toEqual( - expect.objectContaining({ - id: "invoke-negotiating", - paramsJSON: '{"sessionKey":"nested-session"}', - }), - ); - expect(Object.hasOwn(invokePayload ?? {}, "sessionKey")).toBe(false); - expect(mocks.activeRuntime.invoke.mock.invocationCallOrder[0]).toBeLessThan( - mocks.activeRuntime.handleInput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - resolveNegotiation(); - await waitForProtocolFeaturesNegotiation(client); - }); - - it("does not let negotiation block explicit envelopes or unrelated controls", async () => { - const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); - try { - const { options, client } = await startFakeNodeHost(); - const resolveNegotiation = deferNegotiation(client); - - hello(options); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-explicit-envelope", - nodeId: "node-1", - command: "system.run", - timeoutMs: 10, - sessionKey: "agent:main:explicit", - }, - }); - options?.onEvent?.({ - type: "event", - event: "node.invoke.input", - payload: { - id: "invoke-explicit-envelope", - nodeId: "node-1", - seq: 1, - payloadJSON: '{"kind":"data"}', - }, - }); - options?.onEvent?.({ - type: "event", - event: "node.invoke.cancel", - payload: { - invokeId: "invoke-already-running", - nodeId: "node-1", - }, - }); - - await vi.waitFor(() => { - expect(mocks.activeRuntime.invoke).toHaveBeenCalledTimes(1); - expect(mocks.activeRuntime.invoke).toHaveBeenLastCalledWith( - expect.objectContaining({ - id: "invoke-explicit-envelope", - sessionKey: "agent:main:explicit", - timeoutMs: 10, - }), - ); - expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith( - "invoke-explicit-envelope", - 1, - '{"kind":"data"}', - ); - expect(mocks.activeRuntime.cancel).toHaveBeenCalledWith("invoke-already-running"); - }); - expect(mocks.activeRuntime.invoke.mock.invocationCallOrder[0]).toBeLessThan( - mocks.activeRuntime.handleInput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - resolveNegotiation(); - await waitForProtocolFeaturesNegotiation(client); - } finally { - dateNowSpy.mockRestore(); - } - }); - - it("charges queued dispatch against the invoke deadline", async () => { - const dateNowSpy = vi.spyOn(Date, "now"); - let nowMs = 1_000; - dateNowSpy.mockImplementation(() => nowMs); - try { - const { options, client } = await startFakeNodeHost(); - const resolveNegotiation = deferNegotiation(client); - - hello(options); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-with-deadline", - nodeId: "node-1", - command: "system.run", - timeoutMs: 100, - }, - }); - - nowMs += 40; - await vi.waitFor(() => { - expect(mocks.activeRuntime.invoke).toHaveBeenCalledWith( - expect.objectContaining({ - id: "invoke-with-deadline", - timeoutMs: 60, - }), - ); - }); - resolveNegotiation(); - await waitForProtocolFeaturesNegotiation(client); - } finally { - dateNowSpy.mockRestore(); - } - }); - - it("does not dispatch invokes that expire before queued dispatch", async () => { - const dateNowSpy = vi.spyOn(Date, "now"); - let nowMs = 1_000; - dateNowSpy.mockImplementation(() => nowMs); - try { - const { options, client } = await startFakeNodeHost(); - const resolveNegotiation = deferNegotiation(client); - - hello(options); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-expired", - nodeId: "node-1", - command: "system.run", - timeoutMs: 10, - }, - }); - options?.onEvent?.({ - type: "event", - event: "node.invoke.input", - payload: { - id: "invoke-expired", - nodeId: "node-1", - seq: 0, - payloadJSON: '{"kind":"barrier"}', - }, - }); - - nowMs += 10; - await vi.waitFor(() => { - expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith( - "invoke-expired", - 0, - '{"kind":"barrier"}', - ); - }); - expect(mocks.activeRuntime.invoke).not.toHaveBeenCalled(); - resolveNegotiation(); - await waitForProtocolFeaturesNegotiation(client); - } finally { - dateNowSpy.mockRestore(); - } - }); - - it("does not dispatch invokes cancelled before queued dispatch", async () => { - const { options, client } = await startFakeNodeHost(); - const resolveNegotiation = deferNegotiation(client); - - hello(options); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-cancelled", - nodeId: "node-1", - command: "system.run", - }, - }); - options?.onEvent?.({ - type: "event", - event: "node.invoke.cancel", - payload: { - invokeId: "invoke-cancelled", - nodeId: "node-1", - }, - }); - - await vi.waitFor(() => { - expect(mocks.activeRuntime.cancel).toHaveBeenCalledWith("invoke-cancelled"); - }); - expect(mocks.activeRuntime.invoke).not.toHaveBeenCalled(); - resolveNegotiation(); - await waitForProtocolFeaturesNegotiation(client); - }); - - it("preserves absent envelopes only after an old gateway is confirmed", async () => { - const { options, client } = await startFakeNodeHost(); - client?.request.mockRejectedValueOnce( - new GatewayClientRequestError({ - code: "INVALID_REQUEST", - message: "unknown method: node.protocolFeatures.update", - }), - ); - - hello(options); - await waitForProtocolFeaturesNegotiation(client); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-legacy", - nodeId: "node-1", - command: "system.run", - paramsJSON: '{"sessionKey":"legacy-session"}', - }, - }); - - await vi.waitFor(() => expect(mocks.activeRuntime.invoke).toHaveBeenCalledOnce()); - const payload = mocks.activeRuntime.invoke.mock.calls[0]?.[0]; - expect(payload && Object.hasOwn(payload, "sessionKey")).toBe(false); - }); - - it("renegotiates authoritative envelopes after reconnecting from an old gateway", async () => { - const { options, client } = await startFakeNodeHost(); - client?.request.mockRejectedValueOnce( - new GatewayClientRequestError({ - code: "INVALID_REQUEST", - message: "unknown method: node.protocolFeatures.update", - }), - ); - - hello(options); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-legacy", - nodeId: "node-1", - command: "system.run", - }, - }); - await vi.waitFor(() => expect(mocks.activeRuntime.invoke).toHaveBeenCalledOnce()); - expect(Object.hasOwn(mocks.activeRuntime.invoke.mock.calls[0]?.[0] ?? {}, "sessionKey")).toBe( - false, - ); - - options?.onClose?.(1000, "old gateway closed"); - const resolveNegotiation = deferNegotiation(client); - hello(options); - resolveNegotiation(); - await waitForProtocolFeaturesNegotiation(client, 2); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-authoritative", - nodeId: "node-1", - command: "system.run", - }, - }); - - await vi.waitFor(() => expect(mocks.activeRuntime.invoke).toHaveBeenCalledTimes(2)); - expect(mocks.activeRuntime.invoke.mock.calls[1]?.[0]).toEqual( - expect.objectContaining({ - id: "invoke-authoritative", - sessionKey: null, - }), - ); - expect( - client?.request.mock.calls.filter(([method]) => method === "node.protocolFeatures.update"), - ).toHaveLength(2); - }); -}); diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index 54fffcff9747..d41d3f78e4cf 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; import type { GatewayClientOptions } from "../gateway/client.js"; import type { configureNodeHost } from "./config.js"; -import type { NodeInvokeRequestPayload } from "./invoke-types.js"; import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js"; import { runNodeHost } from "./runner.js"; @@ -11,7 +10,7 @@ const mocks = vi.hoisted(() => ({ capturedGatewayClientOptions: [] as GatewayClientOptions[], capturedConfiguredGatewayConfigs: [] as Array<{ contextPath?: string }>, capturedGatewayClients: [] as Array<{ - request: ReturnType Promise>>; + request: ReturnType; stop: ReturnType; updateNodeManifest: ReturnType; }>, @@ -50,7 +49,7 @@ const mocks = vi.hoisted(() => ({ })), resolveGatewayCredentialsWithSecretInputs: vi.fn(async () => ({})), activeRuntime: { - invoke: vi.fn(async (_payload: NodeInvokeRequestPayload) => {}), + invoke: vi.fn(async () => {}), handleInput: vi.fn(), cancel: vi.fn(), cancelAll: vi.fn(), @@ -67,17 +66,9 @@ vi.mock("../gateway/client-start-readiness.js", () => ({ })); vi.mock("../gateway/client.js", () => ({ - GatewayClientRequestError: class MockGatewayClientRequestError extends Error { - readonly gatewayCode: string; - - constructor(params: { code: string; message: string }) { - super(params.message); - this.gatewayCode = params.code; - } - }, GatewayClient: function GatewayClient(opts: GatewayClientOptions) { const client = { - request: vi.fn<(method: string, params?: unknown) => Promise>(async () => ({})), + request: vi.fn(async () => ({})), stop: vi.fn(), updateNodeManifest: vi.fn(), }; @@ -254,27 +245,6 @@ describe("runNodeHost", () => { ); const options = lastCapturedOptions(); - options?.onEvent?.({ - type: "event", - event: "node.invoke.request", - payload: { - id: "invoke-1", - nodeId: "node-1", - command: "system.run", - sessionKey: "agent:main:main", - }, - }); - await vi.waitFor(() => - expect(mocks.activeRuntime.invoke).toHaveBeenCalledWith({ - id: "invoke-1", - nodeId: "node-1", - command: "system.run", - paramsJSON: null, - timeoutMs: null, - idempotencyKey: null, - sessionKey: "agent:main:main", - }), - ); options?.onEvent?.({ type: "event", event: "node.invoke.input", @@ -285,15 +255,10 @@ describe("runNodeHost", () => { event: "node.invoke.cancel", payload: { invokeId: "invoke-1", nodeId: "node-1" }, }); - await vi.waitFor(() => { - expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith( - "invoke-1", - 3, - '{"kind":"data"}', - ); - expect(mocks.activeRuntime.cancel).toHaveBeenCalledWith("invoke-1"); - }); options?.onClose?.(1000, "connection closed"); + + expect(mocks.activeRuntime.handleInput).toHaveBeenCalledWith("invoke-1", 3, '{"kind":"data"}'); + expect(mocks.activeRuntime.cancel).toHaveBeenCalledWith("invoke-1"); expect(mocks.activeRuntime.cancelAll).toHaveBeenCalledOnce(); }); @@ -532,9 +497,6 @@ describe("runNodeHost", () => { features: { methods: [], events: [] }, } as unknown as Parameters>[0]); - expect(client?.request).toHaveBeenCalledWith("node.protocolFeatures.update", { - features: ["node-invoke-session-key-envelope-v1"], - }); expect(client?.request).toHaveBeenCalledWith("node.pluginTools.update", { tools: [ { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index f574d640dfdc..b548c68d44e9 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -4,7 +4,6 @@ import { GATEWAY_CLIENT_NAMES, } from "../../packages/gateway-protocol/src/client-info.js"; import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; -import { NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE } from "../../packages/gateway-protocol/src/schema/nodes.js"; import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js"; import { @@ -22,7 +21,6 @@ import { coerceNodeInvokeInputPayload, coerceNodeInvokePayload, } from "./invoke-payload.js"; -import type { NodeInvokeRequestPayload } from "./invoke-types.js"; import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js"; import { runStartupMigrations } from "./startup-state-migrations.js"; @@ -126,35 +124,6 @@ function isUnsupportedNodeSkillsUpdateError(error: unknown): boolean { ); } -function isUnsupportedNodeProtocolFeaturesUpdateError(error: unknown): boolean { - return ( - error instanceof GatewayClientRequestError && - error.gatewayCode === "INVALID_REQUEST" && - error.message.includes("unknown method: node.protocolFeatures.update") - ); -} - -type NodeInvokeSessionEnvelopeMode = "authoritative" | "legacy"; - -async function negotiateNodeInvokeSessionEnvelope( - client: GatewayClient, -): Promise { - try { - await client.request("node.protocolFeatures.update", { - features: [NODE_INVOKE_SESSION_KEY_ENVELOPE_PROTOCOL_FEATURE], - }); - return "authoritative"; - } catch (error) { - if (isUnsupportedNodeProtocolFeaturesUpdateError(error)) { - return "legacy"; - } - writeStderrLine(`node host protocol feature publish failed: ${String(error)}`); - // Only a confirmed unknown-method response enables the legacy nested field. - // Other failures keep omitted envelopes fail-closed while the connection lives. - return "authoritative"; - } -} - async function publishNodePluginTools(client: GatewayClient, tools: unknown[]): Promise { try { await client.request("node.pluginTools.update", { tools }); @@ -255,38 +224,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const url = `${scheme}://${urlHost}:${port}${contextPath}`; let inventory: NodeHostInventory = preparedRuntime.initialInventory; let gatewayHelloReceived = false; - let gatewayConnectionGeneration = 0; - let nodeInvokeSessionEnvelopeMode = - Promise.resolve("authoritative"); - let nodeInvokeSessionEnvelopeNegotiationComplete = true; - const nodeInvokeEventDispatchByInvokeId = new Map>(); - // Cancellation can arrive before the queued request dispatches. Mark it immediately - // so the request cannot start before its queued cancel runs. - const queuedNodeInvokeCancellations = new Set(); - const queueNodeInvokeEvent = ( - invokeId: string, - dispatch: (mode: NodeInvokeSessionEnvelopeMode) => void, - envelopeMode: Promise = Promise.resolve("authoritative"), - ): void => { - const connectionGeneration = gatewayConnectionGeneration; - const previous = nodeInvokeEventDispatchByInvokeId.get(invokeId) ?? Promise.resolve(); - const queued = previous - .then(async () => { - const mode = await envelopeMode; - if (connectionGeneration === gatewayConnectionGeneration) { - dispatch(mode); - } - }) - .catch((error: unknown) => { - writeStderrLine(`node host invoke event dispatch failed: ${String(error)}`); - }); - nodeInvokeEventDispatchByInvokeId.set(invokeId, queued); - void queued.then(() => { - if (nodeInvokeEventDispatchByInvokeId.get(invokeId) === queued) { - nodeInvokeEventDispatchByInvokeId.delete(invokeId); - } - }); - }; const publishInventory = () => { if (!gatewayHelloReceived) { @@ -323,20 +260,14 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { if (evt.event === "node.invoke.cancel") { const payload = coerceNodeInvokeCancelPayload(evt.payload); if (payload) { - queuedNodeInvokeCancellations.add(payload.invokeId); - queueNodeInvokeEvent(payload.invokeId, () => { - activeRuntime.cancel(payload.invokeId); - queuedNodeInvokeCancellations.delete(payload.invokeId); - }); + activeRuntime.cancel(payload.invokeId); } return; } if (evt.event === "node.invoke.input") { const payload = coerceNodeInvokeInputPayload(evt.payload); if (payload) { - queueNodeInvokeEvent(payload.invokeId, () => { - activeRuntime.handleInput(payload.invokeId, payload.seq, payload.payloadJSON); - }); + activeRuntime.handleInput(payload.invokeId, payload.seq, payload.payloadJSON); } return; } @@ -347,56 +278,11 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { if (!payload) { return; } - const receivedAtMs = Date.now(); - const hasSessionKeyEnvelope = Object.hasOwn(payload, "sessionKey"); - // Omitted envelopes received before negotiation completes still use the legacy - // nested session field. Do not reinterpret them after the response arrives. - const envelopeModeAtReceipt = hasSessionKeyEnvelope - ? Promise.resolve("authoritative") - : nodeInvokeSessionEnvelopeNegotiationComplete - ? nodeInvokeSessionEnvelopeMode - : Promise.resolve("legacy"); - queueNodeInvokeEvent( - payload.id, - (mode) => { - if (queuedNodeInvokeCancellations.delete(payload.id)) { - return; - } - // Older gateways may send non-empty attribution before negotiation. - // Preserve that envelope while still upgrading omitted negotiated requests to a clear. - let invokePayload: NodeInvokeRequestPayload = - mode === "authoritative" && !hasSessionKeyEnvelope - ? { ...payload, sessionKey: null } - : payload; - if (typeof invokePayload.timeoutMs === "number" && invokePayload.timeoutMs > 0) { - // The Gateway sends its remaining deadline budget. Charge negotiation - // time here so delayed state-changing commands cannot run after expiry. - const elapsedMs = Math.max(0, Date.now() - receivedAtMs); - const remainingTimeoutMs = Math.max(0, invokePayload.timeoutMs - elapsedMs); - if (remainingTimeoutMs === 0) { - return; - } - invokePayload = { ...invokePayload, timeoutMs: remainingTimeoutMs }; - } - void activeRuntime.invoke(invokePayload); - }, - envelopeModeAtReceipt, - ); + void activeRuntime.invoke(payload); }, onHelloOk: () => { writeStderrLine(`node host gateway connected: ${url}`); - gatewayConnectionGeneration += 1; - const connectionGeneration = gatewayConnectionGeneration; - nodeInvokeEventDispatchByInvokeId.clear(); - queuedNodeInvokeCancellations.clear(); gatewayHelloReceived = true; - nodeInvokeSessionEnvelopeNegotiationComplete = false; - nodeInvokeSessionEnvelopeMode = negotiateNodeInvokeSessionEnvelope(client).then((mode) => { - if (connectionGeneration === gatewayConnectionGeneration) { - nodeInvokeSessionEnvelopeNegotiationComplete = true; - } - return mode; - }); publishInventory(); }, onConnectError: (err) => { @@ -414,12 +300,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { }); }, onClose: (code, reason) => { - gatewayConnectionGeneration += 1; - nodeInvokeEventDispatchByInvokeId.clear(); - queuedNodeInvokeCancellations.clear(); gatewayHelloReceived = false; - nodeInvokeSessionEnvelopeMode = Promise.resolve("authoritative"); - nodeInvokeSessionEnvelopeNegotiationComplete = true; activeRuntime.cancelAll(); writeStderrLine(`node host gateway closed (${code}): ${reason}`); },