Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/gateway-handshake-gate-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Gateway handshake gate: captured wire proof

Evidence artifact for PR #1425 (fixes #1418), captured at branch tip `5c02ab5`
from a real `dotnet test` execution of `tests/OpenClaw.Shared.Tests` on the
production client code path (`OpenClawGatewayClient` over real WebSockets
against a loopback gateway server). No live gateway or private endpoint was
contacted: the server under test runs inside the test process.

**Capture method:** `dotnet test --filter FullyQualifiedName~HandshakeGate
--logger "trx;LogFileName=proof.trx"`; the `[trace]` lines below are the
unmodified `ITestOutputHelper` output embedded in the TRX results file, with
auth tokens, device ids, and device key material redacted (all values are
test-only). Request ids and test nonces are harmless and kept verbatim.

**Suite status at the captured tip:** affected filter set 278/278 passed
(GatewayClient + live round-trip + AssistantMedia + ShutdownClassification);
protocol drift/contract/closure safety filters 30/30; `Tray.Tests` unchanged
versus a clean base run (18 pre-existing Linux-env failures, identical on
base `6466a04`; CI runs that suite on Windows).

The two scenarios below demonstrate the full gate lifecycle: suppression
before hello-ok (nothing on the wire), the gate holding while disconnected
(suppressed again, submission reported as false, not success), and the
client's **real auto-reconnect path** (receive-loop exit →
`ReconnectWithBackoffAsync`) re-running challenge → connect → hello-ok before
the gate reopens and the mutation reaches the wire.

## Scenario 1: mutation submitted before hello-ok (withheld, zero frames)

**Test:** `HandshakeGate_SuppressedMutation_ReportsNotSentNotSuccess` - outcome **Passed** (duration 00:00:00.1175257)

```text
[trace] socket open; challenge delivered; connect request captured (unanswered): {"type":"req","id":"3e7f62bf-d2e5-43b8-9145-0ecc2ed6a7c9","method":"connect","params":{"minProtocol":3,"maxProtocol":4,"client":{"id":"cli","version":"18.6.0","platform":"windows","deviceFamily":"Windows","mode":"cli","displayName":"OpenClaw Windows Tray"},"role":"operator","scopes":["operator.admin","operator.pairing"],"caps":[],"commands":[],"permissions":{},"auth":{"token":"***REDACTED***"},"locale":"en-US","userAgent":"openclaw-windows-tray/18.6.0","device":{"id":"***REDACTED***","publicKey":"***REDACTED***","signature":"***REDACTED***","signedAt":0,"nonce":"trace-challenge"}}}
[trace] ResetSessionAsync while handshake pending returned: False
[trace] no sessions.reset frame observed on the wire
```

## Scenario 2: reconnect flow (drop → gate holds → auto-reconnect → mutation sends after hello-ok)

**Test:** `HandshakeGate_Reconnect_SuppressesEarlyMutationThenSendsAfterHelloOk` - outcome **Passed** (duration 00:00:01.3212202)

```text
[trace] socket #1 open, challenge withheld: sessions.reset suppressed (submission=false); no frame reached the wire
[trace] challenge delivered; connect captured (unanswered): {"type":"req","id":"81269861-e101-4930-9de6-e3c17d78d6b1","method":"connect","params":{"minProtocol":3,"maxProtocol":4,"client":{"id":"cli","version":"18.6.0","platform":"windows","deviceFamily":"Windows","mode":"cli","displayName":"OpenClaw Windows Tray"},"role":"operator","scopes":["operator.admin","operator.pairing"],"caps":[],"commands":[],"permissions":{},"auth":{"token":"***REDACTED***"},"locale":"en-US","userAgent":"openclaw-windows-tray/18.6.0","device":{"id":"***REDACTED***","publicKey":"***REDACTED***","signature":"***REDACTED***","signedAt":0,"nonce":"trace-challenge"}}}
[trace] hello-ok processed; sessions.reset on the wire: {"type":"req","id":"e502fbba-2ee3-4275-96f7-71e1d14d562f","method":"sessions.reset","params":{"key":"agent:main:main"}}
[trace] server sent one-way Close frame on socket #1; client drop observation begins
[trace] client observed drop: handshake snapshot cleared, readiness false
[trace] sessions.reset during disconnected window: withheld (submission=false)
[trace] socket #2 accepted; challenge auto-resent; connect captured: {"type":"req","id":"0b1ae270-2fd8-424f-8025-08ff33c2b2fa","method":"connect","params":{"minProtocol":3,"maxProtocol":4,"client":{"id":"cli","version":"18.6.0","platform":"windows","deviceFamily":"Windows","mode":"cli","displayName":"OpenClaw Windows Tray"},"role":"operator","scopes":["operator.admin","operator.pairing"],"caps":[],"commands":[],"permissions":{},"auth":{"token":"***REDACTED***"},"locale":"en-US","userAgent":"openclaw-windows-tray/18.6.0","device":{"id":"***REDACTED***","publicKey":"***REDACTED***","signature":"***REDACTED***","signedAt":0,"nonce":"rt-challenge"}}}
[trace] gate reopened after reconnect; sessions.reset on the wire: {"type":"req","id":"415c05df-6e81-4297-9554-ea62575a2f3a","method":"sessions.reset","params":{"key":"agent:main:main"}}
```

**Wire-level assertions backing the trace** (same tests, same run):

- `Assert.Throws<InvalidOperationException>(() => server.FrameFor("sessions.reset"))`
after the pre-handshake submission; no frame was captured on the wire.
- After the one-way server Close: `Assert.False(client.IsConnectedToGateway)`
and the disconnected-window submission returns `false` with no captured
frame.
- After auto-reconnect: `Assert.True(client.IsConnectedToGateway)` only
following the fresh hello-ok, and the second `sessions.reset` is captured
on the wire.
7 changes: 7 additions & 0 deletions src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,13 @@ internal static SessionCompactionMutationResult ParseCompactionMutation(JsonElem
_logger.Warn($"{method} unsupported on gateway");
return null;
}
catch (InvalidOperationException ex) when (
ex.Message.Contains(HandshakePendingError, StringComparison.Ordinal))
{
// #1418: before hello-ok these payload reads are unavailable, not
// failures; callers treat null the same as an unsupported gateway.
return null;
}
}

private static JsonElement? TryGetArray(JsonElement parent, string property)
Expand Down
57 changes: 51 additions & 6 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,18 @@ public Func<CancellationToken, Task<ReconnectAuthorizationResult>>?

public string? OperatorDeviceId => _operatorDeviceId;
public IReadOnlyList<string> GrantedOperatorScopes => _grantedOperatorScopes;
public virtual bool IsConnectedToGateway => IsConnected;

/// <summary>
/// Readiness gate for the operator session: the WebSocket transport is open
/// AND the hello-ok handshake has completed. The gateway closes the socket
/// with 1008 PolicyViolation ("first request must be connect") when any
/// non-connect frame escapes before hello-ok (#1418), so callers must gate
/// application RPCs and "connected" UI state on this property, not on the
/// transport-only socket state. Protocol frames are exempt by design: the
/// handshake drives them over the dedicated SendConnectMessageAsync path,
/// never through the tracked/wizard application-send paths.
/// </summary>
public virtual bool IsConnectedToGateway => IsConnected && HasHandshakeSnapshot;
public int? LastRemoteCloseStatusCode => RemoteCloseStatusCode;

protected override void OnConnectionException(Exception exception)
Expand Down Expand Up @@ -1196,11 +1207,24 @@ private static bool ReadBoolean(JsonElement value, params string[] propertyNames
/// Sends a wizard RPC request and waits for the response payload.
/// Used for wizard.start, wizard.next, wizard.cancel, wizard.status.
/// </summary>
private const string HandshakePendingError =
"Gateway handshake has not completed (hello-ok pending)";

public async Task<JsonElement> SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000)
{
if (!IsConnected)
throw new InvalidOperationException("Gateway connection is not open");

// #1418: wizard requests are application RPCs (models.list,
// device.pair.list, pairing approvals, update.status, chat.abort,
// artifacts.download). Sending one before hello-ok makes the gateway
// 1008-close the socket, so fail with the same contract as a closed
// connection; tolerant callers (pairing approve/reject, models.list
// fallback, media resolution, payload reads) already catch
// InvalidOperationException and degrade gracefully.
if (!HasHandshakeSnapshot)
throw new InvalidOperationException($"{HandshakePendingError}; refusing to send '{method}'");

_logger.Info($"[GatewayClient] Sending frame: {method}");
var requestId = Guid.NewGuid().ToString();
var pending = _pendingRequests.RegisterWizard(requestId, method);
Expand Down Expand Up @@ -1884,7 +1908,7 @@ public async Task<bool> StopChannelAsync(string channelName)
/// </summary>
public async Task<GatewayUpdateStatus?> GetUpdateStatusAsync(int timeoutMs = 5000)
{
if (!IsConnected)
if (!IsConnected || !HasHandshakeSnapshot)
return null;

var response = await SendWizardRequestAsync("update.status", new { }, timeoutMs);
Expand Down Expand Up @@ -2107,15 +2131,37 @@ private ConnectCredential SelectConnectCredential()
private bool HasUsableOperatorDeviceToken =>
!_ignoreStoredDeviceToken && !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);

private async Task SendTrackedRequestAsync(string method, object? parameters = null)
/// <summary>
/// Sends a fire-and-forget tracked request. Returns false when the frame
/// was not sent (socket closed, or hello-ok handshake still pending per
/// #1418) so mutation callers can report an unsuccessful submission
/// instead of a false success. Read-style fire-and-forget callers ignore
/// the result; the post-handshake refresh burst re-requests their state.
/// The connect handshake never rides this path
/// (SendConnectMessageAsync sends via SendRawAsync directly), so protocol
/// frames are exempt by construction.
/// </summary>
private async Task<bool> SendTrackedRequestAsync(string method, object? parameters = null)
{
if (!IsConnected) return;
if (!IsConnected) return false;

if (!HasHandshakeSnapshot)
{
// #1418: the gateway 1008-closes the socket when an application RPC
// escapes before hello-ok. Withhold the frame and report not-sent:
// mutations must not report a successful submission, and read-style
// callers tolerate the withheld refresh (the post-handshake burst
// re-requests the state).
_logger.Debug($"[GatewayClient] {method} suppressed before handshake");
return false;
}

var requestId = Guid.NewGuid().ToString();
var pending = _pendingRequests.RegisterTracked(requestId, method);
try
{
await SendRawAsync(SerializeRequest(requestId, method, parameters));
return true;
}
catch
{
Expand All @@ -2128,8 +2174,7 @@ private async Task<bool> TrySendTrackedRequestAsync(string method, object? param
{
try
{
await SendTrackedRequestAsync(method, parameters);
return true;
return await SendTrackedRequestAsync(method, parameters);
}
catch (Exception ex)
{
Expand Down
Loading
Loading