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 @@
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
index 7800cd709..50a646a42 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs
@@ -13,6 +13,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
@@ -395,9 +396,17 @@ private void ApplyStripVisuals(ConnectionPagePlan plan)
? Visibility.Collapsed
: Visibility.Visible;
- StripHeadline.Text = plan.StripHeadline ?? "";
- StripSub.Text = plan.StripSub ?? "";
- StripSub.Visibility = string.IsNullOrEmpty(plan.StripSub) ? Visibility.Collapsed : Visibility.Visible;
+ var stripHeadline = ResolvePlanText(
+ plan.StripHeadline,
+ plan.StripHeadlineResourceKey,
+ plan);
+ var stripSub = ResolvePlanText(
+ plan.StripSub,
+ plan.StripSubResourceKey,
+ plan);
+ StripHeadline.Text = stripHeadline;
+ StripSub.Text = stripSub;
+ StripSub.Visibility = string.IsNullOrEmpty(stripSub) ? Visibility.Collapsed : Visibility.Visible;
// Primary action button — show only for actions the connection
// toggle can't already do. The toggle covers Connect / Reconnect /
@@ -438,7 +447,9 @@ or OverallConnectionState.Connected
or OverallConnectionState.Ready
or OverallConnectionState.Degraded
or OverallConnectionState.PairingRequired;
- ConnectionToggle.Visibility = hasActive ? Visibility.Visible : Visibility.Collapsed;
+ ConnectionToggle.Visibility = hasActive && plan.AllowConnectionToggle
+ ? Visibility.Visible
+ : Visibility.Collapsed;
// Avoid recursive Toggled events while we sync from snapshot
_suppressConnectionToggle = true;
ConnectionToggle.IsOn = toggleOn;
@@ -801,7 +812,9 @@ or NodeCardState.OnNodeRateLimited
NodeCardState.OnNodePairingRequired => LocalizationHelper.GetString("ConnectionPage_NodeBodyAwaitingApproval"),
NodeCardState.OnNodeRejected => LocalizationHelper.GetString("ConnectionPage_NodeBodyPairingRejected"),
NodeCardState.OnNodeRateLimited => LocalizationHelper.GetString("ConnectionPage_NodeBodyRateLimited"),
- NodeCardState.OnNodeError => plan.NodeErrorDetail ?? LocalizationHelper.GetString("ConnectionPage_NodeBodyError"),
+ NodeCardState.OnNodeError => plan.NodeErrorDetailResourceKey is { } nodeErrorResourceKey
+ ? ResolvePlanText("", nodeErrorResourceKey, plan)
+ : plan.NodeErrorDetail ?? LocalizationHelper.GetString("ConnectionPage_NodeBodyError"),
_ => "",
};
var bodyBrushKey = plan.NodeCard switch
@@ -1062,12 +1075,17 @@ plan.NodeCard is NodeCardState.OnNodeApprovalRequired or
private void ApplyRecoveryBody(ConnectionPagePlan plan)
{
RecoveryBulletsPanel.Children.Clear();
+ RecoveryConnectionActions.Visibility = plan.AllowConnectionToggle
+ ? Visibility.Visible
+ : Visibility.Collapsed;
RecoveryTunnelBlock.Visibility = Visibility.Collapsed;
RecoveryAuthPasteBlock.Visibility = Visibility.Collapsed;
RecoveryApproveCmdBlock.Visibility = Visibility.Collapsed;
RecoveryRepairResultText.Visibility = Visibility.Collapsed;
- RecoveryHelpHeaderText.Text = plan.Recovery switch
+ RecoveryHelpHeaderText.Text = plan.RecoveryHeaderResourceKey is { } recoveryHeaderKey
+ ? ResolvePlanText("", recoveryHeaderKey, plan)
+ : plan.Recovery switch
{
RecoveryCategory.Auth => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderAuth"),
RecoveryCategory.Pairing => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderPairing"),
@@ -1082,7 +1100,11 @@ private void ApplyRecoveryBody(ConnectionPagePlan plan)
_ => LocalizationHelper.GetString("ConnectionPage_RecoveryHeaderServer"),
};
- var bullets = plan.Recovery switch
+ var bullets = plan.RecoveryBulletResourceKeys.Count > 0
+ ? plan.RecoveryBulletResourceKeys
+ .Select(key => ResolvePlanText("", key, plan))
+ .ToArray()
+ : plan.Recovery switch
{
RecoveryCategory.Auth => new[]
{
@@ -1169,6 +1191,24 @@ or RecoveryCategory.TokenDrift
}
}
+ private static string ResolvePlanText(
+ string fallback,
+ string? resourceKey,
+ ConnectionPagePlan plan)
+ {
+ if (string.IsNullOrWhiteSpace(resourceKey))
+ return fallback;
+
+ var value = LocalizationHelper.GetString(resourceKey);
+ return string.Format(
+ CultureInfo.CurrentCulture,
+ value,
+ plan.ProtocolExpectedVersion,
+ plan.ProtocolMinimumVersion,
+ plan.ProtocolMaximumVersion,
+ plan.ProtocolCurrentVersion);
+ }
+
private static Border BuildBulletRow(string text)
{
var grid = new Grid();
@@ -1518,7 +1558,9 @@ private void LoadSavedGateways()
// because that drives the per-row "Connected" badge, and Url
// because the row's sub-line shows it.
var sb = new System.Text.StringBuilder(items.Count * 64);
- sb.Append(_lastSnapshot.OverallState).Append('|');
+ sb.Append(_lastSnapshot.OverallState).Append('/')
+ .Append(_currentPlan?.AllowConnectionToggle != false ? '1' : '0')
+ .Append('|');
foreach (var r in items)
{
sb.Append(r.Id).Append('/').Append(r.IsActive ? '1' : '0').Append('/')
@@ -1757,7 +1799,10 @@ private Border BuildSavedGatewayRowControl(SavedGatewayRow row)
Grid.SetColumn(badge!, 1);
grid.Children.Add(badge!);
}
- else
+ else if (ConnectionPageRowState.ShouldShowConnect(
+ row.IsActive,
+ hasLiveAffordance,
+ _currentPlan?.AllowConnectionToggle ?? true))
{
var connectBtn = new Button
{
@@ -1765,6 +1810,9 @@ private Border BuildSavedGatewayRowControl(SavedGatewayRow row)
Tag = row.Id,
VerticalAlignment = VerticalAlignment.Center,
};
+ Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(
+ connectBtn,
+ "SavedGatewayConnectAction");
connectBtn.Click += OnConnectSavedGateway;
Grid.SetColumn(connectBtn, 1);
grid.Children.Add(connectBtn);
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs
index fe756e373..3f817d965 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs
@@ -121,6 +121,8 @@ internal enum RecoveryCategory
Tailscale,
/// A different or unverified local process owns the managed gateway port.
LocalPortConflict,
+ /// The Gateway and Windows app do not share a supported wire protocol.
+ ProtocolMismatch,
}
///
@@ -136,9 +138,16 @@ internal sealed record ConnectionPagePlan
public ConnectionAccent StripAccent { get; init; } = ConnectionAccent.Neutral;
public string StripHeadline { get; init; } = "Not connected";
public string StripSub { get; init; } = "";
+ public string? StripHeadlineResourceKey { get; init; }
+ public string? StripSubResourceKey { get; init; }
+ public int? ProtocolExpectedVersion { get; init; }
+ public int ProtocolMinimumVersion { get; init; } = GatewayProtocolContract.MinimumSupportedVersion;
+ public int ProtocolMaximumVersion { get; init; } = GatewayProtocolContract.MaximumSupportedVersion;
+ public int ProtocolCurrentVersion { get; init; } = GatewayProtocolContract.CurrentVersion;
public bool StripShowProgress { get; init; }
public string? StripPrimaryLabel { get; init; }
public ConnectionPrimaryAction StripPrimaryAction { get; init; } = ConnectionPrimaryAction.None;
+ public bool AllowConnectionToggle { get; init; } = true;
// ─── Operator card ───
public OperatorCardState OperatorCard { get; init; } = OperatorCardState.Hidden;
@@ -162,10 +171,13 @@ internal sealed record ConnectionPagePlan
new Dictionary(StringComparer.OrdinalIgnoreCase);
/// For OnNodeError — sanitized error string.
public string? NodeErrorDetail { get; init; }
+ public string? NodeErrorDetailResourceKey { get; init; }
// ─── Recovery sub-screen ───
public RecoveryCategory Recovery { get; init; } = RecoveryCategory.None;
public string? RecoveryDetail { get; init; }
+ public string? RecoveryHeaderResourceKey { get; init; }
+ public IReadOnlyList RecoveryBulletResourceKeys { get; init; } = Array.Empty();
/// For RecoveryCategory.Pairing — the CLI command the user should run.
public string? RecoveryApproveCommand { get; init; }
@@ -225,6 +237,16 @@ private static ConnectionPagePlan BuildDerived(
int savedGatewayCount,
string displayName)
{
+ if (snap.ProtocolCompatibility.IsMismatch &&
+ snap.ProtocolCompatibilityRole == GatewayProtocolCompatibilityRole.Node &&
+ snap.OverallState == OverallConnectionState.Degraded)
+ {
+ return BuildNodeProtocolMismatchCockpit(snap, activeRecord, self, settings, displayName);
+ }
+
+ if (snap.ProtocolCompatibility.IsMismatch)
+ return BuildProtocolMismatchRecovery(snap, activeRecord, displayName);
+
// ─── Derived layout ───
return snap.OverallState switch
{
@@ -261,6 +283,70 @@ private static ConnectionPagePlan BuildDerived(
};
}
+ private static ConnectionPagePlan BuildProtocolMismatchRecovery(
+ GatewayConnectionSnapshot snap,
+ GatewayRecord? rec,
+ string name)
+ {
+ var compatibility = snap.ProtocolCompatibility;
+ var (headerKey, detailKey) = GetProtocolMismatchResourceKeys(compatibility);
+ var url = ConnectionCardPlanSanitizer.SanitizeGatewayUrl(rec?.Url ?? snap.GatewayUrl);
+
+ return new ConnectionPagePlan
+ {
+ Mode = ConnectionPageMode.Recovery,
+ Recovery = RecoveryCategory.ProtocolMismatch,
+ StripGlyph = OpenClawTray.Helpers.FluentIconCatalog.StatusErr,
+ StripAccent = ConnectionAccent.Critical,
+ StripHeadlineResourceKey = headerKey,
+ StripSubResourceKey = detailKey,
+ StripPrimaryLabel = null,
+ StripPrimaryAction = ConnectionPrimaryAction.None,
+ AllowConnectionToggle = true,
+ ProtocolExpectedVersion = compatibility.GatewayExpectedProtocol,
+ RecoveryHeaderResourceKey = headerKey,
+ RecoveryBulletResourceKeys = detailKey is null ? [] : [detailKey],
+ ActiveGatewayDisplayName = name,
+ ActiveGatewayDetailLine = url,
+ ActiveGatewayHasSshTunnel = rec?.SshTunnel != null,
+ RelevantGatewayId = rec?.Id
+ };
+ }
+
+ private static ConnectionPagePlan BuildNodeProtocolMismatchCockpit(
+ GatewayConnectionSnapshot snap,
+ GatewayRecord? rec,
+ GatewaySelfInfo? self,
+ SettingsManager? settings,
+ string name)
+ {
+ var compatibility = snap.ProtocolCompatibility;
+ var (headerKey, detailKey) = GetProtocolMismatchResourceKeys(compatibility);
+ return BuildCockpitDegraded(snap, rec, self, settings, name) with
+ {
+ StripHeadlineResourceKey = headerKey,
+ StripSubResourceKey = detailKey,
+ StripPrimaryLabel = null,
+ StripPrimaryAction = ConnectionPrimaryAction.None,
+ OperatorCard = OperatorCardState.Active,
+ NodeErrorDetail = null,
+ NodeErrorDetailResourceKey = detailKey ?? headerKey,
+ ProtocolExpectedVersion = compatibility.GatewayExpectedProtocol
+ };
+ }
+
+ private static (string HeaderKey, string? DetailKey) GetProtocolMismatchResourceKeys(
+ GatewayProtocolCompatibility compatibility)
+ => compatibility.State switch
+ {
+ GatewayProtocolCompatibilityState.GatewayTooOld =>
+ ("ConnectionPage_ProtocolGatewayUpdateRequired", "ConnectionPage_ProtocolGatewayUpdateDetail"),
+ GatewayProtocolCompatibilityState.GatewayTooNew when compatibility.GatewayExpectedProtocol.HasValue =>
+ ("ConnectionPage_ProtocolWindowsUpdateRequired", "ConnectionPage_ProtocolWindowsUpdateDetail"),
+ _ =>
+ ("ConnectionPage_ProtocolUnknownMismatch", null)
+ };
+
// ───────────────────────────────────────────────────────────────────
// Mode builders
// ───────────────────────────────────────────────────────────────────
@@ -893,6 +979,7 @@ private static RecoveryCategory ClassifyError(GatewayConnectionSnapshot snapshot
OpenClaw.Shared.GatewayErrorKind.Tls => RecoveryCategory.Tls,
OpenClaw.Shared.GatewayErrorKind.Tunnel => RecoveryCategory.Tunnel,
OpenClaw.Shared.GatewayErrorKind.LocalPortConflict => RecoveryCategory.LocalPortConflict,
+ OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch => RecoveryCategory.ProtocolMismatch,
OpenClaw.Shared.GatewayErrorKind.Server => RecoveryCategory.Server,
OpenClaw.Shared.GatewayErrorKind.RateLimited => RecoveryCategory.RateLimited,
OpenClaw.Shared.GatewayErrorKind.PairingRejected => RecoveryCategory.Auth,
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPageRowState.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPageRowState.cs
index 2c7fe5a05..55142c7d1 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPageRowState.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPageRowState.cs
@@ -10,6 +10,17 @@ namespace OpenClawTray.Pages;
///
internal static class ConnectionPageRowState
{
+ ///
+ /// Returns true when a saved gateway row should expose Connect. A terminal
+ /// mismatch suppresses retry only for the active row; other saved gateways
+ /// remain available so the user can switch to a compatible gateway.
+ ///
+ internal static bool ShouldShowConnect(
+ bool isActive,
+ bool hasLiveAffordance,
+ bool allowConnectionToggle)
+ => !hasLiveAffordance && (!isActive || allowConnectionToggle);
+
///
/// Returns true when the active row's overflow menu should offer
/// "Disconnect". Tear-down is meaningful while the connection is
diff --git a/src/OpenClaw.Tray.WinUI/Services/ConnectionDiagnosticsProjection.cs b/src/OpenClaw.Tray.WinUI/Services/ConnectionDiagnosticsProjection.cs
index afa1d3366..1ef0a8096 100644
--- a/src/OpenClaw.Tray.WinUI/Services/ConnectionDiagnosticsProjection.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/ConnectionDiagnosticsProjection.cs
@@ -18,7 +18,8 @@ internal static ConnectionStatusDiagnostics BuildStatus(
string? mcpError,
bool nodeBrowserProxyEnabled,
IReadOnlyList recentDiagnostics,
- int diagnosticEventCount)
+ int diagnosticEventCount,
+ GatewaySelfInfo? gatewaySelf = null)
{
var snapshot = currentSnapshot ?? GatewayConnectionSnapshot.Idle;
var legacyStatus = ConnectionStatusPresenter.ToLegacyStatus(snapshot);
@@ -30,11 +31,18 @@ internal static ConnectionStatusDiagnostics BuildStatus(
var nodeSessionLive = BrowserProxyActivation.IsNodeSessionLive(snapshot.NodeState);
return new ConnectionStatusDiagnostics(
- SchemaVersion: 1,
+ SchemaVersion: 2,
ConnectionState: snapshot.OverallState.ToString(),
EffectiveMode: GetEffectiveMode(enableNodeMode, enableMcpServer),
LegacyConnectionStatus: legacyStatus.ToString(),
- Gateway: BuildGateway(activeGateway, snapshot, isActive: true, nodeBrowserProxyEnabled, nodeSessionLive),
+ Gateway: BuildGateway(
+ activeGateway,
+ snapshot,
+ isActive: true,
+ nodeBrowserProxyEnabled,
+ nodeSessionLive,
+ gatewaySelf?.ServerVersion),
+ Protocol: BuildProtocol(snapshot, gatewaySelf),
Operator: new OperatorConnectionDiagnostics(
State: snapshot.OperatorState.ToString(),
Connected: snapshot.OperatorState == RoleConnectionState.Connected,
@@ -91,7 +99,8 @@ internal static GatewayListDiagnostics BuildGateways(
isActive: string.Equals(g.Id, activeGatewayId, StringComparison.Ordinal),
nodeBrowserProxyEnabled,
// Only the active gateway can carry live-session remediation.
- nodeSessionLive: string.Equals(g.Id, activeGatewayId, StringComparison.Ordinal) && nodeSessionLive))
+ nodeSessionLive: string.Equals(g.Id, activeGatewayId, StringComparison.Ordinal) && nodeSessionLive,
+ packageVersion: null))
.OfType()
.ToArray();
@@ -119,7 +128,8 @@ private static CredentialDiagnostics BuildCredential(
GatewayConnectionSnapshot? currentSnapshot,
bool isActive,
bool nodeBrowserProxyEnabled,
- bool nodeSessionLive)
+ bool nodeSessionLive,
+ string? packageVersion)
{
var id = gateway?.Id ?? currentSnapshot?.GatewayId;
var url = GatewayUrlHelper.SanitizeForDisplay(gateway?.Url ?? currentSnapshot?.GatewayUrl);
@@ -141,6 +151,7 @@ private static CredentialDiagnostics BuildCredential(
RequiresV2Signature: gateway?.RequiresV2Signature,
HasSharedGatewayToken: !string.IsNullOrWhiteSpace(gateway?.SharedGatewayToken),
HasBootstrapToken: !string.IsNullOrWhiteSpace(gateway?.BootstrapToken),
+ PackageVersion: packageVersion,
BrowserControlPort: gateway?.BrowserControlPort,
BrowserProxyCaveat: BuildBrowserProxyCaveat(gateway, nodeBrowserProxyEnabled, isActive, nodeSessionLive),
SshTunnel: gateway?.SshTunnel is null ? null : new GatewaySshTunnelDiagnostics(
@@ -152,6 +163,35 @@ private static CredentialDiagnostics BuildCredential(
IncludeBrowserProxyForward: gateway.SshTunnel.IncludeBrowserProxyForward));
}
+ private static ProtocolDiagnostics BuildProtocol(
+ GatewayConnectionSnapshot snapshot,
+ GatewaySelfInfo? gatewaySelf)
+ {
+ var compatibility = snapshot.ProtocolCompatibility;
+ return new ProtocolDiagnostics(
+ SelectedProtocol: compatibility.SelectedProtocol ??
+ (compatibility.IsMismatch ? null : gatewaySelf?.Protocol),
+ CurrentProtocol: GatewayProtocolContract.CurrentVersion,
+ MinimumSupportedProtocol: GatewayProtocolContract.MinimumSupportedVersion,
+ MaximumSupportedProtocol: GatewayProtocolContract.MaximumSupportedVersion,
+ Compatibility: compatibility.NormalizedState,
+ Source: snapshot.ProtocolCompatibilityRole?.ToString().ToLowerInvariant(),
+ GatewayExpectedProtocol: compatibility.GatewayExpectedProtocol,
+ GatewayMinimumProtocol: compatibility.GatewayMinimumProtocol,
+ Retryable: compatibility.Retryable,
+ Operator: ToRoleProtocolDiagnostics(snapshot.OperatorProtocolCompatibility),
+ Node: ToRoleProtocolDiagnostics(snapshot.NodeProtocolCompatibility));
+ }
+
+ private static RoleProtocolDiagnostics ToRoleProtocolDiagnostics(
+ GatewayProtocolCompatibility compatibility) =>
+ new(
+ Compatibility: compatibility.NormalizedState,
+ SelectedProtocol: compatibility.SelectedProtocol,
+ GatewayExpectedProtocol: compatibility.GatewayExpectedProtocol,
+ GatewayMinimumProtocol: compatibility.GatewayMinimumProtocol,
+ Retryable: compatibility.Retryable);
+
private static BrowserProxyDiagnostics BuildBrowserProxy(
GatewayRecord? gateway,
bool nodeBrowserProxyEnabled,
@@ -300,6 +340,7 @@ internal sealed record ConnectionStatusDiagnostics(
[property: JsonPropertyName("effectiveMode")] string EffectiveMode,
[property: JsonPropertyName("legacyConnectionStatus")] string LegacyConnectionStatus,
[property: JsonPropertyName("gateway")] GatewayDiagnostics? Gateway,
+ [property: JsonPropertyName("protocol")] ProtocolDiagnostics Protocol,
[property: JsonPropertyName("operator")] OperatorConnectionDiagnostics Operator,
[property: JsonPropertyName("node")] NodeConnectionDiagnostics Node,
[property: JsonPropertyName("mcp")] McpConnectionDiagnostics Mcp,
@@ -323,10 +364,31 @@ internal sealed record GatewayDiagnostics(
[property: JsonPropertyName("requiresV2Signature")] bool? RequiresV2Signature,
[property: JsonPropertyName("hasSharedGatewayToken")] bool HasSharedGatewayToken,
[property: JsonPropertyName("hasBootstrapToken")] bool HasBootstrapToken,
+ [property: JsonPropertyName("packageVersion")] string? PackageVersion,
[property: JsonPropertyName("browserControlPort")] int? BrowserControlPort,
[property: JsonPropertyName("browserProxyCaveat")] string? BrowserProxyCaveat,
[property: JsonPropertyName("sshTunnel")] GatewaySshTunnelDiagnostics? SshTunnel);
+internal sealed record ProtocolDiagnostics(
+ [property: JsonPropertyName("selectedProtocol")] int? SelectedProtocol,
+ [property: JsonPropertyName("currentProtocol")] int CurrentProtocol,
+ [property: JsonPropertyName("minimumSupportedProtocol")] int MinimumSupportedProtocol,
+ [property: JsonPropertyName("maximumSupportedProtocol")] int MaximumSupportedProtocol,
+ [property: JsonPropertyName("compatibility")] string Compatibility,
+ [property: JsonPropertyName("source")] string? Source,
+ [property: JsonPropertyName("gatewayExpectedProtocol")] int? GatewayExpectedProtocol,
+ [property: JsonPropertyName("gatewayMinimumProtocol")] int? GatewayMinimumProtocol,
+ [property: JsonPropertyName("retryable")] bool Retryable,
+ [property: JsonPropertyName("operator")] RoleProtocolDiagnostics Operator,
+ [property: JsonPropertyName("node")] RoleProtocolDiagnostics Node);
+
+internal sealed record RoleProtocolDiagnostics(
+ [property: JsonPropertyName("compatibility")] string Compatibility,
+ [property: JsonPropertyName("selectedProtocol")] int? SelectedProtocol,
+ [property: JsonPropertyName("gatewayExpectedProtocol")] int? GatewayExpectedProtocol,
+ [property: JsonPropertyName("gatewayMinimumProtocol")] int? GatewayMinimumProtocol,
+ [property: JsonPropertyName("retryable")] bool Retryable);
+
internal sealed record GatewaySshTunnelDiagnostics(
[property: JsonPropertyName("user")] string User,
[property: JsonPropertyName("host")] string Host,
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 02c292072..ae0e8bb49 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -6988,4 +6988,19 @@ Make sure the gateway is running.
Last run stopped
+
+ Gateway update required
+
+
+ This Gateway uses protocol v{0}. This Windows app supports v{1}-v{2}. Its current protocol is v{3}. Update the Gateway to continue.
+
+
+ Windows app update required
+
+
+ This Gateway requires protocol v{0}. This Windows app supports v{1}-v{2}. Its current protocol is v{3}. Update the Windows app to continue.
+
+
+ Gateway version not supported
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 60e556866..b1cac9614 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -6948,4 +6948,19 @@ Le binaire wxc-exec est introuvable. {1} S'il s'agit d'une build développeur, c
Dernière exécution arrêtée
+
+ Mise à jour de la Gateway requise
+
+
+ Cette Gateway utilise le protocole v{0}. Cette application Windows prend en charge les protocoles v{1} à v{2}. Le protocole actuel de l’application est v{3}. Mettez à jour la Gateway pour continuer.
+
+
+ Mise à jour de l’application Windows requise
+
+
+ Cette Gateway nécessite le protocole v{0}. Cette application Windows prend en charge les protocoles v{1} à v{2}. Son protocole actuel est v{3}. Mettez à jour l’application Windows pour continuer.
+
+
+ Version de Gateway non prise en charge
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index cbb9ea5f2..9b7cb92ab 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -6949,4 +6949,19 @@ Het binaire bestand wxc-exec is niet gevonden. {1} Als dit een ontwikkelaarsbuil
Laatste uitvoering gestopt
+
+ Gateway-update vereist
+
+
+ Deze Gateway gebruikt protocol v{0}. Deze Windows-app ondersteunt protocollen v{1} tot en met v{2}. Het huidige protocol van de app is v{3}. Werk de Gateway bij om door te gaan.
+
+
+ Update van Windows-app vereist
+
+
+ Deze Gateway vereist protocol v{0}. Deze Windows-app ondersteunt protocollen v{1} tot en met v{2}. Het huidige protocol is v{3}. Werk de Windows-app bij om door te gaan.
+
+
+ Gateway-versie wordt niet ondersteund
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index bf824cb7a..2664efb31 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -6948,4 +6948,19 @@
上次运行已停止
+
+ 需要更新 Gateway
+
+
+ 此 Gateway 使用协议 v{0}。此 Windows 应用支持协议 v{1} 到 v{2}。该应用的当前协议为 v{3}。请更新 Gateway 以继续。
+
+
+ 需要更新 Windows 应用
+
+
+ 此 Gateway 需要协议 v{0}。此 Windows 应用支持协议 v{1} 到 v{2}。当前协议为 v{3}。请更新 Windows 应用以继续。
+
+
+ 不支持此 Gateway 版本
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 58acfbc95..936750db7 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -6948,4 +6948,19 @@
上次執行已停止
+
+ 需要更新 Gateway
+
+
+ 此 Gateway 使用通訊協定 v{0}。此 Windows 應用程式支援通訊協定 v{1} 到 v{2}。該應用程式目前的通訊協定為 v{3}。請更新 Gateway 以繼續。
+
+
+ 需要更新 Windows 應用程式
+
+
+ 此 Gateway 需要通訊協定 v{0}。此 Windows 應用程式支援通訊協定 v{1} 到 v{2}。目前的通訊協定為 v{3}。請更新 Windows 應用程式以繼續。
+
+
+ 不支援此 Gateway 版本
+
diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md
index f577c9dbd..fa141acc6 100644
--- a/src/OpenClaw.WinNode.Cli/skill.md
+++ b/src/OpenClaw.WinNode.Cli/skill.md
@@ -429,14 +429,23 @@ presence/outcome only.
### app.connection.status
Read-only connection diagnostics for agents and CLIs. No params. Returns:
-`{ schemaVersion, connectionState, effectiveMode, legacyConnectionStatus, gateway, operator, node, mcp, browserProxy, pendingActions, retry, diagnostics }`.
+`{ schemaVersion, connectionState, effectiveMode, legacyConnectionStatus, gateway, protocol, operator, node, mcp, browserProxy, pendingActions, retry, diagnostics }`.
The payload includes the active gateway id/name/url, operator and node role
states, credential sources/statuses, MCP enabled/running/error state, browser
proxy shared-token caveat, pending approval commands, retry hints inferred from
recent diagnostics, and recent connection diagnostic events. `effectiveMode`
reflects Settings mode (`EnableNodeMode` / `EnableMcpServer`); `node.intended`
-reflects the manager snapshot plus current Node mode setting.
+reflects the manager snapshot plus current Node mode setting. `gateway.packageVersion`
+is the installed Gateway npm package version reported by `hello-ok`; it is
+separate from the wire protocol. `protocol` reports the accepted Gateway wire
+protocol, the Windows-supported current/minimum/maximum values (currently
+4/3/4), normalized compatibility
+(`compatible`, `gateway_too_old`, `gateway_too_new`, `mismatch`, or `unknown`),
+the operator/node source, available Gateway protocol expectations, and whether
+the condition is retryable. During an active mismatch, `selectedProtocol` comes
+only from that connection attempt and is otherwise null, even if cached Gateway
+self information remains available.
### app.connection.gateways
Read-only saved gateway diagnostics. No params. Returns:
diff --git a/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs b/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs
index 8f174e9f4..e9bf77d6c 100644
--- a/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs
+++ b/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs
@@ -34,6 +34,26 @@ public void Connecting_HandshakeSucceeded_TransitionsToConnected()
Assert.Equal(RoleConnectionState.Connected, _sm.Current.OperatorState);
}
+ [Theory]
+ [InlineData(3)]
+ [InlineData(5)]
+ public void HandshakeSucceeded_PreservesAcceptedOperatorProtocol(int protocol)
+ {
+ _sm.TryTransition(ConnectionTrigger.ConnectRequested);
+ _sm.SetOperatorProtocolCompatibility(
+ OpenClaw.Shared.GatewayProtocolCompatibility.Compatible(protocol));
+
+ Assert.True(_sm.TryTransition(ConnectionTrigger.HandshakeSucceeded));
+
+ Assert.Equal(
+ protocol,
+ _sm.Current.OperatorProtocolCompatibility.SelectedProtocol);
+ Assert.Equal(protocol, _sm.Current.ProtocolCompatibility.SelectedProtocol);
+ Assert.Equal(
+ GatewayProtocolCompatibilityRole.Operator,
+ _sm.Current.ProtocolCompatibilityRole);
+ }
+
[Fact]
public void Connected_DisconnectRequested_TransitionsToIdle()
{
@@ -144,6 +164,49 @@ public void TypedOperatorFailureKind_IsPreservedInSnapshot_AndClearedOnReconnect
Assert.Null(_sm.Current.OperatorErrorKind);
}
+ [Fact]
+ public void OperatorProtocolMismatch_IsDerivedAndClearedOnReconnect()
+ {
+ _sm.TryTransition(ConnectionTrigger.ConnectRequested);
+ _sm.SetOperatorErrorKind(OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch);
+ _sm.SetOperatorProtocolCompatibility(
+ OpenClaw.Shared.GatewayProtocolCompatibility.FromGatewayExpectation(2, 2));
+ Assert.True(_sm.TryTransition(ConnectionTrigger.WebSocketError, "Transport error"));
+
+ Assert.Equal(
+ OpenClaw.Shared.GatewayProtocolCompatibilityState.GatewayTooOld,
+ _sm.Current.ProtocolCompatibility.State);
+ Assert.Equal(GatewayProtocolCompatibilityRole.Operator, _sm.Current.ProtocolCompatibilityRole);
+ Assert.Equal(2, _sm.Current.ProtocolCompatibility.GatewayExpectedProtocol);
+ Assert.False(_sm.Current.ProtocolCompatibility.Retryable);
+
+ Assert.True(_sm.TryTransition(ConnectionTrigger.ReconnectScheduled));
+ Assert.Equal(
+ OpenClaw.Shared.GatewayProtocolCompatibilityState.Unknown,
+ _sm.Current.ProtocolCompatibility.State);
+ Assert.Null(_sm.Current.ProtocolCompatibilityRole);
+ }
+
+ [Fact]
+ public void OperatorDisconnected_AfterProtocolMismatch_PreservesTerminalRecoveryState()
+ {
+ _sm.TryTransition(ConnectionTrigger.ConnectRequested);
+ _sm.SetOperatorErrorKind(OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch);
+ _sm.SetOperatorProtocolCompatibility(
+ OpenClaw.Shared.GatewayProtocolCompatibility.FromGatewayExpectation(2, 2));
+ Assert.True(_sm.TryTransition(ConnectionTrigger.WebSocketError, "Transport error"));
+
+ Assert.False(_sm.TryTransition(ConnectionTrigger.WebSocketDisconnected));
+
+ Assert.Equal(RoleConnectionState.Error, _sm.Current.OperatorState);
+ Assert.Equal(
+ OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch,
+ _sm.Current.OperatorErrorKind);
+ Assert.Equal(
+ OpenClaw.Shared.GatewayProtocolCompatibilityState.GatewayTooOld,
+ _sm.Current.ProtocolCompatibility.State);
+ }
+
[Fact]
public void Connecting_RateLimited_TransitionsToError()
{
@@ -267,6 +330,24 @@ public void NodeConnected_WithOperatorConnected_DerivesReady()
Assert.Equal(RoleConnectionState.Connected, _sm.Current.NodeState);
}
+ [Theory]
+ [InlineData(3)]
+ [InlineData(5)]
+ public void NodeConnected_PreservesAcceptedNodeProtocol(int protocol)
+ {
+ _sm.SetNodeEnabled(true);
+ GoToConnected();
+ _sm.StartNodeConnecting();
+ _sm.SetNodeProtocolCompatibility(
+ OpenClaw.Shared.GatewayProtocolCompatibility.Compatible(protocol));
+
+ Assert.True(_sm.TryTransition(ConnectionTrigger.NodeConnected));
+
+ Assert.Equal(
+ protocol,
+ _sm.Current.NodeProtocolCompatibility.SelectedProtocol);
+ }
+
[Fact]
public void NodeError_WithOperatorConnected_DerivesDegraded()
{
@@ -279,6 +360,49 @@ public void NodeError_WithOperatorConnected_DerivesDegraded()
Assert.Equal("node failed", _sm.Current.NodeError);
}
+ [Fact]
+ public void NodeProtocolMismatch_IsDerivedWithoutOverwritingCompatibleOperator()
+ {
+ _sm.SetNodeEnabled(true);
+ GoToConnected();
+ _sm.StartNodeConnecting();
+ _sm.SetNodeErrorKind(OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch);
+ _sm.SetNodeProtocolCompatibility(
+ OpenClaw.Shared.GatewayProtocolCompatibility.FromGatewayExpectation(5, 3));
+ Assert.True(_sm.TryTransition(ConnectionTrigger.NodeError, "Node transport error"));
+
+ Assert.Equal(OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch, _sm.Current.NodeErrorKind);
+ Assert.Equal(
+ OpenClaw.Shared.GatewayProtocolCompatibilityState.GatewayTooNew,
+ _sm.Current.ProtocolCompatibility.State);
+ Assert.Equal(GatewayProtocolCompatibilityRole.Node, _sm.Current.ProtocolCompatibilityRole);
+ Assert.Equal(5, _sm.Current.ProtocolCompatibility.GatewayExpectedProtocol);
+ Assert.False(_sm.Current.ProtocolCompatibility.Retryable);
+ }
+
+ [Fact]
+ public void NodeDisconnected_AfterProtocolMismatch_PreservesTerminalRecoveryState()
+ {
+ _sm.SetNodeEnabled(true);
+ GoToConnected();
+ _sm.StartNodeConnecting();
+ _sm.SetNodeErrorKind(OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch);
+ _sm.SetNodeProtocolCompatibility(
+ OpenClaw.Shared.GatewayProtocolCompatibility.FromGatewayExpectation(5, 3));
+ Assert.True(_sm.TryTransition(ConnectionTrigger.NodeError, "Node transport error"));
+
+ Assert.True(_sm.TryTransition(ConnectionTrigger.NodeDisconnected));
+
+ Assert.Equal(RoleConnectionState.Error, _sm.Current.NodeState);
+ Assert.Equal(
+ OpenClaw.Shared.GatewayErrorKind.ProtocolMismatch,
+ _sm.Current.NodeErrorKind);
+ Assert.Equal(
+ OpenClaw.Shared.GatewayProtocolCompatibilityState.GatewayTooNew,
+ _sm.Current.ProtocolCompatibility.State);
+ Assert.Equal(GatewayProtocolCompatibilityRole.Node, _sm.Current.ProtocolCompatibilityRole);
+ }
+
[Fact]
public void NodePairingRequired_WithOperatorConnected_DerivesPairingRequired()
{
@@ -352,6 +476,27 @@ public void NodePaired_TransitionsToConnected()
Assert.Equal(OpenClaw.Shared.PairingStatus.Paired, _sm.Current.NodePairingStatus);
}
+ [Fact]
+ public void NodePaired_PreservesCurrentAttemptProtocol_AndNextAttemptClearsIt()
+ {
+ _sm.SetNodeEnabled(true);
+ GoToConnected();
+ _sm.StartNodeConnecting();
+ _sm.SetNodeProtocolCompatibility(
+ OpenClaw.Shared.GatewayProtocolCompatibility.Compatible(5));
+ _sm.TryTransition(ConnectionTrigger.NodePairingRequired);
+
+ Assert.True(_sm.TryTransition(ConnectionTrigger.NodePaired));
+ Assert.Equal(5, _sm.Current.NodeProtocolCompatibility.SelectedProtocol);
+
+ Assert.True(_sm.TryTransition(ConnectionTrigger.NodeDisconnected));
+ _sm.StartNodeConnecting();
+ Assert.Equal(
+ OpenClaw.Shared.GatewayProtocolCompatibilityState.Unknown,
+ _sm.Current.NodeProtocolCompatibility.State);
+ Assert.Null(_sm.Current.NodeProtocolCompatibility.SelectedProtocol);
+ }
+
[Fact]
public void NodePairingRejected_DerivesDegraded()
{
diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs
index 5c9c4b265..93d1eb23e 100644
--- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs
+++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs
@@ -121,6 +121,202 @@ public async Task ConnectAndReconnect_EmitCompletedOperatorSpans()
Assert.Null(reconnectRoot.GetTagItem(OpenClawTelemetryTagKey.ErrorCategory.ToTelemetryName()));
}
+ [Theory]
+ [InlineData(3)]
+ [InlineData(5)]
+ public async Task SuccessfulHandshake_PreservesAcceptedOperatorProtocol(int protocol)
+ {
+ SetupGateway($"gw-protocol-{protocol}", "wss://test");
+ _resolver.OperatorCredential = new GatewayCredential("tok", false, "test");
+
+ await _manager.ConnectAsync($"gw-protocol-{protocol}");
+ var lifecycle = Assert.Single(_factory.CreatedClients);
+ lifecycle.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.Compatible(protocol));
+ var connected = WaitForOperatorConnectedAsync();
+ lifecycle.SimulateHandshake();
+ await connected;
+
+ var snapshot = _manager.CurrentSnapshot;
+ Assert.Equal(
+ protocol,
+ snapshot.OperatorProtocolCompatibility.SelectedProtocol);
+ Assert.Equal(protocol, snapshot.ProtocolCompatibility.SelectedProtocol);
+ Assert.Equal(
+ GatewayProtocolCompatibilityRole.Operator,
+ snapshot.ProtocolCompatibilityRole);
+ }
+
+ [Fact]
+ public async Task OperatorProtocolMismatch_PropagatesSanitizedDetails()
+ {
+ SetupGateway("gw-protocol-old", "wss://test");
+ _resolver.OperatorCredential = new GatewayCredential("tok", false, "test");
+ using var activities = new ActivityCollector();
+
+ await _manager.ConnectAsync("gw-protocol-old");
+ var lifecycle = Assert.Single(_factory.CreatedClients);
+ var failed = WaitUntilAsync(() =>
+ _manager.CurrentSnapshot.OperatorState == RoleConnectionState.Error);
+ lifecycle.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.FromGatewayExpectation(2, 2));
+ lifecycle.SimulateConnectionFailure(GatewayErrorKind.ProtocolMismatch);
+ lifecycle.SimulateStatusChanged(ConnectionStatus.Error);
+ await failed;
+
+ var snapshot = _manager.CurrentSnapshot;
+ Assert.Equal(GatewayErrorKind.ProtocolMismatch, snapshot.OperatorErrorKind);
+ Assert.Equal(
+ GatewayProtocolCompatibilityState.GatewayTooOld,
+ snapshot.ProtocolCompatibility.State);
+ Assert.Equal(GatewayProtocolCompatibilityRole.Operator, snapshot.ProtocolCompatibilityRole);
+ Assert.Equal(2, snapshot.ProtocolCompatibility.GatewayExpectedProtocol);
+ Assert.Equal(2, snapshot.ProtocolCompatibility.GatewayMinimumProtocol);
+ Assert.False(snapshot.ProtocolCompatibility.Retryable);
+
+ var root = Assert.Single(
+ activities.GetStopped(),
+ activity => activity.OperationName == GatewayConnectionManager.OperatorConnectSpanName);
+ Assert.Equal(
+ (long)GatewayProtocolContract.CurrentVersion,
+ root.GetTagItem(OpenClawTelemetryTagKey.ClientProtocol.ToTelemetryName()));
+ Assert.Equal(
+ "older",
+ root.GetTagItem(OpenClawTelemetryTagKey.GatewayProtocol.ToTelemetryName()));
+ Assert.Equal(
+ "gateway_too_old",
+ root.GetTagItem(OpenClawTelemetryTagKey.ProtocolCompatibility.ToTelemetryName()));
+ Assert.DoesNotContain(root.TagObjects, tag =>
+ tag.Value?.ToString()?.Contains("wss://", StringComparison.OrdinalIgnoreCase) == true);
+ }
+
+ [Fact]
+ public async Task AutomaticReconnectConnecting_PreservesProtocolMismatchUntilExplicitReconnect()
+ {
+ SetupGateway("gw-protocol-auto-reconnect", "wss://test");
+ _resolver.OperatorCredential = new GatewayCredential("tok", false, "test");
+
+ await _manager.ConnectAsync("gw-protocol-auto-reconnect");
+ var lifecycle = Assert.Single(_factory.CreatedClients);
+ var errorObserved = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler? errorHandler = null;
+ errorHandler = (_, snapshot) =>
+ {
+ if (snapshot.OperatorState != RoleConnectionState.Error ||
+ snapshot.OperatorErrorKind != GatewayErrorKind.ProtocolMismatch)
+ {
+ return;
+ }
+
+ _manager.StateChanged -= errorHandler;
+ errorObserved.TrySetResult(snapshot);
+ };
+ _manager.StateChanged += errorHandler;
+
+ lifecycle.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.FromGatewayExpectation(2, 2));
+ lifecycle.SimulateConnectionFailure(GatewayErrorKind.ProtocolMismatch);
+ lifecycle.SimulateStatusChanged(ConnectionStatus.Error);
+ await errorObserved.Task.WaitAsync(TimeSpan.FromSeconds(2));
+
+ var reconnectObserved = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler? reconnectHandler = null;
+ reconnectHandler = (_, snapshot) =>
+ {
+ _manager.StateChanged -= reconnectHandler;
+ reconnectObserved.TrySetResult(snapshot);
+ };
+ _manager.StateChanged += reconnectHandler;
+
+ lifecycle.SimulateStatusChanged(ConnectionStatus.Connecting);
+ var latchedSnapshot = await reconnectObserved.Task.WaitAsync(TimeSpan.FromSeconds(2));
+
+ Assert.Equal(RoleConnectionState.Error, latchedSnapshot.OperatorState);
+ Assert.Equal(GatewayErrorKind.ProtocolMismatch, latchedSnapshot.OperatorErrorKind);
+ Assert.Equal(
+ GatewayProtocolCompatibilityState.GatewayTooOld,
+ latchedSnapshot.OperatorProtocolCompatibility.State);
+ Assert.Equal(2, latchedSnapshot.OperatorProtocolCompatibility.GatewayExpectedProtocol);
+ Assert.False(latchedSnapshot.OperatorProtocolCompatibility.Retryable);
+
+ lifecycle.SimulateStatusChanged(ConnectionStatus.Disconnected);
+ await Task.Delay(50);
+
+ Assert.Equal(RoleConnectionState.Error, _manager.CurrentSnapshot.OperatorState);
+ Assert.Equal(
+ GatewayErrorKind.ProtocolMismatch,
+ _manager.CurrentSnapshot.OperatorErrorKind);
+
+ await _manager.ReconnectAsync();
+
+ Assert.Equal(2, _factory.CreatedClients.Count);
+ Assert.Equal(RoleConnectionState.Connecting, _manager.CurrentSnapshot.OperatorState);
+ Assert.Null(_manager.CurrentSnapshot.OperatorErrorKind);
+ Assert.Equal(
+ GatewayProtocolCompatibilityState.Unknown,
+ _manager.CurrentSnapshot.OperatorProtocolCompatibility.State);
+ }
+
+ [Fact]
+ public void TelemetryErrorCategory_UnknownNeverBecomesProtocolMismatch()
+ {
+ var flags = System.Reflection.BindingFlags.NonPublic |
+ System.Reflection.BindingFlags.Static;
+ var nodeMap = typeof(NodeConnectionCoordinator).GetMethod(
+ "MapNodeConnectionErrorCategory",
+ flags);
+ var operatorMap = typeof(GatewayConnectionManager).GetMethod(
+ "MapConnectionErrorCategory",
+ flags);
+ Assert.NotNull(nodeMap);
+ Assert.NotNull(operatorMap);
+
+ Assert.Equal(
+ ConnectionErrorCategory.InternalError,
+ nodeMap!.Invoke(null, [GatewayErrorKind.Unknown]));
+ Assert.Equal(
+ ConnectionErrorCategory.InternalError,
+ operatorMap!.Invoke(null, [GatewayErrorKind.Unknown]));
+ Assert.Equal(
+ ConnectionErrorCategory.ProtocolMismatch,
+ nodeMap.Invoke(null, [GatewayErrorKind.ProtocolMismatch]));
+ Assert.Equal(
+ ConnectionErrorCategory.ProtocolMismatch,
+ operatorMap.Invoke(null, [GatewayErrorKind.ProtocolMismatch]));
+ }
+
+ [Fact]
+ public async Task ReconnectProtocolMismatch_ReplacesPriorRetryableNetworkFailure()
+ {
+ SetupGateway("gw-protocol-reconnect", "wss://test");
+ _resolver.OperatorCredential = new GatewayCredential("tok", false, "test");
+
+ await _manager.ConnectAsync("gw-protocol-reconnect");
+ var lifecycle = Assert.Single(_factory.CreatedClients);
+ lifecycle.SimulateConnectionFailure(GatewayErrorKind.Network);
+ lifecycle.SimulateStatusChanged(ConnectionStatus.Error);
+ await WaitUntilAsync(() =>
+ _manager.CurrentSnapshot.OperatorErrorKind == GatewayErrorKind.Network);
+
+ await _manager.ReconnectAsync();
+ Assert.Equal(RoleConnectionState.Connecting, _manager.CurrentSnapshot.OperatorState);
+ Assert.Equal(2, _factory.CreatedClients.Count);
+ lifecycle = _factory.CreatedClients[1];
+ lifecycle.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.FromGatewayExpectation(5, 3));
+ lifecycle.SimulateConnectionFailure(GatewayErrorKind.ProtocolMismatch);
+ lifecycle.SimulateStatusChanged(ConnectionStatus.Error);
+ await WaitUntilAsync(() =>
+ _manager.CurrentSnapshot.OperatorErrorKind == GatewayErrorKind.ProtocolMismatch);
+
+ Assert.Equal(
+ GatewayProtocolCompatibilityState.GatewayTooNew,
+ _manager.CurrentSnapshot.ProtocolCompatibility.State);
+ Assert.False(_manager.CurrentSnapshot.ProtocolCompatibility.Retryable);
+ }
+
[Fact]
public async Task RecoverSshTunnelAsync_RevalidatesGatewayAfterWaitingForTransition()
{
@@ -826,6 +1022,152 @@ public async Task TerminalNodeFailure_AllowsNextOperatorHandshakeToRestartNode()
Assert.Equal(RoleConnectionState.Connected, manager.CurrentSnapshot.NodeState);
}
+ [Fact]
+ public async Task NodeProtocolMismatch_PropagatesDetailsAndRemainsTerminal()
+ {
+ SetupGateway("gw-node-protocol", "wss://test");
+ _resolver.OperatorCredential = new GatewayCredential("operator-token", false, "test");
+ _resolver.NodeCredential = new GatewayCredential("node-token", false, "test");
+ var node = new ScriptedNodeConnector();
+ using var activities = new ActivityCollector();
+ using var manager = new GatewayConnectionManager(
+ _resolver,
+ _factory,
+ _registry,
+ NullLogger.Instance,
+ nodeConnector: node,
+ isNodeEnabled: () => true,
+ shouldStartNodeConnection: (_, _) => true);
+
+ await manager.ConnectAsync("gw-node-protocol");
+ Assert.Single(_factory.CreatedClients).SimulateHandshake();
+ await WaitUntilAsync(() => node.ConnectCount == 1);
+ node.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.FromGatewayExpectation(5, 3));
+ node.SimulateConnectionFailure(GatewayErrorKind.ProtocolMismatch);
+ node.SimulateStatus(ConnectionStatus.Error);
+ await WaitUntilAsync(() => manager.CurrentSnapshot.NodeState == RoleConnectionState.Error);
+ node.SimulateStatus(ConnectionStatus.Disconnected);
+ await Task.Delay(50);
+
+ var snapshot = manager.CurrentSnapshot;
+ Assert.Equal(RoleConnectionState.Error, snapshot.NodeState);
+ Assert.Equal(GatewayErrorKind.ProtocolMismatch, snapshot.NodeErrorKind);
+ Assert.Equal(
+ GatewayProtocolCompatibilityState.GatewayTooNew,
+ snapshot.ProtocolCompatibility.State);
+ Assert.Equal(GatewayProtocolCompatibilityRole.Node, snapshot.ProtocolCompatibilityRole);
+ Assert.Equal(5, snapshot.ProtocolCompatibility.GatewayExpectedProtocol);
+ Assert.Equal(3, snapshot.ProtocolCompatibility.GatewayMinimumProtocol);
+ Assert.False(snapshot.ProtocolCompatibility.Retryable);
+
+ var root = Assert.Single(
+ activities.GetStopped(),
+ activity => activity.OperationName == NodeConnectionCoordinator.NodeConnectSpanName);
+ Assert.Equal(
+ "protocolmismatch",
+ root.GetTagItem(OpenClawTelemetryTagKey.ErrorCategory.ToTelemetryName()));
+ Assert.Equal(
+ "gateway_too_new",
+ root.GetTagItem(OpenClawTelemetryTagKey.ProtocolCompatibility.ToTelemetryName()));
+ Assert.Equal(
+ "newer",
+ root.GetTagItem(OpenClawTelemetryTagKey.GatewayProtocol.ToTelemetryName()));
+ }
+
+ [Theory]
+ [InlineData(3)]
+ [InlineData(5)]
+ public async Task SuccessfulNodeHandshake_PreservesAcceptedRoleProtocols(int protocol)
+ {
+ SetupGateway($"gw-node-protocol-{protocol}", "wss://test");
+ _resolver.OperatorCredential = new GatewayCredential("operator-token", false, "test");
+ _resolver.NodeCredential = new GatewayCredential("node-token", false, "test");
+ var node = new ScriptedNodeConnector
+ {
+ ConnectAction = (connector, _) =>
+ {
+ connector.SimulateStatus(ConnectionStatus.Connecting);
+ connector.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.Compatible(protocol));
+ connector.SimulateStatus(ConnectionStatus.Connected);
+ }
+ };
+ using var manager = new GatewayConnectionManager(
+ _resolver,
+ _factory,
+ _registry,
+ NullLogger.Instance,
+ nodeConnector: node,
+ isNodeEnabled: () => true,
+ shouldStartNodeConnection: (_, _) => true);
+
+ await manager.ConnectAsync($"gw-node-protocol-{protocol}");
+ var operatorLifecycle = Assert.Single(_factory.CreatedClients);
+ operatorLifecycle.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.Compatible(protocol));
+ operatorLifecycle.SimulateHandshake();
+ await WaitUntilAsync(() =>
+ manager.CurrentSnapshot.NodeState == RoleConnectionState.Connected);
+
+ var snapshot = manager.CurrentSnapshot;
+ Assert.Equal(
+ protocol,
+ snapshot.OperatorProtocolCompatibility.SelectedProtocol);
+ Assert.Equal(
+ protocol,
+ snapshot.NodeProtocolCompatibility.SelectedProtocol);
+ Assert.Equal(protocol, snapshot.ProtocolCompatibility.SelectedProtocol);
+ Assert.Equal(
+ GatewayProtocolCompatibilityRole.Operator,
+ snapshot.ProtocolCompatibilityRole);
+ }
+
+ [Theory]
+ [InlineData(3)]
+ [InlineData(5)]
+ public async Task NodePairedWithoutConnectedStatus_PublishesAcceptedProtocol(int protocol)
+ {
+ SetupGateway($"gw-node-paired-protocol-{protocol}", "wss://test");
+ _resolver.OperatorCredential = new GatewayCredential("operator-token", false, "test");
+ _resolver.NodeCredential = new GatewayCredential("node-token", false, "test");
+ var node = new ScriptedNodeConnector
+ {
+ ConnectAction = (connector, _) =>
+ {
+ connector.SimulateStatus(ConnectionStatus.Connecting);
+ connector.SimulatePairing(PairingStatus.Pending);
+ }
+ };
+ using var manager = new GatewayConnectionManager(
+ _resolver,
+ _factory,
+ _registry,
+ NullLogger.Instance,
+ nodeConnector: node,
+ isNodeEnabled: () => true,
+ shouldStartNodeConnection: (_, _) => true);
+
+ await manager.ConnectAsync($"gw-node-paired-protocol-{protocol}");
+ var operatorLifecycle = Assert.Single(_factory.CreatedClients);
+ operatorLifecycle.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.Compatible(protocol));
+ operatorLifecycle.SimulateHandshake();
+ await WaitUntilAsync(() =>
+ manager.CurrentSnapshot.NodeState == RoleConnectionState.PairingRequired);
+
+ node.SimulateProtocolCompatibility(
+ GatewayProtocolCompatibility.Compatible(protocol));
+ node.SimulatePairing(PairingStatus.Paired);
+ await WaitUntilAsync(() =>
+ manager.CurrentSnapshot.NodeState == RoleConnectionState.Connected);
+
+ var snapshot = manager.CurrentSnapshot;
+ Assert.False(node.IsConnected);
+ Assert.Equal(protocol, snapshot.NodeProtocolCompatibility.SelectedProtocol);
+ Assert.Equal(protocol, snapshot.ProtocolCompatibility.SelectedProtocol);
+ }
+
[Fact]
public async Task OptionalTokenProbeFailure_DoesNotMarkConnectedOperatorAsError()
{
@@ -4699,6 +5041,9 @@ public void SimulateAuthFailed(string msg) =>
public void SimulateConnectionFailure(GatewayErrorKind kind) =>
_client.SimulateConnectionFailure(kind);
+ public void SimulateProtocolCompatibility(GatewayProtocolCompatibility compatibility) =>
+ _client.SimulateProtocolCompatibility(compatibility);
+
public void SimulateTransportConnected() =>
_client.SimulateTransportConnected();
@@ -4755,6 +5100,17 @@ public void SimulateConnectChallenge()
public void SimulateConnectionFailure(GatewayErrorKind kind) =>
RaiseConnectionFailure(kind);
+ public void SimulateProtocolCompatibility(GatewayProtocolCompatibility compatibility)
+ {
+ var field = typeof(OpenClawGatewayClient).GetField(
+ nameof(ProtocolCompatibilityChanged),
+ System.Reflection.BindingFlags.Instance |
+ System.Reflection.BindingFlags.NonPublic |
+ System.Reflection.BindingFlags.Public);
+ if (field?.GetValue(this) is EventHandler handler)
+ handler.Invoke(this, compatibility);
+ }
+
/// Simulate a successful hello-ok handshake for testing.
public void SimulateHandshakeSucceeded()
{
@@ -5535,6 +5891,7 @@ private sealed class ScriptedNodeConnector : INodeConnector, INodeConnectorTelem
public event EventHandler? DeviceTokenReceived;
public event EventHandler? TransportConnected;
public event EventHandler? ConnectionFailure;
+ public event EventHandler? ProtocolCompatibilityChanged;
#pragma warning disable CS0067 // ClientCreated unused in current tests
public event EventHandler? ClientCreated;
#pragma warning restore CS0067
@@ -5599,6 +5956,9 @@ public void SimulateTransportConnected() =>
public void SimulateConnectionFailure(GatewayErrorKind errorKind) =>
ConnectionFailure?.Invoke(this, errorKind);
+ public void SimulateProtocolCompatibility(GatewayProtocolCompatibility compatibility) =>
+ ProtocolCompatibilityChanged?.Invoke(this, compatibility);
+
public void SimulateDeviceTokenReceived(string token, string role = "node", string[]? scopes = null) =>
DeviceTokenReceived?.Invoke(this, new DeviceTokenReceivedEventArgs(token, scopes, role));
diff --git a/tests/OpenClaw.Shared.Tests/Architecture/GatewayProtocolCoreClosureTests.cs b/tests/OpenClaw.Shared.Tests/Architecture/GatewayProtocolCoreClosureTests.cs
index ac528ab9d..c0747a132 100644
--- a/tests/OpenClaw.Shared.Tests/Architecture/GatewayProtocolCoreClosureTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Architecture/GatewayProtocolCoreClosureTests.cs
@@ -29,8 +29,14 @@ public void GatewayClients_DoNotReintroduce_InlineConnectEnvelopeConstruction()
Assert.Contains("ConnectEnvelopeBuilder.PrepareOperator(", operatorClient.Text);
Assert.Contains("ConnectEnvelopeBuilder.PrepareNode(", nodeClient.Text);
- Assert.Matches(@"\bminProtocol\s*=\s*3\b", builder.Text);
- Assert.Matches(@"\bmaxProtocol\s*=\s*4\b", builder.Text);
+ Assert.Contains(
+ "minProtocol = GatewayProtocolContract.MinimumSupportedVersion",
+ builder.Text);
+ Assert.Contains(
+ "maxProtocol = GatewayProtocolContract.MaximumSupportedVersion",
+ builder.Text);
+ Assert.DoesNotMatch(@"\bminProtocol\s*=\s*\d+\b", builder.Text);
+ Assert.DoesNotMatch(@"\bmaxProtocol\s*=\s*\d+\b", builder.Text);
Assert.Matches(@"\.\s*SignConnectPayloadV2\s*\(", builder.Text);
Assert.Matches(@"\.\s*SignConnectPayloadV3\s*\(", builder.Text);
Assert.Matches(@"\.\s*BuildConnectPayloadV2\s*\(", builder.Text);
diff --git a/tests/OpenClaw.Shared.Tests/GatewayErrorClassifierTests.cs b/tests/OpenClaw.Shared.Tests/GatewayErrorClassifierTests.cs
index 1d73429b5..30fb1bd8d 100644
--- a/tests/OpenClaw.Shared.Tests/GatewayErrorClassifierTests.cs
+++ b/tests/OpenClaw.Shared.Tests/GatewayErrorClassifierTests.cs
@@ -103,6 +103,16 @@ public void Classify_RateLimited_IsRateLimited(string error)
Assert.Equal(GatewayErrorKind.RateLimited, GatewayErrorClassifier.Classify(error));
}
+ [Theory]
+ [InlineData("protocol mismatch")]
+ [InlineData("Gateway rejected connect: protocol mismatch (server=5, client=4)")]
+ public void Classify_GenericProtocolMismatch_IsProtocolMismatch(string error)
+ {
+ Assert.Equal(
+ GatewayErrorKind.ProtocolMismatch,
+ GatewayErrorClassifier.Classify(error));
+ }
+
[Fact]
public void Classify_SshPermissionDenied_IsTunnel_NotScope()
{
@@ -227,6 +237,21 @@ public void ClassifyWithCode_NoCode_FallsBackToTextHeuristic()
GatewayErrorClassifier.ClassifyWithCode("connection refused", null, null));
}
+ [Theory]
+ [InlineData("PROTOCOL_MISMATCH", null)]
+ [InlineData(null, "protocol_mismatch")]
+ public void ClassifyWithCode_ProtocolMismatchCode_IsProtocolMismatch(
+ string? topLevel,
+ string? detailsCode)
+ {
+ Assert.Equal(
+ GatewayErrorKind.ProtocolMismatch,
+ GatewayErrorClassifier.ClassifyWithCode(
+ "connect rejected",
+ topLevel,
+ detailsCode));
+ }
+
[Fact]
public void ClassifyWithCode_NonTokenStructuredCode_FoldsCodeIntoClassification()
{
diff --git a/tests/OpenClaw.Shared.Tests/GatewayProtocolContractTests.cs b/tests/OpenClaw.Shared.Tests/GatewayProtocolContractTests.cs
new file mode 100644
index 000000000..93e41d308
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/GatewayProtocolContractTests.cs
@@ -0,0 +1,223 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using System.Text.Json;
+using OpenClaw.TestSupport;
+
+namespace OpenClaw.Shared.Tests;
+
+public class GatewayProtocolContractTests
+{
+ private sealed class CapturingGatewayClient(string identityPath)
+ : OpenClawGatewayClient(
+ "ws://localhost:18789",
+ "test-token",
+ identityPath: identityPath)
+ {
+ public ConcurrentQueue SentMessages { get; } = new();
+
+ protected override Task SendRawAsync(string message)
+ {
+ SentMessages.Enqueue(message);
+ return Task.CompletedTask;
+ }
+
+ protected override Task SendRawAsync(
+ string message,
+ long expectedConnectionGeneration,
+ CancellationToken cancellationToken)
+ {
+ SentMessages.Enqueue(message);
+ return Task.FromResult(true);
+ }
+ }
+
+ [Fact]
+ public void SupportedRange_IsThreeThroughFour()
+ {
+ Assert.Equal(4, GatewayProtocolContract.SupportedVersion);
+ Assert.Equal(4, GatewayProtocolContract.CurrentVersion);
+ Assert.Equal(3, GatewayProtocolContract.MinimumSupportedVersion);
+ Assert.Equal(4, GatewayProtocolContract.MaximumSupportedVersion);
+ }
+
+ [Fact]
+ public void Clients_use_shared_contract_for_protocol_range()
+ {
+ var repositoryRoot = ProductionSourceFiles.FindRepoRoot();
+
+ var source = File.ReadAllText(
+ Path.Combine(
+ repositoryRoot,
+ "src",
+ "OpenClaw.Shared",
+ "ConnectEnvelopeBuilder.cs"));
+ Assert.Contains("GatewayProtocolContract.MinimumSupportedVersion", source, StringComparison.Ordinal);
+ Assert.Contains("GatewayProtocolContract.MaximumSupportedVersion", source, StringComparison.Ordinal);
+ Assert.DoesNotMatch(@"minProtocol\s*=\s*\d", source);
+ Assert.DoesNotMatch(@"maxProtocol\s*=\s*\d", source);
+ }
+
+ [Fact]
+ public async Task ClientConnectMessages_UseContractRange()
+ {
+ using var operatorIdentity = new TempDirectory("gateway-protocol-operator-");
+ using var operatorClient = new CapturingGatewayClient(operatorIdentity.Path);
+ var sendConnect = typeof(OpenClawGatewayClient).GetMethod(
+ "SendConnectMessageAsync",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(sendConnect);
+ await (Task)sendConnect!.Invoke(
+ operatorClient,
+ [null, 0L, CancellationToken.None])!;
+ Assert.True(operatorClient.SentMessages.TryDequeue(out var operatorMessage));
+
+ using var nodeIdentity = new TempDirectory("gateway-protocol-node-");
+ using var nodeClient = new WindowsNodeClient(
+ "ws://localhost:18789",
+ "test-token",
+ nodeIdentity.Path);
+ var buildNodeConnect = typeof(WindowsNodeClient).GetMethod(
+ "BuildNodeConnectMessage",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(buildNodeConnect);
+ var nodeMessage = (string)buildNodeConnect!.Invoke(nodeClient, ["nonce", null, null])!;
+
+ AssertProtocolRange(operatorMessage!);
+ AssertProtocolRange(nodeMessage);
+ }
+
+ [Theory]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(5)]
+ public void HelloOkProtocolAtOrAboveMinimum_AllowsAdditiveFields(int protocol)
+ {
+ using var document = JsonDocument.Parse(
+ $$"""
+ {
+ "type": "hello-ok",
+ "protocol": {{protocol}},
+ "snapshot": {
+ "futureField": {
+ "nested": true
+ }
+ },
+ "anotherFutureField": [1, 2, 3]
+ }
+ """);
+
+ Assert.True(
+ GatewayProtocolContract.TryValidateHelloOk(
+ document.RootElement,
+ out var error),
+ error);
+ }
+
+ [Theory]
+ [InlineData("""{"type":"hello-ok","protocol":2}""")]
+ [InlineData("""{"type":"hello-ok"}""")]
+ [InlineData("""{"type":"hello-ok","protocol":null}""")]
+ [InlineData("""{"type":"hello-ok","protocol":"4"}""")]
+ [InlineData("""{"type":"hello-ok","protocol":4.5}""")]
+ [InlineData("""{"type":"not-hello","protocol":4}""")]
+ [InlineData("""[]""")]
+ [InlineData("""null""")]
+ public void InvalidHelloOk_IsRejected(string payloadJson)
+ {
+ using var document = JsonDocument.Parse(payloadJson);
+
+ Assert.False(
+ GatewayProtocolContract.TryValidateHelloOk(
+ document.RootElement,
+ out var error));
+ Assert.False(string.IsNullOrWhiteSpace(error));
+ }
+
+ [Theory]
+ [InlineData(2, GatewayProtocolCompatibilityState.GatewayTooOld)]
+ [InlineData(4, GatewayProtocolCompatibilityState.Mismatch)]
+ [InlineData(5, GatewayProtocolCompatibilityState.GatewayTooNew)]
+ public void StructuredMismatch_ParsesSanitizedProtocolDetails(
+ int expectedProtocol,
+ GatewayProtocolCompatibilityState expectedState)
+ {
+ using var document = JsonDocument.Parse(
+ $$"""
+ {
+ "error": {
+ "code": "INVALID_REQUEST",
+ "message": "protocol mismatch",
+ "details": {
+ "code": "PROTOCOL_MISMATCH",
+ "clientMinProtocol": 3,
+ "clientMaxProtocol": 4,
+ "expectedProtocol": {{expectedProtocol}},
+ "minimumProbeProtocol": 2,
+ "ignoredRawDetail": "not propagated"
+ }
+ }
+ }
+ """);
+
+ var compatibility = GatewayProtocolContract.ParseMismatch(document.RootElement);
+
+ Assert.Equal(expectedState, compatibility.State);
+ Assert.Equal(expectedProtocol, compatibility.GatewayExpectedProtocol);
+ Assert.Equal(2, compatibility.GatewayMinimumProtocol);
+ Assert.Equal(3, compatibility.ClientMinimumProtocol);
+ Assert.Equal(4, compatibility.ClientMaximumProtocol);
+ Assert.False(compatibility.Retryable);
+ }
+
+ [Fact]
+ public void NestedStructuredMismatch_ParsesAvailableExpectation()
+ {
+ using var document = JsonDocument.Parse(
+ """
+ {
+ "error": {
+ "message": "connect rejected",
+ "data": {
+ "details": {
+ "code": "PROTOCOL_MISMATCH",
+ "expectedProtocol": 5,
+ "minimumProbeProtocol": 3
+ }
+ }
+ }
+ }
+ """);
+
+ var compatibility = GatewayProtocolContract.ParseMismatch(document.RootElement);
+
+ Assert.Equal(GatewayProtocolCompatibilityState.GatewayTooNew, compatibility.State);
+ Assert.Equal(5, compatibility.GatewayExpectedProtocol);
+ Assert.Equal(3, compatibility.GatewayMinimumProtocol);
+ }
+
+ [Fact]
+ public void GenericMismatch_HasFiniteUnknownDetails()
+ {
+ using var document = JsonDocument.Parse(
+ """{"error":{"message":"protocol mismatch"}}""");
+
+ var compatibility = GatewayProtocolContract.ParseMismatch(document.RootElement);
+
+ Assert.Equal(GatewayProtocolCompatibilityState.Mismatch, compatibility.State);
+ Assert.Null(compatibility.GatewayProtocol);
+ Assert.Equal("mismatch", compatibility.NormalizedState);
+ Assert.False(compatibility.Retryable);
+ }
+
+ private static void AssertProtocolRange(string message)
+ {
+ using var document = JsonDocument.Parse(message);
+ var parameters = document.RootElement.GetProperty("params");
+ Assert.Equal(
+ GatewayProtocolContract.MinimumSupportedVersion,
+ parameters.GetProperty("minProtocol").GetInt32());
+ Assert.Equal(
+ GatewayProtocolContract.MaximumSupportedVersion,
+ parameters.GetProperty("maxProtocol").GetInt32());
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs
index ab344f9b8..690ee109a 100644
--- a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs
+++ b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs
@@ -145,6 +145,7 @@ public async Task ToolsList_KnownCommands_GetCuratedDescriptions()
new FakeCapability("screen", "screen.snapshot"),
new FakeCapability("camera", "camera.snap"),
new FakeCapability("tts", "tts.speak"),
+ new FakeCapability("app-connection", "app.connection.status"),
new FakeCapability("custom", "custom.unknown"),
};
var bridge = CreateBridge(caps);
@@ -163,6 +164,9 @@ public async Task ToolsList_KnownCommands_GetCuratedDescriptions()
Assert.Contains("screenshot", byName["screen.snapshot"]);
Assert.Contains("camera", byName["camera.snap"], System.StringComparison.OrdinalIgnoreCase);
Assert.Contains("Speak text", byName["tts.speak"]);
+ Assert.Contains("package version", byName["app.connection.status"], System.StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("wire protocol", byName["app.connection.status"], System.StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("compatibility", byName["app.connection.status"], System.StringComparison.OrdinalIgnoreCase);
// Unknown commands keep the generic fallback so newly-added capabilities still render.
Assert.Equal("custom capability: custom.unknown", byName["custom.unknown"]);
diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs
index da50c1e94..8cb7acf38 100644
--- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs
+++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs
@@ -276,7 +276,7 @@ public void FromHelloOk_ParsesGatewaySnapshotAndPolicy()
using var doc = JsonDocument.Parse("""
{
"type": "hello-ok",
- "protocol": 1,
+ "protocol": 4,
"server": { "version": "0.7.0", "connId": "abc123" },
"snapshot": {
"presence": [{ "host": "mac", "ts": 123 }],
@@ -298,7 +298,7 @@ public void FromHelloOk_ParsesGatewaySnapshotAndPolicy()
Assert.True(info.HasAnyDetails);
Assert.Equal("0.7.0", info.ServerVersion);
Assert.Equal("abc123", info.ConnectionId);
- Assert.Equal(1, info.Protocol);
+ Assert.Equal(4, info.Protocol);
Assert.Equal(125000, info.UptimeMs);
Assert.Equal("token", info.AuthMode);
Assert.Equal(4, info.StateVersionPresence);
diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
index e42f3591e..b469d332e 100644
--- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
+++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
@@ -751,7 +751,8 @@ public async Task SendWizardRequestAsync_ServiceRestartClose_PreservesCloseStatu
"type": "res",
"id": "req-hello-restart",
"payload": {
- "type": "hello-ok"
+ "type": "hello-ok",
+ "protocol": 4
}
}
""");
@@ -773,6 +774,35 @@ await server.CloseSocketAsync(
Assert.Equal(0, helper.GetPendingRequestCount());
}
+ [Fact]
+ public async Task ProtocolMismatch_AbortsTransportAndRejectsSubsequentOperatorSend()
+ {
+ using var server = new LoopbackWebSocketServer();
+ using var identity = new TempDirectory("operator-protocol-mismatch-");
+ await server.StartAsync();
+ var helper = new GatewayClientTestHelper(
+ gatewayUrl: server.WebSocketUrl,
+ identityPath: identity.Path);
+ using var client = helper.Client;
+ await client.ConnectAsync();
+
+ helper.ProcessRawMessage("""
+ {
+ "type": "res",
+ "id": "connect-mismatch",
+ "payload": {
+ "type": "hello-ok",
+ "protocol": 2
+ }
+ }
+ """);
+
+ Assert.False(client.IsConnectedToGateway);
+ Assert.False(helper.ShouldAutoReconnectForTest());
+ await Assert.ThrowsAsync(
+ () => client.SendChatMessageAsync("blocked after protocol mismatch"));
+ }
+
private static string ReadRequestId(string request)
{
using var document = JsonDocument.Parse(request);
@@ -951,6 +981,7 @@ public void BootstrapNodeHandoff_HelloOkWithNodeRole_DoesNotStorePrimaryNodeToke
"id": "req-hello-node",
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"auth": {
"deviceToken": "node-token",
"role": "node",
@@ -980,6 +1011,7 @@ public void BootstrapNodeHandoff_HelloOkWithOperatorHandoffToken_StoresOperatorT
"id": "req-hello-node",
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"auth": {
"deviceToken": "node-token",
"role": "node",
@@ -1000,29 +1032,32 @@ public void BootstrapNodeHandoff_HelloOkWithOperatorHandoffToken_StoresOperatorT
Assert.Equal("operator-token", helper.GetStoredOperatorDeviceToken());
}
- [Fact]
- public void GuardedValidation_IgnoresUncorrelatedHelloOk()
+ [Theory]
+ [InlineData("""{"type":"hello-ok","protocol":4,"server":{"version":"hostile"}}""")]
+ [InlineData("""{"type":"hello-ok","protocol":5}""")]
+ [InlineData("""{"type":"hello-ok"}""")]
+ public void GuardedValidation_IgnoresUncorrelatedHelloOk(string payloadJson)
{
var helper = new GatewayClientTestHelper();
var handshakeSucceeded = false;
+ var failures = new List();
helper.Client.HandshakeAuthorizationAsync = _ => Task.FromResult(
new ReconnectAuthorizationResult(true, GatewayErrorKind.Unknown, string.Empty));
helper.Client.HandshakeSucceeded += (_, _) => handshakeSucceeded = true;
+ helper.Client.ConnectionFailure += (_, failure) => failures.Add(failure);
- helper.ProcessRawMessage("""
+ helper.ProcessRawMessage($$"""
{
"type": "res",
"id": "unsolicited",
- "payload": {
- "type": "hello-ok",
- "protocol": 4,
- "server": { "version": "hostile" }
- }
+ "payload": {{payloadJson}}
}
""");
Assert.False(handshakeSucceeded);
Assert.False(helper.Client.HasHandshakeSnapshot);
+ Assert.Empty(failures);
+ Assert.True(helper.ShouldAutoReconnectForTest());
}
[Fact]
@@ -1234,6 +1269,7 @@ public void OperatorBootstrap_HelloOkWithNodeHandoffToken_StoresNodeToken()
"id": "req-hello-operator",
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"auth": {
"deviceToken": "operator-token",
"role": "operator",
@@ -1279,6 +1315,7 @@ public void HelloOkWhenTokenWriteFails_CompletesHandshakeAndPublishesToken()
"id": "req-hello-operator",
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"auth": {
"deviceToken": "operator-token",
"role": "operator",
@@ -1294,6 +1331,194 @@ public void HelloOkWhenTokenWriteFails_CompletesHandshakeAndPublishesToken()
Assert.Equal("operator", receivedToken?.Role);
}
+ [Theory]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(5)]
+ public void AcceptedHelloOkProtocol_CompletesHandshakeWithAdditiveSnapshotFields(int protocol)
+ {
+ var helper = new GatewayClientTestHelper();
+ var statuses = new List();
+ var compatibility = new List();
+ var handshakeCount = 0;
+ helper.Client.StatusChanged += (_, status) => statuses.Add(status);
+ helper.Client.ProtocolCompatibilityChanged += (_, value) => compatibility.Add(value);
+ helper.Client.HandshakeSucceeded += (_, _) => handshakeCount++;
+ helper.TrackPendingRequest("req-protocol", "connect");
+
+ helper.ProcessRawMessage(
+ $$"""
+ {
+ "type": "res",
+ "id": "req-protocol",
+ "ok": true,
+ "payload": {
+ "type": "hello-ok",
+ "protocol": {{protocol}},
+ "snapshot": {
+ "futureField": {
+ "nested": true
+ }
+ }
+ }
+ }
+ """);
+
+ Assert.Equal(1, handshakeCount);
+ Assert.Contains(ConnectionStatus.Connected, statuses);
+ Assert.True(helper.Client.HasHandshakeSnapshot);
+ var accepted = Assert.Single(compatibility);
+ Assert.Equal(GatewayProtocolCompatibilityState.Compatible, accepted.State);
+ Assert.Equal(protocol, accepted.SelectedProtocol);
+ Assert.Null(accepted.GatewayExpectedProtocol);
+ }
+
+ [Theory]
+ [InlineData("""{"type":"hello-ok","protocol":2,"auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"hello-ok","auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"hello-ok","protocol":null,"auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"hello-ok","protocol":"4","auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"hello-ok","protocol":4.5,"auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"unexpected-success","protocol":4,"auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""null""")]
+ public void InvalidConnectSuccess_FailsBeforeOperatorHandshakeSideEffects(string payloadJson)
+ {
+ var helper = new GatewayClientTestHelper();
+ var statuses = new List();
+ var failures = new List();
+ var handshakeCount = 0;
+ var tokenCount = 0;
+ var gatewaySelfCount = 0;
+ helper.Client.StatusChanged += (_, status) => statuses.Add(status);
+ helper.Client.ConnectionFailure += (_, kind) => failures.Add(kind);
+ helper.Client.HandshakeSucceeded += (_, _) => handshakeCount++;
+ helper.Client.DeviceTokenReceived += (_, _) => tokenCount++;
+ helper.Client.GatewaySelfUpdated += (_, _) => gatewaySelfCount++;
+ helper.TrackPendingRequest("req-invalid-hello", "connect");
+
+ helper.ProcessRawMessage(
+ $$"""
+ {
+ "type": "res",
+ "id": "req-invalid-hello",
+ "ok": true,
+ "payload": {{payloadJson}}
+ }
+ """);
+
+ Assert.Equal([GatewayErrorKind.ProtocolMismatch], failures);
+ Assert.Contains(ConnectionStatus.Error, statuses);
+ Assert.DoesNotContain(ConnectionStatus.Connected, statuses);
+ Assert.Equal(0, handshakeCount);
+ Assert.Equal(0, tokenCount);
+ Assert.Equal(0, gatewaySelfCount);
+ Assert.Null(helper.GetStoredOperatorDeviceToken());
+ Assert.False(helper.Client.HasHandshakeSnapshot);
+ Assert.False(helper.ShouldAutoReconnectForTest());
+ }
+
+ [Fact]
+ public void ProtocolMismatch_IgnoresSubsequentOperatorEvents()
+ {
+ var helper = new GatewayClientTestHelper();
+ var chatMessageCount = 0;
+ helper.Client.ChatMessageReceived += (_, _) => chatMessageCount++;
+ helper.TrackPendingRequest("req-invalid-hello", "connect");
+
+ helper.ProcessRawMessage(
+ """
+ {
+ "type": "res",
+ "id": "req-invalid-hello",
+ "ok": true,
+ "payload": {
+ "type": "hello-ok",
+ "protocol": 2
+ }
+ }
+ """);
+ helper.ProcessRawMessage(
+ """
+ {
+ "type": "event",
+ "event": "session.message",
+ "payload": {
+ "sessionKey": "agent:main:main",
+ "message": {
+ "role": "assistant",
+ "content": "must not dispatch"
+ },
+ "state": "final"
+ }
+ }
+ """);
+
+ Assert.Equal(0, chatMessageCount);
+ }
+
+ [Theory]
+ [InlineData("""{"message":"connect rejected","code":"PROTOCOL_MISMATCH"}""")]
+ [InlineData("""{"message":"protocol mismatch: gateway requires version 5"}""")]
+ public void ConnectProtocolMismatch_IsClassifiedAndStopsAutomaticReconnect(string errorJson)
+ {
+ var helper = new GatewayClientTestHelper();
+ var statuses = new List();
+ var failures = new List();
+ helper.Client.StatusChanged += (_, status) => statuses.Add(status);
+ helper.Client.ConnectionFailure += (_, kind) => failures.Add(kind);
+ helper.TrackPendingRequest("req-protocol-mismatch", "connect");
+
+ helper.ProcessRawMessage(
+ $$"""
+ {
+ "type": "res",
+ "id": "req-protocol-mismatch",
+ "ok": false,
+ "error": {{errorJson}}
+ }
+ """);
+
+ Assert.Equal([GatewayErrorKind.ProtocolMismatch], failures);
+ Assert.Contains(ConnectionStatus.Error, statuses);
+ Assert.False(helper.ShouldAutoReconnectForTest());
+ Assert.False(helper.GetUseV2Signature());
+ }
+
+ [Fact]
+ public void StructuredProtocolMismatch_PublishesGatewayExpectation()
+ {
+ var helper = new GatewayClientTestHelper();
+ GatewayProtocolCompatibility? compatibility = null;
+ helper.Client.ProtocolCompatibilityChanged += (_, value) => compatibility = value;
+ helper.TrackPendingRequest("req-protocol-details", "connect");
+
+ helper.ProcessRawMessage(
+ """
+ {
+ "type": "res",
+ "id": "req-protocol-details",
+ "ok": false,
+ "error": {
+ "code": "INVALID_REQUEST",
+ "message": "protocol mismatch",
+ "details": {
+ "code": "PROTOCOL_MISMATCH",
+ "clientMinProtocol": 3,
+ "clientMaxProtocol": 4,
+ "expectedProtocol": 5,
+ "minimumProbeProtocol": 3
+ }
+ }
+ }
+ """);
+
+ Assert.NotNull(compatibility);
+ Assert.Equal(GatewayProtocolCompatibilityState.GatewayTooNew, compatibility.State);
+ Assert.Equal(5, compatibility.GatewayExpectedProtocol);
+ Assert.Equal(3, compatibility.GatewayMinimumProtocol);
+ Assert.False(compatibility.Retryable);
+ }
+
[Fact]
public void BootstrapNodeHandoff_PrefersOperatorTokenFromAdditionalDeviceTokens()
{
@@ -4569,7 +4794,8 @@ public void HandleHelloOk_AfterAuthFailed_ClearsAuthFailedFlag()
"type": "res",
"id": "req-hello-1",
"payload": {
- "type": "hello-ok"
+ "type": "hello-ok",
+ "protocol": 4
}
}
""");
diff --git a/tests/OpenClaw.Shared.Tests/Telemetry/OpenClawTelemetryTests.cs b/tests/OpenClaw.Shared.Tests/Telemetry/OpenClawTelemetryTests.cs
index 57923e74b..c9294473e 100644
--- a/tests/OpenClaw.Shared.Tests/Telemetry/OpenClawTelemetryTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Telemetry/OpenClawTelemetryTests.cs
@@ -22,6 +22,11 @@ public void Constants_AreStable()
Assert.Equal("openclaw.reason", OpenClawTelemetryTagKey.Reason.ToTelemetryName());
Assert.Equal("openclaw.status", OpenClawTelemetryTagKey.Status.ToTelemetryName());
Assert.Equal("error.type", OpenClawTelemetryTagKey.ErrorType.ToTelemetryName());
+ Assert.Equal("openclaw.protocol.client", OpenClawTelemetryTagKey.ClientProtocol.ToTelemetryName());
+ Assert.Equal("openclaw.protocol.gateway", OpenClawTelemetryTagKey.GatewayProtocol.ToTelemetryName());
+ Assert.Equal(
+ "openclaw.protocol.compatibility",
+ OpenClawTelemetryTagKey.ProtocolCompatibility.ToTelemetryName());
}
[Fact]
diff --git a/tests/OpenClaw.Shared.Tests/WindowsClientMetadataTests.cs b/tests/OpenClaw.Shared.Tests/WindowsClientMetadataTests.cs
index 65c4440cc..c9d1eb567 100644
--- a/tests/OpenClaw.Shared.Tests/WindowsClientMetadataTests.cs
+++ b/tests/OpenClaw.Shared.Tests/WindowsClientMetadataTests.cs
@@ -120,8 +120,12 @@ public async Task OperatorConnect_V2CompatibilityKeepsProtocolAndCanonicalSerial
var parameters = message.RootElement.GetProperty("params");
Assert.True(client.UseV2Signature);
- Assert.Equal(3, parameters.GetProperty("minProtocol").GetInt32());
- Assert.Equal(4, parameters.GetProperty("maxProtocol").GetInt32());
+ Assert.Equal(
+ GatewayProtocolContract.MinimumSupportedVersion,
+ parameters.GetProperty("minProtocol").GetInt32());
+ Assert.Equal(
+ GatewayProtocolContract.MaximumSupportedVersion,
+ parameters.GetProperty("maxProtocol").GetInt32());
AssertCanonicalMetadata(parameters.GetProperty("client"));
Assert.Equal(
BuildExpectedSignature(client, parameters, useV2: true),
diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs
index 4c8dbbbe1..f04398c83 100644
--- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs
+++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs
@@ -10,6 +10,7 @@
using OpenClaw.Shared;
using OpenClaw.Shared.Capabilities;
using OpenClaw.Shared.Telemetry;
+using OpenClaw.TestSupport;
using Xunit;
namespace OpenClaw.Shared.Tests;
@@ -412,6 +413,7 @@ public void HandleResponse_HelloOkWithDeviceToken_FiresPairingChangedExactlyOnce
"ok": true,
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"nodeId": "test-node-id",
"auth": {
"deviceToken": "test-device-token-abc123"
@@ -460,6 +462,7 @@ public void HandleResponse_HelloOkWhenTokenWriteFails_CompletesHandshakeAndPubli
"ok": true,
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"nodeId": "test-node-id",
"auth": {
"deviceToken": "test-device-token"
@@ -538,6 +541,7 @@ public void HandleResponse_HelloOkNoToken_FiresPendingExactlyOnce()
"ok": true,
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"nodeId": "test-node-id"
}
}
@@ -589,6 +593,7 @@ public void HandleResponse_HelloOkWithStoredToken_FiresPairedOnceNotPending()
"ok": true,
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"nodeId": "test-node-id"
}
}
@@ -638,6 +643,7 @@ public void HandleResponse_HelloOkRepeatedReconnects_FiresPairedExactlyOnce()
"ok": true,
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"nodeId": "test-node-id"
}
}
@@ -659,6 +665,366 @@ public void HandleResponse_HelloOkRepeatedReconnects_FiresPairedExactlyOnce()
}
}
+ [Theory]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(5)]
+ public void HandleResponse_AcceptedHelloOkProtocol_CompletesHandshakeWithAdditiveFields(
+ int protocol)
+ {
+ using var dataPath = new TempDirectory("node-protocol-accepted-");
+ using var client = new WindowsNodeClient(
+ "ws://localhost:18789",
+ "test-token",
+ dataPath.Path);
+ var statuses = new List();
+ var compatibility = new List();
+ var handshakeCount = 0;
+ client.StatusChanged += (_, status) => statuses.Add(status);
+ client.ProtocolCompatibilityChanged += (_, value) => compatibility.Add(value);
+ client.HandshakeSucceeded += (_, _) => handshakeCount++;
+ using var document = JsonDocument.Parse(
+ $$"""
+ {
+ "type": "res",
+ "ok": true,
+ "payload": {
+ "type": "hello-ok",
+ "protocol": {{protocol}},
+ "nodeId": "test-node-id",
+ "futureField": {
+ "nested": true
+ }
+ }
+ }
+ """);
+
+ HandleCorrelatedHelloOk(client, document.RootElement);
+
+ Assert.Equal(1, handshakeCount);
+ Assert.Contains(ConnectionStatus.Connected, statuses);
+ Assert.True(client.IsConnected);
+ Assert.Equal("test-node-id", client.NodeId);
+ var accepted = Assert.Single(compatibility);
+ Assert.Equal(GatewayProtocolCompatibilityState.Compatible, accepted.State);
+ Assert.Equal(protocol, accepted.SelectedProtocol);
+ Assert.Null(accepted.GatewayExpectedProtocol);
+ }
+
+ [Theory]
+ [InlineData("""{"type":"res","ok":true,"payload":{"acknowledged":true}}""")]
+ [InlineData("""{"type":"res","ok":true}""")]
+ public void HandleResponse_SuccessfulNonConnectAcknowledgement_AfterHandshakeIsIgnored(
+ string responseJson)
+ {
+ using var dataPath = new TempDirectory("node-non-connect-response-");
+ using var client = new WindowsNodeClient(
+ "ws://localhost:18789",
+ "test-token",
+ dataPath.Path);
+ var failures = new List();
+ var statuses = new List();
+ client.ConnectionFailure += (_, kind) => failures.Add(kind);
+ client.StatusChanged += (_, status) => statuses.Add(status);
+ using var hello = JsonDocument.Parse(
+ """
+ {
+ "type": "res",
+ "ok": true,
+ "payload": {
+ "type": "hello-ok",
+ "protocol": 4,
+ "nodeId": "test-node-id",
+ "auth": {
+ "deviceToken": "node-device-token"
+ }
+ }
+ }
+ """);
+ HandleCorrelatedHelloOk(client, hello.RootElement);
+ failures.Clear();
+ statuses.Clear();
+ using var acknowledgement = JsonDocument.Parse(responseJson);
+
+ client.HandleResponse(acknowledgement.RootElement);
+
+ Assert.Empty(failures);
+ Assert.Empty(statuses);
+ Assert.True(client.IsConnected);
+ Assert.True(InvokeShouldAutoReconnect(client));
+ }
+
+ [Theory]
+ [InlineData("""{"type":"hello-ok","protocol":2,"auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"hello-ok","auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"hello-ok","protocol":null,"auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"hello-ok","protocol":"4","auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"hello-ok","protocol":4.5,"auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""{"type":"unexpected-success","protocol":4,"auth":{"deviceToken":"must-not-store"}}""")]
+ [InlineData("""null""")]
+ public void HandleResponse_InvalidConnectSuccess_FailsBeforeNodeHandshakeSideEffects(
+ string payloadJson)
+ {
+ using var dataPath = new TempDirectory("node-invalid-hello-");
+ using var client = new WindowsNodeClient(
+ "ws://localhost:18789",
+ "test-token",
+ dataPath.Path);
+ var statuses = new List();
+ var failures = new List();
+ var handshakeCount = 0;
+ var tokenCount = 0;
+ var pairingCount = 0;
+ var gatewaySelfCount = 0;
+ client.StatusChanged += (_, status) => statuses.Add(status);
+ client.ConnectionFailure += (_, kind) => failures.Add(kind);
+ client.HandshakeSucceeded += (_, _) => handshakeCount++;
+ client.DeviceTokenReceived += (_, _) => tokenCount++;
+ client.PairingStatusChanged += (_, _) => pairingCount++;
+ client.GatewaySelfUpdated += (_, _) => gatewaySelfCount++;
+ using var document = JsonDocument.Parse(
+ $$"""
+ {
+ "type": "res",
+ "ok": true,
+ "payload": {{payloadJson}}
+ }
+ """);
+
+ HandleCorrelatedHelloOk(client, document.RootElement);
+
+ Assert.Equal([GatewayErrorKind.ProtocolMismatch], failures);
+ Assert.Contains(ConnectionStatus.Error, statuses);
+ Assert.DoesNotContain(ConnectionStatus.Connected, statuses);
+ Assert.Equal(0, handshakeCount);
+ Assert.Equal(0, tokenCount);
+ Assert.Equal(0, pairingCount);
+ Assert.Equal(0, gatewaySelfCount);
+ Assert.Null(GetDeviceIdentity(client).NodeDeviceToken);
+ Assert.False(client.IsConnected);
+ Assert.False(InvokeShouldAutoReconnect(client));
+ }
+
+ [Theory]
+ [InlineData("""{"type":"hello-ok","protocol":5}""")]
+ [InlineData("""{"type":"hello-ok"}""")]
+ public void HandleResponse_UncorrelatedHelloOk_DoesNotLatchProtocolMismatch(
+ string payloadJson)
+ {
+ using var dataPath = new TempDirectory("node-uncorrelated-hello-");
+ using var client = new WindowsNodeClient(
+ "ws://localhost:18789",
+ "test-token",
+ dataPath.Path);
+ var failures = new List();
+ var statuses = new List();
+ client.ConnectionFailure += (_, kind) => failures.Add(kind);
+ client.StatusChanged += (_, status) => statuses.Add(status);
+ using var stale = JsonDocument.Parse(
+ $$"""
+ {
+ "type": "res",
+ "id": "stale-connect",
+ "ok": true,
+ "payload": {{payloadJson}}
+ }
+ """);
+
+ client.HandleResponse(stale.RootElement);
+
+ Assert.Empty(failures);
+ Assert.Empty(statuses);
+ Assert.False(client.IsConnected);
+ Assert.True(InvokeShouldAutoReconnect(client));
+ }
+
+ [Theory]
+ [InlineData("""{"message":"connect rejected","code":"PROTOCOL_MISMATCH"}""")]
+ [InlineData("""{"message":"protocol mismatch: gateway requires version 5"}""")]
+ public void HandleResponse_ProtocolMismatch_IsClassifiedAndStopsAutomaticReconnect(
+ string errorJson)
+ {
+ using var dataPath = new TempDirectory("node-protocol-mismatch-");
+ using var client = new WindowsNodeClient(
+ "ws://localhost:18789",
+ "test-token",
+ dataPath.Path);
+ var statuses = new List();
+ var failures = new List();
+ client.StatusChanged += (_, status) => statuses.Add(status);
+ client.ConnectionFailure += (_, kind) => failures.Add(kind);
+ using var document = JsonDocument.Parse(
+ $$"""
+ {
+ "type": "res",
+ "ok": false,
+ "error": {{errorJson}}
+ }
+ """);
+
+ client.HandleResponse(document.RootElement);
+
+ Assert.Equal([GatewayErrorKind.ProtocolMismatch], failures);
+ Assert.Contains(ConnectionStatus.Error, statuses);
+ Assert.False(InvokeShouldAutoReconnect(client));
+ Assert.False(client.UseV2Signature);
+ }
+
+ [Fact]
+ public void HandleResponse_StructuredProtocolMismatch_PublishesGatewayExpectation()
+ {
+ using var dataPath = new TempDirectory("node-protocol-details-");
+ using var client = new WindowsNodeClient(
+ "ws://localhost:18789",
+ "test-token",
+ dataPath.Path);
+ GatewayProtocolCompatibility? compatibility = null;
+ client.ProtocolCompatibilityChanged += (_, value) => compatibility = value;
+ using var document = JsonDocument.Parse(
+ """
+ {
+ "type": "res",
+ "ok": false,
+ "error": {
+ "code": "INVALID_REQUEST",
+ "message": "protocol mismatch",
+ "details": {
+ "code": "PROTOCOL_MISMATCH",
+ "clientMinProtocol": 3,
+ "clientMaxProtocol": 4,
+ "expectedProtocol": 2,
+ "minimumProbeProtocol": 2
+ }
+ }
+ }
+ """);
+
+ client.HandleResponse(document.RootElement);
+
+ Assert.NotNull(compatibility);
+ Assert.Equal(GatewayProtocolCompatibilityState.GatewayTooOld, compatibility.State);
+ Assert.Equal(2, compatibility.GatewayExpectedProtocol);
+ Assert.Equal(2, compatibility.GatewayMinimumProtocol);
+ Assert.False(compatibility.Retryable);
+ }
+
+ [Fact]
+ public async Task ProtocolMismatch_BlocksSubsequentNodeCommandDispatch()
+ {
+ using var dataPath = new TempDirectory("node-protocol-command-block-");
+ using var client = new CapturingWindowsNodeClient(
+ "ws://localhost:18789",
+ "test-token",
+ dataPath.Path);
+ var capability = new MockCapability("mock", "mock.ping");
+ client.RegisterCapability(capability);
+ SetPendingConnectRequestId(client, "test-connect-request");
+
+ await InvokeProcessMessageAsync(
+ client,
+ """
+ {
+ "type": "res",
+ "id": "test-connect-request",
+ "ok": true,
+ "payload": {
+ "type": "hello-ok",
+ "protocol": 2
+ }
+ }
+ """);
+ await InvokeProcessMessageAsync(
+ client,
+ """
+ {
+ "type": "req",
+ "id": "req-after-mismatch",
+ "method": "node.invoke",
+ "params": {
+ "requestId": "invoke-after-mismatch",
+ "command": "mock.ping",
+ "args": {}
+ }
+ }
+ """,
+ authenticated: false);
+ await Task.Delay(50);
+
+ Assert.Equal(0, capability.ExecuteCount);
+ Assert.Empty(client.SentMessages);
+ Assert.False(client.IsConnected);
+ Assert.False(InvokeShouldAutoReconnect(client));
+ }
+
+ [Fact]
+ public async Task ProtocolMismatch_AbortsCurrentTransportAndBlocksCommandDispatch()
+ {
+ using var server = new LoopbackWebSocketServer();
+ using var dataPath = new TempDirectory("node-protocol-transport-abort-");
+ using var client = new WindowsNodeClient(
+ server.WebSocketUrl,
+ "test-token",
+ dataPath.Path);
+ var capability = new MockCapability("mock", "mock.ping");
+ client.RegisterCapability(capability);
+ var mismatchObserved = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ client.ConnectionFailure += (_, kind) =>
+ {
+ if (kind == GatewayErrorKind.ProtocolMismatch)
+ mismatchObserved.TrySetResult();
+ };
+
+ await server.StartAsync();
+ await client.ConnectAsync();
+ await server.WaitForAcceptedCountAsync(1, TimeSpan.FromSeconds(2));
+ await server.SendTextAsync(
+ """
+ {
+ "type": "event",
+ "event": "connect.challenge",
+ "payload": {
+ "nonce": "protocol-abort",
+ "ts": 1785824000000
+ }
+ }
+ """);
+ var connectMessage = await server.ReceiveTextAsync()
+ .WaitAsync(TimeSpan.FromSeconds(2));
+ using var connect = JsonDocument.Parse(connectMessage);
+ var requestId = connect.RootElement.GetProperty("id").GetString();
+ Assert.False(string.IsNullOrWhiteSpace(requestId));
+ Assert.Equal(
+ GatewayProtocolContract.MinimumSupportedVersion,
+ connect.RootElement.GetProperty("params").GetProperty("minProtocol").GetInt32());
+ Assert.Equal(
+ GatewayProtocolContract.MaximumSupportedVersion,
+ connect.RootElement.GetProperty("params").GetProperty("maxProtocol").GetInt32());
+
+ await server.SendTextAsync(
+ JsonSerializer.Serialize(new
+ {
+ type = "res",
+ id = requestId,
+ ok = true,
+ payload = new
+ {
+ type = "hello-ok",
+ protocol = 2
+ }
+ }));
+ await mismatchObserved.Task.WaitAsync(TimeSpan.FromSeconds(2));
+ await AssertServerObservedDisconnectAsync(server);
+ await InvokeProcessMessageAsync(
+ client,
+ BuildNodeInvokeRequest("invoke-after-transport-abort", "mock.ping"),
+ authenticated: false);
+
+ Assert.False(client.IsConnected);
+ Assert.False(InvokeShouldAutoReconnect(client));
+ Assert.Equal(0, capability.ExecuteCount);
+ }
+
///
/// When the gateway returns ok: false, ConnectionStatus.Error is raised.
///
@@ -1203,6 +1569,7 @@ await InvokeHandleEventAsync(client, $$"""
"ok": true,
"payload": {
"type": "hello-ok",
+ "protocol": 4,
"nodeId": "test-node-id"
}
}
@@ -2312,12 +2679,7 @@ private static void HandleCorrelatedHelloOk(
JsonElement response)
{
const string requestId = "test-connect-request";
- var pendingRequestField = typeof(WindowsNodeClient).GetField(
- "_pendingConnectRequestId",
- BindingFlags.NonPublic | BindingFlags.Instance);
- Assert.NotNull(pendingRequestField);
- pendingRequestField.SetValue(client, requestId);
- AuthorizeCurrentHandshake(client);
+ SetPendingConnectRequestId(client, requestId);
using var correlated = JsonDocument.Parse(
JsonSerializer.Serialize(new
@@ -2330,6 +2692,18 @@ private static void HandleCorrelatedHelloOk(
client.HandleResponse(correlated.RootElement);
}
+ private static void SetPendingConnectRequestId(
+ WindowsNodeClient client,
+ string requestId)
+ {
+ var pendingRequestField = typeof(WindowsNodeClient).GetField(
+ "_pendingConnectRequestId",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(pendingRequestField);
+ pendingRequestField.SetValue(client, requestId);
+ AuthorizeCurrentHandshake(client);
+ }
+
private static void AuthorizeCurrentHandshake(WindowsNodeClient client)
{
var generationProperty = typeof(WebSocketClientBase).GetProperty(
@@ -2374,6 +2748,15 @@ private static (Dictionary Auth, string TokenForSignature) Invok
private static DeviceIdentity GetDeviceIdentity(WindowsNodeClient client) =>
GetPrivateField(client, "_deviceIdentity");
+ private static bool InvokeShouldAutoReconnect(WindowsNodeClient client)
+ {
+ var method = typeof(WindowsNodeClient).GetMethod(
+ "ShouldAutoReconnect",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(method);
+ return (bool)method!.Invoke(client, [])!;
+ }
+
private static T GetPrivateField(WindowsNodeClient client, string fieldName)
{
var field = typeof(WindowsNodeClient).GetField(
@@ -3556,6 +3939,29 @@ private static async Task InvokeProcessMessageAsync(
await task;
}
+ private static async Task AssertServerObservedDisconnectAsync(
+ LoopbackWebSocketServer server)
+ {
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ try
+ {
+ var unexpected = await server.ReceiveTextAsync(timeout.Token);
+ Assert.Fail($"Expected the node transport to close, but received: {unexpected}");
+ }
+ catch (OperationCanceledException)
+ {
+ Assert.Fail("Timed out waiting for the node transport to close.");
+ }
+ catch (System.Net.WebSockets.WebSocketException)
+ {
+ // Abort closes without a WebSocket close handshake.
+ }
+ catch (InvalidOperationException ex)
+ {
+ Assert.Contains("Expected one complete WebSocket text message", ex.Message);
+ }
+ }
+
private static async Task WaitForSentMessageAsync(
CapturingWindowsNodeClient client,
Func predicate)
diff --git a/tests/OpenClaw.Tray.Tests/ConnectionDiagnosticsProjectionTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionDiagnosticsProjectionTests.cs
index 4e22269a5..07b1e6c02 100644
--- a/tests/OpenClaw.Tray.Tests/ConnectionDiagnosticsProjectionTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ConnectionDiagnosticsProjectionTests.cs
@@ -72,6 +72,92 @@ public void BuildStatus_ExplainsActiveGatewayRolesCredentialsAndActions()
Assert.Contains("failed", status.Diagnostics.LastError);
}
+ [Fact]
+ public void BuildStatus_SeparatesGatewayPackageVersionFromWireProtocolCompatibility()
+ {
+ var compatibility = GatewayProtocolCompatibility.FromGatewayExpectation(5, 3);
+ var snapshot = new GatewayConnectionSnapshot
+ {
+ OverallState = OverallConnectionState.Error,
+ OperatorState = RoleConnectionState.Error,
+ OperatorErrorKind = GatewayErrorKind.ProtocolMismatch,
+ OperatorProtocolCompatibility = compatibility,
+ ProtocolCompatibility = compatibility,
+ ProtocolCompatibilityRole = GatewayProtocolCompatibilityRole.Operator,
+ GatewayId = "gw-1",
+ GatewayUrl = "wss://gateway.example"
+ };
+
+ var status = ConnectionDiagnosticsProjection.BuildStatus(
+ snapshot,
+ new GatewayRecord { Id = "gw-1", Url = "wss://gateway.example" },
+ enableNodeMode: false,
+ enableMcpServer: true,
+ isMcpRunning: true,
+ mcpError: null,
+ nodeBrowserProxyEnabled: false,
+ recentDiagnostics: [],
+ diagnosticEventCount: 0,
+ gatewaySelf: new GatewaySelfInfo
+ {
+ ServerVersion = "2026.6.11-1",
+ Protocol = 4
+ });
+
+ Assert.Equal(2, status.SchemaVersion);
+ Assert.Equal("2026.6.11-1", status.Gateway!.PackageVersion);
+ Assert.Null(status.Protocol.SelectedProtocol);
+ Assert.Equal(4, status.Protocol.CurrentProtocol);
+ Assert.Equal(3, status.Protocol.MinimumSupportedProtocol);
+ Assert.Equal(4, status.Protocol.MaximumSupportedProtocol);
+ Assert.Equal("gateway_too_new", status.Protocol.Compatibility);
+ Assert.Equal("operator", status.Protocol.Source);
+ Assert.Equal(5, status.Protocol.GatewayExpectedProtocol);
+ Assert.Equal(3, status.Protocol.GatewayMinimumProtocol);
+ Assert.False(status.Protocol.Retryable);
+ Assert.Equal("gateway_too_new", status.Protocol.Operator.Compatibility);
+ Assert.Equal("unknown", status.Protocol.Node.Compatibility);
+ }
+
+ [Theory]
+ [InlineData(3)]
+ [InlineData(5)]
+ public void BuildStatus_PreservesAcceptedProtocolAcrossTopLevelAndRoles(int protocol)
+ {
+ var compatibility = GatewayProtocolCompatibility.Compatible(protocol);
+ var snapshot = new GatewayConnectionSnapshot
+ {
+ OverallState = OverallConnectionState.Ready,
+ OperatorState = RoleConnectionState.Connected,
+ NodeConnectionIntended = true,
+ NodeState = RoleConnectionState.Connected,
+ OperatorProtocolCompatibility = compatibility,
+ NodeProtocolCompatibility = compatibility,
+ ProtocolCompatibility = compatibility,
+ ProtocolCompatibilityRole = GatewayProtocolCompatibilityRole.Operator,
+ GatewayId = "gw-1",
+ GatewayUrl = "wss://gateway.example"
+ };
+
+ var status = ConnectionDiagnosticsProjection.BuildStatus(
+ snapshot,
+ new GatewayRecord { Id = "gw-1", Url = "wss://gateway.example" },
+ enableNodeMode: true,
+ enableMcpServer: true,
+ isMcpRunning: true,
+ mcpError: null,
+ nodeBrowserProxyEnabled: false,
+ recentDiagnostics: [],
+ diagnosticEventCount: 0,
+ gatewaySelf: new GatewaySelfInfo { Protocol = 4 });
+
+ Assert.Equal(protocol, status.Protocol.SelectedProtocol);
+ Assert.Equal(protocol, status.Protocol.Operator.SelectedProtocol);
+ Assert.Equal(protocol, status.Protocol.Node.SelectedProtocol);
+ Assert.Equal("operator", status.Protocol.Source);
+ Assert.Equal("compatible", status.Protocol.Compatibility);
+ }
+
[Fact]
public void BuildStatus_OmitsBrowserProxyCaveatWhenNodeSessionIsNotLive()
{
diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageProtocolCompatibilityTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageProtocolCompatibilityTests.cs
new file mode 100644
index 000000000..3d86cd4e1
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/ConnectionPageProtocolCompatibilityTests.cs
@@ -0,0 +1,211 @@
+using OpenClaw.Connection;
+using OpenClaw.Shared;
+using OpenClawTray.Pages;
+using OpenClawTray.Services;
+using System.Xml.Linq;
+
+namespace OpenClaw.Tray.Tests;
+
+public sealed class ConnectionPageProtocolCompatibilityTests
+{
+ [Theory]
+ [InlineData(
+ GatewayProtocolCompatibilityState.GatewayTooOld,
+ 2,
+ "ConnectionPage_ProtocolGatewayUpdateRequired",
+ "ConnectionPage_ProtocolGatewayUpdateDetail")]
+ [InlineData(
+ GatewayProtocolCompatibilityState.GatewayTooNew,
+ 5,
+ "ConnectionPage_ProtocolWindowsUpdateRequired",
+ "ConnectionPage_ProtocolWindowsUpdateDetail")]
+ [InlineData(
+ GatewayProtocolCompatibilityState.Mismatch,
+ null,
+ "ConnectionPage_ProtocolUnknownMismatch",
+ null)]
+ public void ProtocolMismatch_ProjectsDirectionalLocalizedRecoveryWithManualActions(
+ GatewayProtocolCompatibilityState state,
+ int? expectedProtocol,
+ string expectedHeaderKey,
+ string? expectedDetailKey)
+ {
+ var compatibility = new GatewayProtocolCompatibility
+ {
+ State = state,
+ GatewayExpectedProtocol = expectedProtocol,
+ Retryable = false
+ };
+ var snapshot = new GatewayConnectionSnapshot
+ {
+ OverallState = OverallConnectionState.Error,
+ OperatorState = RoleConnectionState.Error,
+ OperatorErrorKind = GatewayErrorKind.ProtocolMismatch,
+ OperatorProtocolCompatibility = compatibility,
+ ProtocolCompatibility = compatibility,
+ ProtocolCompatibilityRole = GatewayProtocolCompatibilityRole.Operator,
+ GatewayId = "gw-1",
+ GatewayUrl = "wss://gateway.example"
+ };
+
+ var plan = ConnectionPagePlan.Build(
+ snapshot,
+ new GatewayRecord { Id = "gw-1", Url = "wss://gateway.example" },
+ self: null,
+ settings: null,
+ savedGatewayCount: 1);
+
+ Assert.Equal(ConnectionPageMode.Recovery, plan.Mode);
+ Assert.Equal(RecoveryCategory.ProtocolMismatch, plan.Recovery);
+ Assert.Equal(expectedHeaderKey, plan.StripHeadlineResourceKey);
+ Assert.Equal(expectedDetailKey, plan.StripSubResourceKey);
+ Assert.Equal(expectedHeaderKey, plan.RecoveryHeaderResourceKey);
+ Assert.Equal(
+ expectedDetailKey is null ? [] : [expectedDetailKey],
+ plan.RecoveryBulletResourceKeys);
+ Assert.Equal(expectedProtocol, plan.ProtocolExpectedVersion);
+ Assert.Equal(3, plan.ProtocolMinimumVersion);
+ Assert.Equal(4, plan.ProtocolMaximumVersion);
+ Assert.Equal(4, plan.ProtocolCurrentVersion);
+ Assert.Equal(ConnectionPrimaryAction.None, plan.StripPrimaryAction);
+ Assert.Null(plan.StripPrimaryLabel);
+ Assert.True(plan.AllowConnectionToggle);
+ }
+
+ [Fact]
+ public void NodeOnlyProtocolMismatch_PreservesConnectedOperatorWithoutRetry()
+ {
+ var settingsDirectory = Path.Combine(
+ Path.GetTempPath(),
+ "OpenClawTrayTests",
+ Guid.NewGuid().ToString("N"));
+ try
+ {
+ var settings = new SettingsManager(settingsDirectory)
+ {
+ EnableNodeMode = true
+ };
+ var compatibility = new GatewayProtocolCompatibility
+ {
+ State = GatewayProtocolCompatibilityState.GatewayTooNew,
+ GatewayExpectedProtocol = 5,
+ Retryable = false
+ };
+ var snapshot = new GatewayConnectionSnapshot
+ {
+ OverallState = OverallConnectionState.Degraded,
+ OperatorState = RoleConnectionState.Connected,
+ NodeConnectionIntended = true,
+ NodeState = RoleConnectionState.Error,
+ NodeErrorKind = GatewayErrorKind.ProtocolMismatch,
+ OperatorProtocolCompatibility = GatewayProtocolCompatibility.Compatible(4),
+ NodeProtocolCompatibility = compatibility,
+ ProtocolCompatibility = compatibility,
+ ProtocolCompatibilityRole = GatewayProtocolCompatibilityRole.Node,
+ GatewayId = "gw-1",
+ GatewayUrl = "wss://gateway.example"
+ };
+
+ var plan = ConnectionPagePlan.Build(
+ snapshot,
+ new GatewayRecord { Id = "gw-1", Url = "wss://gateway.example" },
+ self: null,
+ settings,
+ savedGatewayCount: 1);
+
+ Assert.Equal(ConnectionPageMode.Cockpit, plan.Mode);
+ Assert.Equal(OperatorCardState.Active, plan.OperatorCard);
+ Assert.Equal(NodeCardState.OnNodeError, plan.NodeCard);
+ Assert.Equal(
+ "ConnectionPage_ProtocolWindowsUpdateRequired",
+ plan.StripHeadlineResourceKey);
+ Assert.Equal(
+ "ConnectionPage_ProtocolWindowsUpdateDetail",
+ plan.StripSubResourceKey);
+ Assert.Equal(
+ "ConnectionPage_ProtocolWindowsUpdateDetail",
+ plan.NodeErrorDetailResourceKey);
+ Assert.Equal(5, plan.ProtocolExpectedVersion);
+ Assert.Equal(ConnectionPrimaryAction.None, plan.StripPrimaryAction);
+ Assert.Null(plan.StripPrimaryLabel);
+ Assert.True(plan.AllowConnectionToggle);
+ }
+ finally
+ {
+ if (Directory.Exists(settingsDirectory))
+ Directory.Delete(settingsDirectory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ConnectionPageApplicator_UsesPlanToShowManualRecoveryActions()
+ {
+ var root = TestRepositoryPaths.GetRepositoryRoot();
+ var xaml = File.ReadAllText(Path.Combine(
+ root,
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Pages",
+ "ConnectionPage.xaml"));
+ var codeBehind = File.ReadAllText(Path.Combine(
+ root,
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Pages",
+ "ConnectionPage.xaml.cs"));
+
+ Assert.Contains("x:Name=\"RecoveryConnectionActions\"", xaml);
+ Assert.Contains(
+ "RecoveryConnectionActions.Visibility = plan.AllowConnectionToggle",
+ codeBehind);
+ Assert.Contains(
+ "_currentPlan?.AllowConnectionToggle ?? true",
+ codeBehind);
+ Assert.Contains("plan.ProtocolMinimumVersion", codeBehind);
+ Assert.Contains("plan.ProtocolMaximumVersion", codeBehind);
+ Assert.Contains("plan.ProtocolCurrentVersion", codeBehind);
+ }
+
+ [Fact]
+ public void LocalizedProtocolCopy_UsesExpectedRangeAndCurrentPlaceholders()
+ {
+ var root = TestRepositoryPaths.GetRepositoryRoot();
+ foreach (var locale in new[] { "en-us", "fr-fr", "nl-nl", "zh-cn", "zh-tw" })
+ {
+ var document = XDocument.Load(Path.Combine(
+ root,
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Strings",
+ locale,
+ "Resources.resw"));
+ var gatewayUpdate = GetResourceValue(
+ document,
+ "ConnectionPage_ProtocolGatewayUpdateDetail");
+ var windowsUpdate = GetResourceValue(
+ document,
+ "ConnectionPage_ProtocolWindowsUpdateDetail");
+
+ Assert.Contains("{0}", gatewayUpdate);
+ Assert.Contains("{1}", gatewayUpdate);
+ Assert.Contains("{2}", gatewayUpdate);
+ Assert.Contains("{3}", gatewayUpdate);
+ Assert.DoesNotContain("v4", gatewayUpdate, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("{0}", windowsUpdate);
+ Assert.Contains("{1}", windowsUpdate);
+ Assert.Contains("{2}", windowsUpdate);
+ Assert.Contains("{3}", windowsUpdate);
+ Assert.DoesNotContain("v4", windowsUpdate, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ private static string GetResourceValue(XDocument document, string key) =>
+ document.Root!
+ .Elements("data")
+ .Single(element => string.Equals(
+ (string?)element.Attribute("name"),
+ key,
+ StringComparison.Ordinal))
+ .Element("value")!
+ .Value;
+}
diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageRowStateTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageRowStateTests.cs
index 36acb91e5..3944586df 100644
--- a/tests/OpenClaw.Tray.Tests/ConnectionPageRowStateTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ConnectionPageRowStateTests.cs
@@ -12,6 +12,25 @@ namespace OpenClaw.Tray.Tests;
///
public sealed class ConnectionPageRowStateTests
{
+ [Theory]
+ [InlineData(true, false, false, false)]
+ [InlineData(true, false, true, true)]
+ [InlineData(false, false, false, true)]
+ [InlineData(false, true, true, false)]
+ public void ShouldShowConnect_SuppressesOnlyActiveTerminalRetry(
+ bool isActive,
+ bool hasLiveAffordance,
+ bool allowConnectionToggle,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ ConnectionPageRowState.ShouldShowConnect(
+ isActive,
+ hasLiveAffordance,
+ allowConnectionToggle));
+ }
+
[Theory]
[InlineData(OverallConnectionState.Connected)]
[InlineData(OverallConnectionState.Ready)]
diff --git a/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs
index 5a87d40cf..dbe0ec6d4 100644
--- a/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs
+++ b/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs
@@ -52,6 +52,20 @@ public void SkillMd_command_set_matches_capability_registry()
}
}
+ [Fact]
+ public void ConnectionStatus_documents_package_and_protocol_compatibility_separately()
+ {
+ var content = File.ReadAllText(LocateSkillMd());
+
+ Assert.Contains("gateway.packageVersion", content, StringComparison.Ordinal);
+ Assert.Contains("wire protocol", content, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("gateway_too_old", content, StringComparison.Ordinal);
+ Assert.Contains("gateway_too_new", content, StringComparison.Ordinal);
+ Assert.Contains("retryable", content, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("4/3/4", content, StringComparison.Ordinal);
+ Assert.Contains("selectedProtocol", content, StringComparison.Ordinal);
+ }
+
///
/// skill.md lists each command under its own H3 heading like
/// ### system.notify. Anything matching ### <dotted.name>