diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 810840343..0c99bad54 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,6 +82,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Windows node connection generation, cancellation, start ordering, recovery, events, and telemetry | `NodeConnectionCoordinator` | authoritative | | Bootstrap/shared/device credential handoff, durable clear gate, and operator token recovery timing | `BootstrapTokenLifecycle` | authoritative | | Device role-upgrade approval, confirmation, and bounded node reconnect queue | `DevicePairApprovalCoordinator` | authoritative | +| Gateway wire protocol range, minimal `hello-ok` validation, and sanitized compatibility state | `GatewayProtocolContract` + `GatewayProtocolCompatibility` | authoritative | | Capability UI metadata | `NodeCapabilityUiCatalog` (planned) | planned | | Capability registration/gating | `NodeCapabilityRegistrationPolicy` (planned) | planned | | Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned | @@ -195,6 +196,8 @@ leading and trailing pipe. Columns, in order: | connect-envelope | authoritative | src/OpenClaw.Shared/OpenClawGatewayClient.cs + src/OpenClaw.Shared/WindowsNodeClient.cs | connect envelope wire shape, auth field mapping, and v3/v2 signing arguments | ConnectEnvelopeBuilder | callers explicitly select role, scopes, credential profile, and lifecycle/fallback state | the builder cannot infer credential precedence; exact v3/v2 signing bytes and protocol 3/4 remain unchanged | ConnectEnvelopeBuilderTests.Build_CompleteProfileMatrix_PreservesWireShapeAndSigningArguments | golden | - | | gateway-connect-inline-closed | closed | src/OpenClaw.Shared/OpenClawGatewayClient.cs + src/OpenClaw.Shared/WindowsNodeClient.cs | anonymous connect envelope, auth dictionary, and direct signature/payload construction | ConnectEnvelopeBuilder | explicit role/scope/credential profile selection, lifecycle/fallback state and persistence, redacted logging, transport send | both clients delegate connect construction without moving credential precedence into the builder | GatewayProtocolCoreClosureTests.GatewayClients_DoNotReintroduce_InlineConnectEnvelopeConstruction | source-shape | when both clients are removed or no longer initiate gateway connect handshakes | | gateway-pending-inline-closed | closed | src/OpenClaw.Shared/OpenClawGatewayClient.cs | pending maps and locks plus Track-Take-Clear and chat-send helper families | PendingRequestRegistry | request-id creation, timeout policy, response parsing/routing, transport | the client delegates registration, take, removal, and disconnect drain so request ids cannot leak and only one completion wins | GatewayProtocolCoreClosureTests.OpenClawGatewayClient_DoesNotReintroduce_InlinePendingRequestTracking | source-shape | when OpenClawGatewayClient is removed or no longer issues correlated requests | +| gateway-protocol-contract | authoritative | ConnectEnvelopeBuilder + src/OpenClaw.Shared/OpenClawGatewayClient.cs + src/OpenClaw.Shared/WindowsNodeClient.cs | duplicated wire-version range, ad hoc successful hello acceptance, and raw mismatch interpretation | GatewayProtocolContract + GatewayProtocolCompatibility | the builder retains exact envelope construction; clients retain credential precedence, signature fallback, generation fencing, and role-specific success side effects | Windows advertises Gateway protocols 3 through 4 and validates the minimal hello-ok shape before success side effects; after the Gateway accepts that range, an integer hello-ok protocol at or above 3 is accepted because it reports the Gateway current constant rather than a negotiated selection; structured mismatch details remain finite and sanitized relative to the advertised range | GatewayProtocolContractTests.SupportedRange_IsThreeThroughFour | behavioral | - | +| gateway-protocol-literals-closed | closed | ConnectEnvelopeBuilder | private minProtocol and maxProtocol numeric literals | GatewayProtocolContract | the builder serializes its existing envelopes using the shared constants | operator and node connect envelopes advertise the same 3-through-4 protocol range | GatewayProtocolContractTests.Clients_use_shared_contract_for_protocol_range | source-shape | when connect envelopes no longer carry a Gateway protocol range | | ui-dispatcher | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | UI-thread marshaling abstraction for presentation code | IUiDispatcher | App and existing WinUI code may call DispatcherQueue directly until the view-model migration | presentation view models depend on IUiDispatcher not a concrete DispatcherQueue | UiDispatcherContractTests.PageViewModel_ReceivesRegisteredDispatcher | behavioral | - | | navigation-scope | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs | page view-model activation/deactivation and disposal lifetime | NavigationScopeManager | HubWindow keeps frame navigation back-stack and rail selection | transient page view models are activated on navigation and deactivated then disposed on navigate-away | NavigationScopeManagerTests.NavigatingAway_DeactivatesAndDisposesPreviousViewModel | behavioral | - | | composition-root | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | presentation-layer service construction and wiring | AppServiceRegistration | App remains the composition root and owns non-DI service lifetimes | one validated root ServiceProvider; App-owned singletons registered as instances are never disposed by the container | AppServiceRegistrationTests.Dispose_DoesNotDisposeAppOwnedInstanceSingletons | behavioral | - | diff --git a/docs/CONNECTION_PROTOCOL_RESEARCH.md b/docs/CONNECTION_PROTOCOL_RESEARCH.md index ed2353184..5f8c5f201 100644 --- a/docs/CONNECTION_PROTOCOL_RESEARCH.md +++ b/docs/CONNECTION_PROTOCOL_RESEARCH.md @@ -71,8 +71,11 @@ stable release-validation evidence. The managed gateway release pin is not the WebSocket protocol pin. Windows currently advertises `minProtocol: 3` and `maxProtocol: 4`; the gateway reports its current protocol constant in `hello-ok.protocol`, not a per-connection -negotiated value. Windows records that value for diagnostics but does not -currently branch behavior on it. Current upstream gateways use the protocol-3 +negotiated value. After the gateway accepts the advertised range, Windows records +that integer constant for diagnostics and requires it to be at least +`MinimumSupportedVersion`; a lower or malformed value is a terminal protocol +failure. A structured `PROTOCOL_MISMATCH` rejection determines which side falls +outside the advertised range. Current upstream gateways use the protocol-3 N-1 node window only when both `role` and `client.mode` are `node` and the client range does not support the gateway's current protocol. Because Windows advertises `maxProtocol: 4`, its node connection uses protocol 4 against a diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index d77843325..e07e1d4e5 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -100,10 +100,25 @@ The tray exports gateway lifecycle diagnostics when an endpoint is configured: - structured state logs in the `OpenClaw.Telemetry.Connection` category Lifecycle attributes are limited to role, operation, outcome, coarse error -category, and finite operator/node/overall states. Gateway URLs, IDs, device -IDs, pairing request IDs, credentials, error messages, and diagnostic-ring +category, finite operator/node/overall states, and these protocol fields: + +- `openclaw.protocol.client`: the current client wire protocol integer; Windows + currently advertises support from protocol 3 through protocol 4 +- `openclaw.protocol.gateway`: the finite relative bucket `older`, `current`, + `newer`, or `unknown`, relative to the advertised 3-through-4 range; this + bucket is not itself a compatibility verdict +- `openclaw.protocol.compatibility`: `unknown`, `compatible`, + `gateway_too_old`, `gateway_too_new`, or `mismatch` + +Gateway package versions are not protocol values and are not exported by this +instrumentation. Gateway URLs, IDs, device IDs, pairing request IDs, +credentials, integrity values, error messages, raw frames, and diagnostic-ring text are not exported. +Only an explicit protocol mismatch maps to the `protocolmismatch` error +category. Unknown or otherwise unclassified Gateway errors map to +`internalerror`; they are never promoted to protocol mismatch. + The operator phase spans distinguish local credential/client/tunnel preparation, WebSocket transport establishment, and the gateway challenge/hello handshake. The Windows node initiates its gateway connection: its prepare span includes diff --git a/docs/proof/gateway-compatibility/gateway-too-new.png b/docs/proof/gateway-compatibility/gateway-too-new.png new file mode 100644 index 000000000..490cda3fa Binary files /dev/null and b/docs/proof/gateway-compatibility/gateway-too-new.png differ diff --git a/docs/proof/gateway-compatibility/gateway-too-old.png b/docs/proof/gateway-compatibility/gateway-too-old.png new file mode 100644 index 000000000..be2e9903a Binary files /dev/null and b/docs/proof/gateway-compatibility/gateway-too-old.png differ diff --git a/src/OpenClaw.Connection/ConnectionStateMachine.cs b/src/OpenClaw.Connection/ConnectionStateMachine.cs index 83b9ff129..979284912 100644 --- a/src/OpenClaw.Connection/ConnectionStateMachine.cs +++ b/src/OpenClaw.Connection/ConnectionStateMachine.cs @@ -14,6 +14,11 @@ internal sealed class ConnectionStateMachine private string? _operatorError; private OpenClaw.Shared.GatewayErrorKind? _operatorErrorKind; private string? _nodeError; + private OpenClaw.Shared.GatewayErrorKind? _nodeErrorKind; + private OpenClaw.Shared.GatewayProtocolCompatibility _operatorProtocolCompatibility = + OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; + private OpenClaw.Shared.GatewayProtocolCompatibility _nodeProtocolCompatibility = + OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; private string? _operatorCredentialSource; private string? _nodeCredentialSource; private GatewayCredentialResolutionStatus? _operatorCredentialStatus; @@ -135,6 +140,8 @@ public void SetNodeEnabled(bool enabled) { _nodeState = RoleConnectionState.Disabled; _nodeError = null; + _nodeErrorKind = null; + _nodeProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; _nodeCredentialSource = null; _nodeCredentialStatus = null; _nodeCredentialFallbackUsed = false; @@ -145,6 +152,8 @@ public void SetNodeEnabled(bool enabled) { _nodeState = RoleConnectionState.Idle; _nodeError = null; + _nodeErrorKind = null; + _nodeProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; } RebuildSnapshot(); } @@ -157,6 +166,9 @@ public void Reset() _operatorError = null; _operatorErrorKind = null; _nodeError = null; + _nodeErrorKind = null; + _operatorProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; + _nodeProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; _operatorCredentialSource = null; _nodeCredentialSource = null; _operatorCredentialStatus = null; @@ -177,6 +189,8 @@ public void StartNodeConnecting() { _nodeState = RoleConnectionState.Connecting; _nodeError = null; + _nodeErrorKind = null; + _nodeProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; RebuildSnapshot(); } } @@ -215,6 +229,26 @@ internal void SetOperatorErrorKind(OpenClaw.Shared.GatewayErrorKind? kind) RebuildSnapshot(); } + internal void SetOperatorProtocolCompatibility( + OpenClaw.Shared.GatewayProtocolCompatibility compatibility) + { + _operatorProtocolCompatibility = compatibility; + RebuildSnapshot(); + } + + internal void SetNodeErrorKind(OpenClaw.Shared.GatewayErrorKind? kind) + { + _nodeErrorKind = kind; + RebuildSnapshot(); + } + + internal void SetNodeProtocolCompatibility( + OpenClaw.Shared.GatewayProtocolCompatibility compatibility) + { + _nodeProtocolCompatibility = compatibility; + RebuildSnapshot(); + } + /// Update node info (device ID, pairing status, optional request ID) in the snapshot. internal void SetNodeInfo( string? deviceId, @@ -302,6 +336,7 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) _operatorState = RoleConnectionState.Connecting; _operatorError = null; _operatorErrorKind = null; + _operatorProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; break; case ConnectionTrigger.ConnectRequestSent: @@ -369,6 +404,9 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) _operatorError = null; _operatorErrorKind = null; _nodeError = null; + _nodeErrorKind = null; + _operatorProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; + _nodeProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; _operatorCredentialSource = null; _nodeCredentialSource = null; _operatorCredentialStatus = null; @@ -385,6 +423,7 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) _operatorState = RoleConnectionState.Connecting; _operatorError = null; _operatorErrorKind = null; + _operatorProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; break; case ConnectionTrigger.ReconnectSuppressed: @@ -401,21 +440,29 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) case ConnectionTrigger.NodeConnected: _nodeState = RoleConnectionState.Connected; _nodeError = null; + _nodeErrorKind = null; break; case ConnectionTrigger.NodeDisconnected: + if (_nodeErrorKind == OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch) + break; + _nodeState = RoleConnectionState.Idle; _nodeError = null; + _nodeErrorKind = null; + _nodeProtocolCompatibility = OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; break; case ConnectionTrigger.NodePairingRequired: _nodeState = RoleConnectionState.PairingRequired; _nodeError = null; + _nodeErrorKind = null; break; case ConnectionTrigger.NodePaired: _nodeState = RoleConnectionState.Connected; _nodeError = null; + _nodeErrorKind = null; break; case ConnectionTrigger.NodePairingRejected: @@ -437,6 +484,11 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) private void RebuildSnapshot() { + var (protocolCompatibility, protocolRole) = + GatewayConnectionSnapshot.DeriveProtocolCompatibility( + _operatorProtocolCompatibility, + _nodeProtocolCompatibility, + _nodeEnabled); Current = Current with { OverallState = GatewayConnectionSnapshot.DeriveOverall(_operatorState, _nodeState, _nodeEnabled), @@ -448,6 +500,7 @@ private void RebuildSnapshot() OperatorCredentialFallbackUsed = _operatorCredentialFallbackUsed, OperatorCredentialBootstrapRequired = _operatorCredentialBootstrapRequired, OperatorCredentialDetail = _operatorCredentialDetail, + OperatorProtocolCompatibility = _operatorProtocolCompatibility, OperatorPairingRequired = _operatorState == RoleConnectionState.PairingRequired, // Clear requestId when no longer in PairingRequired to prevent stale reads OperatorPairingRequestId = _operatorState == RoleConnectionState.PairingRequired @@ -455,11 +508,15 @@ private void RebuildSnapshot() NodeConnectionIntended = _nodeEnabled, NodeState = _nodeState, NodeError = _nodeError, + NodeErrorKind = _nodeErrorKind, NodeCredentialSource = _nodeCredentialSource, NodeCredentialStatus = _nodeCredentialStatus, NodeCredentialFallbackUsed = _nodeCredentialFallbackUsed, NodeCredentialBootstrapRequired = _nodeCredentialBootstrapRequired, NodeCredentialDetail = _nodeCredentialDetail, + NodeProtocolCompatibility = _nodeProtocolCompatibility, + ProtocolCompatibility = protocolCompatibility, + ProtocolCompatibilityRole = protocolRole, // Clear requestId when no longer in PairingRequired to prevent stale reads NodePairingRequestId = _nodeState == RoleConnectionState.PairingRequired ? Current.NodePairingRequestId : null, diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index f2c57556a..533b227d2 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -74,6 +74,7 @@ private readonly Func _userDisconnectedGatewayIds = new(StringComparer.Ordinal); // Shared exclusive lease serializing destructive gateway lifecycle operations (manual WSL @@ -98,6 +99,9 @@ private readonly Func + { + if (!IsCurrentGatewayAttempt(gen, subscribedGatewayId)) return; + RecordOperatorProtocolCompatibility(gen, compatibility); + }; lifecycle.DataClient.TransportConnected += (s, e) => { if (!IsCurrentGatewayAttempt(gen, subscribedGatewayId)) return; @@ -2036,14 +2046,18 @@ private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, lon _diagnostics.RecordWebSocketEvent("WebSocket error"); if (_stateMachine.Current.OperatorState != RoleConnectionState.PairingRequired) { + _stateMachine.SetOperatorProtocolCompatibility( + ReadOperatorProtocolCompatibility(gen)); // AuthenticationFailed and Status=Error are raised back-to-back and handled // asynchronously. If the auth handler already promoted the failure to a more // specific terminal kind (for example LocalPortConflict), never let the later // generic status handler overwrite it with the original token/transport kind. - if (_stateMachine.Current.OperatorState != RoleConnectionState.Error || + var failureKind = ReadOperatorFailureKind(gen); + if (failureKind == GatewayErrorKind.ProtocolMismatch || + _stateMachine.Current.OperatorState != RoleConnectionState.Error || _stateMachine.Current.OperatorErrorKind is null) { - _stateMachine.SetOperatorErrorKind(ReadOperatorFailureKind(gen)); + _stateMachine.SetOperatorErrorKind(failureKind); _stateMachine.TryTransition( ConnectionTrigger.WebSocketError, "Transport error"); @@ -2052,7 +2066,7 @@ private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, lon CompleteOperatorTelemetryAttempt( gen, "failure", - ConnectionErrorCategory.NetworkUnreachable); + MapConnectionErrorCategory(ReadOperatorFailureKind(gen))); break; case ConnectionStatus.Connecting: _diagnostics.RecordWebSocketEvent("WebSocket connecting"); @@ -2129,12 +2143,14 @@ await _bootstrapTokenLifecycle.TryScheduleOperatorTokenRecoveryAsync( return; _diagnostics.Record("error", "Authentication failed", message); + _stateMachine.SetOperatorProtocolCompatibility( + ReadOperatorProtocolCompatibility(gen)); _stateMachine.SetOperatorErrorKind(failureKind); _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, message); CompleteOperatorTelemetryAttempt( gen, "failure", - ConnectionErrorCategory.AuthFailure); + MapConnectionErrorCategory(failureKind)); EmitStateChanged(); } finally @@ -2172,6 +2188,55 @@ private void ClearOperatorFailureKind(long generation) } } + private void RecordOperatorProtocolCompatibility( + long generation, + GatewayProtocolCompatibility compatibility) + { + lock (_operatorProtocolCompatibilityLock) + { + _pendingOperatorProtocolGeneration = generation; + _pendingOperatorProtocolCompatibility = compatibility; + } + + lock (_telemetryLock) + { + if (_operatorTelemetryAttempt?.Generation == generation) + _operatorTelemetryAttempt.ProtocolCompatibility = compatibility; + } + } + + private GatewayProtocolCompatibility ReadOperatorProtocolCompatibility(long generation) + { + lock (_operatorProtocolCompatibilityLock) + { + return _pendingOperatorProtocolGeneration == generation + ? _pendingOperatorProtocolCompatibility + : GatewayProtocolCompatibility.Unknown; + } + } + + private static ConnectionErrorCategory MapConnectionErrorCategory( + GatewayErrorKind? errorKind) => + errorKind switch + { + GatewayErrorKind.Auth or + GatewayErrorKind.TokenDrift or + GatewayErrorKind.DeviceTokenMismatch or + GatewayErrorKind.ScopeMismatch => ConnectionErrorCategory.AuthFailure, + GatewayErrorKind.PairingRequired => ConnectionErrorCategory.PairingPending, + GatewayErrorKind.PairingRejected => ConnectionErrorCategory.PairingRejected, + GatewayErrorKind.RateLimited => ConnectionErrorCategory.RateLimited, + GatewayErrorKind.Tunnel => ConnectionErrorCategory.SshTunnelFailure, + GatewayErrorKind.Network or + GatewayErrorKind.Tls => ConnectionErrorCategory.NetworkUnreachable, + GatewayErrorKind.Server => ConnectionErrorCategory.ServerClose, + GatewayErrorKind.ProtocolMismatch => ConnectionErrorCategory.ProtocolMismatch, + GatewayErrorKind.LocalPortConflict => ConnectionErrorCategory.InternalError, + GatewayErrorKind.Unknown => ConnectionErrorCategory.InternalError, + null => ConnectionErrorCategory.NetworkUnreachable, + _ => ConnectionErrorCategory.InternalError + }; + // Auto credential recovery clears a device token and falls back to a stronger shared/bootstrap // credential. Restrict that to trusted endpoints (mirrors the Mac app, which only retries // credentials on loopback or explicitly trusted transport): a loopback/local endpoint (traffic @@ -2415,6 +2480,8 @@ private async Task HandleHandshakeSucceededAsync(long gen) var prev = _stateMachine.Current.OverallState; _diagnostics.Record("state", "Handshake succeeded (hello-ok)"); + _stateMachine.SetOperatorProtocolCompatibility( + ReadOperatorProtocolCompatibility(gen)); _stateMachine.TryTransition(ConnectionTrigger.HandshakeSucceeded); CompleteOperatorTelemetryAttempt(gen, "success"); var nodeModeIntended = SyncNodeIntentFromSettings(); @@ -2744,6 +2811,13 @@ private void OnNodeConnectionFailure(object? sender, GatewayErrorKind errorKind) _nodeConnectionCoordinator.HandleConnectionFailure(errorKind); } + private void OnNodeProtocolCompatibilityChanged( + object? sender, + GatewayProtocolCompatibility compatibility) + { + _nodeConnectionCoordinator.HandleProtocolCompatibilityChanged(compatibility); + } + private void OnNodeDeviceTokenReceived(object? sender, DeviceTokenReceivedEventArgs e) { _nodeConnectionCoordinator.HandleDeviceTokenReceived(e); @@ -2902,6 +2976,8 @@ async Task INodeConnectionStateSink.PublishNodeStatusAsync( switch (status) { case ConnectionStatus.Connected: + _stateMachine.SetNodeProtocolCompatibility( + connector.ProtocolCompatibility); _stateMachine.TryTransition(ConnectionTrigger.NodeConnected); break; case ConnectionStatus.Connecting: @@ -2913,11 +2989,16 @@ async Task INodeConnectionStateSink.PublishNodeStatusAsync( break; case ConnectionStatus.Error: if (_stateMachine.Current.NodeState != RoleConnectionState.PairingRequired) + { + _stateMachine.SetNodeProtocolCompatibility( + connector.ProtocolCompatibility); + _stateMachine.SetNodeErrorKind(connector.FailureKind); _stateMachine.TryTransition( ConnectionTrigger.NodeError, string.IsNullOrWhiteSpace(_stateMachine.Current.NodeError) ? "Node transport error" : _stateMachine.Current.NodeError); + } break; } @@ -2959,6 +3040,8 @@ async Task INodeConnectionStateSink.PublishNodePairingAsync( if (!_nodeConnectionCoordinator.IsCurrentNodeAttempt(attempt)) return false; + _stateMachine.SetNodeProtocolCompatibility( + connector.ProtocolCompatibility); switch (pairing.Status) { case PairingStatus.Paired: @@ -3250,6 +3333,22 @@ private static void FinishConnectionTelemetryAttempt( OpenClawTelemetryTag.String(OperationTag, attempt.Operation), OpenClawTelemetryTag.String(OpenClawTelemetryTagKey.Outcome, outcome) }; + var compatibility = attempt.ProtocolCompatibility; + tags.Add(OpenClawTelemetryTag.Number( + OpenClawTelemetryTagKey.ClientProtocol, + GatewayProtocolContract.CurrentVersion)); + tags.Add(OpenClawTelemetryTag.String( + OpenClawTelemetryTagKey.GatewayProtocol, + compatibility.GatewayProtocol switch + { + < GatewayProtocolContract.MinimumSupportedVersion => "older", + > GatewayProtocolContract.MaximumSupportedVersion => "newer", + not null => "current", + _ => "unknown" + })); + tags.Add(OpenClawTelemetryTag.String( + OpenClawTelemetryTagKey.ProtocolCompatibility, + compatibility.NormalizedState)); if (errorCategory.HasValue) { tags.Add(OpenClawTelemetryTag.String( @@ -3410,6 +3509,7 @@ private async Task DisposeCoreAsync() { telemetryEvents.TransportConnected -= OnNodeTransportConnected; telemetryEvents.ConnectionFailure -= OnNodeConnectionFailure; + telemetryEvents.ProtocolCompatibilityChanged -= OnNodeProtocolCompatibilityChanged; } } await _devicePairApprovalCoordinator.StopAsync().ConfigureAwait(false); @@ -3468,6 +3568,8 @@ private sealed record TelemetryAttempt( public Activity? PhaseActivity { get; set; } public string? PhaseName { get; set; } public long PhaseGeneration { get; set; } + public GatewayProtocolCompatibility ProtocolCompatibility { get; set; } = + GatewayProtocolCompatibility.Unknown; } private void ObserveBackgroundFault(Task task, string message) diff --git a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs index 4ba8ebd36..120b607c9 100644 --- a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs +++ b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs @@ -1,5 +1,11 @@ namespace OpenClaw.Connection; +public enum GatewayProtocolCompatibilityRole +{ + Operator, + Node +} + /// /// Immutable, cross-thread-safe representation of the entire connection /// state at a point in time. Safe to cache, compare, and pass between threads. @@ -20,6 +26,8 @@ public sealed record GatewayConnectionSnapshot public bool OperatorCredentialFallbackUsed { get; init; } public bool OperatorCredentialBootstrapRequired { get; init; } public string? OperatorCredentialDetail { get; init; } + public OpenClaw.Shared.GatewayProtocolCompatibility OperatorProtocolCompatibility { get; init; } = + OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; /// /// The requestId returned by the gateway when operator pairing is required. /// Used by setup flows to approve the specific pairing request via CLI. @@ -30,6 +38,7 @@ public sealed record GatewayConnectionSnapshot public bool NodeConnectionIntended { get; init; } public RoleConnectionState NodeState { get; init; } public string? NodeError { get; init; } + public OpenClaw.Shared.GatewayErrorKind? NodeErrorKind { get; init; } public OpenClaw.Shared.PairingStatus NodePairingStatus { get; init; } public string? NodeDeviceId { get; init; } public string? NodeCredentialSource { get; init; } @@ -37,6 +46,8 @@ public sealed record GatewayConnectionSnapshot public bool NodeCredentialFallbackUsed { get; init; } public bool NodeCredentialBootstrapRequired { get; init; } public string? NodeCredentialDetail { get; init; } + public OpenClaw.Shared.GatewayProtocolCompatibility NodeProtocolCompatibility { get; init; } = + OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; /// /// The requestId returned by the gateway when node pairing is required. /// Used by the connection page to show the correct approval command. @@ -50,6 +61,10 @@ public sealed record GatewayConnectionSnapshot public string? GatewayName { get; init; } // ─── Derived ─── + public OpenClaw.Shared.GatewayProtocolCompatibility ProtocolCompatibility { get; init; } = + OpenClaw.Shared.GatewayProtocolCompatibility.Unknown; + public GatewayProtocolCompatibilityRole? ProtocolCompatibilityRole { get; init; } + public bool IsFullyConnected => OperatorState == RoleConnectionState.Connected && NodeState == RoleConnectionState.Connected; @@ -62,6 +77,25 @@ public sealed record GatewayConnectionSnapshot NodePairingStatus = OpenClaw.Shared.PairingStatus.Unknown }; + internal static ( + OpenClaw.Shared.GatewayProtocolCompatibility Compatibility, + GatewayProtocolCompatibilityRole? Role) + DeriveProtocolCompatibility( + OpenClaw.Shared.GatewayProtocolCompatibility operatorCompatibility, + OpenClaw.Shared.GatewayProtocolCompatibility nodeCompatibility, + bool nodeEnabled) + { + if (operatorCompatibility.IsMismatch) + return (operatorCompatibility, GatewayProtocolCompatibilityRole.Operator); + if (nodeEnabled && nodeCompatibility.IsMismatch) + return (nodeCompatibility, GatewayProtocolCompatibilityRole.Node); + if (operatorCompatibility.State == OpenClaw.Shared.GatewayProtocolCompatibilityState.Compatible) + return (operatorCompatibility, GatewayProtocolCompatibilityRole.Operator); + if (nodeEnabled && nodeCompatibility.State == OpenClaw.Shared.GatewayProtocolCompatibilityState.Compatible) + return (nodeCompatibility, GatewayProtocolCompatibilityRole.Node); + return (OpenClaw.Shared.GatewayProtocolCompatibility.Unknown, null); + } + /// /// Derive the overall connection state from operator and node sub-states. /// diff --git a/src/OpenClaw.Connection/INodeConnector.cs b/src/OpenClaw.Connection/INodeConnector.cs index 45bfad80b..b7bd85fd4 100644 --- a/src/OpenClaw.Connection/INodeConnector.cs +++ b/src/OpenClaw.Connection/INodeConnector.cs @@ -57,6 +57,7 @@ public interface INodeConnectorTelemetryEvents { event EventHandler TransportConnected; event EventHandler ConnectionFailure; + event EventHandler ProtocolCompatibilityChanged; } public interface INodeConnectorReconnectPolicy diff --git a/src/OpenClaw.Connection/NodeConnectionCoordinator.cs b/src/OpenClaw.Connection/NodeConnectionCoordinator.cs index ada5c3409..d7225c124 100644 --- a/src/OpenClaw.Connection/NodeConnectionCoordinator.cs +++ b/src/OpenClaw.Connection/NodeConnectionCoordinator.cs @@ -68,7 +68,9 @@ internal interface INodeConnectionStateSource internal readonly record struct NodeConnectorSnapshot( bool IsConnected, PairingStatus PairingStatus, - string? NodeDeviceId); + string? NodeDeviceId, + GatewayProtocolCompatibility ProtocolCompatibility, + GatewayErrorKind? FailureKind); internal enum NodeAutomaticStartDisposition { @@ -152,6 +154,9 @@ internal sealed class NodeConnectionCoordinator : INodePairReconnectPort private long _startGuardVersion; private CancellationTokenSource? _operationCts; private string? _tokenRecoveryAttemptedGatewayId; + private GatewayProtocolCompatibility _protocolCompatibility = + GatewayProtocolCompatibility.Unknown; + private GatewayErrorKind? _failureKind; private TelemetryAttempt? _telemetryAttempt; private int _stopped; @@ -397,6 +402,8 @@ private async Task StartAttemptAsync( operationToken = operationCts.Token; var nodeGeneration = Interlocked.Increment(ref _nodeGeneration); attempt = new NodeAttemptStamp(gatewayAttempt, nodeGeneration); + _protocolCompatibility = GatewayProtocolCompatibility.Unknown; + _failureKind = null; _operationCts = operationCts; } @@ -745,6 +752,7 @@ internal void HandleConnectionFailure(GatewayErrorKind errorKind) lock (_operationLock) { + _failureKind = errorKind; Interlocked.CompareExchange( ref _startLifecycleGeneration, -1, @@ -766,6 +774,27 @@ internal void HandleConnectionFailure(GatewayErrorKind errorKind) } } + internal void HandleProtocolCompatibilityChanged( + GatewayProtocolCompatibility compatibility) + { + var attempt = CaptureCurrentAttempt(); + if (!IsCurrentNodeAttempt(attempt)) + return; + + lock (_operationLock) + { + if (!IsCurrentNodeAttempt(attempt)) + return; + _protocolCompatibility = compatibility; + } + + lock (_telemetryLock) + { + if (_telemetryAttempt?.Generation == attempt.NodeGeneration) + _telemetryAttempt.ProtocolCompatibility = compatibility; + } + } + internal void HandleDeviceTokenReceived(DeviceTokenReceivedEventArgs token) { var attempt = CaptureCurrentAttempt(); @@ -1058,11 +1087,18 @@ private long AcquireStartGuard(long lifecycleGeneration) return version; } - private NodeConnectorSnapshot CaptureConnectorSnapshot() => - new( - _nodeConnector?.IsConnected == true, - _nodeConnector?.PairingStatus ?? PairingStatus.Unknown, - _nodeConnector?.NodeDeviceId); + private NodeConnectorSnapshot CaptureConnectorSnapshot() + { + lock (_operationLock) + { + return new( + _nodeConnector?.IsConnected == true, + _nodeConnector?.PairingStatus ?? PairingStatus.Unknown, + _nodeConnector?.NodeDeviceId, + _protocolCompatibility, + _failureKind); + } + } private void TrackBackground(Task task) { @@ -1291,6 +1327,22 @@ private void FinishTelemetryAttempt( OpenClawTelemetryTag.String(OperationTag, attempt.Operation), OpenClawTelemetryTag.String(OpenClawTelemetryTagKey.Outcome, outcome) }; + var compatibility = attempt.ProtocolCompatibility; + tags.Add(OpenClawTelemetryTag.Number( + OpenClawTelemetryTagKey.ClientProtocol, + GatewayProtocolContract.CurrentVersion)); + tags.Add(OpenClawTelemetryTag.String( + OpenClawTelemetryTagKey.GatewayProtocol, + compatibility.GatewayProtocol switch + { + < GatewayProtocolContract.MinimumSupportedVersion => "older", + > GatewayProtocolContract.MaximumSupportedVersion => "newer", + not null => "current", + _ => "unknown" + })); + tags.Add(OpenClawTelemetryTag.String( + OpenClawTelemetryTagKey.ProtocolCompatibility, + compatibility.NormalizedState)); if (errorCategory.HasValue) { tags.Add(OpenClawTelemetryTag.String( @@ -1364,7 +1416,8 @@ GatewayErrorKind.DeviceTokenMismatch or GatewayErrorKind.Network or GatewayErrorKind.Tls => ConnectionErrorCategory.NetworkUnreachable, GatewayErrorKind.Server => ConnectionErrorCategory.ServerClose, - _ => ConnectionErrorCategory.ProtocolMismatch + GatewayErrorKind.ProtocolMismatch => ConnectionErrorCategory.ProtocolMismatch, + _ => ConnectionErrorCategory.InternalError }; private static bool HasPersistedIdentityFailure( @@ -1391,6 +1444,8 @@ private sealed record TelemetryAttempt( public Activity? PhaseActivity { get; set; } public string? PhaseName { get; set; } public long PhaseGeneration { get; set; } + public GatewayProtocolCompatibility ProtocolCompatibility { get; set; } = + GatewayProtocolCompatibility.Unknown; } private sealed class NodeStartGuardLease diff --git a/src/OpenClaw.Connection/NodeConnector.cs b/src/OpenClaw.Connection/NodeConnector.cs index d5a9af349..c928b1f37 100644 --- a/src/OpenClaw.Connection/NodeConnector.cs +++ b/src/OpenClaw.Connection/NodeConnector.cs @@ -25,6 +25,7 @@ public sealed class NodeConnector : INodeConnector, INodeConnectorTelemetryEvent public event EventHandler? ClientCreated; public event EventHandler? TransportConnected; public event EventHandler? ConnectionFailure; + public event EventHandler? ProtocolCompatibilityChanged; public NodeConnector(IOpenClawLogger logger, ConnectionDiagnostics? diagnostics = null) { @@ -174,6 +175,8 @@ private async Task ConnectCoreAsync( ForwardIfCurrent(s, generation, EventArgs.Empty, TransportConnected); client.ConnectionFailure += (s, e) => ForwardIfCurrent(s, generation, e, ConnectionFailure); + client.ProtocolCompatibilityChanged += (s, e) => + ForwardIfCurrent(s, generation, e, ProtocolCompatibilityChanged); client.PairingStatusChanged += (s, e) => ForwardIfCurrent(s, generation, e, PairingStatusChanged); client.DeviceTokenReceived += (s, e) => diff --git a/src/OpenClaw.Shared/ConnectEnvelopeBuilder.cs b/src/OpenClaw.Shared/ConnectEnvelopeBuilder.cs index 2e4bb5e5e..d6afef662 100644 --- a/src/OpenClaw.Shared/ConnectEnvelopeBuilder.cs +++ b/src/OpenClaw.Shared/ConnectEnvelopeBuilder.cs @@ -297,8 +297,8 @@ internal string Serialize(string? signature) method = "connect", @params = new { - minProtocol = 3, - maxProtocol = 4, + minProtocol = GatewayProtocolContract.MinimumSupportedVersion, + maxProtocol = GatewayProtocolContract.MaximumSupportedVersion, client = new { id = SigningArguments.ClientId, diff --git a/src/OpenClaw.Shared/GatewayErrorClassifier.cs b/src/OpenClaw.Shared/GatewayErrorClassifier.cs index 6d3172f86..6c31d9d50 100644 --- a/src/OpenClaw.Shared/GatewayErrorClassifier.cs +++ b/src/OpenClaw.Shared/GatewayErrorClassifier.cs @@ -65,6 +65,9 @@ public enum GatewayErrorKind /// Rate limited by the gateway. RateLimited, + + /// The Gateway wire protocol is incompatible with this client. + ProtocolMismatch, } /// @@ -81,6 +84,9 @@ public static GatewayErrorKind Classify(string? error) var e = error.ToLowerInvariant(); + if (Contains(e, "protocol mismatch")) + return GatewayErrorKind.ProtocolMismatch; + if ((Contains(e, "rate") && Contains(e, "limit")) || Contains(e, "429") || Contains(e, "too many request")) return GatewayErrorKind.RateLimited; @@ -169,6 +175,9 @@ public static bool IsSharedGatewayTokenMismatch(string? message) => /// The structured gateway code for a wrong shared/gateway token. public const string SharedTokenMismatchCode = "AUTH_TOKEN_MISMATCH"; + /// The structured gateway code for an incompatible wire protocol. + public const string ProtocolMismatchCode = "PROTOCOL_MISMATCH"; + /// /// Code-aware classification. Structured error codes (top-level error.code and /// nested error.details.code) are authoritative and are checked BEFORE the textual @@ -188,6 +197,8 @@ public static GatewayErrorKind ClassifyWithCode(string? message, params string?[ { if (string.IsNullOrWhiteSpace(code)) continue; + if (string.Equals(code, ProtocolMismatchCode, StringComparison.OrdinalIgnoreCase)) + return GatewayErrorKind.ProtocolMismatch; if (string.Equals(code, DeviceTokenMismatchCode, StringComparison.OrdinalIgnoreCase)) return GatewayErrorKind.DeviceTokenMismatch; // A wrong shared/gateway token is terminal auth but must NOT be treated as a diff --git a/src/OpenClaw.Shared/GatewayProtocolCompatibility.cs b/src/OpenClaw.Shared/GatewayProtocolCompatibility.cs new file mode 100644 index 000000000..ff1437405 --- /dev/null +++ b/src/OpenClaw.Shared/GatewayProtocolCompatibility.cs @@ -0,0 +1,74 @@ +namespace OpenClaw.Shared; + +public enum GatewayProtocolCompatibilityState +{ + Unknown, + Compatible, + GatewayTooOld, + GatewayTooNew, + Mismatch +} + +/// +/// Sanitized Gateway wire-protocol compatibility details safe for state, +/// diagnostics, and low-cardinality telemetry. +/// +public sealed record GatewayProtocolCompatibility +{ + public required GatewayProtocolCompatibilityState State { get; init; } + public int ClientMinimumProtocol { get; init; } = GatewayProtocolContract.MinimumSupportedVersion; + public int ClientMaximumProtocol { get; init; } = GatewayProtocolContract.MaximumSupportedVersion; + public int? SelectedProtocol { get; init; } + public int? GatewayExpectedProtocol { get; init; } + public int? GatewayMinimumProtocol { get; init; } + public bool Retryable { get; init; } + + public bool IsMismatch => + State is GatewayProtocolCompatibilityState.GatewayTooOld + or GatewayProtocolCompatibilityState.GatewayTooNew + or GatewayProtocolCompatibilityState.Mismatch; + + public int? GatewayProtocol => SelectedProtocol ?? GatewayExpectedProtocol; + + public string NormalizedState => State switch + { + GatewayProtocolCompatibilityState.Compatible => "compatible", + GatewayProtocolCompatibilityState.GatewayTooOld => "gateway_too_old", + GatewayProtocolCompatibilityState.GatewayTooNew => "gateway_too_new", + GatewayProtocolCompatibilityState.Mismatch => "mismatch", + _ => "unknown" + }; + + public static GatewayProtocolCompatibility Unknown { get; } = new() + { + State = GatewayProtocolCompatibilityState.Unknown, + Retryable = true + }; + + public static GatewayProtocolCompatibility Compatible(int protocol) => new() + { + State = GatewayProtocolCompatibilityState.Compatible, + SelectedProtocol = protocol, + Retryable = false + }; + + public static GatewayProtocolCompatibility FromGatewayExpectation( + int? expectedProtocol, + int? minimumProbeProtocol = null) + { + var state = expectedProtocol switch + { + < GatewayProtocolContract.MinimumSupportedVersion => GatewayProtocolCompatibilityState.GatewayTooOld, + > GatewayProtocolContract.MaximumSupportedVersion => GatewayProtocolCompatibilityState.GatewayTooNew, + _ => GatewayProtocolCompatibilityState.Mismatch + }; + + return new GatewayProtocolCompatibility + { + State = state, + GatewayExpectedProtocol = expectedProtocol, + GatewayMinimumProtocol = minimumProbeProtocol, + Retryable = false + }; + } +} diff --git a/src/OpenClaw.Shared/GatewayProtocolContract.cs b/src/OpenClaw.Shared/GatewayProtocolContract.cs new file mode 100644 index 000000000..9edd47749 --- /dev/null +++ b/src/OpenClaw.Shared/GatewayProtocolContract.cs @@ -0,0 +1,105 @@ +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// Defines the Windows clients' supported Gateway wire protocol and validates +/// the minimal successful handshake contract. +/// +public static class GatewayProtocolContract +{ + public const int SupportedVersion = 4; + public const int CurrentVersion = SupportedVersion; + public const int MinimumSupportedVersion = 3; + public const int MaximumSupportedVersion = CurrentVersion; + public const string HelloOkType = "hello-ok"; + + public static bool IsHelloOk(JsonElement payload) => + payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty("type", out var type) && + type.ValueKind == JsonValueKind.String && + string.Equals(type.GetString(), HelloOkType, StringComparison.Ordinal); + + public static bool TryValidateHelloOk(JsonElement payload, out string error) + { + if (payload.ValueKind != JsonValueKind.Object) + { + error = "hello-ok payload must be an object"; + return false; + } + + if (!IsHelloOk(payload)) + { + error = "connect success payload must have type hello-ok"; + return false; + } + + if (!payload.TryGetProperty("protocol", out var protocol) || + protocol.ValueKind != JsonValueKind.Number || + !protocol.TryGetInt32(out var version)) + { + error = "hello-ok protocol must be an integer"; + return false; + } + + // hello-ok.protocol is the Gateway's current protocol constant, not a + // negotiated selection. Once the Gateway accepts our advertised range, + // future protocol values remain valid unless they fall below our floor. + if (version < MinimumSupportedVersion) + { + error = $"hello-ok protocol {version} is unsupported"; + return false; + } + + error = string.Empty; + return true; + } + + public static bool TryGetProtocol(JsonElement payload, out int protocol) + { + protocol = default; + return payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty("protocol", out var protocolValue) && + protocolValue.ValueKind == JsonValueKind.Number && + protocolValue.TryGetInt32(out protocol); + } + + public static GatewayProtocolCompatibility ParseMismatch(JsonElement response) + { + if (!TryGetErrorDetails(response, out var details)) + return GatewayProtocolCompatibility.FromGatewayExpectation(expectedProtocol: null); + + return GatewayProtocolCompatibility.FromGatewayExpectation( + TryGetInteger(details, "expectedProtocol"), + TryGetInteger(details, "minimumProbeProtocol")); + } + + private static bool TryGetErrorDetails(JsonElement response, out JsonElement details) + { + details = default; + if (response.ValueKind != JsonValueKind.Object || + !response.TryGetProperty("error", out var error) || + error.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (error.TryGetProperty("details", out details) && + details.ValueKind == JsonValueKind.Object) + { + return true; + } + + return error.TryGetProperty("data", out var data) && + data.ValueKind == JsonValueKind.Object && + data.TryGetProperty("details", out details) && + details.ValueKind == JsonValueKind.Object; + } + + private static int? TryGetInteger(JsonElement value, string propertyName) => + value.TryGetProperty(propertyName, out var property) && + property.ValueKind == JsonValueKind.Number && + property.TryGetInt32(out var integer) + ? integer + : null; +} diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index 9828790e0..d5b6a09ca 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -386,7 +386,7 @@ private object HandleToolsList() ["app.chat.queue.cancel"] = "Cancel/remove one native chat outgoing queue entry before it is sent. Args: queuedMessageId (string, required), threadId/sessionKey (string, required; use the threadId returned by app.chat.queue.list or app.chat.snapshot). Only Queued/Failed entries can be removed; Sending entries may already have reached the gateway. Returns { canceled, threadId, queuedMessageId, remainingCount, error? }.", ["app.connection.status"] = - "READ-ONLY local MCP connection diagnostics. No args. Returns effective mode/state, active gateway metadata, operator/node credential resolution, MCP runtime state, browser proxy caveat, pending approval actions, retry hints, and recent diagnostic events.", + "READ-ONLY local MCP connection diagnostics. No args. Returns effective mode/state, installed Gateway package version, selected wire protocol and normalized compatibility details, active gateway metadata, operator/node credential resolution, MCP runtime state, browser proxy caveat, pending approval actions, retry hints, and recent diagnostic events.", ["app.connection.gateways"] = "READ-ONLY saved gateway diagnostics. No args. Returns { activeGatewayId, count, gateways[] } with per-gateway id/name/url, active flag, lastConnected, credential presence booleans, SSH/browser-proxy configuration, and no token values.", ["app.connection.applySetupCode"] = diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index e929b0727..b8e9f8450 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -96,6 +96,7 @@ internal IConnectEnvelopeSigner ConnectEnvelopeSigner private string? _pairingRequiredRequestId; private bool _authFailed; private readonly HandshakeChallengeGate _handshakeChallengeGate = new(); + private bool _protocolMismatch; private string? _lastSkillsStatusAgentId; private readonly bool _tokenIsBootstrapToken; private readonly bool _bootstrapPairAsNode; @@ -178,7 +179,7 @@ protected void RaiseTransportConnected() => protected override bool ShouldAutoReconnect() { // PairingRequired must stay visible, but approval only takes effect on a fresh socket. - return !_authFailed; + return !_authFailed && !_protocolMismatch; } protected override void OnDisconnected() @@ -268,6 +269,8 @@ public Func>? /// kind for policy/UI decisions and keep the accompanying text only for sanitized detail. /// public event EventHandler? ConnectionFailure; + /// Raised with sanitized wire-protocol compatibility details for the current handshake. + public event EventHandler? ProtocolCompatibilityChanged; public string? OperatorDeviceId => _operatorDeviceId; public IReadOnlyList GrantedOperatorScopes => _grantedOperatorScopes; @@ -1919,6 +1922,12 @@ private void ProcessMessageForConnection( if (!IsCurrentConnectionGeneration(sourceConnectionGeneration)) return; + if (_protocolMismatch) + { + _logger.Warn("[HANDSHAKE] Ignoring message after terminal protocol mismatch"); + return; + } + try { using var doc = JsonDocument.Parse(json); @@ -2032,7 +2041,30 @@ private void HandleResponseForConnection( return; } - if (!root.TryGetProperty("payload", out var payload)) return; + if (!root.TryGetProperty("payload", out var payload)) + { + if (string.Equals(requestMethod, "connect", StringComparison.Ordinal)) + HandleProtocolMismatch("connect success response has no payload"); + return; + } + + var isHelloOk = GatewayProtocolContract.IsHelloOk(payload); + if (isHelloOk && + HandshakeAuthorizationAsync is not null && + !string.Equals(requestMethod, "connect", StringComparison.Ordinal)) + { + _logger.Warn("[HANDSHAKE] Ignoring uncorrelated hello-ok on guarded validation connection."); + return; + } + + if ((string.Equals(requestMethod, "connect", StringComparison.Ordinal) || isHelloOk) && + !GatewayProtocolContract.TryValidateHelloOk(payload, out var protocolError)) + { + var compatibility = GatewayProtocolCompatibility.FromGatewayExpectation( + GatewayProtocolContract.TryGetProtocol(payload, out var protocol) ? protocol : null); + HandleProtocolMismatch(protocolError, compatibility); + return; + } if (!string.IsNullOrEmpty(requestMethod) && HandleKnownResponse(requestMethod!, payload)) { @@ -2040,7 +2072,7 @@ private void HandleResponseForConnection( } // Handle handshake acknowledgement payload. - if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok") + if (isHelloOk) { if (!IsCurrentConnectionGeneration(sourceConnectionGeneration) || !_handshakeChallengeGate.IsAuthorized(sourceConnectionGeneration) || @@ -2054,6 +2086,11 @@ private void HandleResponseForConnection( Volatile.Write(ref _pairingRequiredAwaitingApproval, false); Volatile.Write(ref _pairingRequiredRequestId, null); _authFailed = false; + _protocolMismatch = false; + _ = GatewayProtocolContract.TryGetProtocol(payload, out var acceptedProtocol); + ProtocolCompatibilityChanged?.Invoke( + this, + GatewayProtocolCompatibility.Compatible(acceptedProtocol)); ResetReconnectAttempts(); _operatorDeviceId = TryGetHandshakeDeviceId(payload); _grantedOperatorScopes = TryGetHandshakeScopes(payload); @@ -2343,6 +2380,7 @@ private void HandleRequestError(string? method, JsonElement root) { var message = TryGetErrorMessage(root) ?? "request failed"; var detailCode = method == "connect" ? TryGetErrorDetailCode(root) : null; + var topLevelCode = method == "connect" ? TryGetErrorTopLevelCode(root) : null; if (string.IsNullOrEmpty(method)) { @@ -2359,6 +2397,14 @@ private void HandleRequestError(string? method, JsonElement root) _logger.Info($"[HANDSHAKE] Raw error response: {rawJson}"); } + if (method == "connect" && + GatewayErrorClassifier.ClassifyWithCode(message, topLevelCode, detailCode) == + GatewayErrorKind.ProtocolMismatch) + { + HandleProtocolMismatch(message, GatewayProtocolContract.ParseMismatch(root)); + return; + } + if (method == "connect" && detailCode == "DEVICE_AUTH_SIGNATURE_EXPIRED") { _authFailed = true; @@ -2405,7 +2451,6 @@ private void HandleRequestError(string? method, JsonElement root) // error.code and the structured error.details.code so a device-token mismatch delivered in // either place is recognized (the gateway may send the reason only as a code with a generic // message). - var topLevelCode = TryGetErrorTopLevelCode(root); if (method == "connect" && (IsTerminalAuthError(message) || IsTerminalAuthDetailCode(detailCode) || IsTerminalAuthDetailCode(topLevelCode))) { @@ -2500,6 +2545,20 @@ private void HandleRequestError(string? method, JsonElement root) _logger.Warn($"{method} failed: {message}"); } + private void HandleProtocolMismatch( + string detail, + GatewayProtocolCompatibility? compatibility = null) + { + _protocolMismatch = true; + AbortCurrentWebSocket(CurrentConnectionGeneration); + _logger.Warn($"[HANDSHAKE] Gateway protocol mismatch: {TokenSanitizer.Sanitize(detail)}"); + ProtocolCompatibilityChanged?.Invoke( + this, + compatibility ?? GatewayProtocolCompatibility.FromGatewayExpectation(expectedProtocol: null)); + RaiseConnectionFailure(GatewayErrorKind.ProtocolMismatch); + RaiseStatusChanged(ConnectionStatus.Error); + } + private static bool TryGetSessionsPayload(JsonElement payload, out JsonElement sessions) { if (payload.ValueKind == JsonValueKind.Object && diff --git a/src/OpenClaw.Shared/Telemetry/OpenClawTelemetryTags.cs b/src/OpenClaw.Shared/Telemetry/OpenClawTelemetryTags.cs index 9bc304069..721a1ed38 100644 --- a/src/OpenClaw.Shared/Telemetry/OpenClawTelemetryTags.cs +++ b/src/OpenClaw.Shared/Telemetry/OpenClawTelemetryTags.cs @@ -7,7 +7,10 @@ public enum OpenClawTelemetryTagKey ErrorCategory, ErrorType, Reason, - Status + Status, + ClientProtocol, + GatewayProtocol, + ProtocolCompatibility } /// @@ -24,6 +27,9 @@ public static string ToTelemetryName(this OpenClawTelemetryTagKey key) => OpenClawTelemetryTagKey.ErrorType => "error.type", OpenClawTelemetryTagKey.Reason => "openclaw.reason", OpenClawTelemetryTagKey.Status => "openclaw.status", + OpenClawTelemetryTagKey.ClientProtocol => "openclaw.protocol.client", + OpenClawTelemetryTagKey.GatewayProtocol => "openclaw.protocol.gateway", + OpenClawTelemetryTagKey.ProtocolCompatibility => "openclaw.protocol.compatibility", _ => throw new ArgumentOutOfRangeException(nameof(key), key, "Unknown OpenClaw telemetry tag key.") }; } diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 171f49fe6..886bfa671 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -38,6 +38,7 @@ public class WindowsNodeClient : WebSocketClientBase // even after OnDisconnected clears _isPendingApproval. private volatile bool _pairingBlocked; private volatile bool _rateLimited; + private volatile bool _protocolMismatch; private bool _useV2Signature; // true after v3 signature rejected by gateway public bool UseV2Signature { get => _useV2Signature; set => _useV2Signature = value; } private readonly HandshakeChallengeGate _handshakeChallengeGate = new(); @@ -90,6 +91,8 @@ internal IConnectEnvelopeSigner ConnectEnvelopeSigner /// public Func>? HandshakeAuthorizationAsync { get; set; } + /// Raised with sanitized wire-protocol compatibility details for the current handshake. + public event EventHandler? ProtocolCompatibilityChanged; protected override void OnReconnectAuthorizationDenied( ReconnectAuthorizationResult authorization) @@ -297,6 +300,12 @@ protected override async Task ProcessMessageForConnectionAsync( } var type = typeProp.GetString(); _logger.Debug($"[NODE] Processing message type: {type}"); + + if (_protocolMismatch) + { + _logger.Warn("[NODE] Ignoring message after terminal protocol mismatch"); + return; + } switch (type) { @@ -874,12 +883,32 @@ private void HandleResponseForConnection( if (!root.TryGetProperty("payload", out var payload)) { - _logger.Warn("[NODE] Response has no payload"); + if (isConnectResponse) + HandleProtocolMismatch("connect success response has no payload"); + return; + } + + var isHelloOk = GatewayProtocolContract.IsHelloOk(payload); + if (isHelloOk && !isConnectResponse) + { + _logger.Warn("[HANDSHAKE] Ignoring uncorrelated node hello-ok."); return; } + + if (isConnectResponse && + !GatewayProtocolContract.TryValidateHelloOk(payload, out var protocolError)) + { + var compatibility = GatewayProtocolCompatibility.FromGatewayExpectation( + GatewayProtocolContract.TryGetProtocol(payload, out var protocol) ? protocol : null); + HandleProtocolMismatch(protocolError, compatibility); + return; + } + + if (!isHelloOk) + return; // Handle hello-ok (successful registration) - if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok") + if (isHelloOk) { if (!isConnectResponse || !_handshakeChallengeGate.IsAuthorized(sourceConnectionGeneration)) @@ -894,6 +923,11 @@ private void HandleResponseForConnection( var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect; _isConnected = true; _rateLimited = false; // Clear transient rate-limit on successful connect + _protocolMismatch = false; + _ = GatewayProtocolContract.TryGetProtocol(payload, out var acceptedProtocol); + ProtocolCompatibilityChanged?.Invoke( + this, + GatewayProtocolCompatibility.Compatible(acceptedProtocol)); ResetReconnectAttempts(); // Extract node ID if returned @@ -1032,6 +1066,13 @@ private void HandleRequestError(JsonElement root) var effectiveErrorCode = detailCode ?? errorCode; _logger.Info($"[HANDSHAKE] Connect error: message=\"{error}\", code={errorCode}, detailCode={detailCode ?? "none"}"); + if (!_isConnected && + ClassifyConnectionFailure(error, errorCode, detailCode) == GatewayErrorKind.ProtocolMismatch) + { + HandleProtocolMismatch(error, GatewayProtocolContract.ParseMismatch(root)); + return; + } + if (string.Equals(errorCode, "NOT_PAIRED", StringComparison.OrdinalIgnoreCase)) { if (_isPendingApproval) @@ -1113,6 +1154,22 @@ private static GatewayErrorKind ClassifyConnectionFailure(string error, string e return GatewayErrorClassifier.ClassifyWithCode(error, errorCode, detailsCode); } + private void HandleProtocolMismatch( + string detail, + GatewayProtocolCompatibility? compatibility = null) + { + _protocolMismatch = true; + _isConnected = false; + Volatile.Write(ref _pendingConnectRequestId, null); + AbortCurrentWebSocket(CurrentConnectionGeneration); + _logger.Warn($"[NODE] Gateway protocol mismatch: {TokenSanitizer.Sanitize(detail)}"); + ProtocolCompatibilityChanged?.Invoke( + this, + compatibility ?? GatewayProtocolCompatibility.FromGatewayExpectation(expectedProtocol: null)); + ConnectionFailure?.Invoke(this, GatewayErrorKind.ProtocolMismatch); + RaiseStatusChanged(ConnectionStatus.Error); + } + // Structured terminal-auth codes (wrong shared/bootstrap token, rate limit, token not // configured, device-token mismatch). These are permanent for the current connection, so the // node client must stop its own auto-reconnect even when the human message is generic. @@ -1876,6 +1933,9 @@ protected override bool ShouldAutoReconnect() if (_rateLimited) return false; + if (_protocolMismatch) + return false; + return true; } diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 8e93ebbe9..6a9b6547b 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -269,7 +269,8 @@ private void WireAppCapabilityHandlers() mcpError: mcpPlan.ShouldShow ? mcpPlan.Message : null, nodeBrowserProxyEnabled: _settings?.NodeBrowserProxyEnabled != false, recentDiagnostics: recentDiagnostics, - diagnosticEventCount: diagnostics?.Count ?? recentDiagnostics.Count)); + diagnosticEventCount: diagnostics?.Count ?? recentDiagnostics.Count, + gatewaySelf: _appState?.GatewaySelf)); }; connection.GatewaysHandler = () => diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml index 4b6220e84..1cc80730b 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml @@ -683,7 +683,8 @@ - +