11using System ;
2+ using System . Collections . Concurrent ;
23using System . Collections . Frozen ;
34using System . Collections . Generic ;
45using System . Diagnostics ;
@@ -17,6 +18,11 @@ namespace OpenClaw.Shared;
1718/// </summary>
1819public class WindowsNodeClient : WebSocketClientBase
1920{
21+ private const string NodeInvokeSessionKeyEnvelopeProtocolFeature =
22+ "node-invoke-session-key-envelope-v1" ;
23+ private static readonly TimeSpan s_protocolFeatureNegotiationTimeout =
24+ TimeSpan . FromSeconds ( 5 ) ;
25+
2026 private readonly DeviceIdentity _deviceIdentity ;
2127
2228 // Node capabilities registry
@@ -60,6 +66,14 @@ public class WindowsNodeClient : WebSocketClientBase
6066 // at 8. When full, the gateway receives an immediate "node busy, retry" error response.
6167 private readonly SemaphoreSlim _invokeSemaphore = new ( 8 , 8 ) ;
6268 private readonly InvocationCancellationRegistry _activeInvocations = new ( ) ;
69+ private readonly ConcurrentDictionary <
70+ string ,
71+ TaskCompletionSource < NodeInvokeSessionEnvelopeMode > > _protocolFeatureRequests = new ( ) ;
72+ private Task < NodeInvokeSessionEnvelopeMode > _nodeInvokeSessionEnvelopeMode =
73+ Task . FromResult ( NodeInvokeSessionEnvelopeMode . Authoritative ) ;
74+ private int _gatewayConnectionGeneration ;
75+ private readonly object _nodeInvokeEventDispatchLock = new ( ) ;
76+ private Task _nodeInvokeEventDispatch = Task . CompletedTask ;
6377
6478 // Events
6579 public event EventHandler < NodeInvokeRequest > ? InvokeReceived ;
@@ -318,10 +332,18 @@ private async Task HandleEventAsync(JsonElement root)
318332 await HandlePairingResolvedEventAsync ( root , eventType ) ;
319333 break ;
320334 case "node.invoke.request" :
321- await HandleNodeInvokeEventAsync ( root ) ;
335+ QueueNodeInvokeMessage (
336+ root . Clone ( ) ,
337+ QueuedNodeInvokeKind . Invoke ,
338+ containerName : "payload" ,
339+ responseId : null ) ;
322340 break ;
323341 case "node.invoke.cancel" :
324- await HandleNodeInvokeCancelAsync ( root , "payload" , responseId : null ) ;
342+ QueueNodeInvokeMessage (
343+ root . Clone ( ) ,
344+ QueuedNodeInvokeKind . Cancel ,
345+ containerName : "payload" ,
346+ responseId : null ) ;
325347 break ;
326348 case "health" :
327349 if ( root . TryGetProperty ( "payload" , out var payload ) )
@@ -422,7 +444,67 @@ private async Task HandlePairingResolvedEventAsync(JsonElement root, string? eve
422444 }
423445 }
424446
425- private async Task HandleNodeInvokeEventAsync ( JsonElement root )
447+ private void QueueNodeInvokeMessage (
448+ JsonElement root ,
449+ QueuedNodeInvokeKind kind ,
450+ string containerName ,
451+ string ? responseId )
452+ {
453+ var envelopeModeTask = _nodeInvokeSessionEnvelopeMode ;
454+ var connectionGeneration = _gatewayConnectionGeneration ;
455+ lock ( _nodeInvokeEventDispatchLock )
456+ {
457+ _nodeInvokeEventDispatch = _nodeInvokeEventDispatch
458+ . ContinueWith (
459+ _ => DispatchNodeInvokeEventAsync (
460+ root ,
461+ kind ,
462+ containerName ,
463+ responseId ,
464+ envelopeModeTask ,
465+ connectionGeneration ) ,
466+ CancellationToken . None ,
467+ TaskContinuationOptions . ExecuteSynchronously ,
468+ TaskScheduler . Default )
469+ . Unwrap ( ) ;
470+ }
471+ }
472+
473+ private async Task DispatchNodeInvokeEventAsync (
474+ JsonElement root ,
475+ QueuedNodeInvokeKind kind ,
476+ string containerName ,
477+ string ? responseId ,
478+ Task < NodeInvokeSessionEnvelopeMode > envelopeModeTask ,
479+ int connectionGeneration )
480+ {
481+ try
482+ {
483+ var envelopeMode = await envelopeModeTask ;
484+ if ( connectionGeneration != _gatewayConnectionGeneration )
485+ return ;
486+
487+ switch ( kind )
488+ {
489+ case QueuedNodeInvokeKind . Invoke :
490+ // This returns after active-invocation registration and Task.Run handoff;
491+ // the next queued cancel is not serialized behind capability execution.
492+ await StartNodeInvokeEventAsync ( root , envelopeMode ) ;
493+ break ;
494+ case QueuedNodeInvokeKind . Cancel :
495+ await HandleNodeInvokeCancelAsync ( root , containerName , responseId ) ;
496+ break ;
497+ }
498+ }
499+ catch ( Exception ex )
500+ {
501+ _logger . Error ( "Node invoke event dispatch failed" , ex ) ;
502+ }
503+ }
504+
505+ private async Task StartNodeInvokeEventAsync (
506+ JsonElement root ,
507+ NodeInvokeSessionEnvelopeMode envelopeMode )
426508 {
427509 var telemetry = new NodeToolInvocation ( NodeToolTransport . Gateway ) ;
428510 _logger . Info ( "[NODE] Received node.invoke.request event" ) ;
@@ -508,7 +590,7 @@ await SendGatewayResultAndCompleteTelemetryAsync(
508590 }
509591 }
510592
511- var sessionKey = ExtractGatewayNodeInvokeSessionKey ( payload ) ;
593+ var sessionKey = ExtractGatewayNodeInvokeSessionKey ( payload , args , envelopeMode ) ;
512594
513595 _logger . Info ( $ "[NODE] Invoking command: { command } ") ;
514596
@@ -731,6 +813,22 @@ private string BuildNodeConnectMessage(string? nonce, long? challengeTimestampMs
731813
732814 internal void HandleResponse ( JsonElement root )
733815 {
816+ if ( root . TryGetProperty ( "id" , out var idProp ) &&
817+ idProp . ValueKind == JsonValueKind . String &&
818+ idProp . GetString ( ) is { } responseId &&
819+ _protocolFeatureRequests . TryRemove ( responseId , out var negotiation ) )
820+ {
821+ var mode = IsUnsupportedNodeProtocolFeaturesResponse ( root )
822+ ? NodeInvokeSessionEnvelopeMode . Legacy
823+ : NodeInvokeSessionEnvelopeMode . Authoritative ;
824+ negotiation . TrySetResult ( mode ) ;
825+ if ( mode == NodeInvokeSessionEnvelopeMode . Legacy )
826+ {
827+ _logger . Debug ( "[NODE] Gateway does not accept node protocol feature publication" ) ;
828+ }
829+ return ;
830+ }
831+
734832 if ( root . TryGetProperty ( "ok" , out var okProp ) &&
735833 okProp . ValueKind == JsonValueKind . False )
736834 {
@@ -748,6 +846,7 @@ internal void HandleResponse(JsonElement root)
748846 if ( payload . TryGetProperty ( "type" , out var t ) && t . GetString ( ) == "hello-ok" )
749847 {
750848 _logger . Info ( "[HANDSHAKE] Received hello-ok!" ) ;
849+ _gatewayConnectionGeneration ++ ;
751850 PublishGatewaySelf ( GatewaySelfInfo . FromHelloOk ( payload ) ) ;
752851 var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect ;
753852 _isConnected = true ;
@@ -827,10 +926,75 @@ internal void HandleResponse(JsonElement root)
827926 }
828927
829928 RaiseStatusChanged ( ConnectionStatus . Connected ) ;
929+ _ = PublishNodeProtocolFeaturesAsync ( ) ;
830930 HandshakeSucceeded ? . Invoke ( this , EventArgs . Empty ) ;
831931 }
832932 }
833933
934+ private async Task PublishNodeProtocolFeaturesAsync ( )
935+ {
936+ var requestId = Guid . NewGuid ( ) . ToString ( ) ;
937+ var negotiation = new TaskCompletionSource < NodeInvokeSessionEnvelopeMode > (
938+ TaskCreationOptions . RunContinuationsAsynchronously ) ;
939+ _nodeInvokeSessionEnvelopeMode = negotiation . Task ;
940+ var request = new
941+ {
942+ type = "req" ,
943+ id = requestId ,
944+ method = "node.protocolFeatures.update" ,
945+ @params = new
946+ {
947+ features = new [ ] { NodeInvokeSessionKeyEnvelopeProtocolFeature }
948+ }
949+ } ;
950+
951+ try
952+ {
953+ _protocolFeatureRequests . TryAdd ( requestId , negotiation ) ;
954+ _ = ResolveNodeProtocolFeaturesTimeoutAsync ( requestId , negotiation ) ;
955+ await SendRawAsync ( JsonSerializer . Serialize ( request ) ) ;
956+ }
957+ catch ( Exception ex )
958+ {
959+ _protocolFeatureRequests . TryRemove ( requestId , out _ ) ;
960+ negotiation . TrySetResult ( NodeInvokeSessionEnvelopeMode . Authoritative ) ;
961+ // Only an explicit unknown-method response enables legacy attribution.
962+ // Transport failures keep omitted envelopes fail-closed.
963+ _logger . Warn ( $ "[NODE] Failed to publish protocol features: { ex . Message } ") ;
964+ }
965+ }
966+
967+ private async Task ResolveNodeProtocolFeaturesTimeoutAsync (
968+ string requestId ,
969+ TaskCompletionSource < NodeInvokeSessionEnvelopeMode > negotiation )
970+ {
971+ await Task . Delay ( s_protocolFeatureNegotiationTimeout ) ;
972+ if ( _protocolFeatureRequests . TryGetValue ( requestId , out var pending ) &&
973+ ReferenceEquals ( pending , negotiation ) )
974+ {
975+ _logger . Warn ( "[NODE] Protocol feature publication timed out; using fail-closed envelopes" ) ;
976+ negotiation . TrySetResult ( NodeInvokeSessionEnvelopeMode . Authoritative ) ;
977+ }
978+ }
979+
980+ private static bool IsUnsupportedNodeProtocolFeaturesResponse ( JsonElement response )
981+ {
982+ if ( ! response . TryGetProperty ( "ok" , out var ok ) ||
983+ ok . ValueKind != JsonValueKind . False ||
984+ ! response . TryGetProperty ( "error" , out var error ) ||
985+ error . ValueKind != JsonValueKind . Object )
986+ {
987+ return false ;
988+ }
989+
990+ return error . TryGetProperty ( "code" , out var code ) &&
991+ string . Equals ( code . GetString ( ) , "INVALID_REQUEST" , StringComparison . OrdinalIgnoreCase ) &&
992+ error . TryGetProperty ( "message" , out var message ) &&
993+ message . GetString ( ) ? . Contains (
994+ "unknown method: node.protocolFeatures.update" ,
995+ StringComparison . Ordinal ) == true ;
996+ }
997+
834998 private void TryStoreHandshakeDeviceToken ( string token , string [ ] ? scopes )
835999 {
8361000 try
@@ -1119,7 +1283,11 @@ private async Task HandleRequestAsync(JsonElement root)
11191283 await HandleNodeInvokeAsync ( root , id ) ;
11201284 break ;
11211285 case "node.invoke.cancel" :
1122- await HandleNodeInvokeCancelAsync ( root , "params" , id ) ;
1286+ QueueNodeInvokeMessage (
1287+ root . Clone ( ) ,
1288+ QueuedNodeInvokeKind . Cancel ,
1289+ containerName : "params" ,
1290+ responseId : id ) ;
11231291 break ;
11241292 case "ping" :
11251293 await SendPongAsync ( id ) ;
@@ -1568,19 +1736,48 @@ private static bool TryGetCancellationTargetId(JsonElement container, out string
15681736 return false ;
15691737 }
15701738
1571- private static string ? ExtractGatewayNodeInvokeSessionKey ( JsonElement envelope )
1739+ private static string ? ExtractGatewayNodeInvokeSessionKey (
1740+ JsonElement envelope ,
1741+ JsonElement args ,
1742+ NodeInvokeSessionEnvelopeMode envelopeMode )
15721743 {
1573- if ( envelope . TryGetProperty ( "sessionKey" , out var envelopeSessionKey ) &&
1574- envelopeSessionKey . ValueKind == JsonValueKind . String )
1744+ if ( envelope . TryGetProperty ( "sessionKey" , out var envelopeSessionKey ) )
15751745 {
1576- var sessionKey = envelopeSessionKey . GetString ( ) ;
1746+ if ( envelopeSessionKey . ValueKind == JsonValueKind . String )
1747+ {
1748+ var sessionKey = envelopeSessionKey . GetString ( ) ;
1749+ if ( ! string . IsNullOrWhiteSpace ( sessionKey ) )
1750+ return sessionKey ;
1751+ }
1752+
1753+ return null ;
1754+ }
1755+
1756+ if ( envelopeMode == NodeInvokeSessionEnvelopeMode . Legacy &&
1757+ args . ValueKind == JsonValueKind . Object &&
1758+ args . TryGetProperty ( "sessionKey" , out var legacySessionKey ) &&
1759+ legacySessionKey . ValueKind == JsonValueKind . String )
1760+ {
1761+ var sessionKey = legacySessionKey . GetString ( ) ;
15771762 if ( ! string . IsNullOrWhiteSpace ( sessionKey ) )
15781763 return sessionKey ;
15791764 }
15801765
15811766 return null ;
15821767 }
15831768
1769+ private enum NodeInvokeSessionEnvelopeMode
1770+ {
1771+ Authoritative ,
1772+ Legacy
1773+ }
1774+
1775+ private enum QueuedNodeInvokeKind
1776+ {
1777+ Invoke ,
1778+ Cancel
1779+ }
1780+
15841781 private sealed record CommandDispatchEntry (
15851782 INodeCapability Capability ,
15861783 string CanonicalName ) ;
@@ -1732,6 +1929,7 @@ protected override bool ShouldAutoReconnect()
17321929 protected override void OnDisconnected ( )
17331930 {
17341931 _activeInvocations . CancelAll ( ) ;
1932+ ResetNodeInvokeSessionEnvelopeNegotiation ( ) ;
17351933 _isConnected = false ;
17361934 // Don't reset pairing state when disconnected due to pairing — gateway
17371935 // closes the socket after PAIRING_REQUIRED but we're still waiting for approval
@@ -1745,6 +1943,7 @@ protected override void OnDisconnected()
17451943 protected override void OnError ( Exception ex )
17461944 {
17471945 _activeInvocations . CancelAll ( ) ;
1946+ ResetNodeInvokeSessionEnvelopeNegotiation ( ) ;
17481947 _isConnected = false ;
17491948 if ( ! _pairingBlocked )
17501949 {
@@ -1756,5 +1955,22 @@ protected override void OnError(Exception ex)
17561955 protected override void OnDisposing ( )
17571956 {
17581957 _activeInvocations . CancelAll ( ) ;
1958+ ResetNodeInvokeSessionEnvelopeNegotiation ( ) ;
1959+ }
1960+
1961+ private void ResetNodeInvokeSessionEnvelopeNegotiation ( )
1962+ {
1963+ _gatewayConnectionGeneration ++ ;
1964+ foreach ( var request in _protocolFeatureRequests . Values )
1965+ {
1966+ request . TrySetResult ( NodeInvokeSessionEnvelopeMode . Authoritative ) ;
1967+ }
1968+ _protocolFeatureRequests . Clear ( ) ;
1969+ _nodeInvokeSessionEnvelopeMode =
1970+ Task . FromResult ( NodeInvokeSessionEnvelopeMode . Authoritative ) ;
1971+ lock ( _nodeInvokeEventDispatchLock )
1972+ {
1973+ _nodeInvokeEventDispatch = Task . CompletedTask ;
1974+ }
17591975 }
17601976}
0 commit comments