diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0d7c352d0..b59b91d31 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -66,6 +66,9 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
| Capability UI metadata | `NodeCapabilityUiCatalog` (planned) | planned |
| Capability registration/gating | `NodeCapabilityRegistrationPolicy` (planned) | planned |
| Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned |
+| Node runtime client boundary | `INodeRuntimeClient` + `INodeRuntimeClientFactory` | authoritative |
+| Windows node capability execution | `NodeCapabilityDispatcher` | authoritative |
+| Rust sidecar protocol and capability adaptation | `WindowsSidecarSupervisor` + `WindowsSidecarCapabilityAdapter` | authoritative, not selectable |
| Gateway connect envelope | `ConnectEnvelopeBuilder` (planned) | planned |
| Gateway request tracking | `PendingRequestRegistry` (planned) | planned |
@@ -80,6 +83,8 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
| `src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs` | `ConnectionPagePlan` (pure), `ConnectionPageViewModel`, `GatewayDirectConnectService`, gateway row models |
| `src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs` | settings read/persist → `SettingsPageViewModel` + `ISettingsStore`; keep gateway-uninstall, uptime timer, saved-indicator, and app-info in the view |
| `src/OpenClaw.Tray.WinUI/Services/NodeService.cs` | `McpServerHost`, `CanvasWindowManager`, `MediaCapabilityHost`, `RecordingConsentService`, `NodeCapabilityRegistry` |
+| `src/OpenClaw.Connection/NodeConnector.cs` | runtime implementations behind `INodeRuntimeClientFactory`; keep lifecycle arbitration in the connector |
+| `src/OpenClaw.Shared/WindowsNodeClient.cs` | wire parsing and response framing only; keep capability routing, bounds, cancellation, and telemetry in `NodeCapabilityDispatcher` |
| `src/OpenClaw.Shared/OpenClawGatewayClient.cs` | `PendingRequestRegistry`, `ConnectEnvelopeBuilder`, `GatewayMessageRouter`, per-domain API facades |
| `src/OpenClaw.Shared/Models.cs` | per-domain model files + `*Mapper` classes |
| `src/OpenClaw.Shared/Capabilities/SystemCapability.cs` | `ExecApprovalService` |
@@ -138,6 +143,9 @@ leading and trailing pipe. Columns, in order:
| 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 | - |
+| node-runtime-client-boundary | authoritative | NodeConnector + NodeService | concrete WindowsNodeClient coupling across node lifecycle and capability registration | INodeRuntimeClient + INodeRuntimeClientFactory | WindowsNodeClient remains the default in-process implementation | a substitute runtime receives configuration and capability-host setup before its connect handshake | NodeConnectorTests.ConnectAsync_UsesInjectedRuntimeClientBeforeHandshake | behavioral | - |
+| node-capability-dispatcher | authoritative | src/OpenClaw.Shared/WindowsNodeClient.cs | capability indexing, bounded execution, duplicate tracking, cancellation, telemetry, and completion events | NodeCapabilityDispatcher | WindowsNodeClient retains Gateway envelope parsing and response framing; future adapters remain ineligible for selection until dispatcher conformance passes | the current C# runtime executes Windows-owned capabilities through one bounded dispatcher with first-registration-wins routing and structured completion | NodeCapabilityDispatcherTests.DispatchAsync_UsesFirstRegisteredCapabilityAndPreservesEventSender | behavioral | when every selectable runtime adapter proves it does not execute INodeCapability directly |
+| rust-sidecar-capability-adapter | authoritative | future Rust runtime adapter planning | authenticated sidecar framing, handshake, immutable manifest admission, and native capability dispatch | WindowsSidecarSupervisor + WindowsSidecarCapabilityAdapter | process launch, protected credential bootstrap, runtime lifecycle projection, audit, rollout, rollback, and the default C# runtime remain outside this proof adapter | every admitted ordinary sidecar invocation is byte-compatible with the Rust corpus and executes only through NodeCapabilityDispatcher | WindowsSidecarCapabilityAdapterTests.Supervisor_DrivesAuthenticatedFramesIntoWindowsDispatcher | behavioral | when a selectable INodeRuntimeClient adapter subsumes this implementation with real Rust process and Gateway proof |
| node-summary-text | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | node-summary clipboard text formatting | NodeSummaryText | App keeps the clipboard side effect (building the DataPackage and setting clipboard content) | copied node-summary text is projected only by NodeSummaryText.Build (online/offline state, display-name fallback, short id, detail text, newline join) | NodeSummaryTextTests.Build_MultipleNodes_OneLinePerNodeJoinedByNewline | behavioral | - |
| reactor-chat-timeline | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | production chat message virtualization, row realization, and imperative scroll follow | ReactorChatTimeline through OpenClawReactorChatRoot and ReactorHostControl | OpenClawChatTimeline remains a legacy focused-test surface while its runtime route is migrated | the default chat route mounts one direct ReactorHostControl per XAML chat target; Reactor owns stable-key ItemsView and ItemContainer realization without a custom native list, collection reconciler, or scroll-layout mutation | review-only: user explicitly deferred new tests for this migration; required build and existing shared/tray suites still run | review-only | when Reactor timeline proof coverage replaces the legacy focused UI host coverage |
| chat-tool-activity-renderer | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs | production standalone tool-call and grouped activity presentation, summaries, disclosures, and detail rendering | ChatToolActivityPresentation + ToolCallCardRenderer | ReactorChatTimeline projects rows and delegates realization only | consecutive invocation grouping preserves source chronology; stable group identity comes from session, generation, and first tool entry; selectable output remains capped at 240px | ChatToolActivityPresentationTests.Project_GroupsOnlyConsecutiveSpansOfAtLeastTwoTools | behavioral | - |
diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md
index fafe6ee24..bfdba60ca 100644
--- a/docs/CONNECTION_ARCHITECTURE.md
+++ b/docs/CONNECTION_ARCHITECTURE.md
@@ -16,13 +16,17 @@ OpenClaw.Tray.WinUI (net10.0-windows) - UI app, tray icon, pages, windows
**OpenClaw.Shared** owns the low-level gateway clients (`OpenClawGatewayClient`, `WindowsNodeClient`, `WebSocketClientBase`), device identity/signing (`DeviceIdentity`), protocol models, and the `IOperatorGatewayClient` interface.
-`WindowsNodeClient` also owns gateway invocation lifetime at the transport
-boundary. Active invokes are registered by invoke ID in a focused cancellation
-registry, linked to the node connection lifetime, and cancelled individually by
-the gateway `node.invoke.cancel` event. Active invocations atomically transition
-to cancelled or completed when capability execution returns; whichever
-transition wins determines the protocol outcome. Capability implementations
-remain responsible for cooperative cancellation of their own underlying work.
+`WindowsNodeClient` owns Gateway envelope decoding and response framing, then
+delegates Windows capability execution to `NodeCapabilityDispatcher`. The
+dispatcher registers active invokes by invoke ID, links them to the node
+connection lifetime, and applies individual `node.invoke.cancel` requests.
+Active invocations atomically transition to cancelled or completed when
+capability execution returns; whichever transition wins determines the protocol
+outcome. Capability implementations remain responsible for cooperative
+cancellation of their own underlying work. A future runtime adapter must route
+decoded invokes through this same dispatcher rather than execute Windows
+capabilities itself, and remains ineligible for runtime selection until an
+adapter-level conformance test proves that route.
**OpenClaw.Connection** owns all connection management: `GatewayConnectionManager`, `GatewayRegistry`, `CredentialResolver`, `ConnectionStateMachine`, `NodeConnector`, `SshTunnelService/Manager`, `SetupCodeDecoder`, and all connection interfaces/DTOs/enums. This project has zero WinUI dependencies and is independently testable.
diff --git a/docs/RUST_NODE_RUNTIME_MIGRATION.md b/docs/RUST_NODE_RUNTIME_MIGRATION.md
new file mode 100644
index 000000000..fd4d0f3f2
--- /dev/null
+++ b/docs/RUST_NODE_RUNTIME_MIGRATION.md
@@ -0,0 +1,81 @@
+# Rust node runtime migration seam
+
+The Windows node is already the native Windows capability host. Its WinUI,
+operator controls, MCP server, approval UX, and Windows-native command handlers
+should remain in this repository. The proposed OpenClaw Rust runtime can replace
+the duplicated Gateway transport and node lifecycle without replacing those
+Windows surfaces.
+
+This change introduces the first executable seam:
+
+- `INodeRuntimeClient` is the contract consumed by the Windows capability host.
+- `INodeRuntimeClientFactory` selects the runtime implementation.
+- `WindowsNodeClient` remains the default, so production behavior is unchanged.
+- `NodeConnector` still owns connection arbitration and ensures capabilities and
+ permissions are registered before the selected runtime begins its handshake.
+- `NodeCapabilityDispatcher` is the shared Windows-owned execution path for
+ command lookup, concurrency, cancellation, telemetry, and completion. The
+ C# client now uses it. A Rust adapter is not eligible for runtime selection
+ until adapter-level conformance proves it routes every decoded invocation
+ through this dispatcher instead of copying capability policy.
+
+The next fork-only evidence slice now implements the Windows half of the shared
+sidecar contract without selecting it in production:
+
+- `AuthenticatedSidecarChannel` independently reproduces the Rust framing and
+ HMAC vector, enforces directional sequence and generation bounds, and retires
+ permanently after inbound validation failure.
+- `SidecarSupervisorHandshake` independently reproduces the Rust offer and
+ accept vectors and lowers the active frame ceiling to the negotiated limit.
+- `WindowsSidecarSupervisor` enforces handshake, one-time immutable
+ configuration, and post-configuration message order.
+- `WindowsSidecarCapabilityAdapter` requires an unchanged admission before
+ dispatch, rejects wrong-node and undeclared work, and routes invocation and
+ cancellation only through `NodeCapabilityDispatcher`.
+- The copied fixtures are byte-exact evidence from OpenClaw fork PRs #193,
+ #194, and #195 at the combined head `5bab2c9ecf6`. They are conformance
+ inputs, not a second wire authority.
+
+This proof deliberately does not implement `INodeRuntimeClient` or runtime
+selection yet. The current shared messages do not project the complete pairing,
+issued-token, health, Gateway-self, reconnect-authorization, and node-event
+surface required by that interface. A production adapter also still needs a
+verified Rust artifact, process supervision, protected bootstrap and credential
+handoff, concrete local IPC, resource bounds, audit correlation, and rollback.
+
+The proof also records one generic compatibility gap: the current Rust
+`CommandRuntime` rejects all `system.*` registrations. That is appropriate for
+the standalone experimental host, but it prevents the official Windows node's
+existing `system.run`, `system.which`, and `system.notify` capabilities from
+using the sidecar. Runtime PR3 therefore needs an explicit OpenClaw-authorized
+command-namespace mechanism before the Windows adapter can be selected. The
+Windows proof fails closed instead of bypassing that restriction.
+
+## Intended follow-up slices
+
+1. ~~Define the versioned, authenticated local IPC messages against the shared
+ OpenClaw node lifecycle fixtures.~~ Implemented as fork conformance evidence.
+2. Add an opt-in sidecar adapter that implements `INodeRuntimeClient` over that
+ versioned, authenticated local IPC protocol. The Rust process owns Gateway
+ connection, registration, invoke/result/progress/cancellation, reconnect, and
+ runtime lifecycle.
+3. Run the C# and Rust implementations through the same registration and
+ invocation conformance fixtures, including cancellable blocked-connect and
+ adapter-to-dispatcher routing tests. Keep the existing C# runtime as the
+ default while the Rust path gathers real Gateway proof.
+4. Switch the Windows node role to the Rust adapter behind an explicit rollout
+ gate. C# continues to execute Windows-native capabilities and return results
+ through the runtime contract.
+5. Remove the duplicated C# Gateway node transport only after parity, rollout,
+ and rollback criteria are met.
+
+## Non-goals of this slice
+
+- It does not add or vendor a Rust binary.
+- It does not change the production runtime selection.
+- It does not claim complete `INodeRuntimeClient` lifecycle or pairing parity.
+- It does not add commands or change native capability ownership.
+- It does not move the operator or MCP roles into Rust.
+
+This separation lets the Windows Companion and the Windows tray share an
+OpenClaw-owned Rust runtime while preserving the app-specific Windows surfaces.
diff --git a/src/OpenClaw.Connection/INodeConnector.cs b/src/OpenClaw.Connection/INodeConnector.cs
index 45bfad80b..8b23218c3 100644
--- a/src/OpenClaw.Connection/INodeConnector.cs
+++ b/src/OpenClaw.Connection/INodeConnector.cs
@@ -4,7 +4,7 @@ namespace OpenClaw.Connection;
///
/// Manages the node-side connection for a given gateway.
-/// Owns the WindowsNodeClient lifecycle but delegates capability
+/// Owns the node runtime lifecycle but delegates capability
/// setup to NodeService (which has WinUI dependencies).
///
public interface INodeConnector : IDisposable
@@ -21,7 +21,7 @@ public interface INodeConnector : IDisposable
event EventHandler DeviceTokenReceived;
///
- /// Raised right after a new is constructed
+ /// Raised right after a new is constructed
/// but BEFORE its ConnectAsync() call. Subscribers (typically
/// NodeService) must register the node's capabilities on the new
/// client synchronously so the outbound "connect" handshake includes
@@ -67,12 +67,12 @@ public interface INodeConnectorReconnectPolicy
public sealed class NodeClientCreatedEventArgs : EventArgs
{
- public NodeClientCreatedEventArgs(WindowsNodeClient client, string? bearerToken)
+ public NodeClientCreatedEventArgs(INodeRuntimeClient client, string? bearerToken)
{
Client = client;
BearerToken = bearerToken;
}
- public WindowsNodeClient Client { get; }
+ public INodeRuntimeClient Client { get; }
public string? BearerToken { get; }
}
diff --git a/src/OpenClaw.Connection/INodeRuntimeClientFactory.cs b/src/OpenClaw.Connection/INodeRuntimeClientFactory.cs
new file mode 100644
index 000000000..214fdc0ad
--- /dev/null
+++ b/src/OpenClaw.Connection/INodeRuntimeClientFactory.cs
@@ -0,0 +1,33 @@
+using OpenClaw.Shared;
+
+namespace OpenClaw.Connection;
+
+///
+/// Creates the node runtime used by .
+///
+public interface INodeRuntimeClientFactory
+{
+ INodeRuntimeClient Create(
+ string gatewayUrl,
+ GatewayCredential credential,
+ string identityPath,
+ IOpenClawLogger logger);
+}
+
+///
+/// Preserves the existing in-process C# node runtime as the default.
+///
+public sealed class WindowsNodeRuntimeClientFactory : INodeRuntimeClientFactory
+{
+ public INodeRuntimeClient Create(
+ string gatewayUrl,
+ GatewayCredential credential,
+ string identityPath,
+ IOpenClawLogger logger) =>
+ new WindowsNodeClient(
+ gatewayUrl,
+ credential.IsBootstrapToken ? "" : credential.Token,
+ identityPath,
+ logger,
+ bootstrapToken: credential.IsBootstrapToken ? credential.Token : null);
+}
diff --git a/src/OpenClaw.Connection/NodeConnector.cs b/src/OpenClaw.Connection/NodeConnector.cs
index 89fcea00c..cd7b0413d 100644
--- a/src/OpenClaw.Connection/NodeConnector.cs
+++ b/src/OpenClaw.Connection/NodeConnector.cs
@@ -3,7 +3,7 @@
namespace OpenClaw.Connection;
///
-/// Lightweight node connector that creates and manages a WindowsNodeClient.
+/// Lightweight node connector that creates and manages a node runtime client.
/// Capability setup (canvas, screen capture, etc.) is handled by NodeService,
/// which has WinUI dependencies and remains in App.xaml.cs for now.
///
@@ -11,9 +11,10 @@ public sealed class NodeConnector : INodeConnector, INodeConnectorTelemetryEvent
{
private readonly IOpenClawLogger _logger;
private readonly ConnectionDiagnostics? _diagnostics;
+ private readonly INodeRuntimeClientFactory _clientFactory;
private readonly SemaphoreSlim _connectSemaphore = new(1, 1);
private readonly object _clientLifecycleLock = new();
- private WindowsNodeClient? _client;
+ private INodeRuntimeClient? _client;
private long _clientGeneration;
private bool _disposed;
public Func>? HandshakeAuthorizationAsync { get; set; }
@@ -26,10 +27,14 @@ public sealed class NodeConnector : INodeConnector, INodeConnectorTelemetryEvent
public event EventHandler? TransportConnected;
public event EventHandler? ConnectionFailure;
- public NodeConnector(IOpenClawLogger logger, ConnectionDiagnostics? diagnostics = null)
+ public NodeConnector(
+ IOpenClawLogger logger,
+ ConnectionDiagnostics? diagnostics = null,
+ INodeRuntimeClientFactory? clientFactory = null)
{
_logger = logger;
_diagnostics = diagnostics;
+ _clientFactory = clientFactory ?? new WindowsNodeRuntimeClientFactory();
}
public bool IsConnected => _client?.IsConnected ?? false;
@@ -43,8 +48,8 @@ public NodeConnector(IOpenClawLogger logger, ConnectionDiagnostics? diagnostics
public string? NodeDeviceId => _client?.FullDeviceId;
public NodeConnectionMode Mode { get; private set; } = NodeConnectionMode.Disabled;
- /// The underlying node client, for capability registration by NodeService.
- public WindowsNodeClient? Client => _client;
+ /// The underlying node runtime, for capability registration by NodeService.
+ public INodeRuntimeClient? Client => _client;
public Task ConnectAsync(
string gatewayUrl,
@@ -97,12 +102,7 @@ private async Task ConnectCoreAsync(
? new DiagnosticTeeLogger(_logger, _diagnostics)
: _logger;
- var client = new WindowsNodeClient(
- gatewayUrl,
- credential.IsBootstrapToken ? "" : credential.Token,
- identityPath,
- nodeLogger,
- bootstrapToken: credential.IsBootstrapToken ? credential.Token : null);
+ var client = _clientFactory.Create(gatewayUrl, credential, identityPath, nodeLogger);
client.HandshakeAuthorizationAsync = HandshakeAuthorizationAsync;
client.ReconnectAuthorizationAsync = ReconnectAuthorizationAsync;
@@ -110,31 +110,36 @@ private async Task ConnectCoreAsync(
if (useV2Signature)
client.UseV2Signature = true;
- long generation;
+ long generation = 0;
+ bool rejectCandidate;
lock (_clientLifecycleLock)
{
- if (_disposed || cancellationToken.IsCancellationRequested)
+ rejectCandidate = _disposed || cancellationToken.IsCancellationRequested;
+ if (!rejectCandidate)
{
- try { client.Dispose(); }
- catch (Exception ex) { _logger.Warn($"[NodeConnector] Candidate dispose error: {ex.Message}"); }
- cancellationToken.ThrowIfCancellationRequested();
- return;
+ generation = Interlocked.Increment(ref _clientGeneration);
+ _client = client;
+ Mode = NodeConnectionMode.Gateway;
}
+ }
- generation = Interlocked.Increment(ref _clientGeneration);
- _client = client;
- Mode = NodeConnectionMode.Gateway;
+ if (rejectCandidate)
+ {
+ DisposeClient(client);
+ cancellationToken.ThrowIfCancellationRequested();
+ return;
}
- using var cancellationRegistration = cancellationToken.Register(
- () => DisconnectIfCurrent(generation));
- cancellationToken.ThrowIfCancellationRequested();
+ if (cancellationToken.IsCancellationRequested)
+ {
+ RetireIfCurrent(client, generation);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
- // CRITICAL: fire ClientCreated BEFORE await _client.ConnectAsync() so subscribers
- // (NodeService) can register capabilities synchronously. WindowsNodeClient
- // serializes _registration.Capabilities/Commands into the outbound "connect"
- // message during the connect handshake — registering after that point means
- // the gateway sees an empty caps array for this session.
+ // CRITICAL: fire ClientCreated BEFORE await client.ConnectAsync() so subscribers
+ // (NodeService) can register capabilities synchronously. Runtime implementations
+ // serialize the registration into their outbound connect handshake; registering
+ // after that point means the gateway sees an empty caps array for this session.
try
{
lock (_clientLifecycleLock)
@@ -151,12 +156,17 @@ private async Task ConnectCoreAsync(
cancellationToken.IsCancellationRequested ||
!IsCurrentClient(client, generation))
{
+ RetireIfCurrent(client, generation);
throw;
}
catch (Exception ex)
{
- if (cancellationToken.IsCancellationRequested ||
- !IsCurrentClient(client, generation))
+ if (cancellationToken.IsCancellationRequested)
+ {
+ RetireIfCurrent(client, generation);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ if (!IsCurrentClient(client, generation))
{
return;
}
@@ -181,24 +191,25 @@ private async Task ConnectCoreAsync(
try
{
- Task connectTask;
lock (_clientLifecycleLock)
- {
ThrowIfNotCurrent(client, generation, cancellationToken);
- connectTask = client.ConnectAsync();
- }
- await connectTask;
+ await client.ConnectAsync(cancellationToken);
lock (_clientLifecycleLock)
ThrowIfNotCurrent(client, generation, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
+ RetireIfCurrent(client, generation);
throw;
}
catch (Exception ex)
{
- if (cancellationToken.IsCancellationRequested ||
- !IsCurrentClient(client, generation))
+ if (cancellationToken.IsCancellationRequested)
+ {
+ RetireIfCurrent(client, generation);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ if (!IsCurrentClient(client, generation))
{
return;
}
@@ -268,15 +279,6 @@ private void ForwardIfCurrent(
}
}
- private void DisconnectIfCurrent(long generation)
- {
- lock (_clientLifecycleLock)
- {
- if (Interlocked.Read(ref _clientGeneration) == generation)
- DisconnectInternalCore();
- }
- }
-
private void DisconnectCurrentClient()
{
DisconnectInternal();
@@ -284,25 +286,46 @@ private void DisconnectCurrentClient()
private void DisconnectInternal()
{
+ INodeRuntimeClient? old;
lock (_clientLifecycleLock)
- DisconnectInternalCore();
+ old = RetireCurrentClientCore();
+ DisposeClient(old);
}
- private void DisconnectInternalCore()
+ private void RetireIfCurrent(INodeRuntimeClient client, long generation)
+ {
+ INodeRuntimeClient? retired = null;
+ lock (_clientLifecycleLock)
+ {
+ if (Interlocked.Read(ref _clientGeneration) == generation &&
+ ReferenceEquals(client, _client))
+ {
+ retired = RetireCurrentClientCore();
+ }
+ }
+ DisposeClient(retired);
+ }
+
+ private INodeRuntimeClient? RetireCurrentClientCore()
{
Interlocked.Increment(ref _clientGeneration);
var old = _client;
_client = null;
- if (old != null)
- {
- try { old.Dispose(); }
- catch (Exception ex) { _logger.Warn($"[NodeConnector] Dispose error: {ex.Message}"); }
- }
Mode = NodeConnectionMode.Disabled;
+ return old;
+ }
+
+ private void DisposeClient(INodeRuntimeClient? client)
+ {
+ if (client == null)
+ return;
+
+ try { client.Dispose(); }
+ catch (Exception ex) { _logger.Warn($"[NodeConnector] Dispose error: {ex.Message}"); }
}
private void ThrowIfNotCurrent(
- WindowsNodeClient client,
+ INodeRuntimeClient client,
long generation,
CancellationToken cancellationToken)
{
@@ -316,11 +339,13 @@ private void ThrowIfNotCurrent(
public void Dispose()
{
+ INodeRuntimeClient? old;
lock (_clientLifecycleLock)
{
if (_disposed) return;
_disposed = true;
- DisconnectInternalCore();
+ old = RetireCurrentClientCore();
}
+ DisposeClient(old);
}
}
diff --git a/src/OpenClaw.Shared/INodeRuntimeClient.cs b/src/OpenClaw.Shared/INodeRuntimeClient.cs
new file mode 100644
index 000000000..4019375c7
--- /dev/null
+++ b/src/OpenClaw.Shared/INodeRuntimeClient.cs
@@ -0,0 +1,56 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using OpenClaw.Shared.Telemetry;
+
+namespace OpenClaw.Shared;
+
+///
+/// Runtime boundary consumed by the Windows capability host.
+///
+///
+/// The current implementation is . Keeping the
+/// capability host behind this contract allows a future OpenClaw Rust sidecar
+/// to own Gateway transport and node lifecycle while C# continues to own and
+/// execute Windows-native capability handlers.
+///
+public interface INodeRuntimeClient : IDisposable
+{
+ bool UseV2Signature { get; set; }
+ Func>?
+ HandshakeAuthorizationAsync { get; set; }
+ Func>?
+ ReconnectAuthorizationAsync { get; set; }
+ bool IsConnected { get; }
+ string? NodeId { get; }
+ string GatewayUrl { get; }
+ IReadOnlyList Capabilities { get; }
+ bool IsPendingApproval { get; }
+ bool IsPaired { get; }
+ string ShortDeviceId { get; }
+ string FullDeviceId { get; }
+ string DisplayName { get; }
+ int RegisteredCapabilityCount { get; }
+ int RegisteredCommandCount { get; }
+ IEnumerable RegisteredCommandsSample { get; }
+
+ event EventHandler StatusChanged;
+ event EventHandler InvokeCompleted;
+ event EventHandler ToolTelemetryCompleted;
+ event EventHandler PairingStatusChanged;
+ event EventHandler HealthReceived;
+ event EventHandler GatewaySelfUpdated;
+ event EventHandler DeviceTokenReceived;
+ event EventHandler TransportConnected;
+ event EventHandler ConnectionFailure;
+ event EventHandler Disposed;
+
+ void RegisterCapability(INodeCapability capability);
+ void SetPermission(string permission, bool value);
+ ///
+ /// Connects the runtime. Cancellation must promptly abort the in-progress
+ /// attempt and make the client safe to retire.
+ ///
+ Task ConnectAsync(CancellationToken cancellationToken);
+ Task DisconnectAsync();
+ Task SendNodeEventAsync(string eventName, JsonObject payload);
+}
diff --git a/src/OpenClaw.Shared/NodeCapabilityDispatcher.cs b/src/OpenClaw.Shared/NodeCapabilityDispatcher.cs
new file mode 100644
index 000000000..e9561d39f
--- /dev/null
+++ b/src/OpenClaw.Shared/NodeCapabilityDispatcher.cs
@@ -0,0 +1,431 @@
+using System.Collections.Frozen;
+using System.Diagnostics;
+using OpenClaw.Shared.Telemetry;
+
+namespace OpenClaw.Shared;
+
+///
+/// Executes Windows-owned node capabilities independently of the Gateway transport.
+///
+///
+/// Both the in-process C# client and a future Rust runtime adapter use this
+/// dispatcher so command routing, bounds, cancellation, telemetry, and result
+/// semantics remain owned by the Windows capability host.
+///
+public sealed class NodeCapabilityDispatcher
+{
+ private const int MaxConcurrentInvocations = 8;
+
+ private readonly object _eventSender;
+ private readonly Func _nodeId;
+ private readonly IOpenClawLogger _logger;
+ private readonly List _capabilities = new();
+ private FrozenDictionary _commandMap =
+ FrozenDictionary.Empty;
+ private readonly SemaphoreSlim _invokeSemaphore =
+ new(MaxConcurrentInvocations, MaxConcurrentInvocations);
+ private readonly InvocationCancellationRegistry _activeInvocations = new();
+
+ public NodeCapabilityDispatcher(
+ object eventSender,
+ Func nodeId,
+ IOpenClawLogger logger)
+ {
+ _eventSender = eventSender ?? throw new ArgumentNullException(nameof(eventSender));
+ _nodeId = nodeId ?? throw new ArgumentNullException(nameof(nodeId));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public IReadOnlyList Capabilities => _capabilities;
+
+ public event EventHandler? InvokeReceived;
+ public event EventHandler? InvokeCompleted;
+ public event EventHandler? ToolTelemetryCompleted;
+
+ public void RegisterCapability(INodeCapability capability)
+ {
+ ArgumentNullException.ThrowIfNull(capability);
+ if (_capabilities.Contains(capability))
+ return;
+
+ _capabilities.Add(capability);
+ RebuildCommandMap();
+ }
+
+ ///
+ /// Dispatches one transport-decoded invocation without blocking the caller
+ /// while the capability runs.
+ ///
+ public async Task DispatchAsync(
+ NodeInvokeRequest request,
+ Func sendResponse,
+ Func sendErrorResponse,
+ CancellationToken connectionCancellation)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(sendResponse);
+ ArgumentNullException.ThrowIfNull(sendErrorResponse);
+
+ var telemetry = request.Telemetry ?? new NodeToolInvocation(NodeToolTransport.Gateway);
+ request.Telemetry = telemetry;
+ var dispatchEntry = Volatile.Read(ref _commandMap).GetValueOrDefault(request.Command);
+
+ if (dispatchEntry == null)
+ {
+ var error = $"Command not supported: {request.Command}";
+ _logger.Warn($"[NODE] No capability registered for command: {request.Command}");
+ await SendFailureAndCompleteTelemetryAsync(
+ telemetry,
+ () => sendErrorResponse(error),
+ NodeToolErrorCategory.UnsupportedCommand);
+ RaiseInvokeCompleted(request, false, error, TimeSpan.Zero);
+ return;
+ }
+
+ telemetry.SetCommand(dispatchEntry.CanonicalName);
+ if (!_invokeSemaphore.Wait(0))
+ {
+ const string error = "node busy, retry";
+ _logger.Warn($"[NODE] Invoke slots full, rejecting {request.Command} ({request.Id})");
+ await SendFailureAndCompleteTelemetryAsync(
+ telemetry,
+ () => sendErrorResponse(error),
+ NodeToolErrorCategory.NodeBusy);
+ RaiseInvokeCompleted(request, false, error, TimeSpan.Zero);
+ return;
+ }
+
+ if (!_activeInvocations.TryRegister(
+ request.Id,
+ connectionCancellation,
+ out var invocation))
+ {
+ _invokeSemaphore.Release();
+ const string error = "duplicate active request id";
+ _logger.Warn($"[NODE] Duplicate active invoke ID: {request.Id}");
+ await SendFailureAndCompleteTelemetryAsync(
+ telemetry,
+ () => sendErrorResponse(error),
+ NodeToolErrorCategory.InvalidRequest);
+ RaiseInvokeCompleted(request, false, error, TimeSpan.Zero);
+ return;
+ }
+
+ _ = Task.Run(
+ () => ExecuteCapabilityAsync(
+ request,
+ dispatchEntry.Capability,
+ sendResponse,
+ sendErrorResponse,
+ invocation!),
+ CancellationToken.None);
+ }
+
+ public bool TryCancel(string requestId) => _activeInvocations.TryCancel(requestId);
+
+ public void CancelAll() => _activeInvocations.CancelAll();
+
+ internal void CompleteTelemetry(
+ NodeToolInvocation telemetry,
+ NodeToolOutcome outcome,
+ NodeToolErrorCategory category,
+ NodeToolExecutionMode? executionMode = null,
+ Type? errorType = null)
+ {
+ var completion = telemetry.Complete(outcome, category, executionMode, errorType);
+ if (completion == null)
+ return;
+
+ try
+ {
+ ToolTelemetryCompleted?.Invoke(_eventSender, completion);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn($"[NODE] Tool telemetry completion handler failed: {ex.GetType().Name}");
+ }
+ }
+
+ internal async Task SendFailureAndCompleteTelemetryAsync(
+ NodeToolInvocation telemetry,
+ Func send,
+ NodeToolErrorCategory category)
+ {
+ try
+ {
+ await send();
+ CompleteTelemetry(telemetry, NodeToolOutcome.Failure, category);
+ }
+ catch (Exception ex)
+ {
+ CompleteTelemetry(
+ telemetry,
+ NodeToolOutcome.Failure,
+ NodeToolErrorCategory.TransportFailure,
+ errorType: ex.GetType());
+ throw;
+ }
+ }
+
+ private void RebuildCommandMap()
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var capability in _capabilities)
+ {
+ foreach (var command in capability.Commands)
+ map.TryAdd(command, new CommandDispatchEntry(capability, command));
+ }
+
+ Volatile.Write(
+ ref _commandMap,
+ map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
+ }
+
+ private async Task ExecuteCapabilityAsync(
+ NodeInvokeRequest request,
+ INodeCapability capability,
+ Func sendResponse,
+ Func sendErrorResponse,
+ InvocationCancellationRegistry.InvocationCancellation invocation)
+ {
+ using var activeInvocation = invocation;
+ var cancellationToken = activeInvocation.Token;
+ var telemetry = request.Telemetry!;
+ var stopwatch = Stopwatch.StartNew();
+ var executeActivity = telemetry.StartChild(NodeToolInvocation.ExecuteSpanName);
+ request.TelemetryParentContext = executeActivity?.Context ?? telemetry.Context;
+ var capabilityStarted = false;
+ var executeActivityCompleted = false;
+
+ try
+ {
+ InvokeReceived?.Invoke(_eventSender, request);
+ capabilityStarted = true;
+ var response = await capability.ExecuteAsync(request, cancellationToken);
+ response.Id = request.Id;
+
+ if (!activeInvocation.TryComplete())
+ {
+ if (activeInvocation.CancelledByCaller)
+ {
+ await SendCancellationResponseAndCompleteTelemetryAsync(
+ request,
+ telemetry,
+ executeActivity,
+ executeActivityCompleted,
+ sendErrorResponse,
+ stopwatch);
+ }
+ else
+ {
+ NodeToolInvocation.CompleteChild(
+ executeActivity,
+ NodeToolOutcome.Canceled,
+ NodeToolErrorCategory.Other);
+ CompleteTelemetry(
+ telemetry,
+ NodeToolOutcome.Canceled,
+ NodeToolErrorCategory.Other);
+ }
+ return;
+ }
+
+ var diagnostic = response.Diagnostic;
+ var outcome = diagnostic != null || !response.Ok
+ ? NodeToolOutcome.Failure
+ : NodeToolOutcome.Success;
+ var category = diagnostic?.ErrorCategory ??
+ (response.Ok ? NodeToolErrorCategory.None : NodeToolErrorCategory.CapabilityFailure);
+ NodeToolInvocation.CompleteChild(
+ executeActivity,
+ outcome,
+ category,
+ diagnostic?.ExecutionMode,
+ sandboxDenialReason: diagnostic?.SandboxDenialReason);
+ executeActivityCompleted = true;
+
+ try
+ {
+ await sendResponse(response);
+ CompleteTelemetry(telemetry, outcome, category, diagnostic?.ExecutionMode);
+ }
+ catch (Exception sendEx)
+ {
+ _logger.Debug($"[NODE] Failed to deliver completed invoke {request.Id}: {sendEx.Message}");
+ CompleteTelemetry(
+ telemetry,
+ NodeToolOutcome.Failure,
+ NodeToolErrorCategory.TransportFailure,
+ errorType: sendEx.GetType());
+ }
+
+ stopwatch.Stop();
+ RaiseInvokeCompleted(request, response.Ok, response.Error, stopwatch.Elapsed);
+ }
+ catch (OperationCanceledException) when (activeInvocation.CancelledByCaller)
+ {
+ await SendCancellationResponseAndCompleteTelemetryAsync(
+ request,
+ telemetry,
+ executeActivity,
+ executeActivityCompleted,
+ sendErrorResponse,
+ stopwatch);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ if (!executeActivityCompleted)
+ {
+ NodeToolInvocation.CompleteChild(
+ executeActivity,
+ NodeToolOutcome.Canceled,
+ NodeToolErrorCategory.Other);
+ }
+ CompleteTelemetry(telemetry, NodeToolOutcome.Canceled, NodeToolErrorCategory.Other);
+ }
+ catch (Exception ex)
+ {
+ if (!activeInvocation.TryComplete())
+ {
+ if (activeInvocation.CancelledByCaller)
+ {
+ await SendCancellationResponseAndCompleteTelemetryAsync(
+ request,
+ telemetry,
+ executeActivity,
+ executeActivityCompleted,
+ sendErrorResponse,
+ stopwatch);
+ }
+ else
+ {
+ if (!executeActivityCompleted)
+ {
+ NodeToolInvocation.CompleteChild(
+ executeActivity,
+ NodeToolOutcome.Canceled,
+ NodeToolErrorCategory.Other);
+ }
+ CompleteTelemetry(
+ telemetry,
+ NodeToolOutcome.Canceled,
+ NodeToolErrorCategory.Other);
+ }
+ return;
+ }
+
+ var category = capabilityStarted
+ ? NodeToolErrorCategory.CapabilityFailure
+ : NodeToolErrorCategory.InternalFailure;
+ if (!executeActivityCompleted)
+ {
+ NodeToolInvocation.CompleteChild(
+ executeActivity,
+ NodeToolOutcome.Failure,
+ category,
+ errorType: ex.GetType());
+ }
+ _logger.Error($"Command execution failed: {request.Command}", ex);
+
+ try
+ {
+ await sendErrorResponse("Command execution failed");
+ CompleteTelemetry(
+ telemetry,
+ NodeToolOutcome.Failure,
+ category,
+ errorType: ex.GetType());
+ }
+ catch (Exception sendEx)
+ {
+ _logger.Debug($"[NODE] Failed to send error response for {request.Id}: {sendEx.Message}");
+ CompleteTelemetry(
+ telemetry,
+ NodeToolOutcome.Failure,
+ NodeToolErrorCategory.TransportFailure,
+ errorType: sendEx.GetType());
+ }
+
+ stopwatch.Stop();
+ RaiseInvokeCompleted(request, false, "Command execution failed", stopwatch.Elapsed);
+ }
+ finally
+ {
+ _invokeSemaphore.Release();
+ }
+ }
+
+ private async Task SendCancellationResponseAndCompleteTelemetryAsync(
+ NodeInvokeRequest request,
+ NodeToolInvocation telemetry,
+ Activity? executeActivity,
+ bool executeActivityCompleted,
+ Func sendErrorResponse,
+ Stopwatch stopwatch)
+ {
+ if (!executeActivityCompleted)
+ {
+ NodeToolInvocation.CompleteChild(
+ executeActivity,
+ NodeToolOutcome.Canceled,
+ NodeToolErrorCategory.Other);
+ }
+
+ try
+ {
+ await sendErrorResponse("cancelled");
+ CompleteTelemetry(telemetry, NodeToolOutcome.Canceled, NodeToolErrorCategory.Other);
+ }
+ catch (Exception sendEx)
+ {
+ _logger.Debug($"[NODE] Failed to send cancellation response for {request.Id}: {sendEx.Message}");
+ CompleteTelemetry(
+ telemetry,
+ NodeToolOutcome.Failure,
+ NodeToolErrorCategory.TransportFailure,
+ errorType: sendEx.GetType());
+ }
+
+ stopwatch.Stop();
+ RaiseInvokeCompleted(request, false, "cancelled", stopwatch.Elapsed);
+ }
+
+ private void RaiseInvokeCompleted(
+ NodeInvokeRequest request,
+ bool ok,
+ string? error,
+ TimeSpan duration)
+ {
+ var handlers = InvokeCompleted;
+ if (handlers is null)
+ return;
+
+ var args = new NodeInvokeCompletedEventArgs
+ {
+ RequestId = request.Id,
+ Command = request.Command,
+ Ok = ok,
+ Error = error,
+ Duration = duration,
+ NodeId = _nodeId()
+ };
+
+ foreach (var handler in handlers.GetInvocationList())
+ {
+ try
+ {
+ ((EventHandler)handler)(_eventSender, args);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn(
+ $"[NODE] InvokeCompleted subscriber " +
+ $"{handler.Method.DeclaringType?.Name}.{handler.Method.Name} threw: {ex.Message}");
+ }
+ }
+ }
+
+ private sealed record CommandDispatchEntry(
+ INodeCapability Capability,
+ string CanonicalName);
+}
diff --git a/src/OpenClaw.Shared/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj
index 596558efd..589b59a17 100644
--- a/src/OpenClaw.Shared/OpenClaw.Shared.csproj
+++ b/src/OpenClaw.Shared/OpenClaw.Shared.csproj
@@ -5,6 +5,7 @@
enable
enable
OpenClaw.Shared
+ true
diff --git a/src/OpenClaw.Shared/RustSidecar/AuthenticatedSidecarChannel.cs b/src/OpenClaw.Shared/RustSidecar/AuthenticatedSidecarChannel.cs
new file mode 100644
index 000000000..c25a65bd5
--- /dev/null
+++ b/src/OpenClaw.Shared/RustSidecar/AuthenticatedSidecarChannel.cs
@@ -0,0 +1,201 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace OpenClaw.Shared.RustSidecar;
+
+internal enum SidecarPeerRole
+{
+ Supervisor,
+ Runtime
+}
+
+///
+/// Windows implementation of the OpenClaw authenticated sidecar frame contract.
+/// A channel belongs to one process generation and is terminal after any inbound failure.
+///
+internal sealed class AuthenticatedSidecarChannel : IDisposable
+{
+ internal const ushort ProtocolMajor = 1;
+ internal const ushort ProtocolMinor = 0;
+ private const int AuthenticationTagBytes = 32;
+ private const int FixedHeaderBytes = 31;
+ private static ReadOnlySpan Magic => "OCSC"u8;
+
+ private readonly SidecarPeerRole _role;
+ private readonly byte[] _sessionId;
+ private readonly ulong _generation;
+ private readonly byte[] _key;
+ private uint _maxFrameBytes;
+ private ulong _sendSequence;
+ private ulong _receiveSequence;
+ private bool _retired;
+
+ internal AuthenticatedSidecarChannel(
+ SidecarPeerRole role,
+ string sessionId,
+ ulong generation,
+ ReadOnlySpan sessionKey,
+ uint maxFrameBytes)
+ {
+ ArgumentNullException.ThrowIfNull(sessionId);
+ if (sessionId.Length == 0)
+ throw new ArgumentException("Sidecar session id must not be empty.", nameof(sessionId));
+ if (generation == 0)
+ throw new ArgumentOutOfRangeException(nameof(generation));
+ if (sessionKey.Length != AuthenticationTagBytes)
+ throw new ArgumentException("Sidecar session keys must contain 32 bytes.", nameof(sessionKey));
+
+ _sessionId = Encoding.UTF8.GetBytes(sessionId);
+ if (_sessionId.Length > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(sessionId));
+ if (maxFrameBytes < MinimumFrameBytes(_sessionId.Length))
+ throw new ArgumentOutOfRangeException(nameof(maxFrameBytes));
+
+ _role = role;
+ _generation = generation;
+ _key = sessionKey.ToArray();
+ _maxFrameBytes = maxFrameBytes;
+ }
+
+ internal bool IsRetired => _retired;
+ internal SidecarPeerRole LocalRole => _role;
+ internal uint MaxFrameBytes => _maxFrameBytes;
+ internal int MaxPayloadBytes => checked((int)_maxFrameBytes - FixedHeaderBytes - _sessionId.Length - AuthenticationTagBytes);
+
+ internal void LowerFrameLimit(uint maxFrameBytes)
+ {
+ ThrowIfRetired();
+ if (maxFrameBytes < MinimumFrameBytes(_sessionId.Length) || maxFrameBytes > _maxFrameBytes)
+ {
+ Retire();
+ throw new SidecarProtocolException("Invalid negotiated sidecar frame limit.");
+ }
+ _maxFrameBytes = maxFrameBytes;
+ }
+
+ internal byte[] Seal(ReadOnlySpan jsonPayload)
+ {
+ ThrowIfRetired();
+ if (_sendSequence == ulong.MaxValue)
+ throw new SidecarProtocolException("Sidecar send sequence is exhausted.");
+
+ var frameLength = checked(FixedHeaderBytes + _sessionId.Length + jsonPayload.Length + AuthenticationTagBytes);
+ if (frameLength > _maxFrameBytes)
+ throw new SidecarProtocolException("Sidecar frame exceeds the negotiated limit.");
+
+ var frame = new byte[frameLength];
+ var cursor = 0;
+ Magic.CopyTo(frame.AsSpan(cursor));
+ cursor += Magic.Length;
+ BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(cursor), ProtocolMajor);
+ cursor += sizeof(ushort);
+ BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(cursor), ProtocolMinor);
+ cursor += sizeof(ushort);
+ frame[cursor++] = _role == SidecarPeerRole.Supervisor ? (byte)1 : (byte)2;
+ BinaryPrimitives.WriteUInt64BigEndian(frame.AsSpan(cursor), _generation);
+ cursor += sizeof(ulong);
+ BinaryPrimitives.WriteUInt64BigEndian(frame.AsSpan(cursor), _sendSequence + 1);
+ cursor += sizeof(ulong);
+ BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(cursor), checked((ushort)_sessionId.Length));
+ cursor += sizeof(ushort);
+ BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(cursor), checked((uint)jsonPayload.Length));
+ cursor += sizeof(uint);
+ _sessionId.CopyTo(frame.AsSpan(cursor));
+ cursor += _sessionId.Length;
+ jsonPayload.CopyTo(frame.AsSpan(cursor));
+ cursor += jsonPayload.Length;
+
+ HMACSHA256.HashData(_key, frame.AsSpan(0, cursor), frame.AsSpan(cursor, AuthenticationTagBytes));
+ _sendSequence++;
+ return frame;
+ }
+
+ internal byte[] Open(ReadOnlySpan frame)
+ {
+ ThrowIfRetired();
+ try
+ {
+ if (frame.Length > _maxFrameBytes || frame.Length < FixedHeaderBytes + AuthenticationTagBytes)
+ throw new SidecarProtocolException("Invalid sidecar frame size.");
+
+ var authenticatedLength = frame.Length - AuthenticationTagBytes;
+ Span expectedTag = stackalloc byte[AuthenticationTagBytes];
+ HMACSHA256.HashData(_key, frame[..authenticatedLength], expectedTag);
+ if (!CryptographicOperations.FixedTimeEquals(expectedTag, frame[authenticatedLength..]))
+ throw new SidecarProtocolException("Sidecar frame authentication failed.");
+
+ var cursor = 0;
+ if (!frame[..Magic.Length].SequenceEqual(Magic))
+ throw new SidecarProtocolException("Invalid sidecar frame magic.");
+ cursor += Magic.Length;
+ var major = ReadUInt16(frame, ref cursor);
+ var minor = ReadUInt16(frame, ref cursor);
+ if (major != ProtocolMajor || minor > ProtocolMinor)
+ throw new SidecarProtocolException("Unsupported sidecar frame version.");
+
+ var expectedDirection = _role == SidecarPeerRole.Supervisor ? (byte)2 : (byte)1;
+ if (Take(frame, ref cursor, 1)[0] != expectedDirection)
+ throw new SidecarProtocolException("Sidecar frame direction does not match the channel role.");
+ if (ReadUInt64(frame, ref cursor) != _generation)
+ throw new SidecarProtocolException("Sidecar frame belongs to another generation.");
+ if (_receiveSequence == ulong.MaxValue || ReadUInt64(frame, ref cursor) != _receiveSequence + 1)
+ throw new SidecarProtocolException("Unexpected sidecar receive sequence.");
+
+ var sessionLength = ReadUInt16(frame, ref cursor);
+ var payloadLength = ReadUInt32(frame, ref cursor);
+ if (!Take(frame, ref cursor, sessionLength).SequenceEqual(_sessionId))
+ throw new SidecarProtocolException("Sidecar frame belongs to another session.");
+ var payload = Take(frame[..authenticatedLength], ref cursor, checked((int)payloadLength));
+ if (cursor != authenticatedLength)
+ throw new SidecarProtocolException("Sidecar frame contains trailing bytes.");
+
+ _receiveSequence++;
+ return payload.ToArray();
+ }
+ catch
+ {
+ Retire();
+ throw;
+ }
+ }
+
+ internal void Retire()
+ {
+ if (_retired)
+ return;
+ CryptographicOperations.ZeroMemory(_key);
+ _retired = true;
+ }
+
+ public void Dispose() => Retire();
+
+ private static int MinimumFrameBytes(int sessionIdBytes) =>
+ checked(FixedHeaderBytes + sessionIdBytes + AuthenticationTagBytes + 1);
+
+ private static ushort ReadUInt16(ReadOnlySpan bytes, ref int cursor) =>
+ BinaryPrimitives.ReadUInt16BigEndian(Take(bytes, ref cursor, sizeof(ushort)));
+
+ private static uint ReadUInt32(ReadOnlySpan bytes, ref int cursor) =>
+ BinaryPrimitives.ReadUInt32BigEndian(Take(bytes, ref cursor, sizeof(uint)));
+
+ private static ulong ReadUInt64(ReadOnlySpan bytes, ref int cursor) =>
+ BinaryPrimitives.ReadUInt64BigEndian(Take(bytes, ref cursor, sizeof(ulong)));
+
+ private static ReadOnlySpan Take(ReadOnlySpan bytes, ref int cursor, int length)
+ {
+ if (length < 0 || cursor > bytes.Length - length)
+ throw new SidecarProtocolException("Sidecar frame is truncated.");
+ var value = bytes.Slice(cursor, length);
+ cursor += length;
+ return value;
+ }
+
+ private void ThrowIfRetired()
+ {
+ if (_retired)
+ throw new SidecarProtocolException("Sidecar channel is retired.");
+ }
+}
+
+internal sealed class SidecarProtocolException(string message) : Exception(message);
diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs
new file mode 100644
index 000000000..6cff79aee
--- /dev/null
+++ b/src/OpenClaw.Shared/RustSidecar/SidecarContracts.cs
@@ -0,0 +1,890 @@
+using System.Buffers;
+using System.Globalization;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+
+namespace OpenClaw.Shared.RustSidecar;
+
+internal sealed record SidecarLimits(
+ uint MaxFrameBytes,
+ ushort MaxInFlight,
+ uint BootstrapTimeoutMs);
+
+internal sealed record SidecarPeerIdentity(
+ SidecarPeerRole Role,
+ string Name,
+ string Version,
+ string ArtifactIdentity);
+
+internal sealed record SidecarProtocolOffer(
+ ushort ProtocolMajor,
+ ushort ProtocolMinor,
+ SidecarPeerIdentity Peer,
+ ulong FeatureBits,
+ SidecarLimits Limits);
+
+internal sealed record SidecarProtocolSelection(
+ ushort ProtocolMajor,
+ ushort ProtocolMinor,
+ ulong FeatureBits,
+ SidecarLimits Limits);
+
+internal sealed record SidecarRuntimeConfiguration(
+ ulong ManifestGeneration,
+ IReadOnlyList Capabilities,
+ IReadOnlyList Commands,
+ ushort MaxConcurrency,
+ uint MaxInputBytes,
+ uint MaxOutputBytes,
+ uint DefaultTimeoutMs,
+ uint MaxTimeoutMs,
+ uint ResultGraceMs)
+{
+ internal JsonObject ToConfigureMessage()
+ {
+ var capabilities = new JsonArray(
+ Capabilities.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray());
+ var commands = new JsonArray(
+ Commands.Select(name => (JsonNode)new JsonObject { ["name"] = name }).ToArray());
+ return new JsonObject
+ {
+ ["type"] = "configure",
+ ["configuration"] = new JsonObject
+ {
+ ["manifestGeneration"] = ManifestGeneration,
+ ["capabilities"] = capabilities,
+ ["commands"] = commands,
+ ["maxConcurrency"] = MaxConcurrency,
+ ["maxInputBytes"] = MaxInputBytes,
+ ["maxOutputBytes"] = MaxOutputBytes,
+ ["defaultTimeoutMs"] = DefaultTimeoutMs,
+ ["maxTimeoutMs"] = MaxTimeoutMs,
+ ["resultGraceMs"] = ResultGraceMs
+ }
+ };
+ }
+
+ internal JsonObject ToManifest() => new()
+ {
+ ["manifestGeneration"] = ManifestGeneration,
+ ["capabilities"] = new JsonArray(
+ Capabilities.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray()),
+ ["commands"] = new JsonArray(
+ Commands.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray())
+ };
+}
+
+internal static class SidecarJson
+{
+ internal const ulong MaxPortableInteger = 9_007_199_254_740_991;
+ // serde_json starts with 128 remaining levels and rejects the 128th container.
+ internal const int MaxDepth = 127;
+ private const long MinimumCanonicalizationHeadroomBytes = 4 * 1024;
+ private const long MaximumCanonicalizationHeadroomBytes = 64 * 1024 * 1024;
+ private const int MaximumCanonicalizationBufferSlackBytes = 16 * 1024;
+ private static readonly AsyncLocal CurrentCanonicalizationBudget = new();
+
+ internal static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false,
+ MaxDepth = MaxDepth,
+ Encoder = SerdeJsonEncoder.Instance,
+ Converters =
+ {
+ SerdeDoubleConverter.Instance,
+ SerdeSingleConverter.Instance,
+ SerdeDecimalConverter.Instance,
+ SerdeJsonElementConverter.Instance,
+ SerdeJsonDocumentConverter.Instance,
+ SerdeJsonNodeConverterFactory.Instance
+ }
+ };
+
+ internal static byte[] Serialize(JsonNode node) =>
+ JsonSerializer.SerializeToUtf8Bytes(node, SerializerOptions);
+
+ internal static IDisposable BeginCanonicalizationBudget(uint maxOutputBytes)
+ {
+ var previous = CurrentCanonicalizationBudget.Value;
+ var proportionalHeadroom = checked((long)maxOutputBytes * 7);
+ var headroom = Math.Clamp(
+ proportionalHeadroom,
+ MinimumCanonicalizationHeadroomBytes,
+ MaximumCanonicalizationHeadroomBytes);
+ CurrentCanonicalizationBudget.Value = new CanonicalizationBudget(
+ checked((long)maxOutputBytes + headroom));
+ return new CanonicalizationBudgetScope(previous);
+ }
+
+ internal static JsonElement Parse(ReadOnlySpan json)
+ {
+ using var document = JsonDocument.Parse(
+ json.ToArray(),
+ new JsonDocumentOptions { MaxDepth = MaxDepth });
+ return document.RootElement.Clone();
+ }
+
+ internal static bool ValueEquals(JsonElement left, JsonElement right)
+ {
+ if (left.ValueKind != right.ValueKind)
+ return false;
+ return left.ValueKind switch
+ {
+ JsonValueKind.Object => ObjectEquals(left, right),
+ JsonValueKind.Array => left.EnumerateArray().SequenceEqual(
+ right.EnumerateArray(),
+ JsonElementValueComparer.Instance),
+ JsonValueKind.String => left.GetString() == right.GetString(),
+ JsonValueKind.Number => NumberEquals(left, right),
+ JsonValueKind.True or JsonValueKind.False => left.GetBoolean() == right.GetBoolean(),
+ JsonValueKind.Null => true,
+ _ => false
+ };
+ }
+
+ internal static JsonElement NormalizeValue(JsonElement value)
+ {
+ var normalized = NormalizeNode(value);
+ return normalized is null ? Parse("null"u8) : Parse(Serialize(normalized));
+ }
+
+ private static JsonNode? NormalizeNode(JsonElement value)
+ {
+ switch (value.ValueKind)
+ {
+ case JsonValueKind.Object:
+ var jsonObject = new JsonObject();
+ foreach (var property in value.EnumerateObject())
+ jsonObject[property.Name] = NormalizeNode(property.Value);
+ return jsonObject;
+ case JsonValueKind.Array:
+ return new JsonArray(value.EnumerateArray().Select(NormalizeNode).ToArray());
+ case JsonValueKind.Null:
+ return null;
+ case JsonValueKind.Number:
+ return NormalizeNumber(value);
+ default:
+ return JsonNode.Parse(
+ value.GetRawText(),
+ documentOptions: new JsonDocumentOptions { MaxDepth = MaxDepth });
+ }
+ }
+
+ private static JsonNode NormalizeNumber(JsonElement value)
+ {
+ return GetNumberKind(value) switch
+ {
+ JsonNumberKind.PositiveInteger when value.TryGetUInt64(out var unsigned) =>
+ JsonValue.Create(unsigned),
+ JsonNumberKind.NegativeInteger when value.TryGetInt64(out var signed) =>
+ JsonValue.Create(signed),
+ JsonNumberKind.Float when value.TryGetDouble(out var floating) &&
+ double.IsFinite(floating) => JsonNode.Parse(FormatSerdeFloat(floating))!,
+ _ => JsonNode.Parse(
+ value.GetRawText(),
+ documentOptions: new JsonDocumentOptions { MaxDepth = MaxDepth })!
+ };
+ }
+
+ private static bool ObjectEquals(JsonElement left, JsonElement right)
+ {
+ var leftProperties = ToPropertyMap(left);
+ var rightProperties = ToPropertyMap(right);
+ return leftProperties.Count == rightProperties.Count &&
+ leftProperties.All(property =>
+ rightProperties.TryGetValue(property.Key, out var value) &&
+ ValueEquals(property.Value, value));
+ }
+
+ private static Dictionary ToPropertyMap(JsonElement value)
+ {
+ var properties = new Dictionary(StringComparer.Ordinal);
+ foreach (var property in value.EnumerateObject())
+ properties[property.Name] = property.Value;
+ return properties;
+ }
+
+ private static bool NumberEquals(JsonElement left, JsonElement right)
+ {
+ var leftKind = GetNumberKind(left);
+ if (leftKind != GetNumberKind(right))
+ return false;
+ return leftKind switch
+ {
+ JsonNumberKind.PositiveInteger =>
+ left.TryGetUInt64(out var leftUnsigned) &&
+ right.TryGetUInt64(out var rightUnsigned) &&
+ leftUnsigned == rightUnsigned,
+ JsonNumberKind.NegativeInteger =>
+ left.TryGetInt64(out var leftSigned) &&
+ right.TryGetInt64(out var rightSigned) &&
+ leftSigned == rightSigned,
+ _ => left.TryGetDouble(out var leftFloat) &&
+ right.TryGetDouble(out var rightFloat) &&
+ leftFloat.Equals(rightFloat)
+ };
+ }
+
+ private static JsonNumberKind GetNumberKind(JsonElement value)
+ {
+ var raw = value.GetRawText();
+ if (raw.Contains('.') || raw.Contains('e') || raw.Contains('E') || raw == "-0")
+ return JsonNumberKind.Float;
+ return raw[0] == '-' ? JsonNumberKind.NegativeInteger : JsonNumberKind.PositiveInteger;
+ }
+
+ private enum JsonNumberKind
+ {
+ PositiveInteger,
+ NegativeInteger,
+ Float
+ }
+
+ private sealed class JsonElementValueComparer : IEqualityComparer
+ {
+ internal static readonly JsonElementValueComparer Instance = new();
+ public bool Equals(JsonElement x, JsonElement y) => ValueEquals(x, y);
+ public int GetHashCode(JsonElement obj) => throw new NotSupportedException();
+ }
+
+ private static string FormatSerdeFloat(double value)
+ {
+ if (!double.IsFinite(value))
+ throw new JsonException("Sidecar JSON cannot encode non-finite floating-point values.");
+ var text = value.ToString("R", CultureInfo.InvariantCulture);
+ if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ||
+ BitConverter.DoubleToInt64Bits(parsed) != BitConverter.DoubleToInt64Bits(value))
+ text = value.ToString("G17", CultureInfo.InvariantCulture);
+ return FormatSerdeFloatText(
+ text,
+ minimumFixedExponent: -5,
+ maximumFixedExponent: 15,
+ maximumFixedIntegerDigits: 16);
+ }
+
+ private static string FormatSerdeFloat(float value)
+ {
+ if (!float.IsFinite(value))
+ throw new JsonException("Sidecar JSON cannot encode non-finite floating-point values.");
+ var text = value.ToString("R", CultureInfo.InvariantCulture);
+ if (!float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ||
+ BitConverter.SingleToInt32Bits(parsed) != BitConverter.SingleToInt32Bits(value))
+ text = value.ToString("G9", CultureInfo.InvariantCulture);
+ return FormatSerdeFloatText(
+ text,
+ minimumFixedExponent: -6,
+ maximumFixedExponent: 12,
+ maximumFixedIntegerDigits: 13);
+ }
+
+ private static string FormatSerdeFloatText(
+ string text,
+ int minimumFixedExponent,
+ int maximumFixedExponent,
+ int maximumFixedIntegerDigits)
+ {
+ var exponentIndex = text.IndexOf('E');
+ if (exponentIndex >= 0)
+ {
+ var exponent = int.Parse(
+ text.AsSpan(exponentIndex + 1),
+ NumberStyles.AllowLeadingSign,
+ CultureInfo.InvariantCulture);
+ var mantissa = text[..exponentIndex];
+ if (exponent >= minimumFixedExponent && exponent <= maximumFixedExponent)
+ return ExpandSerdeFloat(mantissa, exponent);
+ return string.Concat(
+ mantissa,
+ exponent >= 0 ? "e+" : "e",
+ exponent.ToString(CultureInfo.InvariantCulture));
+ }
+ var unsigned = text[0] == '-' ? text[1..] : text;
+ var decimalPoint = unsigned.IndexOf('.');
+ var integerDigits = decimalPoint >= 0 ? decimalPoint : unsigned.Length;
+ if (integerDigits > maximumFixedIntegerDigits)
+ {
+ var digits = unsigned.Replace(".", string.Empty, StringComparison.Ordinal)
+ .TrimEnd('0');
+ var mantissa = digits.Length == 1
+ ? digits
+ : string.Concat(digits.AsSpan(0, 1), ".", digits.AsSpan(1));
+ return string.Concat(
+ text[0] == '-' ? "-" : string.Empty,
+ mantissa,
+ "e+",
+ (integerDigits - 1).ToString(CultureInfo.InvariantCulture));
+ }
+ return text.Contains('.') ? text : string.Concat(text, ".0");
+ }
+
+ private static string ExpandSerdeFloat(string mantissa, int exponent)
+ {
+ var negative = mantissa[0] == '-';
+ var digits = (negative ? mantissa[1..] : mantissa)
+ .Replace(".", string.Empty, StringComparison.Ordinal);
+ var decimalPoint = 1 + exponent;
+ string expanded;
+ if (decimalPoint <= 0)
+ {
+ expanded = string.Concat("0.", new string('0', -decimalPoint), digits);
+ }
+ else if (decimalPoint >= digits.Length)
+ {
+ expanded = string.Concat(
+ digits,
+ new string('0', decimalPoint - digits.Length),
+ ".0");
+ }
+ else
+ {
+ expanded = string.Concat(
+ digits.AsSpan(0, decimalPoint),
+ ".",
+ digits.AsSpan(decimalPoint));
+ }
+ return negative ? string.Concat("-", expanded) : expanded;
+ }
+
+ private sealed class SerdeDoubleConverter : JsonConverter
+ {
+ internal static readonly SerdeDoubleConverter Instance = new();
+
+ public override double Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options) => reader.GetDouble();
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ double value,
+ JsonSerializerOptions options)
+ {
+ if (!double.IsFinite(value))
+ {
+ writer.WriteNullValue();
+ return;
+ }
+ writer.WriteRawValue(FormatSerdeFloat(value));
+ }
+ }
+
+ private sealed class SerdeSingleConverter : JsonConverter
+ {
+ internal static readonly SerdeSingleConverter Instance = new();
+
+ public override float Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options) => reader.GetSingle();
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ float value,
+ JsonSerializerOptions options)
+ {
+ if (!float.IsFinite(value))
+ {
+ writer.WriteNullValue();
+ return;
+ }
+ writer.WriteRawValue(FormatSerdeFloat(value));
+ }
+ }
+
+ private sealed class SerdeDecimalConverter : JsonConverter
+ {
+ internal static readonly SerdeDecimalConverter Instance = new();
+
+ public override decimal Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options) => reader.GetDecimal();
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ decimal value,
+ JsonSerializerOptions options) =>
+ writer.WriteRawValue(FormatSerdeFloat(decimal.ToDouble(value)));
+ }
+
+ private sealed class SerdeJsonElementConverter : JsonConverter
+ {
+ internal static readonly SerdeJsonElementConverter Instance = new();
+
+ public override JsonElement Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options)
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ return document.RootElement.Clone();
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ JsonElement value,
+ JsonSerializerOptions options)
+ {
+ PreflightRawValue(value);
+ WriteNormalizedValue(writer, value);
+ }
+
+ internal static void PreflightRawValue(JsonElement value)
+ {
+ var budget = CurrentCanonicalizationBudget.Value;
+ if (budget is null)
+ return;
+ var buffer = new CanonicalizationBudgetBufferWriter(budget);
+ using var rawWriter = new Utf8JsonWriter(buffer, new JsonWriterOptions
+ {
+ Encoder = SerdeJsonEncoder.Instance,
+ MaxDepth = MaxDepth,
+ SkipValidation = true
+ });
+ value.WriteTo(rawWriter);
+ rawWriter.Flush();
+ }
+
+ internal static void PreflightRawNode(JsonNode value)
+ {
+ var budget = CurrentCanonicalizationBudget.Value;
+ if (budget is null)
+ return;
+ var remainingBefore = budget.RemainingBytes;
+ var buffer = new CanonicalizationBudgetBufferWriter(budget);
+ using var rawWriter = new Utf8JsonWriter(buffer, new JsonWriterOptions
+ {
+ Encoder = SerdeJsonEncoder.Instance,
+ MaxDepth = MaxDepth,
+ SkipValidation = true
+ });
+ try
+ {
+ value.WriteTo(rawWriter);
+ rawWriter.Flush();
+ }
+ catch (ArgumentException)
+ {
+ // Programmatically created JsonNode values may contain NaN or
+ // infinity. Their containers are already materialized; keep
+ // charging every remaining parsed subtree while the normalized
+ // writer below maps those non-finite leaves to serde nulls.
+ rawWriter.Dispose();
+ budget.Restore(remainingBefore);
+ PreflightMaterializedNodeChildren(value);
+ }
+ }
+
+ private static void PreflightMaterializedNodeChildren(JsonNode value)
+ {
+ switch (value)
+ {
+ case JsonObject jsonObject:
+ foreach (var property in jsonObject)
+ {
+ if (property.Value is not null)
+ PreflightRawNode(property.Value);
+ }
+ return;
+ case JsonArray jsonArray:
+ foreach (var item in jsonArray)
+ {
+ if (item is not null)
+ PreflightRawNode(item);
+ }
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var element):
+ PreflightRawValue(element);
+ return;
+ }
+ }
+
+ internal static void WriteNormalizedValue(Utf8JsonWriter writer, JsonElement value)
+ {
+ switch (value.ValueKind)
+ {
+ case JsonValueKind.Object:
+ writer.WriteStartObject();
+ var properties = new Dictionary(StringComparer.Ordinal);
+ foreach (var property in value.EnumerateObject())
+ properties[property.Name] = property.Value;
+ foreach (var property in properties)
+ {
+ writer.WritePropertyName(property.Key);
+ WriteNormalizedValue(writer, property.Value);
+ }
+ writer.WriteEndObject();
+ return;
+ case JsonValueKind.Array:
+ writer.WriteStartArray();
+ foreach (var item in value.EnumerateArray())
+ WriteNormalizedValue(writer, item);
+ writer.WriteEndArray();
+ return;
+ case JsonValueKind.String:
+ writer.WriteStringValue(value.GetString());
+ return;
+ case JsonValueKind.Number:
+ WriteNormalizedNumber(writer, value);
+ return;
+ case JsonValueKind.True:
+ writer.WriteBooleanValue(true);
+ return;
+ case JsonValueKind.False:
+ writer.WriteBooleanValue(false);
+ return;
+ case JsonValueKind.Null:
+ writer.WriteNullValue();
+ return;
+ default:
+ throw new JsonException("Sidecar JSON contains an unsupported value kind.");
+ }
+ }
+
+ private static void WriteNormalizedNumber(Utf8JsonWriter writer, JsonElement value)
+ {
+ switch (GetNumberKind(value))
+ {
+ case JsonNumberKind.PositiveInteger when value.TryGetUInt64(out var unsigned):
+ writer.WriteNumberValue(unsigned);
+ return;
+ case JsonNumberKind.NegativeInteger when value.TryGetInt64(out var signed):
+ writer.WriteNumberValue(signed);
+ return;
+ case JsonNumberKind.Float when value.TryGetDouble(out var floating) &&
+ double.IsFinite(floating):
+ writer.WriteRawValue(FormatSerdeFloat(floating));
+ return;
+ default:
+ // Preserve integers outside the portable range so the caller's
+ // post-serialization validation can return its stable error.
+ writer.WriteRawValue(value.GetRawText());
+ return;
+ }
+ }
+ }
+
+ private sealed class SerdeJsonNodeConverterFactory : JsonConverterFactory
+ {
+ internal static readonly SerdeJsonNodeConverterFactory Instance = new();
+
+ public override bool CanConvert(Type typeToConvert) =>
+ typeof(JsonNode).IsAssignableFrom(typeToConvert);
+
+ public override JsonConverter CreateConverter(
+ Type typeToConvert,
+ JsonSerializerOptions options) =>
+ (JsonConverter)Activator.CreateInstance(
+ typeof(SerdeJsonNodeConverter<>).MakeGenericType(typeToConvert))!;
+ }
+
+ private sealed class SerdeJsonDocumentConverter : JsonConverter
+ {
+ internal static readonly SerdeJsonDocumentConverter Instance = new();
+
+ public override JsonDocument Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options) => JsonDocument.ParseValue(ref reader);
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ JsonDocument value,
+ JsonSerializerOptions options)
+ {
+ SerdeJsonElementConverter.PreflightRawValue(value.RootElement);
+ SerdeJsonElementConverter.WriteNormalizedValue(writer, value.RootElement);
+ }
+ }
+
+ private sealed class SerdeJsonNodeConverter : JsonConverter
+ where TNode : JsonNode
+ {
+ public override TNode? Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options)
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ return JsonNode.Parse(document.RootElement.GetRawText()) as TNode
+ ?? throw new JsonException($"JSON value is not a {typeToConvert.Name}.");
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ TNode value,
+ JsonSerializerOptions options)
+ {
+ SerdeJsonElementConverter.PreflightRawNode(value);
+ WriteNormalizedNode(writer, value);
+ }
+
+ private static void WriteNormalizedNode(Utf8JsonWriter writer, JsonNode? node)
+ {
+ switch (node)
+ {
+ case null:
+ writer.WriteNullValue();
+ return;
+ case JsonObject jsonObject:
+ writer.WriteStartObject();
+ foreach (var property in jsonObject)
+ {
+ writer.WritePropertyName(property.Key);
+ WriteNormalizedNode(writer, property.Value);
+ }
+ writer.WriteEndObject();
+ return;
+ case JsonArray jsonArray:
+ writer.WriteStartArray();
+ foreach (var item in jsonArray)
+ WriteNormalizedNode(writer, item);
+ writer.WriteEndArray();
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var element):
+ SerdeJsonElementConverter.WriteNormalizedValue(writer, element);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var byteValue):
+ writer.WriteNumberValue(byteValue);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var sbyteValue):
+ writer.WriteNumberValue(sbyteValue);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var shortValue):
+ writer.WriteNumberValue(shortValue);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var ushortValue):
+ writer.WriteNumberValue(ushortValue);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var intValue):
+ writer.WriteNumberValue(intValue);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var uintValue):
+ writer.WriteNumberValue(uintValue);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var longValue):
+ writer.WriteNumberValue(longValue);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var ulongValue):
+ writer.WriteNumberValue(ulongValue);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var decimalValue):
+ writer.WriteRawValue(FormatSerdeFloat(decimal.ToDouble(decimalValue)));
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var doubleValue):
+ SerdeDoubleConverter.Instance.Write(writer, doubleValue, SerializerOptions);
+ return;
+ case JsonValue jsonValue when jsonValue.TryGetValue(out var singleValue):
+ SerdeSingleConverter.Instance.Write(writer, singleValue, SerializerOptions);
+ return;
+ case JsonValue jsonValue:
+ jsonValue.WriteTo(writer);
+ return;
+ default:
+ throw new JsonException("Sidecar JSON contains an unsupported node type.");
+ }
+ }
+ }
+
+ private sealed class CanonicalizationBudget(long remainingBytes)
+ {
+ internal long RemainingBytes { get; private set; } = remainingBytes;
+
+ internal void Consume(int bytes)
+ {
+ if (bytes < 0 || bytes > RemainingBytes)
+ throw new SidecarCanonicalizationLimitException();
+ RemainingBytes -= bytes;
+ }
+
+ internal void Restore(long remainingBytes) => RemainingBytes = remainingBytes;
+ }
+
+ private sealed class CanonicalizationBudgetScope(CanonicalizationBudget? previous) : IDisposable
+ {
+ private bool _disposed;
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ CurrentCanonicalizationBudget.Value = previous;
+ _disposed = true;
+ }
+ }
+
+ private sealed class CanonicalizationBudgetBufferWriter(CanonicalizationBudget budget) : IBufferWriter
+ {
+ private byte[] _buffer = Array.Empty();
+ private int _available;
+
+ public void Advance(int count)
+ {
+ if (count < 0 || count > _available)
+ throw new ArgumentOutOfRangeException(nameof(count));
+ budget.Consume(count);
+ _available = 0;
+ }
+
+ public Memory GetMemory(int sizeHint = 0)
+ {
+ var requested = Math.Max(sizeHint, 256);
+ if (requested > budget.RemainingBytes &&
+ requested > MaximumCanonicalizationBufferSlackBytes)
+ throw new SidecarCanonicalizationLimitException();
+ if (_buffer.Length < requested)
+ _buffer = new byte[requested];
+ _available = requested;
+ return _buffer.AsMemory(0, requested);
+ }
+
+ public Span GetSpan(int sizeHint = 0) => GetMemory(sizeHint).Span;
+ }
+
+ private sealed unsafe class SerdeJsonEncoder : JavaScriptEncoder
+ {
+ internal static readonly SerdeJsonEncoder Instance = new();
+
+ public override int MaxOutputCharactersPerInputCharacter => 6;
+
+ public override bool WillEncode(int unicodeScalar) =>
+ unicodeScalar is >= 0 and <= 0x1f or '"' or '\\';
+
+ public override int FindFirstCharacterToEncode(char* text, int textLength)
+ {
+ if (text == null)
+ throw new ArgumentNullException(nameof(text));
+ for (var index = 0; index < textLength; index++)
+ {
+ var character = text[index];
+ if (WillEncode(character))
+ return index;
+ if (char.IsHighSurrogate(character))
+ {
+ if (index + 1 < textLength && char.IsLowSurrogate(text[index + 1]))
+ {
+ index++;
+ continue;
+ }
+ return index;
+ }
+ if (char.IsLowSurrogate(character))
+ return index;
+ }
+ return -1;
+ }
+
+ public override bool TryEncodeUnicodeScalar(
+ int unicodeScalar,
+ char* buffer,
+ int bufferLength,
+ out int numberOfCharactersWritten)
+ {
+ if (buffer == null)
+ throw new ArgumentNullException(nameof(buffer));
+ ReadOnlySpan escape = unicodeScalar switch
+ {
+ '"' => "\\\"",
+ '\\' => "\\\\",
+ '\b' => "\\b",
+ '\t' => "\\t",
+ '\n' => "\\n",
+ '\f' => "\\f",
+ '\r' => "\\r",
+ _ => default
+ };
+ if (!escape.IsEmpty)
+ {
+ if (bufferLength < escape.Length)
+ {
+ numberOfCharactersWritten = 0;
+ return false;
+ }
+ escape.CopyTo(new Span(buffer, bufferLength));
+ numberOfCharactersWritten = escape.Length;
+ return true;
+ }
+ if (unicodeScalar is < 0 or > 0x1f || bufferLength < 6)
+ {
+ numberOfCharactersWritten = 0;
+ return false;
+ }
+ const string hex = "0123456789abcdef";
+ buffer[0] = '\\';
+ buffer[1] = 'u';
+ buffer[2] = '0';
+ buffer[3] = '0';
+ buffer[4] = hex[(unicodeScalar >> 4) & 0xf];
+ buffer[5] = hex[unicodeScalar & 0xf];
+ numberOfCharactersWritten = 6;
+ return true;
+ }
+ }
+
+ internal static JsonElement RequiredObject(JsonElement parent, string name)
+ {
+ if (!parent.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Object)
+ throw new SidecarProtocolException($"Sidecar field '{name}' must be an object.");
+ return value;
+ }
+
+ internal static string RequiredString(JsonElement parent, string name)
+ {
+ if (!parent.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String)
+ throw new SidecarProtocolException($"Sidecar field '{name}' must be a string.");
+ return value.GetString()!;
+ }
+
+ internal static ulong RequiredUInt64(JsonElement parent, string name)
+ {
+ if (!parent.TryGetProperty(name, out var value) ||
+ !value.TryGetUInt64(out var result) ||
+ result > MaxPortableInteger)
+ {
+ throw new SidecarProtocolException(
+ $"Sidecar field '{name}' must be a portable unsigned integer.");
+ }
+ return result;
+ }
+
+ internal static void EnsureObjectShape(JsonElement value, params string[] expected)
+ {
+ if (value.ValueKind != JsonValueKind.Object)
+ throw new SidecarProtocolException("Sidecar value must be an object.");
+ var allowed = expected.ToHashSet(StringComparer.Ordinal);
+ var count = 0;
+ foreach (var property in value.EnumerateObject())
+ {
+ count++;
+ if (!allowed.Contains(property.Name))
+ throw new SidecarProtocolException($"Unknown sidecar field '{property.Name}'.");
+ }
+ if (count != allowed.Count || allowed.Any(name => !value.TryGetProperty(name, out _)))
+ throw new SidecarProtocolException("Sidecar value is missing a required field.");
+ }
+
+ internal static bool IsPortableJson(JsonElement value) => value.ValueKind switch
+ {
+ JsonValueKind.Object => value.EnumerateObject().All(property => IsPortableJson(property.Value)),
+ JsonValueKind.Array => value.EnumerateArray().All(IsPortableJson),
+ JsonValueKind.Number => IsPortableNumber(value),
+ _ => true
+ };
+
+ private static bool IsPortableNumber(JsonElement value)
+ {
+ if (value.TryGetInt64(out var signed))
+ return signed >= -checked((long)MaxPortableInteger) &&
+ signed <= checked((long)MaxPortableInteger);
+ if (value.TryGetUInt64(out var unsigned))
+ return unsigned <= MaxPortableInteger;
+ if (!value.TryGetDouble(out var floating) || !double.IsFinite(floating))
+ return false;
+ return floating != Math.Truncate(floating) || Math.Abs(floating) <= MaxPortableInteger;
+ }
+}
+
+internal sealed class SidecarCanonicalizationLimitException : Exception;
diff --git a/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs b/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs
new file mode 100644
index 000000000..4afef9a91
--- /dev/null
+++ b/src/OpenClaw.Shared/RustSidecar/SidecarSupervisorHandshake.cs
@@ -0,0 +1,207 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace OpenClaw.Shared.RustSidecar;
+
+internal sealed class SidecarSupervisorHandshake
+{
+ private readonly AuthenticatedSidecarChannel _channel;
+ private readonly SidecarProtocolOffer _localOffer;
+ private bool _started;
+
+ internal SidecarSupervisorHandshake(
+ AuthenticatedSidecarChannel channel,
+ SidecarProtocolOffer localOffer)
+ {
+ _channel = channel ?? throw new ArgumentNullException(nameof(channel));
+ try
+ {
+ if (channel.LocalRole != SidecarPeerRole.Supervisor)
+ throw new SidecarProtocolException("Sidecar supervisor handshake requires a supervisor channel.");
+ _localOffer = ValidateOffer(localOffer, SidecarPeerRole.Supervisor);
+ if (_localOffer.ProtocolMajor != AuthenticatedSidecarChannel.ProtocolMajor ||
+ _localOffer.ProtocolMinor > AuthenticatedSidecarChannel.ProtocolMinor)
+ {
+ throw new SidecarProtocolException("Unsupported local sidecar protocol version.");
+ }
+ }
+ catch
+ {
+ channel.Retire();
+ throw;
+ }
+ }
+
+ internal bool IsAuthenticated { get; private set; }
+ internal SidecarProtocolSelection? Selection { get; private set; }
+ internal string? RuntimeVersion { get; private set; }
+
+ internal byte[] Start()
+ {
+ if (_started || IsAuthenticated)
+ return Fail("Sidecar handshake has already started.");
+ _started = true;
+ try
+ {
+ return _channel.Seal(SidecarJson.Serialize(new JsonObject
+ {
+ ["type"] = "offer",
+ ["offer"] = OfferJson(_localOffer)
+ }));
+ }
+ catch
+ {
+ _channel.Retire();
+ throw;
+ }
+ }
+
+ internal void Accept(ReadOnlySpan frame)
+ {
+ if (!_started || IsAuthenticated)
+ {
+ Fail
-public class WindowsNodeClient : WebSocketClientBase
+public class WindowsNodeClient : WebSocketClientBase, INodeRuntimeClient
{
private readonly DeviceIdentity _deviceIdentity;
-
+
// Node capabilities registry
- private readonly List _capabilities = new();
- private FrozenDictionary _commandMap =
- FrozenDictionary.Empty;
+ private readonly NodeCapabilityDispatcher _capabilityDispatcher;
private readonly NodeRegistration _registration;
// Connection state
private bool _isConnected;
@@ -46,7 +42,19 @@ public class WindowsNodeClient : WebSocketClientBase
private PairingStatus? _lastEmittedPairingStatus;
private readonly string _gatewayToken;
private readonly string? _bootstrapToken;
-
+
+ ///
+ /// Connects this candidate runtime for a connector-owned attempt. Cancelling
+ /// the attempt retires the candidate and interrupts the underlying socket.
+ ///
+ public async Task ConnectAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ using var registration = cancellationToken.Register(Dispose);
+ await base.ConnectAsync();
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
// Cached serialization/validation — reused on every message instead of allocating per-call
private static readonly JsonSerializerOptions s_ignoreNullOptions = new()
{
@@ -56,17 +64,22 @@ public class WindowsNodeClient : WebSocketClientBase
private static readonly Regex s_commandValidator = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled);
private static readonly Regex s_pairingRequestIdValidator = new(@"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", RegexOptions.Compiled);
- // Bounded concurrency for capability invocations: prevents a slow capability (e.g. a
- // 5-minute screen.record) from blocking health pings on the same WS receive loop.
- // Invocations are fire-and-forget off the receive loop; this semaphore caps concurrency
- // at 8. When full, the gateway receives an immediate "node busy, retry" error response.
- private readonly SemaphoreSlim _invokeSemaphore = new(8, 8);
- private readonly InvocationCancellationRegistry _activeInvocations = new();
-
// Events
- public event EventHandler? InvokeReceived;
- public event EventHandler? InvokeCompleted;
- public event EventHandler? ToolTelemetryCompleted;
+ public event EventHandler? InvokeReceived
+ {
+ add => _capabilityDispatcher.InvokeReceived += value;
+ remove => _capabilityDispatcher.InvokeReceived -= value;
+ }
+ public event EventHandler? InvokeCompleted
+ {
+ add => _capabilityDispatcher.InvokeCompleted += value;
+ remove => _capabilityDispatcher.InvokeCompleted -= value;
+ }
+ public event EventHandler? ToolTelemetryCompleted
+ {
+ add => _capabilityDispatcher.ToolTelemetryCompleted += value;
+ remove => _capabilityDispatcher.ToolTelemetryCompleted -= value;
+ }
public event EventHandler? PairingStatusChanged;
public event EventHandler? HealthReceived;
public event EventHandler? GatewaySelfUpdated;
@@ -90,23 +103,23 @@ protected override void OnReconnectAuthorizationDenied(
{
ConnectionFailure?.Invoke(this, authorization.FailureKind);
}
-
+
public new bool IsConnected => _isConnected;
public string? NodeId => _nodeId;
public string GatewayUrl => GatewayUrlForDisplay;
- public IReadOnlyList Capabilities => _capabilities;
-
+ public IReadOnlyList Capabilities => _capabilityDispatcher.Capabilities;
+
/// True if connected but waiting for pairing approval on gateway
public bool IsPendingApproval => _isPendingApproval;
-
+
/// True if device is paired via a stored token or an explicit gateway approval event.
public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
-
+
/// Device ID for display/approval (first 16 chars of full ID)
- public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16
- ? _deviceIdentity.DeviceId[..16]
+ public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16
+ ? _deviceIdentity.DeviceId[..16]
: _deviceIdentity.DeviceId;
-
+
/// Full device ID for approval command
public string FullDeviceId => _deviceIdentity.DeviceId;
@@ -136,7 +149,7 @@ protected override Task OnConnectedAsync()
TransportConnected?.Invoke(this, EventArgs.Empty);
return Task.CompletedTask;
}
-
+
public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null)
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath, logger), logger)
{
@@ -147,7 +160,7 @@ public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpen
_deviceIdentity = new DeviceIdentity(dataPath, _logger);
_deviceIdentity.Initialize();
_useV2Signature |= !string.IsNullOrEmpty(_bootstrapToken) && string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
-
+
// Initialize registration
_registration = new NodeRegistration
{
@@ -157,6 +170,10 @@ public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpen
DeviceFamily = WindowsClientMetadata.DeviceFamily,
DisplayName = $"Windows Node ({Environment.MachineName})"
};
+ _capabilityDispatcher = new NodeCapabilityDispatcher(
+ this,
+ () => _nodeId ?? _deviceIdentity.DeviceId,
+ _logger);
}
private static string NormalizeOptionalCredential(string? credential)
@@ -194,17 +211,14 @@ public static bool HasStoredNodeDeviceToken(string dataPath, IOpenClawLogger? lo
{
return DeviceIdentity.TryReadStoredDeviceTokenForRole(dataPath, "node", logger);
}
-
+
///
/// Register a capability handler
///
public void RegisterCapability(INodeCapability capability)
{
- if (!_capabilities.Contains(capability))
- {
- _capabilities.Add(capability);
- }
-
+ _capabilityDispatcher.RegisterCapability(capability);
+
// Update registration
if (!_registration.Capabilities.Contains(capability.Category))
{
@@ -217,29 +231,10 @@ public void RegisterCapability(INodeCapability capability)
_registration.Commands.Add(cmd);
}
}
-
- // Rebuild the O(1) command dispatch map so node.invoke lookups stay fast
- // regardless of how many capabilities or commands are registered.
- RebuildCommandMap();
-
+
_logger.Info($"Registered capability: {capability.Category} ({capability.Commands.Count} commands)");
}
-
- ///
- /// Builds a FrozenDictionary mapping each command name to the capability that owns it.
- /// First-registered capability wins on collision (matching the former FirstOrDefault semantics).
- ///
- private void RebuildCommandMap()
- {
- var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var cap in _capabilities)
- foreach (var cmd in cap.Commands)
- map.TryAdd(cmd, new CommandDispatchEntry(cap, cmd));
- Volatile.Write(
- ref _commandMap,
- map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
- }
-
+
///
/// Set a permission for the node
///
@@ -247,7 +242,7 @@ public void SetPermission(string permission, bool value)
{
_registration.Permissions[permission] = value;
}
-
+
///
/// Disconnect from gateway
///
@@ -266,10 +261,10 @@ protected override async Task ProcessMessageAsync(string json)
{
// Log raw messages at debug level (visible in dbgview, not in log file noise)
_logger.Debug($"[NODE RX] {TokenSanitizer.Sanitize(json)}");
-
+
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
-
+
if (!root.TryGetProperty("type", out var typeProp))
{
_logger.Warn("[NODE] Message has no 'type' field");
@@ -277,7 +272,7 @@ protected override async Task ProcessMessageAsync(string json)
}
var type = typeProp.GetString();
_logger.Debug($"[NODE] Processing message type: {type}");
-
+
switch (type)
{
case "event":
@@ -303,7 +298,7 @@ protected override async Task ProcessMessageAsync(string json)
_logger.Error("Message processing error", ex);
}
}
-
+
private async Task HandleEventAsync(JsonElement root)
{
if (!root.TryGetProperty("event", out var eventProp)) return;
@@ -325,7 +320,7 @@ and not "node.pair.resolved"
{
_logger.Info($"[NODE] Received event: {eventType}");
}
-
+
switch (eventType)
{
case "connect.challenge":
@@ -448,17 +443,17 @@ private async Task StartNodeInvokeEventAsync(JsonElement root)
{
var telemetry = new NodeToolInvocation(NodeToolTransport.Gateway);
_logger.Info("[NODE] Received node.invoke.request event");
-
+
if (!root.TryGetProperty("payload", out var payload))
{
_logger.Warn("[NODE] node.invoke.request has no payload");
- CompleteToolTelemetry(
+ _capabilityDispatcher.CompleteTelemetry(
telemetry,
NodeToolOutcome.Failure,
NodeToolErrorCategory.InvalidRequest);
return;
}
-
+
// Extract request ID
string? requestId = null;
if (payload.TryGetProperty("requestId", out var reqIdProp))
@@ -469,42 +464,42 @@ private async Task StartNodeInvokeEventAsync(JsonElement root)
{
requestId = idProp.GetString();
}
-
+
if (string.IsNullOrEmpty(requestId))
{
_logger.Warn("[NODE] node.invoke.request has no requestId");
- CompleteToolTelemetry(
+ _capabilityDispatcher.CompleteTelemetry(
telemetry,
NodeToolOutcome.Failure,
NodeToolErrorCategory.InvalidRequest);
return;
}
-
+
// Extract command
if (!payload.TryGetProperty("command", out var cmdProp))
{
_logger.Warn("[NODE] node.invoke.request has no command");
- await SendGatewayResultAndCompleteTelemetryAsync(
+ await _capabilityDispatcher.SendFailureAndCompleteTelemetryAsync(
telemetry,
() => SendNodeInvokeResultAsync(requestId, false, null, "Missing command"),
NodeToolErrorCategory.InvalidRequest);
return;
}
-
+
var command = cmdProp.GetString() ?? "";
-
+
// Validate command format
- if (string.IsNullOrEmpty(command) || command.Length > 100 ||
+ if (string.IsNullOrEmpty(command) || command.Length > 100 ||
!s_commandValidator.IsMatch(command))
{
_logger.Warn($"[NODE] Invalid command format: {command}");
- await SendGatewayResultAndCompleteTelemetryAsync(
+ await _capabilityDispatcher.SendFailureAndCompleteTelemetryAsync(
telemetry,
() => SendNodeInvokeResultAsync(requestId, false, null, "Invalid command format"),
NodeToolErrorCategory.InvalidRequest);
return;
}
-
+
// Args can be in "args" or "paramsJSON" (JSON string)
JsonElement args = default;
if (payload.TryGetProperty("args", out var argsEl))
@@ -533,7 +528,7 @@ await SendGatewayResultAndCompleteTelemetryAsync(
var sessionKey = ExtractGatewayNodeInvokeSessionKey(payload);
_logger.Info($"[NODE] Invoking command: {command}");
-
+
// Create request and dispatch to capability handlers
var request = new NodeInvokeRequest
{
@@ -543,66 +538,18 @@ await SendGatewayResultAndCompleteTelemetryAsync(
SessionKey = sessionKey,
Telemetry = telemetry
};
-
- // Find capability that can handle this command
- var dispatchEntry = Volatile.Read(ref _commandMap).GetValueOrDefault(command);
-
- if (dispatchEntry == null)
- {
- _logger.Warn($"[NODE] No capability registered for command: {command}");
- await SendGatewayResultAndCompleteTelemetryAsync(
- telemetry,
- () => SendNodeInvokeResultAsync(requestId, false, null, $"Command not supported: {command}"),
- NodeToolErrorCategory.UnsupportedCommand);
- RaiseInvokeCompleted(requestId, command, false, $"Command not supported: {command}", TimeSpan.Zero);
- return;
- }
- var capability = dispatchEntry.Capability;
- telemetry.SetCommand(dispatchEntry.CanonicalName);
-
- // Reject immediately if all invoke slots are in use; otherwise fire-and-forget off
- // the receive loop so that health/pair events aren't blocked by slow capabilities.
- if (!_invokeSemaphore.Wait(0))
- {
- _logger.Warn($"[NODE] Invoke slots full, rejecting {command} ({requestId})");
- await SendGatewayResultAndCompleteTelemetryAsync(
- telemetry,
- () => SendNodeInvokeResultAsync(requestId, false, null, "node busy, retry"),
- NodeToolErrorCategory.NodeBusy);
- RaiseInvokeCompleted(requestId, command, false, "node busy, retry", TimeSpan.Zero);
- return;
- }
-
- if (!_activeInvocations.TryRegister(requestId, CancellationToken, out var invocation))
- {
- _invokeSemaphore.Release();
- _logger.Warn($"[NODE] Duplicate active invoke ID: {requestId}");
- await SendGatewayResultAndCompleteTelemetryAsync(
- telemetry,
- () => SendNodeInvokeResultAsync(
- requestId,
- false,
- null,
- "duplicate active request id"),
- NodeToolErrorCategory.InvalidRequest);
- RaiseInvokeCompleted(requestId, command, false, "duplicate active request id", TimeSpan.Zero);
- return;
- }
- _ = Task.Run(
- () => ExecuteGatewayCapabilityAsync(
- request,
- capability,
- response => SendNodeInvokeResultAsync(
- requestId,
- response.Ok,
- response.Payload,
- response.Error),
- error => SendNodeInvokeResultAsync(requestId, false, null, error),
- invocation!),
- CancellationToken.None);
+ await _capabilityDispatcher.DispatchAsync(
+ request,
+ response => SendNodeInvokeResultAsync(
+ requestId,
+ response.Ok,
+ response.Payload,
+ response.Error),
+ error => SendNodeInvokeResultAsync(requestId, false, null, error),
+ CancellationToken);
}
-
+
private async Task SendNodeInvokeResultAsync(string requestId, bool success, object? payload, string? error)
{
// Gateway expects: id (not requestId), nodeId, ok, payload (not result)
@@ -620,12 +567,12 @@ private async Task SendNodeInvokeResultAsync(string requestId, bool success, obj
error = error == null ? null : new { message = error }
}
};
-
+
var json = JsonSerializer.Serialize(response, s_ignoreNullOptions);
_logger.Info($"[NODE] Sending invoke result for {requestId}: ok={success}");
await SendRawAsync(json);
}
-
+
private async Task HandleConnectChallengeAsync(JsonElement root)
{
var connectionGeneration = CurrentConnectionGeneration;
@@ -637,7 +584,7 @@ private async Task HandleConnectChallengeAsync(JsonElement root)
string? nonce = null;
long? challengeTimestampMs = null;
-
+
if (root.TryGetProperty("payload", out var payload))
{
if (payload.TryGetProperty("nonce", out var nonceProp))
@@ -674,9 +621,9 @@ private async Task HandleConnectChallengeAsync(JsonElement root)
await SendNodeConnectAsync(nonce, challengeTimestampMs);
}
-
+
private const string ClientId = "node-host"; // Must be "node-host" for nodes
-
+
private async Task SendNodeConnectAsync(string? nonce, long? challengeTimestampMs)
{
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
@@ -709,7 +656,7 @@ private string BuildNodeConnectMessage(
string? signature = null;
var signedAt = ConnectAuthTimestamp.ResolveSignedAt(challengeTimestampMs);
var (auth, tokenForSignature) = BuildConnectAuth();
-
+
if (!string.IsNullOrEmpty(nonce))
{
try
@@ -784,7 +731,7 @@ private string BuildNodeConnectMessage(
return (new Dictionary { ["token"] = _gatewayToken }, _gatewayToken);
}
-
+
internal void HandleResponse(JsonElement root)
{
var responseId = root.TryGetProperty("id", out var idProp)
@@ -809,7 +756,7 @@ internal void HandleResponse(JsonElement root)
_logger.Warn("[NODE] Response has no payload");
return;
}
-
+
// Handle hello-ok (successful registration)
if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok")
{
@@ -826,13 +773,13 @@ internal void HandleResponse(JsonElement root)
_isConnected = true;
_rateLimited = false; // Clear transient rate-limit on successful connect
ResetReconnectAttempts();
-
+
// Extract node ID if returned
if (payload.TryGetProperty("nodeId", out var nodeIdProp))
{
_nodeId = nodeIdProp.GetString();
}
-
+
// Check for device token in auth — if present, pairing is confirmed in this response.
// Use gotNewToken to guard the fallback check below and avoid a double-fire of
// PairingStatusChanged when the gateway includes auth.deviceToken in hello-ok.
@@ -860,12 +807,12 @@ internal void HandleResponse(JsonElement root)
}
_logger.Info($"Node registered successfully! ID: {_nodeId ?? _deviceIdentity.DeviceId[..16]}");
-
+
// Pairing happens at connect time via device identity, no separate request needed.
// Skip this block if we already fired PairingStatusChanged above via gotNewToken.
if (!gotNewToken)
{
- if (string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken))
+ if (string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken))
{
if (reconnectingAfterApproval)
{
@@ -882,7 +829,7 @@ internal void HandleResponse(JsonElement root)
_logger.Info("Not yet paired - check 'openclaw devices list' for pending approval");
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
- PairingStatus.Pending,
+ PairingStatus.Pending,
_deviceIdentity.DeviceId,
$"Run: openclaw devices approve {ShortDeviceId}..."));
}
@@ -894,7 +841,7 @@ internal void HandleResponse(JsonElement root)
_pairingApprovedAwaitingReconnect = false;
_logger.Info("Already paired with stored device token");
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
- PairingStatus.Paired,
+ PairingStatus.Paired,
_deviceIdentity.DeviceId));
}
}
@@ -1174,7 +1121,7 @@ private static bool TryGetString(JsonElement element, string propertyName, out s
return values.Count == 0 ? null : values.Distinct(StringComparer.Ordinal).ToArray();
}
-
+
private async Task HandleRequestAsync(JsonElement root)
{
if (!_isConnected)
@@ -1185,13 +1132,13 @@ private async Task HandleRequestAsync(JsonElement root)
if (!root.TryGetProperty("method", out var methodProp)) return;
var method = methodProp.GetString();
-
+
string? id = null;
if (root.TryGetProperty("id", out var idProp))
{
id = idProp.GetString();
}
-
+
switch (method)
{
case "node.invoke":
@@ -1212,58 +1159,58 @@ private async Task HandleRequestAsync(JsonElement root)
break;
}
}
-
+
private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId)
{
var telemetry = new NodeToolInvocation(NodeToolTransport.Gateway);
if (requestId == null)
{
_logger.Warn("node.invoke without request ID");
- CompleteToolTelemetry(
+ _capabilityDispatcher.CompleteTelemetry(
telemetry,
NodeToolOutcome.Failure,
NodeToolErrorCategory.InvalidRequest);
return;
}
-
+
if (!root.TryGetProperty("params", out var paramsEl))
{
- await SendGatewayResultAndCompleteTelemetryAsync(
+ await _capabilityDispatcher.SendFailureAndCompleteTelemetryAsync(
telemetry,
() => SendErrorResponseAsync(requestId, "Missing params"),
NodeToolErrorCategory.InvalidRequest);
return;
}
-
+
if (!paramsEl.TryGetProperty("command", out var cmdProp))
{
- await SendGatewayResultAndCompleteTelemetryAsync(
+ await _capabilityDispatcher.SendFailureAndCompleteTelemetryAsync(
telemetry,
() => SendErrorResponseAsync(requestId, "Missing command"),
NodeToolErrorCategory.InvalidRequest);
return;
}
-
+
var command = cmdProp.GetString() ?? "";
-
+
// Validate command format - only allow alphanumeric, dots, underscores, hyphens
- if (string.IsNullOrEmpty(command) || command.Length > 100 ||
+ if (string.IsNullOrEmpty(command) || command.Length > 100 ||
!s_commandValidator.IsMatch(command))
{
_logger.Warn($"Invalid command format: {(command.Length > 50 ? command[..50] + "..." : command)}");
- await SendGatewayResultAndCompleteTelemetryAsync(
+ await _capabilityDispatcher.SendFailureAndCompleteTelemetryAsync(
telemetry,
() => SendErrorResponseAsync(requestId, "Invalid command format"),
NodeToolErrorCategory.InvalidRequest);
return;
}
-
+
// Clone args to ensure it survives document disposal after fire-and-forget
- var args = paramsEl.TryGetProperty("args", out var argsEl)
- ? argsEl.Clone()
+ var args = paramsEl.TryGetProperty("args", out var argsEl)
+ ? argsEl.Clone()
: default;
_logger.Info($"Received node.invoke: {command}");
-
+
var request = new NodeInvokeRequest
{
Id = requestId,
@@ -1272,333 +1219,12 @@ await SendGatewayResultAndCompleteTelemetryAsync(
SessionKey = ExtractGatewayNodeInvokeSessionKey(paramsEl),
Telemetry = telemetry
};
-
- // Find capability that can handle this command
- var dispatchEntry = Volatile.Read(ref _commandMap).GetValueOrDefault(command);
-
- if (dispatchEntry == null)
- {
- _logger.Warn($"No capability registered for command: {command}");
- await SendGatewayResultAndCompleteTelemetryAsync(
- telemetry,
- () => SendErrorResponseAsync(requestId, $"Command not supported: {command}"),
- NodeToolErrorCategory.UnsupportedCommand);
- RaiseInvokeCompleted(requestId, command, false, $"Command not supported: {command}", TimeSpan.Zero);
- return;
- }
- var capability = dispatchEntry.Capability;
- telemetry.SetCommand(dispatchEntry.CanonicalName);
-
- // Reject immediately if all invoke slots are in use; otherwise fire-and-forget off
- // the receive loop so that health/pair events aren't blocked by slow capabilities.
- if (!_invokeSemaphore.Wait(0))
- {
- _logger.Warn($"Invoke slots full, rejecting {command} ({requestId})");
- await SendGatewayResultAndCompleteTelemetryAsync(
- telemetry,
- () => SendErrorResponseAsync(requestId, "node busy, retry"),
- NodeToolErrorCategory.NodeBusy);
- RaiseInvokeCompleted(requestId, command, false, "node busy, retry", TimeSpan.Zero);
- return;
- }
-
- if (!_activeInvocations.TryRegister(requestId, CancellationToken, out var invocation))
- {
- _invokeSemaphore.Release();
- _logger.Warn($"Duplicate active invoke ID: {requestId}");
- await SendGatewayResultAndCompleteTelemetryAsync(
- telemetry,
- () => SendErrorResponseAsync(requestId, "duplicate active request id"),
- NodeToolErrorCategory.InvalidRequest);
- RaiseInvokeCompleted(requestId, command, false, "duplicate active request id", TimeSpan.Zero);
- return;
- }
-
- _ = Task.Run(
- () => ExecuteGatewayCapabilityAsync(
- request,
- capability,
- SendInvokeResponseAsync,
- error => SendErrorResponseAsync(requestId, error),
- invocation!),
- CancellationToken.None);
- }
-
- private async Task ExecuteGatewayCapabilityAsync(
- NodeInvokeRequest request,
- INodeCapability capability,
- Func sendResponse,
- Func sendErrorResponse,
- InvocationCancellationRegistry.InvocationCancellation invocation)
- {
- using var activeInvocation = invocation;
- var cancellationToken = activeInvocation.Token;
- var telemetry = request.Telemetry!;
- var stopwatch = Stopwatch.StartNew();
- var executeActivity = telemetry.StartChild(NodeToolInvocation.ExecuteSpanName);
- request.TelemetryParentContext = executeActivity?.Context ?? telemetry.Context;
- var capabilityStarted = false;
- var executeActivityCompleted = false;
-
- try
- {
- InvokeReceived?.Invoke(this, request);
- capabilityStarted = true;
- var response = await capability.ExecuteAsync(request, cancellationToken);
- response.Id = request.Id;
-
- if (!activeInvocation.TryComplete())
- {
- if (activeInvocation.CancelledByCaller)
- {
- await SendCancellationResponseAndCompleteTelemetryAsync(
- request,
- telemetry,
- executeActivity,
- executeActivityCompleted,
- sendErrorResponse,
- stopwatch);
- }
- else
- {
- NodeToolInvocation.CompleteChild(
- executeActivity,
- NodeToolOutcome.Canceled,
- NodeToolErrorCategory.Other);
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Canceled,
- NodeToolErrorCategory.Other);
- }
- return;
- }
-
- var diagnostic = response.Diagnostic;
- var outcome = diagnostic != null || !response.Ok
- ? NodeToolOutcome.Failure
- : NodeToolOutcome.Success;
- var category = diagnostic?.ErrorCategory ??
- (response.Ok ? NodeToolErrorCategory.None : NodeToolErrorCategory.CapabilityFailure);
- NodeToolInvocation.CompleteChild(
- executeActivity,
- outcome,
- category,
- diagnostic?.ExecutionMode,
- sandboxDenialReason: diagnostic?.SandboxDenialReason);
- executeActivityCompleted = true;
-
- try
- {
- await sendResponse(response);
- CompleteToolTelemetry(
- telemetry,
- outcome,
- category,
- diagnostic?.ExecutionMode);
- }
- catch (Exception sendEx)
- {
- _logger.Debug($"[NODE] Failed to deliver completed invoke {request.Id}: {sendEx.Message}");
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Failure,
- NodeToolErrorCategory.TransportFailure,
- errorType: sendEx.GetType());
- }
-
- stopwatch.Stop();
- RaiseInvokeCompleted(
- request.Id,
- request.Command,
- response.Ok,
- response.Error,
- stopwatch.Elapsed);
- }
- // slopwatch-ignore: SW003 Caller cancellation has a protocol response; shutdown cancellation does not.
- catch (OperationCanceledException) when (activeInvocation.CancelledByCaller)
- {
- await SendCancellationResponseAndCompleteTelemetryAsync(
- request,
- telemetry,
- executeActivity,
- executeActivityCompleted,
- sendErrorResponse,
- stopwatch);
- }
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
- {
- if (!executeActivityCompleted)
- {
- NodeToolInvocation.CompleteChild(
- executeActivity,
- NodeToolOutcome.Canceled,
- NodeToolErrorCategory.Other);
- }
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Canceled,
- NodeToolErrorCategory.Other);
- }
- catch (Exception ex)
- {
- if (!activeInvocation.TryComplete())
- {
- if (activeInvocation.CancelledByCaller)
- {
- await SendCancellationResponseAndCompleteTelemetryAsync(
- request,
- telemetry,
- executeActivity,
- executeActivityCompleted,
- sendErrorResponse,
- stopwatch);
- }
- else
- {
- if (!executeActivityCompleted)
- {
- NodeToolInvocation.CompleteChild(
- executeActivity,
- NodeToolOutcome.Canceled,
- NodeToolErrorCategory.Other);
- }
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Canceled,
- NodeToolErrorCategory.Other);
- }
- return;
- }
-
- var category = capabilityStarted
- ? NodeToolErrorCategory.CapabilityFailure
- : NodeToolErrorCategory.InternalFailure;
- if (!executeActivityCompleted)
- {
- NodeToolInvocation.CompleteChild(
- executeActivity,
- NodeToolOutcome.Failure,
- category,
- errorType: ex.GetType());
- }
- _logger.Error($"Command execution failed: {request.Command}", ex);
- try
- {
- await sendErrorResponse("Command execution failed");
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Failure,
- category,
- errorType: ex.GetType());
- }
- catch (Exception sendEx)
- {
- _logger.Debug($"[NODE] Failed to send error response for {request.Id}: {sendEx.Message}");
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Failure,
- NodeToolErrorCategory.TransportFailure,
- errorType: sendEx.GetType());
- }
-
- stopwatch.Stop();
- RaiseInvokeCompleted(
- request.Id,
- request.Command,
- false,
- "Command execution failed",
- stopwatch.Elapsed);
- }
- finally
- {
- _invokeSemaphore.Release();
- }
- }
-
- private async Task SendCancellationResponseAndCompleteTelemetryAsync(
- NodeInvokeRequest request,
- NodeToolInvocation telemetry,
- Activity? executeActivity,
- bool executeActivityCompleted,
- Func sendErrorResponse,
- Stopwatch stopwatch)
- {
- if (!executeActivityCompleted)
- {
- NodeToolInvocation.CompleteChild(
- executeActivity,
- NodeToolOutcome.Canceled,
- NodeToolErrorCategory.Other);
- }
-
- try
- {
- await sendErrorResponse("cancelled");
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Canceled,
- NodeToolErrorCategory.Other);
- }
- catch (Exception sendEx)
- {
- _logger.Debug($"[NODE] Failed to send cancellation response for {request.Id}: {sendEx.Message}");
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Failure,
- NodeToolErrorCategory.TransportFailure,
- errorType: sendEx.GetType());
- }
-
- stopwatch.Stop();
- RaiseInvokeCompleted(
- request.Id,
- request.Command,
- false,
- "cancelled",
- stopwatch.Elapsed);
- }
-
- private async Task SendGatewayResultAndCompleteTelemetryAsync(
- NodeToolInvocation telemetry,
- Func send,
- NodeToolErrorCategory category)
- {
- try
- {
- await send();
- CompleteToolTelemetry(telemetry, NodeToolOutcome.Failure, category);
- }
- catch (Exception ex)
- {
- CompleteToolTelemetry(
- telemetry,
- NodeToolOutcome.Failure,
- NodeToolErrorCategory.TransportFailure,
- errorType: ex.GetType());
- throw;
- }
- }
-
- private void CompleteToolTelemetry(
- NodeToolInvocation telemetry,
- NodeToolOutcome outcome,
- NodeToolErrorCategory category,
- NodeToolExecutionMode? executionMode = null,
- Type? errorType = null)
- {
- var completion = telemetry.Complete(outcome, category, executionMode, errorType);
- if (completion == null)
- return;
-
- try
- {
- ToolTelemetryCompleted?.Invoke(this, completion);
- }
- catch (Exception ex)
- {
- _logger.Warn($"[NODE] Tool telemetry completion handler failed: {ex.GetType().Name}");
- }
+ await _capabilityDispatcher.DispatchAsync(
+ request,
+ SendInvokeResponseAsync,
+ error => SendErrorResponseAsync(requestId, error),
+ CancellationToken);
}
private async Task HandleNodeInvokeCancelAsync(
@@ -1618,7 +1244,7 @@ private async Task HandleNodeInvokeCancelAsync(
return;
}
- var cancelled = _activeInvocations.TryCancel(requestId);
+ var cancelled = _capabilityDispatcher.TryCancel(requestId);
_logger.Info(cancelled
? $"[NODE] Cancelled node.invoke request: {requestId}"
: $"[NODE] node.invoke.cancel target is not active: {requestId}");
@@ -1662,41 +1288,6 @@ private static bool TryGetCancellationTargetId(JsonElement container, out string
return null;
}
- private sealed record CommandDispatchEntry(
- INodeCapability Capability,
- string CanonicalName);
-
- private void RaiseInvokeCompleted(string requestId, string command, bool ok, string? error, TimeSpan duration)
- {
- var handlers = InvokeCompleted;
- if (handlers is null)
- return;
-
- var args = new NodeInvokeCompletedEventArgs
- {
- RequestId = requestId,
- Command = command,
- Ok = ok,
- Error = error,
- Duration = duration,
- NodeId = _nodeId ?? _deviceIdentity.DeviceId
- };
-
- foreach (var handler in handlers.GetInvocationList())
- {
- try
- {
- ((EventHandler)handler)(this, args);
- }
- catch (Exception ex)
- {
- _logger.Warn(
- $"[NODE] InvokeCompleted subscriber " +
- $"{handler.Method.DeclaringType?.Name}.{handler.Method.Name} threw: {ex.Message}");
- }
- }
- }
-
private async Task SendInvokeResponseAsync(NodeInvokeResponse response)
{
var msg = new
@@ -1707,12 +1298,12 @@ private async Task SendInvokeResponseAsync(NodeInvokeResponse response)
payload = response.Payload,
error = response.Ok ? null : new { message = response.Error }
};
-
+
await SendRawAsync(JsonSerializer.Serialize(msg, s_ignoreNullOptions));
-
+
_logger.Info($"Sent invoke response: ok={response.Ok}");
}
-
+
private async Task SendErrorResponseAsync(string requestId, string error)
{
var msg = new
@@ -1722,7 +1313,7 @@ private async Task SendErrorResponseAsync(string requestId, string error)
ok = false,
error = new { message = error }
};
-
+
await SendRawAsync(JsonSerializer.Serialize(msg));
}
@@ -1738,7 +1329,7 @@ private async Task SendSuccessResponseAsync(string requestId, object payload)
await SendRawAsync(JsonSerializer.Serialize(msg));
}
-
+
///
/// Sends a node.event request with JSON payload.
/// Returns false when not connected or when the transport send fails.
@@ -1776,7 +1367,7 @@ public async Task SendNodeEventAsync(string eventName, System.Text.Json.No
private async Task SendPongAsync(string? requestId)
{
if (requestId == null) return;
-
+
var msg = new
{
type = "res",
@@ -1784,7 +1375,7 @@ private async Task SendPongAsync(string? requestId)
ok = true,
payload = new { pong = true }
};
-
+
await SendRawAsync(JsonSerializer.Serialize(msg));
}
@@ -1795,7 +1386,7 @@ private void PublishGatewaySelf(GatewaySelfInfo info)
GatewaySelfUpdated?.Invoke(this, info);
}
-
+
protected override bool ShouldAutoReconnect()
{
// Don't reconnect while awaiting pairing approval — each reconnect
@@ -1812,7 +1403,7 @@ protected override bool ShouldAutoReconnect()
protected override void OnDisconnected()
{
- _activeInvocations.CancelAll();
+ _capabilityDispatcher.CancelAll();
_isConnected = false;
Volatile.Write(ref _pendingConnectRequestId, null);
// Don't reset pairing state when disconnected due to pairing — gateway
@@ -1826,7 +1417,7 @@ protected override void OnDisconnected()
protected override void OnError(Exception ex)
{
- _activeInvocations.CancelAll();
+ _capabilityDispatcher.CancelAll();
_isConnected = false;
if (!_pairingBlocked)
{
@@ -1837,6 +1428,6 @@ protected override void OnError(Exception ex)
protected override void OnDisposing()
{
- _activeInvocations.CancelAll();
+ _capabilityDispatcher.CancelAll();
}
}
diff --git a/src/OpenClaw.Tray.WinUI/A2UI/Actions/GatewayActionTransport.cs b/src/OpenClaw.Tray.WinUI/A2UI/Actions/GatewayActionTransport.cs
index b7d02dba5..c803d7305 100644
--- a/src/OpenClaw.Tray.WinUI/A2UI/Actions/GatewayActionTransport.cs
+++ b/src/OpenClaw.Tray.WinUI/A2UI/Actions/GatewayActionTransport.cs
@@ -43,7 +43,7 @@ public sealed class A2UIActionStatusEventArgs : EventArgs
///
public sealed class GatewayActionTransport : IA2UIActionTransport
{
- private readonly Func _clientProvider;
+ private readonly Func _clientProvider;
private readonly IGatewayActionContext _context;
private readonly IOpenClawLogger _logger;
@@ -51,7 +51,7 @@ public sealed class GatewayActionTransport : IA2UIActionTransport
public event EventHandler? ActionStatus;
public GatewayActionTransport(
- Func clientProvider,
+ Func clientProvider,
IGatewayActionContext context,
IOpenClawLogger logger)
{
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index 583d46258..04c002b8b 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -32,7 +32,7 @@ public sealed class NodeService : IDisposable, IAsyncDisposable
private TaskCompletionSource? _screenConsentInFlight;
private TaskCompletionSource? _cameraConsentInFlight;
private Task? _disposeTask;
- private WindowsNodeClient? _nodeClient;
+ private INodeRuntimeClient? _nodeClient;
private CanvasWindow? _canvasWindow;
// Invariant: _a2uiCanvasWindow is only read/written from the UI dispatcher
// (DispatcherQueue.TryEnqueue callbacks). Today's WinUI dispatcher serializes,
@@ -271,7 +271,7 @@ public async Task DisconnectAsync()
{
await StopMcpServerAsync().ConfigureAwait(false);
- WindowsNodeClient? previous;
+ INodeRuntimeClient? previous;
lock (_clientLock)
{
previous = _nodeClient;
@@ -496,20 +496,20 @@ public void RegisterMcpOnlyCapability(INodeCapability capability)
}
///
- /// Adopt a created by an outside party
+ /// Adopt an created by an outside party
/// (typically )
/// and register all current capabilities on it. Called via
///
/// every time the connector spins up a fresh client (initial connect AND
/// reconnect). Idempotent on the capability list — the same capability
- /// objects get registered against the new client; WindowsNodeClient
- /// dedupes by category+command into its _registration structure.
+ /// objects get registered against the new runtime. Runtime implementations
+ /// dedupe the advertised category and command manifest.
///
/// Must run synchronously before the client's outbound "connect" message
/// is serialized — otherwise the gateway sees this node as having no
/// advertised commands and the agent can't invoke anything.
///
- public void AttachClient(WindowsNodeClient client, string? bearerToken = null)
+ public void AttachClient(INodeRuntimeClient client, string? bearerToken = null)
{
if (client is null) return;
@@ -520,7 +520,7 @@ public void AttachClient(WindowsNodeClient client, string? bearerToken = null)
// an old client or double-subscribe the same client. Unconditional
// unsubscribe-then-subscribe makes the wiring idempotent regardless of
// whether the previous client is the same instance or null.
- WindowsNodeClient? previous;
+ INodeRuntimeClient? previous;
lock (_clientLock)
{
previous = _nodeClient;
@@ -534,7 +534,7 @@ public void AttachClient(WindowsNodeClient client, string? bearerToken = null)
// Wire NodeService event re-emitters to the manager-owned client.
// App.OnPairingStatusChanged + OnNodeStatusChanged subscribe to NodeService events;
// those subscriptions are stable across reconnects because the subscriptions are
- // on NodeService, not on the underlying WindowsNodeClient. (Pre-unification this
+ // on NodeService, not on the underlying node runtime. (Pre-unification this
// wiring lived in NodeService.ConnectAsync — moved here so the unified
// manager-owned lifecycle still drives NodeService's event surface.)
// -= before += so a re-attach of the same client (e.g. AttachClient called
@@ -588,7 +588,7 @@ public void AttachClient(WindowsNodeClient client, string? bearerToken = null)
_logger.Info($"[NodeService] AttachClient DONE: client.Registration.Capabilities={client.RegisteredCapabilityCount}, client.Registration.Commands={client.RegisteredCommandCount}");
}
- private void DetachClientHandlers(WindowsNodeClient client)
+ private void DetachClientHandlers(INodeRuntimeClient client)
{
client.StatusChanged -= OnNodeStatusChanged;
client.Disposed -= OnNodeClientDisposed;
@@ -1106,7 +1106,7 @@ private void OnNodeClientDisposed(object? sender, EventArgs args)
var retired = false;
lock (_clientLock)
{
- if (sender is WindowsNodeClient client && ReferenceEquals(_nodeClient, client))
+ if (sender is INodeRuntimeClient client && ReferenceEquals(_nodeClient, client))
{
DetachClientHandlers(client);
_nodeClient = null;
@@ -1701,9 +1701,9 @@ private void EnsureCanvasWindow()
// resolveMainSessionKey() fallback.
private sealed class GatewayActionContext : IGatewayActionContext
{
- private readonly Func _client;
+ private readonly Func _client;
private string _sessionKey = "main";
- public GatewayActionContext(Func client) { _client = client; }
+ public GatewayActionContext(Func client) { _client = client; }
public string SessionKey
{
get => _sessionKey;
@@ -2422,7 +2422,7 @@ private async Task DisposeCoreAsync()
{
await StopMcpServerAsync().ConfigureAwait(false);
- WindowsNodeClient? client;
+ INodeRuntimeClient? client;
lock (_clientLock)
{
client = _nodeClient;
diff --git a/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs b/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs
index d7e1a3823..1285665b2 100644
--- a/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs
+++ b/tests/OpenClaw.Connection.Tests/NodeConnectorTests.cs
@@ -14,6 +14,103 @@ public void Warn(string message) { }
public void Error(string message, Exception? ex = null) { }
}
+ private sealed class StubNodeRuntimeClientFactory(INodeRuntimeClient client)
+ : INodeRuntimeClientFactory
+ {
+ public string? GatewayUrl { get; private set; }
+ public GatewayCredential? Credential { get; private set; }
+ public string? IdentityPath { get; private set; }
+
+ public INodeRuntimeClient Create(
+ string gatewayUrl,
+ GatewayCredential credential,
+ string identityPath,
+ IOpenClawLogger logger)
+ {
+ GatewayUrl = gatewayUrl;
+ Credential = credential;
+ IdentityPath = identityPath;
+ return client;
+ }
+ }
+
+ private sealed class DelegateNodeRuntimeClientFactory(Func create)
+ : INodeRuntimeClientFactory
+ {
+ public INodeRuntimeClient Create(
+ string gatewayUrl,
+ GatewayCredential credential,
+ string identityPath,
+ IOpenClawLogger logger) => create();
+ }
+
+ private sealed class StubNodeRuntimeClient : INodeRuntimeClient
+ {
+ private readonly Dictionary _permissions = [];
+
+ public bool UseV2Signature { get; set; }
+ public Func>?
+ HandshakeAuthorizationAsync { get; set; }
+ public Func>?
+ ReconnectAuthorizationAsync { get; set; }
+ public bool IsConnected { get; private set; }
+ public string? NodeId => null;
+ public string GatewayUrl => "ws://runtime.example";
+ public IReadOnlyList Capabilities => [];
+ public bool IsPendingApproval => false;
+ public bool IsPaired => false;
+ public string ShortDeviceId => "stub";
+ public string FullDeviceId => "stub-runtime-client";
+ public string DisplayName => "Stub runtime client";
+ public int RegisteredCapabilityCount => 0;
+ public int RegisteredCommandCount => 0;
+ public IEnumerable RegisteredCommandsSample => [];
+ public bool PermissionWasSetBeforeConnect { get; private set; }
+ public Func? ConnectOverride { get; init; }
+ public bool WasDisposed { get; private set; }
+ public bool ConnectWasCalled { get; private set; }
+
+ public event EventHandler StatusChanged { add { } remove { } }
+ public event EventHandler InvokeCompleted { add { } remove { } }
+ public event EventHandler ToolTelemetryCompleted { add { } remove { } }
+ public event EventHandler PairingStatusChanged { add { } remove { } }
+ public event EventHandler HealthReceived { add { } remove { } }
+ public event EventHandler GatewaySelfUpdated { add { } remove { } }
+ public event EventHandler DeviceTokenReceived { add { } remove { } }
+ public event EventHandler TransportConnected { add { } remove { } }
+ public event EventHandler ConnectionFailure { add { } remove { } }
+ public event EventHandler Disposed { add { } remove { } }
+
+ public void RegisterCapability(INodeCapability capability) { }
+ public void SetPermission(string permission, bool value) => _permissions[permission] = value;
+
+ public async Task ConnectAsync(CancellationToken cancellationToken)
+ {
+ ConnectWasCalled = true;
+ PermissionWasSetBeforeConnect = _permissions.GetValueOrDefault("test.permission");
+ if (ConnectOverride != null)
+ await ConnectOverride(cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ IsConnected = true;
+ }
+
+ public Task DisconnectAsync()
+ {
+ IsConnected = false;
+ return Task.CompletedTask;
+ }
+
+ public Task SendNodeEventAsync(
+ string eventName,
+ System.Text.Json.Nodes.JsonObject payload) => Task.FromResult(true);
+
+ public void Dispose()
+ {
+ WasDisposed = true;
+ IsConnected = false;
+ }
+ }
+
[Fact]
public void InitialState_IsConnected_IsFalse()
{
@@ -120,6 +217,147 @@ public async Task ConnectAsync_WhenClientCreatedHandlerThrows_AbortsBeforeHandsh
Assert.Equal(NodeConnectionMode.Disabled, connector.Mode);
}
+ [Fact]
+ public async Task ConnectAsync_UsesInjectedRuntimeClientBeforeHandshake()
+ {
+ var runtimeClient = new StubNodeRuntimeClient();
+ var factory = new StubNodeRuntimeClientFactory(runtimeClient);
+ using var connector = new NodeConnector(new StubLogger(), clientFactory: factory);
+ Func> reconnectAuthorization =
+ _ => Task.FromResult(ReconnectAuthorizationResult.AllowedResult);
+ connector.ReconnectAuthorizationAsync = reconnectAuthorization;
+ INodeRuntimeClient? createdClient = null;
+ connector.ClientCreated += (_, args) =>
+ {
+ createdClient = args.Client;
+ args.Client.SetPermission("test.permission", true);
+ };
+
+ var credential = new GatewayCredential("token", false, "test");
+ await connector.ConnectAsync(
+ "ws://gateway.example",
+ credential,
+ "identity-path",
+ useV2Signature: true);
+
+ Assert.Same(runtimeClient, connector.Client);
+ Assert.Same(runtimeClient, createdClient);
+ Assert.Equal("ws://gateway.example", factory.GatewayUrl);
+ Assert.Equal(credential, factory.Credential);
+ Assert.Equal("identity-path", factory.IdentityPath);
+ Assert.True(runtimeClient.UseV2Signature);
+ Assert.Same(reconnectAuthorization, runtimeClient.ReconnectAuthorizationAsync);
+ Assert.True(runtimeClient.PermissionWasSetBeforeConnect);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_CancelledBlockedRuntime_ReleasesConnectorForNextAttempt()
+ {
+ var connectStarted = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var blockedRuntime = new StubNodeRuntimeClient
+ {
+ ConnectOverride = async cancellationToken =>
+ {
+ connectStarted.TrySetResult();
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ }
+ };
+ var nextRuntime = new StubNodeRuntimeClient();
+ var clients = new Queue([blockedRuntime, nextRuntime]);
+ var factory = new DelegateNodeRuntimeClientFactory(() => clients.Dequeue());
+ using var connector = new NodeConnector(new StubLogger(), clientFactory: factory);
+ using var cts = new CancellationTokenSource();
+
+ var blockedAttempt = connector.ConnectAsync(
+ "ws://gateway.example",
+ new GatewayCredential("token", false, "test"),
+ "identity-path",
+ useV2Signature: false,
+ cancellationToken: cts.Token);
+ await connectStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ cts.Cancel();
+ await Assert.ThrowsAnyAsync(
+ () => blockedAttempt.WaitAsync(TimeSpan.FromSeconds(5)));
+
+ Assert.True(blockedRuntime.WasDisposed);
+ Assert.Null(connector.Client);
+ Assert.Equal(NodeConnectionMode.Disabled, connector.Mode);
+
+ await connector.ConnectAsync(
+ "ws://gateway.example",
+ new GatewayCredential("token", false, "test"),
+ "identity-path");
+
+ Assert.Same(nextRuntime, connector.Client);
+ Assert.True(nextRuntime.IsConnected);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_CancelledDuringClientCreated_RetiresBeforeHandshake()
+ {
+ var runtime = new StubNodeRuntimeClient();
+ var factory = new StubNodeRuntimeClientFactory(runtime);
+ using var connector = new NodeConnector(new StubLogger(), clientFactory: factory);
+ using var cts = new CancellationTokenSource();
+ connector.ClientCreated += (_, _) => cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(() =>
+ connector.ConnectAsync(
+ "ws://gateway.example",
+ new GatewayCredential("token", false, "test"),
+ "identity-path",
+ useV2Signature: false,
+ cancellationToken: cts.Token));
+
+ Assert.True(runtime.WasDisposed);
+ Assert.False(runtime.ConnectWasCalled);
+ Assert.Null(connector.Client);
+ Assert.Equal(NodeConnectionMode.Disabled, connector.Mode);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_CancelledRuntimeThrowsTransportError_StillRetiresCandidate()
+ {
+ var connectStarted = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var runtime = new StubNodeRuntimeClient
+ {
+ ConnectOverride = async cancellationToken =>
+ {
+ connectStarted.TrySetResult();
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw new IOException("transport aborted during cancellation");
+ }
+ }
+ };
+ var factory = new StubNodeRuntimeClientFactory(runtime);
+ using var connector = new NodeConnector(new StubLogger(), clientFactory: factory);
+ using var cts = new CancellationTokenSource();
+
+ var attempt = connector.ConnectAsync(
+ "ws://gateway.example",
+ new GatewayCredential("token", false, "test"),
+ "identity-path",
+ useV2Signature: false,
+ cancellationToken: cts.Token);
+ await connectStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ cts.Cancel();
+ await Assert.ThrowsAnyAsync(
+ () => attempt.WaitAsync(TimeSpan.FromSeconds(5)));
+
+ Assert.True(runtime.WasDisposed);
+ Assert.Null(connector.Client);
+ Assert.Equal(NodeConnectionMode.Disabled, connector.Mode);
+ }
+
[Fact]
public async Task DisconnectAsync_WhenNotConnected_CompletesWithoutError()
{
@@ -149,15 +387,13 @@ await connector.ConnectAsync(
"ws://127.0.0.1:1",
new GatewayCredential("tok", false, "test"),
"id-path");
- var clientA = connector.Client;
- Assert.NotNull(clientA);
+ var clientA = Assert.IsType(connector.Client);
await connector.ConnectAsync(
"ws://127.0.0.1:2",
new GatewayCredential("tok2", false, "test"),
"id-path");
- var clientB = connector.Client;
- Assert.NotNull(clientB);
+ var clientB = Assert.IsType(connector.Client);
Assert.NotSame(clientA, clientB);
statuses.Clear();
@@ -180,8 +416,7 @@ await connector.ConnectAsync(
"ws://127.0.0.1:1",
new GatewayCredential("tok", false, "test"),
"id-path");
- var retiredClient = connector.Client;
- Assert.NotNull(retiredClient);
+ var retiredClient = Assert.IsType(connector.Client);
await connector.DisconnectAsync();
@@ -202,14 +437,13 @@ public async Task CurrentClientStatusHandler_CanReadConnectorProperties_WithoutB
bool? wasConnected = null;
PairingStatus? pairingStatus = null;
NodeConnectionMode? mode = null;
- WindowsNodeClient? clientRef = null;
+ INodeRuntimeClient? clientRef = null;
await connector.ConnectAsync(
"ws://127.0.0.1:1",
new GatewayCredential("tok", false, "test"),
"id-path");
- var currentClient = connector.Client;
- Assert.NotNull(currentClient);
+ var currentClient = Assert.IsType(connector.Client);
connector.StatusChanged += (_, status) =>
{
diff --git a/tests/OpenClaw.Shared.Tests/NodeCapabilityDispatcherTests.cs b/tests/OpenClaw.Shared.Tests/NodeCapabilityDispatcherTests.cs
new file mode 100644
index 000000000..9937c728a
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/NodeCapabilityDispatcherTests.cs
@@ -0,0 +1,141 @@
+using System.Text.Json;
+using OpenClaw.Shared;
+using Xunit;
+
+namespace OpenClaw.Shared.Tests;
+
+public sealed class NodeCapabilityDispatcherTests
+{
+ [Fact]
+ public async Task DispatchAsync_UsesFirstRegisteredCapabilityAndPreservesEventSender()
+ {
+ var owner = new object();
+ var dispatcher = new NodeCapabilityDispatcher(owner, () => "node-1", new TestLogger());
+ dispatcher.RegisterCapability(new StubCapability("first", "example.echo"));
+ dispatcher.RegisterCapability(new StubCapability("second", "EXAMPLE.ECHO"));
+ var responseTcs = NewCompletion();
+ object? invokeSender = null;
+ object? completedSender = null;
+ var completedTcs = NewCompletion();
+ dispatcher.InvokeReceived += (sender, _) => invokeSender = sender;
+ dispatcher.InvokeCompleted += (sender, args) =>
+ {
+ completedSender = sender;
+ completedTcs.TrySetResult(args);
+ };
+
+ await dispatcher.DispatchAsync(
+ Request("invoke-1", "example.echo"),
+ response =>
+ {
+ responseTcs.TrySetResult(response);
+ return Task.CompletedTask;
+ },
+ error => Task.FromException(new Xunit.Sdk.XunitException(error)),
+ CancellationToken.None);
+
+ var response = await responseTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ var completed = await completedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.True(response.Ok);
+ Assert.Equal("first", Assert.IsType(response.Payload));
+ Assert.Same(owner, invokeSender);
+ Assert.Same(owner, completedSender);
+ Assert.Equal("node-1", completed.NodeId);
+ Assert.True(completed.Ok);
+ }
+
+ [Fact]
+ public async Task DispatchAsync_RejectsUnsupportedCommandWithStructuredCompletion()
+ {
+ var dispatcher = new NodeCapabilityDispatcher(new object(), () => "node-1", new TestLogger());
+ var errorTcs = NewCompletion();
+ var completedTcs = NewCompletion();
+ dispatcher.InvokeCompleted += (_, args) => completedTcs.TrySetResult(args);
+
+ await dispatcher.DispatchAsync(
+ Request("invoke-2", "example.missing"),
+ _ => Task.FromException(new Xunit.Sdk.XunitException("unexpected response")),
+ error =>
+ {
+ errorTcs.TrySetResult(error);
+ return Task.CompletedTask;
+ },
+ CancellationToken.None);
+
+ Assert.Equal(
+ "Command not supported: example.missing",
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)));
+ var completed = await completedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.False(completed.Ok);
+ Assert.Equal("invoke-2", completed.RequestId);
+ Assert.Equal("node-1", completed.NodeId);
+ }
+
+ [Fact]
+ public async Task TryCancel_PropagatesToWindowsCapabilityAndReturnsCancelledResult()
+ {
+ var capability = new CancellableCapability();
+ var dispatcher = new NodeCapabilityDispatcher(new object(), () => "node-1", new TestLogger());
+ dispatcher.RegisterCapability(capability);
+ var errorTcs = NewCompletion();
+
+ await dispatcher.DispatchAsync(
+ Request("invoke-3", "example.wait"),
+ _ => Task.FromException(new Xunit.Sdk.XunitException("unexpected response")),
+ error =>
+ {
+ errorTcs.TrySetResult(error);
+ return Task.CompletedTask;
+ },
+ CancellationToken.None);
+
+ await capability.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.True(dispatcher.TryCancel("invoke-3"));
+ Assert.Equal("cancelled", await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)));
+ Assert.False(dispatcher.TryCancel("invoke-3"));
+ }
+
+ private static NodeInvokeRequest Request(string id, string command)
+ {
+ using var document = JsonDocument.Parse("{}");
+ return new NodeInvokeRequest
+ {
+ Id = id,
+ Command = command,
+ Args = document.RootElement.Clone()
+ };
+ }
+
+ private static TaskCompletionSource NewCompletion() =>
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private sealed class StubCapability(string value, string command) : INodeCapability
+ {
+ public string Category => "example";
+ public IReadOnlyList Commands { get; } = [command];
+ public bool CanHandle(string candidate) =>
+ string.Equals(candidate, command, StringComparison.OrdinalIgnoreCase);
+ public Task ExecuteAsync(NodeInvokeRequest request) =>
+ Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = value });
+ }
+
+ private sealed class CancellableCapability : INodeCapability
+ {
+ public TaskCompletionSource Started { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public string Category => "example";
+ public IReadOnlyList Commands { get; } = ["example.wait"];
+ public bool CanHandle(string command) => command == "example.wait";
+ public Task ExecuteAsync(NodeInvokeRequest request) =>
+ throw new NotSupportedException();
+
+ public async Task ExecuteAsync(
+ NodeInvokeRequest request,
+ CancellationToken cancellationToken)
+ {
+ Started.TrySetResult();
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return new NodeInvokeResponse { Ok = true };
+ }
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj b/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
index dc0b74c1a..a95139010 100644
--- a/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
+++ b/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
@@ -13,6 +13,9 @@
PreserveNewest
+
+ PreserveNewest
+
diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-handshake-v1.json b/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-handshake-v1.json
new file mode 100644
index 000000000..0b22c949f
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-handshake-v1.json
@@ -0,0 +1,52 @@
+{
+ "schemaVersion": 1,
+ "session": {
+ "id": "handshake-session",
+ "generation": 9,
+ "keyBase64": "PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw="
+ },
+ "supervisorOffer": {
+ "protocolMajor": 1,
+ "protocolMinor": 0,
+ "peer": {
+ "role": "supervisor",
+ "name": "test-product",
+ "version": "1.0.0",
+ "artifactIdentity": "sha256:test-only"
+ },
+ "featureBits": 7,
+ "limits": {
+ "maxFrameBytes": 4096,
+ "maxInFlight": 8,
+ "bootstrapTimeoutMs": 1000
+ }
+ },
+ "runtimeOffer": {
+ "protocolMajor": 1,
+ "protocolMinor": 0,
+ "peer": {
+ "role": "runtime",
+ "name": "openclaw-node",
+ "version": "1.0.0",
+ "artifactIdentity": "sha256:test-only"
+ },
+ "featureBits": 11,
+ "limits": {
+ "maxFrameBytes": 2048,
+ "maxInFlight": 8,
+ "bootstrapTimeoutMs": 1000
+ }
+ },
+ "selection": {
+ "protocolMajor": 1,
+ "protocolMinor": 0,
+ "featureBits": 3,
+ "limits": {
+ "maxFrameBytes": 2048,
+ "maxInFlight": 8,
+ "bootstrapTimeoutMs": 1000
+ }
+ },
+ "offerFrameBase64": "T0NTQwABAAABAAAAAAAAAAkAAAAAAAAAAQARAAABA2hhbmRzaGFrZS1zZXNzaW9ueyJ0eXBlIjoib2ZmZXIiLCJvZmZlciI6eyJwcm90b2NvbE1ham9yIjoxLCJwcm90b2NvbE1pbm9yIjowLCJwZWVyIjp7InJvbGUiOiJzdXBlcnZpc29yIiwibmFtZSI6InRlc3QtcHJvZHVjdCIsInZlcnNpb24iOiIxLjAuMCIsImFydGlmYWN0SWRlbnRpdHkiOiJzaGEyNTY6dGVzdC1vbmx5In0sImZlYXR1cmVCaXRzIjo3LCJsaW1pdHMiOnsibWF4RnJhbWVCeXRlcyI6NDA5NiwibWF4SW5GbGlnaHQiOjgsImJvb3RzdHJhcFRpbWVvdXRNcyI6MTAwMH19fTSxSPVri786qBe92/qj1NGhn0efEyqVJfiZrCOdLbr1",
+ "acceptFrameBase64": "T0NTQwABAAACAAAAAAAAAAkAAAAAAAAAAQARAAABj2hhbmRzaGFrZS1zZXNzaW9ueyJ0eXBlIjoiYWNjZXB0Iiwib2ZmZXIiOnsicHJvdG9jb2xNYWpvciI6MSwicHJvdG9jb2xNaW5vciI6MCwicGVlciI6eyJyb2xlIjoicnVudGltZSIsIm5hbWUiOiJvcGVuY2xhdy1ub2RlIiwidmVyc2lvbiI6IjEuMC4wIiwiYXJ0aWZhY3RJZGVudGl0eSI6InNoYTI1Njp0ZXN0LW9ubHkifSwiZmVhdHVyZUJpdHMiOjExLCJsaW1pdHMiOnsibWF4RnJhbWVCeXRlcyI6MjA0OCwibWF4SW5GbGlnaHQiOjgsImJvb3RzdHJhcFRpbWVvdXRNcyI6MTAwMH19LCJzZWxlY3Rpb24iOnsicHJvdG9jb2xNYWpvciI6MSwicHJvdG9jb2xNaW5vciI6MCwiZmVhdHVyZUJpdHMiOjMsImxpbWl0cyI6eyJtYXhGcmFtZUJ5dGVzIjoyMDQ4LCJtYXhJbkZsaWdodCI6OCwiYm9vdHN0cmFwVGltZW91dE1zIjoxMDAwfX19DY3CiBg6igkouQFlG07FuMhFJuNEFCONtdVA6glc5tU="
+}
diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-protocol-v1.json b/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-protocol-v1.json
new file mode 100644
index 000000000..5081e7321
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-protocol-v1.json
@@ -0,0 +1,15 @@
+{
+ "schemaVersion": 1,
+ "session": {
+ "id": "session-7",
+ "generation": 7,
+ "sessionKeyBase64": "WlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlo="
+ },
+ "supervisorProbe": {
+ "payload": {
+ "requestId": "abc",
+ "type": "probe"
+ },
+ "frameBase64": "T0NTQwABAAABAAAAAAAAAAcAAAAAAAAAAQAJAAAAInNlc3Npb24tN3sicmVxdWVzdElkIjoiYWJjIiwidHlwZSI6InByb2JlIn04my4v5goF1qHj7BfwsC4o3oTzJCbGaE5jWu5WdqOVlQ=="
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-runtime-v1.json b/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-runtime-v1.json
new file mode 100644
index 000000000..33c143576
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/RustSidecar/Fixtures/node-sidecar-runtime-v1.json
@@ -0,0 +1,99 @@
+{
+ "schemaVersion": 1,
+ "messages": [
+ {
+ "type": "configure",
+ "configuration": {
+ "manifestGeneration": 3,
+ "capabilities": ["native.settings", "native.status"],
+ "commands": [
+ { "name": "product.settings" },
+ { "name": "product.status" }
+ ],
+ "maxConcurrency": 2,
+ "maxInputBytes": 1024,
+ "maxOutputBytes": 1024,
+ "defaultTimeoutMs": 1000,
+ "maxTimeoutMs": 5000,
+ "resultGraceMs": 50
+ }
+ },
+ {
+ "type": "configured",
+ "manifest": {
+ "manifestGeneration": 3,
+ "capabilities": ["native.settings", "native.status"],
+ "commands": ["product.settings", "product.status"]
+ }
+ },
+ {
+ "type": "admission-request",
+ "invocation": {
+ "id": "invoke-1",
+ "nodeId": "node-1",
+ "command": "product.status",
+ "params": { "verbose": true },
+ "timeoutMs": 1000,
+ "idempotencyKey": "idem-1",
+ "sessionKey": "agent:main:main"
+ }
+ },
+ {
+ "type": "admission-decision",
+ "invocationId": "invoke-1",
+ "decision": { "outcome": "allow" }
+ },
+ {
+ "type": "invoke",
+ "invocation": {
+ "id": "invoke-1",
+ "nodeId": "node-1",
+ "command": "product.status",
+ "params": { "verbose": true },
+ "timeoutMs": 1000,
+ "idempotencyKey": "idem-1",
+ "sessionKey": "agent:main:main"
+ }
+ },
+ {
+ "type": "result",
+ "invocationId": "invoke-1",
+ "result": {
+ "outcome": "success",
+ "payload": { "ready": true }
+ }
+ },
+ { "type": "cancel", "invocationId": "invoke-1" },
+ {
+ "type": "status",
+ "status": {
+ "state": "ready",
+ "manifestGeneration": 3,
+ "runtimeVersion": "1.0.0",
+ "attempt": 1,
+ "reason": null
+ }
+ },
+ {
+ "type": "status",
+ "status": {
+ "state": "ready",
+ "manifestGeneration": 9007199254740991,
+ "runtimeVersion": "1.0.0",
+ "attempt": 9007199254740991,
+ "reason": null
+ }
+ }
+ ],
+ "canonicalJson": [
+ "{\"type\":\"configure\",\"configuration\":{\"manifestGeneration\":3,\"capabilities\":[\"native.settings\",\"native.status\"],\"commands\":[{\"name\":\"product.settings\"},{\"name\":\"product.status\"}],\"maxConcurrency\":2,\"maxInputBytes\":1024,\"maxOutputBytes\":1024,\"defaultTimeoutMs\":1000,\"maxTimeoutMs\":5000,\"resultGraceMs\":50}}",
+ "{\"type\":\"configured\",\"manifest\":{\"manifestGeneration\":3,\"capabilities\":[\"native.settings\",\"native.status\"],\"commands\":[\"product.settings\",\"product.status\"]}}",
+ "{\"type\":\"admission-request\",\"invocation\":{\"id\":\"invoke-1\",\"nodeId\":\"node-1\",\"command\":\"product.status\",\"params\":{\"verbose\":true},\"timeoutMs\":1000,\"idempotencyKey\":\"idem-1\",\"sessionKey\":\"agent:main:main\"}}",
+ "{\"type\":\"admission-decision\",\"invocationId\":\"invoke-1\",\"decision\":{\"outcome\":\"allow\"}}",
+ "{\"type\":\"invoke\",\"invocation\":{\"id\":\"invoke-1\",\"nodeId\":\"node-1\",\"command\":\"product.status\",\"params\":{\"verbose\":true},\"timeoutMs\":1000,\"idempotencyKey\":\"idem-1\",\"sessionKey\":\"agent:main:main\"}}",
+ "{\"type\":\"result\",\"invocationId\":\"invoke-1\",\"result\":{\"outcome\":\"success\",\"payload\":{\"ready\":true}}}",
+ "{\"type\":\"cancel\",\"invocationId\":\"invoke-1\"}",
+ "{\"type\":\"status\",\"status\":{\"state\":\"ready\",\"manifestGeneration\":3,\"runtimeVersion\":\"1.0.0\",\"attempt\":1,\"reason\":null}}",
+ "{\"type\":\"status\",\"status\":{\"state\":\"ready\",\"manifestGeneration\":9007199254740991,\"runtimeVersion\":\"1.0.0\",\"attempt\":9007199254740991,\"reason\":null}}"
+ ]
+}
diff --git a/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs
new file mode 100644
index 000000000..2800e47d4
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/RustSidecar/WindowsSidecarCapabilityAdapterTests.cs
@@ -0,0 +1,1902 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using OpenClaw.Shared.RustSidecar;
+
+namespace OpenClaw.Shared.Tests.RustSidecar;
+
+public sealed class WindowsSidecarCapabilityAdapterTests
+{
+ [Fact]
+ public void ProtocolCodec_ReproducesRustFrameVectorExactly()
+ {
+ using var fixture = ReadFixture("node-sidecar-protocol-v1.json");
+ var session = fixture.RootElement.GetProperty("session");
+ var probe = fixture.RootElement.GetProperty("supervisorProbe");
+ var key = Convert.FromBase64String(session.GetProperty("sessionKeyBase64").GetString()!);
+ using var supervisor = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Supervisor,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+ var payload = JsonSerializer.SerializeToUtf8Bytes(probe.GetProperty("payload"));
+
+ var frame = supervisor.Seal(payload);
+
+ Assert.Equal(probe.GetProperty("frameBase64").GetString(), Convert.ToBase64String(frame));
+ using var runtime = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+ Assert.Equal(payload, runtime.Open(frame));
+ }
+
+ [Fact]
+ public void ProtocolCodec_AuthenticationFailurePermanentlyRetiresChannel()
+ {
+ var key = Enumerable.Repeat((byte)0x5A, 32).ToArray();
+ using var supervisor = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Supervisor, "session-7", 7, key, 4096);
+ using var runtime = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime, "session-7", 7, key, 4096);
+ var frame = supervisor.Seal("{}"u8);
+ frame[^1] ^= 1;
+
+ Assert.Throws(() => runtime.Open(frame));
+ Assert.True(runtime.IsRetired);
+ Assert.Throws(() => runtime.Open(frame));
+ Assert.Throws(() => runtime.Seal("{}"u8));
+ }
+
+ [Fact]
+ public void Handshake_ReproducesRustOfferAndAcceptVectorsExactly()
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ using var channel = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Supervisor,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!),
+ 4096);
+ var handshake = new SidecarSupervisorHandshake(
+ channel,
+ ParseOffer(root.GetProperty("supervisorOffer")));
+
+ Assert.Equal(root.GetProperty("offerFrameBase64").GetString(), Convert.ToBase64String(handshake.Start()));
+ handshake.Accept(Convert.FromBase64String(root.GetProperty("acceptFrameBase64").GetString()!));
+
+ Assert.True(handshake.IsAuthenticated);
+ Assert.Equal(2048u, channel.MaxFrameBytes);
+ Assert.Equal(3ul, handshake.Selection!.FeatureBits);
+ }
+
+ [Fact]
+ public void Handshake_RejectsUnsupportedLocalOfferBeforeSending()
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ using var channel = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Supervisor,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!),
+ 4096);
+ var offer = ParseOffer(root.GetProperty("supervisorOffer")) with { ProtocolMajor = 2 };
+
+ Assert.Throws(() => new SidecarSupervisorHandshake(channel, offer));
+ Assert.True(channel.IsRetired);
+ }
+
+ [Fact]
+ public void Handshake_RejectsRuntimeChannelBeforeSending()
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ using var channel = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!),
+ 4096);
+
+ Assert.Throws(() =>
+ new SidecarSupervisorHandshake(channel, ParseOffer(root.GetProperty("supervisorOffer"))));
+ Assert.True(channel.IsRetired);
+ }
+
+ [Fact]
+ public void Handshake_RejectsUnknownFieldsAtEveryAcceptanceLevel()
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ var key = Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!);
+ foreach (var target in new[]
+ {
+ "message", "offer", "peer", "offerLimits", "selection", "selectionLimits"
+ })
+ {
+ using var supervisorChannel = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Supervisor,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+ using var runtimeChannel = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+ var handshake = new SidecarSupervisorHandshake(
+ supervisorChannel,
+ ParseOffer(root.GetProperty("supervisorOffer")));
+ _ = handshake.Start();
+ var acceptance = new JsonObject
+ {
+ ["type"] = "accept",
+ ["offer"] = JsonNode.Parse(root.GetProperty("runtimeOffer").GetRawText()),
+ ["selection"] = JsonNode.Parse(root.GetProperty("selection").GetRawText())
+ };
+ var parent = target switch
+ {
+ "message" => acceptance,
+ "offer" => acceptance["offer"]!.AsObject(),
+ "peer" => acceptance["offer"]!["peer"]!.AsObject(),
+ "offerLimits" => acceptance["offer"]!["limits"]!.AsObject(),
+ "selection" => acceptance["selection"]!.AsObject(),
+ _ => acceptance["selection"]!["limits"]!.AsObject()
+ };
+ parent["unexpected"] = "secret-bearing-extension";
+
+ Assert.Throws(() =>
+ handshake.Accept(runtimeChannel.Seal(SidecarJson.Serialize(acceptance))));
+ Assert.True(supervisorChannel.IsRetired);
+ }
+ }
+
+ [Fact]
+ public void RuntimeCorpus_RoundTripsEveryCanonicalRustMessageExactly()
+ {
+ using var fixture = ReadFixture("node-sidecar-runtime-v1.json");
+ foreach (var canonical in fixture.RootElement.GetProperty("canonicalJson").EnumerateArray())
+ {
+ var json = canonical.GetString()!;
+ var parsed = SidecarJson.Parse(Encoding.UTF8.GetBytes(json));
+ Assert.Equal(json, Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(parsed)));
+ }
+ }
+
+ [Fact]
+ public void SidecarJson_UsesRustCompatibleDepthLimit()
+ {
+ var supported = Encoding.UTF8.GetBytes(
+ new string('[', SidecarJson.MaxDepth) + "0" + new string(']', SidecarJson.MaxDepth));
+ var tooDeep = Encoding.UTF8.GetBytes(
+ new string('[', SidecarJson.MaxDepth + 1) + "0" + new string(']', SidecarJson.MaxDepth + 1));
+
+ var parsed = SidecarJson.Parse(supported);
+
+ Assert.Equal(
+ supported,
+ JsonSerializer.SerializeToUtf8Bytes(parsed, SidecarJson.SerializerOptions));
+ Assert.ThrowsAny(() => SidecarJson.Parse(tooDeep));
+ }
+
+ [Fact]
+ public void SidecarJson_EmitsAllValidUnicodeScalarsLikeSerdeJson()
+ {
+ const string scalars = "\u00a0\u2028\u2029\u3000😀";
+ var encoded = Encoding.UTF8.GetString(SidecarJson.Serialize(new JsonObject
+ {
+ ["value"] = scalars
+ }));
+
+ Assert.Equal($$"""{"value":"{{scalars}}"}""", encoded);
+ }
+
+ [Fact]
+ public void Adapter_AcceptsReorderedConfiguredManifest()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ _ = adapter.BeginConfiguration(
+ 1,
+ new SidecarProtocolSelection(1, 0, 0, new SidecarLimits(4096, 8, 1000)));
+ var configured = ParseJson("""
+ {"type":"configured","manifest":{"commands":["product.status"],"manifestGeneration":1,"capabilities":["native.status"]}}
+ """);
+
+ adapter.ConfirmConfigured(configured);
+
+ Assert.True(adapter.IsConfigured);
+ }
+
+ [Fact]
+ public void Adapter_RejectsCaseInsensitiveWindowsCommandCollisions()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.first",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+
+ Assert.Throws(() => adapter.RegisterCapability(new TestCapability(
+ "native.second",
+ "PRODUCT.STATUS",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true }))));
+ Assert.Single(adapter.Capabilities);
+ }
+
+ [Fact]
+ public async Task Adapter_UsesExactConfigurationAndRoutesInvocationThroughDispatcher()
+ {
+ using var fixture = ReadFixture("node-sidecar-runtime-v1.json");
+ var canonical = fixture.RootElement.GetProperty("canonicalJson");
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = true,
+ Payload = new { ready = true }
+ })));
+ adapter.RegisterCapability(new TestCapability(
+ "native.settings",
+ "product.settings",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ var selection = new SidecarProtocolSelection(
+ 1, 0, 3, new SidecarLimits(2048, 2, 1000));
+
+ var configure = adapter.BeginConfiguration(
+ 3,
+ selection,
+ maxInputBytes: 1024,
+ maxOutputBytes: 1024,
+ defaultTimeoutMs: 1000,
+ maxTimeoutMs: 5000,
+ resultGraceMs: 50);
+ Assert.Equal(canonical[0].GetString(), Encoding.UTF8.GetString(SidecarJson.Serialize(configure)));
+ adapter.ConfirmConfigured(ParseCanonical(canonical[1]));
+
+ var admission = await adapter.HandleRuntimeMessageAsync(
+ ParseCanonical(canonical[2]),
+ CancellationToken.None);
+ Assert.Equal(canonical[3].GetString(), Encoding.UTF8.GetString(SidecarJson.Serialize(admission!)));
+
+ var result = await adapter.HandleRuntimeMessageAsync(
+ ParseCanonical(canonical[4]),
+ CancellationToken.None);
+ Assert.Equal(canonical[5].GetString(), Encoding.UTF8.GetString(SidecarJson.Serialize(result!)));
+ }
+
+ [Fact]
+ public async Task Supervisor_DrivesAuthenticatedFramesIntoWindowsDispatcher()
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ var key = Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = true,
+ Payload = new { ready = true }
+ })));
+ using var supervisor = new WindowsSidecarSupervisor(
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096,
+ ParseOffer(root.GetProperty("supervisorOffer")),
+ adapter,
+ manifestGeneration: 3);
+ using var runtime = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+
+ _ = runtime.Open(supervisor.Start());
+ var accept = new JsonObject
+ {
+ ["type"] = "accept",
+ ["offer"] = JsonNode.Parse(root.GetProperty("runtimeOffer").GetRawText()),
+ ["selection"] = JsonNode.Parse(root.GetProperty("selection").GetRawText())
+ };
+ var acceptanceFrame = runtime.Seal(SidecarJson.Serialize(accept));
+ runtime.LowerFrameLimit(2048);
+ var configurationFrame = supervisor.CompleteHandshake(acceptanceFrame);
+ var configuration = SidecarJson.Parse(runtime.Open(configurationFrame));
+ var configured = new JsonObject
+ {
+ ["type"] = "configured",
+ ["manifest"] = new JsonObject
+ {
+ ["manifestGeneration"] = 3,
+ ["capabilities"] = new JsonArray("native.status"),
+ ["commands"] = new JsonArray("product.status")
+ }
+ };
+ Assert.Equal("configure", configuration.GetProperty("type").GetString());
+ await supervisor.ReceiveAsync(
+ runtime.Seal(SidecarJson.Serialize(configured)),
+ CancellationToken.None);
+
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-1","nodeId":"node-1","command":"product.status","params":{"verbose":true},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ await supervisor.ReceiveAsync(
+ runtime.Seal(JsonSerializer.SerializeToUtf8Bytes(admission)),
+ CancellationToken.None);
+ var admissionFrame = await supervisor.ReadOutboundAsync(CancellationToken.None);
+ var decision = SidecarJson.Parse(runtime.Open(admissionFrame!));
+ Assert.Equal("allow", decision.GetProperty("decision").GetProperty("outcome").GetString());
+
+ var invoke = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-1","nodeId":"node-1","command":"product.status","params":{"verbose":true},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ await supervisor.ReceiveAsync(
+ runtime.Seal(JsonSerializer.SerializeToUtf8Bytes(invoke)),
+ CancellationToken.None);
+ var resultFrame = await supervisor.ReadOutboundAsync(CancellationToken.None);
+ var result = SidecarJson.Parse(runtime.Open(resultFrame!));
+ Assert.True(result.GetProperty("result").GetProperty("payload").GetProperty("ready").GetBoolean());
+ }
+
+ [Fact]
+ public async Task Supervisor_ReturnsStableFailureWhenResultEnvelopeExceedsDepthLimit()
+ {
+ var nested = new string('[', 126) + "0" + new string(']', 126);
+ var payload = SidecarJson.Parse(Encoding.UTF8.GetBytes(nested));
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ var session = await CreateConfiguredSupervisorAsync(adapter);
+ using var supervisor = session.Supervisor;
+ using var runtime = session.Runtime;
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-deep-envelope","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ await supervisor.ReceiveAsync(
+ runtime.Seal(JsonSerializer.SerializeToUtf8Bytes(admission, SidecarJson.SerializerOptions)),
+ CancellationToken.None);
+ _ = SidecarJson.Parse(runtime.Open(
+ await supervisor.ReadOutboundAsync(CancellationToken.None)));
+ var invoke = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-deep-envelope","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ await supervisor.ReceiveAsync(
+ runtime.Seal(JsonSerializer.SerializeToUtf8Bytes(invoke, SidecarJson.SerializerOptions)),
+ CancellationToken.None);
+ var resultFrame = await supervisor.ReadOutboundAsync(CancellationToken.None);
+ var result = SidecarJson.Parse(runtime.Open(resultFrame));
+
+ Assert.Equal(
+ "SIDECAR_MESSAGE_TOO_LARGE",
+ result.GetProperty("result").GetProperty("code").GetString());
+ Assert.False(supervisor.IsRetired);
+ }
+
+ [Fact]
+ public async Task Adapter_CancelMessageReachesActiveWindowsCapability()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.blocking",
+ "product.blocking",
+ async (_, cancellationToken) =>
+ {
+ started.TrySetResult();
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ throw new InvalidOperationException("unreachable");
+ }
+ catch (OperationCanceledException)
+ {
+ cancelled.TrySetResult();
+ throw;
+ }
+ }));
+ Configure(adapter);
+ var invoke = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-block","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-block","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var decision = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None);
+ Assert.Equal("allow", decision!["decision"]!["outcome"]!.GetValue());
+ var invocation = adapter.HandleRuntimeMessageAsync(invoke, CancellationToken.None);
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ var response = await adapter.HandleRuntimeMessageAsync(
+ ParseJson("""{"type":"cancel","invocationId":"invoke-block"}"""),
+ CancellationToken.None);
+
+ Assert.Null(response);
+ await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ var result = await invocation.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal("failure", result!["result"]!["outcome"]!.GetValue());
+ Assert.Equal("cancelled", result["result"]!["message"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Supervisor_ReadsCancelWhileInvocationIsBlocked()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.blocking",
+ "product.blocking",
+ async (_, cancellationToken) =>
+ {
+ started.TrySetResult();
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ throw new InvalidOperationException("unreachable");
+ }
+ catch (OperationCanceledException)
+ {
+ cancelled.TrySetResult();
+ throw;
+ }
+ }));
+ var session = await CreateConfiguredSupervisorAsync(adapter);
+ using var supervisor = session.Supervisor;
+ using var runtime = session.Runtime;
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-block","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ await supervisor.ReceiveAsync(
+ runtime.Seal(JsonSerializer.SerializeToUtf8Bytes(admission)),
+ CancellationToken.None);
+ _ = runtime.Open(await supervisor.ReadOutboundAsync(CancellationToken.None));
+ var invoke = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-block","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ await supervisor.ReceiveAsync(
+ runtime.Seal(JsonSerializer.SerializeToUtf8Bytes(invoke)),
+ CancellationToken.None);
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await supervisor.ReceiveAsync(
+ runtime.Seal("""{"type":"cancel","invocationId":"invoke-block"}"""u8),
+ CancellationToken.None);
+
+ await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ var result = SidecarJson.Parse(runtime.Open(
+ await supervisor.ReadOutboundAsync(CancellationToken.None)));
+ Assert.Equal("failure", result.GetProperty("result").GetProperty("outcome").GetString());
+ Assert.Equal("cancelled", result.GetProperty("result").GetProperty("message").GetString());
+ }
+
+ [Fact]
+ public async Task Adapter_ReturnsFailureAndReleasesSlotWhenResultCannotSerialize()
+ {
+ var cyclic = new Dictionary();
+ cyclic["self"] = cyclic;
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = cyclic })));
+ Configure(adapter, maxInFlight: 1);
+
+ var first = ParseJson("""
+ {"id":"invoke-1","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, first);
+ var result = await InvokeAsync(adapter, first);
+
+ Assert.Equal("RESULT_SERIALIZATION", result["result"]!["code"]!.GetValue());
+ var second = ParseJson("""
+ {"id":"invoke-2","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ var decision = await AdmitAsync(adapter, second);
+ Assert.Equal("allow", decision["decision"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_BoundsCapabilityErrorBeforeBuildingResultEnvelope()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = false,
+ Error = new string('e', 4096)
+ })));
+ Configure(adapter, maxOutputBytes: 128);
+ var invocation = ParseJson("""
+ {"id":"invoke-error","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("OUTPUT_TOO_LARGE", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_CountsJsonEscapingAgainstCapabilityErrorLimit()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = false,
+ Error = new string('\\', 100)
+ })));
+ Configure(adapter, maxOutputBytes: 128);
+ var invocation = ParseJson("""
+ {"id":"invoke-escaped-error","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("OUTPUT_TOO_LARGE", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_CountsFullCapabilityFailurePayloadAgainstOutputLimit()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = false,
+ Error = new string('e', 100)
+ })));
+ Configure(adapter, maxOutputBytes: 128);
+ var invocation = ParseJson("""
+ {"id":"invoke-error-payload","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("OUTPUT_TOO_LARGE", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_CountsLogicalOutputUsingSerdeCompatibleUtf8()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = false,
+ Error = new string('é', 40)
+ })));
+ Configure(adapter, maxOutputBytes: 128);
+ var invocation = ParseJson("""
+ {"id":"invoke-utf8-error","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("WINDOWS_CAPABILITY", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_ReturnsStableFailureForInvalidUtf16CapabilityError()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = false,
+ Error = "\ud800"
+ })));
+ Configure(adapter);
+ var invocation = ParseJson("""
+ {"id":"invoke-invalid-utf16","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":0,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation).WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Equal("RESULT_SERIALIZATION", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_RoutesDispatcherErrorsThroughBoundedFailureBuilder()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => throw new InvalidOperationException(new string('e', 4096))));
+ Configure(adapter, maxOutputBytes: 128);
+ var invocation = ParseJson("""
+ {"id":"invoke-dispatch-error","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("WINDOWS_CAPABILITY", result["result"]!["code"]!.GetValue());
+ Assert.Equal(
+ "Command execution failed",
+ result["result"]!["message"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_AllowsSerdeDepthCapabilityOutput()
+ {
+ var nested = new string('[', 80) + "0" + new string(']', 80);
+ var payload = SidecarJson.Parse(Encoding.UTF8.GetBytes(nested));
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-deep-output","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("success", result["result"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_EmitsCapabilityFloatsLikeSerdeJson()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = true,
+ Payload = new
+ {
+ doubleIntegral = 1.0,
+ negativeZero = -0.0,
+ doubleExponent = 1.25e-7,
+ doubleBoundary = Math.Pow(2, -25),
+ singleExponent = 1e-7f,
+ singleFixedBoundary = 1e-6f,
+ singleScientificBoundary = 1e13f,
+ nonFiniteDouble = double.NaN,
+ nonFiniteSingle = float.PositiveInfinity,
+ typedDecimals = Enumerable.Repeat(
+ 1.0000000000000000000000000000m,
+ 40).ToArray()
+ }
+ })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-floats","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+ var encoded = Encoding.UTF8.GetString(SidecarJson.Serialize(result));
+
+ Assert.Equal("success", result["result"]!["outcome"]!.GetValue());
+ Assert.Contains("\"doubleIntegral\":1.0", encoded);
+ Assert.Contains("\"negativeZero\":-0.0", encoded);
+ Assert.Contains("\"doubleExponent\":1.25e-7", encoded);
+ Assert.Contains("\"doubleBoundary\":2.9802322387695312e-8", encoded);
+ Assert.Contains("\"singleExponent\":1e-7", encoded);
+ Assert.Contains("\"singleFixedBoundary\":1e-6", encoded);
+ Assert.Contains("\"singleScientificBoundary\":10000000000000.0", encoded);
+ Assert.Contains("\"nonFiniteDouble\":null", encoded);
+ Assert.Contains("\"nonFiniteSingle\":null", encoded);
+ Assert.All(result["result"]!["payload"]!["typedDecimals"]!.AsArray(), value =>
+ Assert.Equal(1.0, value!.GetValue()));
+ Assert.Equal(
+ "{\"fixedSmall\":0.00001,\"scientificLarge\":1e+16}",
+ Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(
+ new { fixedSmall = 1e-5, scientificLarge = 1e16 },
+ SidecarJson.SerializerOptions)));
+ }
+
+ [Fact]
+ public async Task Adapter_NormalizesUntypedCapabilityNumbersLikeSerdeJson()
+ {
+ var redundantFraction = "1." + new string('0', 2048);
+ var payload = SidecarJson.Parse(Encoding.UTF8.GetBytes(
+ $$"""{"integral":1e0,"rounded":1.0000000000000001,"fixedSmall":1e-5,"compacted":{{redundantFraction}},"duplicate":1,"duplicate":2}"""));
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-untyped-numbers","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal(
+ "{\"integral\":1.0,\"rounded\":1.0,\"fixedSmall\":0.00001,\"compacted\":1.0,\"duplicate\":2}",
+ result["result"]!["payload"]!.ToJsonString(SidecarJson.SerializerOptions));
+ }
+
+ [Fact]
+ public async Task Adapter_NormalizesJsonNodeCapabilityNumbersLikeSerdeJson()
+ {
+ var redundantFraction = "1." + new string('0', 2048);
+ var payload = JsonNode.Parse($$"""{"compacted":{{redundantFraction}}}""")!.AsObject();
+ payload["nan"] = double.NaN;
+ payload["infinity"] = float.PositiveInfinity;
+ payload["decimals"] = new JsonArray(Enumerable.Range(0, 40)
+ .Select(_ => (JsonNode?)JsonValue.Create(1.0000000000000000000000000000m))
+ .ToArray());
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-json-node-numbers","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ var resultPayload = result["result"]!["payload"]!;
+ Assert.Equal(1.0, resultPayload["compacted"]!.GetValue());
+ Assert.Null(resultPayload["nan"]);
+ Assert.Null(resultPayload["infinity"]);
+ Assert.All(resultPayload["decimals"]!.AsArray(), value =>
+ Assert.Equal(1.0, value!.GetValue()));
+ }
+
+ [Fact]
+ public async Task Adapter_NormalizesJsonDocumentCapabilityNumbersLikeSerdeJson()
+ {
+ var redundantFraction = "1." + new string('0', 2048);
+ using var payload = JsonDocument.Parse($$"""{"compacted":{{redundantFraction}}}""");
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-json-document-numbers","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal(
+ "{\"compacted\":1.0}",
+ result["result"]!["payload"]!.ToJsonString(SidecarJson.SerializerOptions));
+ }
+
+ [Fact]
+ public async Task Adapter_BoundsUntypedCanonicalizationWork()
+ {
+ var redundantFraction = "1." + new string('0', 9000);
+ using var payload = JsonDocument.Parse($$"""{"compacted":{{redundantFraction}}}""");
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-canonicalization-work","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("OUTPUT_TOO_LARGE", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_BoundsJsonNodeCanonicalizationWork()
+ {
+ var redundantFraction = "1." + new string('0', 9000);
+ var payload = JsonNode.Parse($$"""{"compacted":{{redundantFraction}}}""");
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-json-node-work","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("OUTPUT_TOO_LARGE", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_BoundsLazyJsonNodeContainerMaterialization()
+ {
+ var duplicateProperties = string.Join(',', Enumerable.Repeat("\"value\":0", 2000));
+ var payload = JsonNode.Parse($$"""{ {{duplicateProperties}} }""");
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-lazy-json-node-work","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("OUTPUT_TOO_LARGE", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_ContinuesJsonNodeWorkBudgetAfterNonFiniteValue()
+ {
+ var redundantFraction = "1." + new string('0', 9000);
+ var payload = new JsonObject
+ {
+ ["nan"] = double.NaN,
+ ["compacted"] = JsonNode.Parse(redundantFraction)
+ };
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-nonfinite-json-node-work","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("OUTPUT_TOO_LARGE", result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_DoesNotDoubleChargeNodePrefixBeforeNonFiniteValue()
+ {
+ var redundantFraction = "1." + new string('0', 4100);
+ var payload = new JsonObject
+ {
+ ["compacted"] = JsonNode.Parse(redundantFraction),
+ ["nan"] = double.NaN
+ };
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true, Payload = payload })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-nonfinite-prefix-work","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("success", result["result"]!["outcome"]!.GetValue());
+ Assert.Equal(1.0, result["result"]!["payload"]!["compacted"]!.GetValue());
+ Assert.Null(result["result"]!["payload"]!["nan"]);
+ }
+
+ [Fact]
+ public async Task Adapter_DoesNotChargeResultSerializationToHandlerDeadline()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = true,
+ Payload = new SlowSerializationPayload(TimeSpan.FromMilliseconds(125))
+ })));
+ Configure(
+ adapter,
+ defaultTimeoutMs: 200,
+ maxTimeoutMs: 200,
+ resultGraceMs: 100);
+ var invocation = ParseJson("""
+ {"id":"invoke-slow-serialization","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":200,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation).WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Equal("success", result["result"]!["outcome"]!.GetValue());
+ Assert.Equal("serialized", result["result"]!["payload"]!["value"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_RejectsNonPortableIntegerCapabilityOutput()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = true,
+ Payload = new { unsafeValue = long.MaxValue }
+ })));
+ Configure(adapter, maxOutputBytes: 1024);
+ var invocation = ParseJson("""
+ {"id":"invoke-unsafe","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal(
+ "SIDECAR_NON_PORTABLE_JSON",
+ result["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_RejectsOversizedParametersBeforeWindowsDispatch()
+ {
+ var invoked = false;
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) =>
+ {
+ invoked = true;
+ return Task.FromResult(new NodeInvokeResponse { Ok = true });
+ }));
+ Configure(adapter, maxInputBytes: 16);
+ var invocation = ParseJson($$"""
+ {"id":"invoke-large-input","nodeId":"node-1","command":"product.status","params":{"data":"{{new string('x', 100)}}"},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+
+ var admission = await AdmitAsync(adapter, invocation);
+
+ Assert.Equal(
+ "INPUT_TOO_LARGE",
+ admission["decision"]!["code"]!.GetValue());
+ Assert.False(invoked);
+ }
+
+ [Fact]
+ public async Task Supervisor_UsesSidecarErrorForOversizedResultEnvelopeWithoutRetiringSession()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse
+ {
+ Ok = true,
+ Payload = new string('x', 1950)
+ })));
+ var session = await CreateConfiguredSupervisorAsync(adapter);
+ using var supervisor = session.Supervisor;
+ using var runtime = session.Runtime;
+ var invocation = ParseJson("""
+ {"id":"invoke-large","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await supervisor.ReceiveAsync(
+ runtime.Seal(SidecarJson.Serialize(new JsonObject
+ {
+ ["type"] = "admission-request",
+ ["invocation"] = JsonNode.Parse(invocation.GetRawText())
+ })),
+ CancellationToken.None);
+ _ = runtime.Open(await supervisor.ReadOutboundAsync(CancellationToken.None));
+ await supervisor.ReceiveAsync(
+ runtime.Seal(SidecarJson.Serialize(new JsonObject
+ {
+ ["type"] = "invoke",
+ ["invocation"] = JsonNode.Parse(invocation.GetRawText())
+ })),
+ CancellationToken.None);
+
+ var result = SidecarJson.Parse(runtime.Open(
+ await supervisor.ReadOutboundAsync(CancellationToken.None)));
+ Assert.Equal(
+ "SIDECAR_MESSAGE_TOO_LARGE",
+ result.GetProperty("result").GetProperty("code").GetString());
+ Assert.False(supervisor.IsRetired);
+ }
+
+ [Fact]
+ public void Adapter_RejectsInvalidLogicalByteLimitsBeforeConfigurationStarts()
+ {
+ var selection = new SidecarProtocolSelection(
+ 1, 0, 0, new SidecarLimits(4096, 8, 1000));
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+
+ Assert.Throws(() =>
+ adapter.BeginConfiguration(1, selection, maxInputBytes: 0));
+ Assert.Throws(() =>
+ adapter.BeginConfiguration(1, selection, maxOutputBytes: 0));
+ Assert.Throws(() =>
+ adapter.BeginConfiguration(1, selection, maxOutputBytes: 1));
+ Assert.False(adapter.IsConfigured);
+ }
+
+ [Fact]
+ public void Supervisor_RejectsConfigurationWhenWorstCaseStatusCannotFit()
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ var key = Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ using var supervisor = new WindowsSidecarSupervisor(
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096,
+ ParseOffer(root.GetProperty("supervisorOffer")),
+ adapter,
+ manifestGeneration: 3);
+ using var runtime = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+ _ = runtime.Open(supervisor.Start());
+ var runtimeOffer = JsonNode.Parse(root.GetProperty("runtimeOffer").GetRawText())!.AsObject();
+ runtimeOffer["peer"]!["version"] = new string('v', 900);
+ runtimeOffer["limits"]!["maxFrameBytes"] = 1024;
+ var selection = JsonNode.Parse(root.GetProperty("selection").GetRawText())!.AsObject();
+ selection["limits"]!["maxFrameBytes"] = 1024;
+ var acceptance = new JsonObject
+ {
+ ["type"] = "accept",
+ ["offer"] = runtimeOffer,
+ ["selection"] = selection
+ };
+
+ Assert.Throws(() =>
+ supervisor.CompleteHandshake(runtime.Seal(SidecarJson.Serialize(acceptance))));
+ Assert.True(supervisor.IsRetired);
+ }
+
+ [Fact]
+ public void Supervisor_RejectsConfigurationWhenStableResultFailureCannotFit()
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ var sessionId = session.GetProperty("id").GetString()!;
+ var key = Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!);
+ var runtimeVersion = root.GetProperty("runtimeOffer").GetProperty("peer")
+ .GetProperty("version").GetString()!;
+ var statusPayload = SidecarJson.Serialize(new JsonObject
+ {
+ ["type"] = "status",
+ ["status"] = new JsonObject
+ {
+ ["state"] = "backing-off",
+ ["manifestGeneration"] = 3,
+ ["runtimeVersion"] = runtimeVersion,
+ ["attempt"] = SidecarJson.MaxPortableInteger,
+ ["reason"] = "delivery-saturated"
+ }
+ });
+ var stableFailure = SidecarJson.Serialize(
+ WindowsSidecarCapabilityAdapter.MessageTooLargeFailure(string.Empty));
+ Assert.True(stableFailure.Length > statusPayload.Length);
+ var negotiatedFrameBytes = checked((uint)(31 + Encoding.UTF8.GetByteCount(sessionId) + 32 + statusPayload.Length));
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ using var supervisor = new WindowsSidecarSupervisor(
+ sessionId,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096,
+ ParseOffer(root.GetProperty("supervisorOffer")),
+ adapter,
+ manifestGeneration: 3);
+ using var runtime = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime,
+ sessionId,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+ _ = runtime.Open(supervisor.Start());
+ var runtimeOffer = JsonNode.Parse(root.GetProperty("runtimeOffer").GetRawText())!.AsObject();
+ runtimeOffer["limits"]!["maxFrameBytes"] = negotiatedFrameBytes;
+ var selection = JsonNode.Parse(root.GetProperty("selection").GetRawText())!.AsObject();
+ selection["limits"]!["maxFrameBytes"] = negotiatedFrameBytes;
+ var acceptance = new JsonObject
+ {
+ ["type"] = "accept",
+ ["offer"] = runtimeOffer,
+ ["selection"] = selection
+ };
+
+ Assert.Throws(() =>
+ supervisor.CompleteHandshake(runtime.Seal(SidecarJson.Serialize(acceptance))));
+ Assert.True(supervisor.IsRetired);
+ }
+
+ [Fact]
+ public void Supervisor_RejectsConfigurationWhenAdmissionDecisionCannotFit()
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ var sessionId = session.GetProperty("id").GetString()!;
+ var key = Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!);
+ var stableFailureBytes = SidecarJson.Serialize(
+ WindowsSidecarCapabilityAdapter.MessageTooLargeFailure(string.Empty)).Length;
+ var admissionBytes = WindowsSidecarCapabilityAdapter.MaximumAdmissionDecisionBytes(string.Empty);
+ Assert.True(admissionBytes > stableFailureBytes);
+ var negotiatedPayloadBytes = admissionBytes - 1;
+ var negotiatedFrameBytes = checked((uint)(
+ 31 + Encoding.UTF8.GetByteCount(sessionId) + 32 + negotiatedPayloadBytes));
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ using var supervisor = new WindowsSidecarSupervisor(
+ sessionId,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096,
+ ParseOffer(root.GetProperty("supervisorOffer")),
+ adapter,
+ manifestGeneration: 3);
+ using var runtime = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime,
+ sessionId,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+ _ = runtime.Open(supervisor.Start());
+ var runtimeOffer = JsonNode.Parse(root.GetProperty("runtimeOffer").GetRawText())!.AsObject();
+ runtimeOffer["limits"]!["maxFrameBytes"] = negotiatedFrameBytes;
+ var selection = JsonNode.Parse(root.GetProperty("selection").GetRawText())!.AsObject();
+ selection["limits"]!["maxFrameBytes"] = negotiatedFrameBytes;
+ var acceptance = new JsonObject
+ {
+ ["type"] = "accept",
+ ["offer"] = runtimeOffer,
+ ["selection"] = selection
+ };
+
+ Assert.Throws(() =>
+ supervisor.CompleteHandshake(runtime.Seal(SidecarJson.Serialize(acceptance))));
+ Assert.True(supervisor.IsRetired);
+ }
+
+ [Fact]
+ public async Task Supervisor_DiscardsBufferedFramesOnTerminalAuthenticationFailure()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ var session = await CreateConfiguredSupervisorAsync(adapter);
+ using var supervisor = session.Supervisor;
+ using var runtime = session.Runtime;
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-buffered","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ await supervisor.ReceiveAsync(
+ runtime.Seal(JsonSerializer.SerializeToUtf8Bytes(admission)),
+ CancellationToken.None);
+ var invalid = runtime.Seal("""{"type":"cancel","invocationId":"invoke-buffered"}"""u8);
+ invalid[^1] ^= 1;
+
+ await Assert.ThrowsAsync(() =>
+ supervisor.ReceiveAsync(invalid, CancellationToken.None));
+ await Assert.ThrowsAsync(async () =>
+ await supervisor.ReadOutboundAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Adapter_RejectsRuntimeTrafficBeforeConfiguration()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ var message = ParseJson("""{"type":"cancel","invocationId":"invoke-1"}""");
+
+ await Assert.ThrowsAsync(
+ () => adapter.HandleRuntimeMessageAsync(message, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Adapter_RequiresAnUnchangedAdmissionBeforeDispatch()
+ {
+ var executions = 0;
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) =>
+ {
+ executions++;
+ return Task.FromResult(new NodeInvokeResponse { Ok = true });
+ }));
+ Configure(adapter);
+ var notAdmitted = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-1","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var missing = await adapter.HandleRuntimeMessageAsync(notAdmitted, CancellationToken.None);
+ Assert.Equal("ADMISSION_REQUIRED", missing!["result"]!["code"]!.GetValue());
+
+ var admitted = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-2","nodeId":"node-1","command":"product.status","params":{"value":1},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ await adapter.HandleRuntimeMessageAsync(admitted, CancellationToken.None);
+ var changed = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-2","nodeId":"node-1","command":"product.status","params":{"value":2},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var mismatch = await adapter.HandleRuntimeMessageAsync(changed, CancellationToken.None);
+
+ Assert.Equal("ADMISSION_MISMATCH", mismatch!["result"]!["code"]!.GetValue());
+ Assert.Equal(0, executions);
+ }
+
+ [Fact]
+ public async Task Adapter_UsesSerdeFloatingPointEqualityForAdmission()
+ {
+ var executions = 0;
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) =>
+ {
+ executions++;
+ return Task.FromResult(new NodeInvokeResponse { Ok = true });
+ }));
+ Configure(adapter);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-float","nodeId":"node-1","command":"product.status","params":{"value":1.0},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var invocation = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-float","nodeId":"node-1","command":"product.status","params":{"value":1.0000000000000001},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ _ = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None);
+ var result = await adapter.HandleRuntimeMessageAsync(invocation, CancellationToken.None);
+
+ Assert.Equal("success", result!["result"]!["outcome"]!.GetValue());
+ Assert.Equal(1, executions);
+ }
+
+ [Fact]
+ public async Task Adapter_TreatsReorderedInvocationObjectsAsUnchanged()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ Configure(adapter);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-reordered","nodeId":"node-1","command":"product.status","params":{"first":1,"nested":{"left":2,"right":3}},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var invocation = ParseJson("""
+ {"type":"invoke","invocation":{"sessionKey":null,"idempotencyKey":null,"timeoutMs":1000,"params":{"nested":{"right":3,"left":2},"first":1},"command":"product.status","nodeId":"node-1","id":"invoke-reordered"}}
+ """);
+
+ _ = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None);
+ var result = await adapter.HandleRuntimeMessageAsync(invocation, CancellationToken.None);
+
+ Assert.Equal("success", result!["result"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_NormalizesDuplicateParameterKeysBeforeAdmissionAndDispatch()
+ {
+ JsonElement? dispatchedParameters = null;
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (request, _) =>
+ {
+ dispatchedParameters = request.Args;
+ return Task.FromResult(new NodeInvokeResponse { Ok = true });
+ }));
+ Configure(adapter);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-duplicates","nodeId":"node-1","command":"product.status","params":{"value":1,"value":2},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var invocation = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-duplicates","nodeId":"node-1","command":"product.status","params":{"value":999,"value":2},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ _ = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None);
+ var result = await adapter.HandleRuntimeMessageAsync(invocation, CancellationToken.None);
+
+ Assert.Equal("success", result!["result"]!["outcome"]!.GetValue());
+ Assert.NotNull(dispatchedParameters);
+ Assert.Single(dispatchedParameters.Value.EnumerateObject());
+ Assert.Equal(2, dispatchedParameters.Value.GetProperty("value").GetInt32());
+ }
+
+ [Fact]
+ public async Task Adapter_RejectsOutOfRangeChangedParameterWithoutRetiringSession()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ Configure(adapter, maxInFlight: 1);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-range","nodeId":"node-1","command":"product.status","params":{"value":1},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var invocation = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-range","nodeId":"node-1","command":"product.status","params":{"value":18446744073709551616},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ _ = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None);
+ var result = await adapter.HandleRuntimeMessageAsync(invocation, CancellationToken.None);
+ var next = await adapter.HandleRuntimeMessageAsync(
+ ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-next","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """),
+ CancellationToken.None);
+
+ Assert.Equal("SIDECAR_NON_PORTABLE_JSON", result!["result"]!["code"]!.GetValue());
+ Assert.Equal("allow", next!["decision"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_DistinguishesSerdeNegativeZeroFromIntegerZero()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ Configure(adapter);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-negative-zero","nodeId":"node-1","command":"product.status","params":{"value":-0},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var invocation = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-negative-zero","nodeId":"node-1","command":"product.status","params":{"value":0},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ _ = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None);
+ var result = await adapter.HandleRuntimeMessageAsync(invocation, CancellationToken.None);
+
+ Assert.Equal("ADMISSION_MISMATCH", result!["result"]!["code"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_AllowsSerdeDepthAndUtf8InvocationInput()
+ {
+ var nested = new string('[', 80) + "\"" + new string('é', 40) + "\"" + new string(']', 80);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ Configure(adapter, maxInputBytes: 256);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-deep-input","nodeId":"node-1","command":"product.status","params":__PARAMS__,"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """.Replace("__PARAMS__", nested, StringComparison.Ordinal));
+
+ var decision = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None);
+
+ Assert.Equal("allow", decision!["decision"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_RejectsNonPortableInvocationTimeout()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ Configure(adapter);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-unsafe-timeout","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":9007199254740992,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ await Assert.ThrowsAsync(
+ () => adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Adapter_ReleasesAdmissionWhenInvocationChangesToOversizedInput()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ Configure(adapter, maxInFlight: 1, maxInputBytes: 32);
+ var admitted = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-changed","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var changed = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-changed","nodeId":"node-1","command":"product.status","params":{"data":"__DATA__"},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """.Replace("__DATA__", new string('x', 100), StringComparison.Ordinal));
+ var next = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-next","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ _ = await adapter.HandleRuntimeMessageAsync(admitted, CancellationToken.None);
+ var mismatch = await adapter.HandleRuntimeMessageAsync(changed, CancellationToken.None);
+ var nextDecision = await adapter.HandleRuntimeMessageAsync(next, CancellationToken.None);
+
+ Assert.Equal("INPUT_TOO_LARGE", mismatch!["result"]!["code"]!.GetValue());
+ Assert.Equal("allow", nextDecision!["decision"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_HoldsAdmissionSlotAndIdUntilInvocationIsTerminal()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.blocking",
+ "product.blocking",
+ async (_, cancellationToken) =>
+ {
+ started.TrySetResult();
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return new NodeInvokeResponse { Ok = true };
+ }));
+ Configure(adapter, maxInFlight: 1);
+ var firstAdmission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-1","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ await adapter.HandleRuntimeMessageAsync(firstAdmission, CancellationToken.None);
+ var firstInvoke = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-1","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var active = adapter.HandleRuntimeMessageAsync(firstInvoke, CancellationToken.None);
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ var secondAdmission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-2","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":1000,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var saturated = await adapter.HandleRuntimeMessageAsync(secondAdmission, CancellationToken.None);
+ var duplicate = await adapter.HandleRuntimeMessageAsync(firstAdmission, CancellationToken.None);
+ Assert.Equal("ADMISSION_SATURATED", saturated!["decision"]!["code"]!.GetValue());
+ Assert.Equal("ADMISSION_SATURATED", duplicate!["decision"]!["code"]!.GetValue());
+
+ await adapter.HandleRuntimeMessageAsync(
+ ParseJson("""{"type":"cancel","invocationId":"invoke-1"}"""),
+ CancellationToken.None);
+ _ = await active.WaitAsync(TimeSpan.FromSeconds(5));
+ var admittedAfterCompletion = await adapter.HandleRuntimeMessageAsync(
+ secondAdmission,
+ CancellationToken.None);
+ Assert.Equal("allow", admittedAfterCompletion!["decision"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_ClampsAndEnforcesNegotiatedInvocationTimeout()
+ {
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.blocking",
+ "product.blocking",
+ async (_, cancellationToken) =>
+ {
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return new NodeInvokeResponse { Ok = true };
+ }
+ finally
+ {
+ cancelled.TrySetResult();
+ }
+ }));
+ Configure(
+ adapter,
+ defaultTimeoutMs: 100,
+ maxTimeoutMs: 100,
+ resultGraceMs: 10);
+ var invocation = ParseJson("""
+ {"id":"invoke-timeout","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":10000,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation).WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Equal("HANDLER_TIMEOUT", result["result"]!["code"]!.GetValue());
+ await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ [Fact]
+ public async Task Adapter_UsesFullDefaultInvocationTimeoutLikeRustRuntime()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.slow",
+ "product.slow",
+ async (_, _) =>
+ {
+ await Task.Delay(175);
+ return new NodeInvokeResponse { Ok = true };
+ }));
+ Configure(
+ adapter,
+ defaultTimeoutMs: 250,
+ maxTimeoutMs: 250,
+ resultGraceMs: 150);
+ var invocation = ParseJson("""
+ {"id":"invoke-default-timeout","nodeId":"node-1","command":"product.slow","params":{},"timeoutMs":null,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation).WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Equal("success", result["result"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_HoldsTimedOutAdmissionUntilNonCooperativeHandlerTerminates()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.blocking",
+ "product.blocking",
+ async (_, _) =>
+ {
+ started.TrySetResult();
+ await release.Task;
+ return new NodeInvokeResponse { Ok = true };
+ }));
+ Configure(
+ adapter,
+ maxInFlight: 1,
+ defaultTimeoutMs: 50,
+ maxTimeoutMs: 50,
+ resultGraceMs: 10);
+ var invocation = ParseJson("""
+ {"id":"invoke-noncooperative","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":50,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+ var running = InvokeAsync(adapter, invocation);
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ var timeout = await running.WaitAsync(TimeSpan.FromSeconds(5));
+ var whileRunning = await AdmitAsync(
+ adapter,
+ ParseJson("""
+ {"id":"invoke-next","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":50,"idempotencyKey":null,"sessionKey":null}
+ """));
+ release.TrySetResult();
+
+ Assert.Equal("HANDLER_TIMEOUT", timeout["result"]!["code"]!.GetValue());
+ Assert.Equal("ADMISSION_SATURATED", whileRunning["decision"]!["code"]!.GetValue());
+ JsonObject? afterTermination = null;
+ for (var attempt = 0; attempt < 50; attempt++)
+ {
+ afterTermination = await AdmitAsync(
+ adapter,
+ ParseJson("""
+ {"id":"invoke-after","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":50,"idempotencyKey":null,"sessionKey":null}
+ """));
+ if (afterTermination["decision"]!["outcome"]!.GetValue() == "allow")
+ break;
+ await Task.Delay(10);
+ }
+ Assert.Equal("allow", afterTermination!["decision"]!["outcome"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_UsesRustMessageForElapsedPreDispatchDeadline()
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.status",
+ "product.status",
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+ Configure(
+ adapter,
+ defaultTimeoutMs: 100,
+ maxTimeoutMs: 100,
+ resultGraceMs: 10);
+ var invocation = ParseJson("""
+ {"id":"invoke-elapsed","nodeId":"node-1","command":"product.status","params":{},"timeoutMs":5,"idempotencyKey":null,"sessionKey":null}
+ """);
+ await AdmitAsync(adapter, invocation);
+
+ var result = await InvokeAsync(adapter, invocation);
+
+ Assert.Equal("HANDLER_TIMEOUT", result["result"]!["code"]!.GetValue());
+ Assert.Equal(
+ "command handler deadline already elapsed",
+ result["result"]!["message"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Adapter_MalformedDuplicateInvokeCannotBreakActiveCancellation()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "native.blocking",
+ "product.blocking",
+ async (_, cancellationToken) =>
+ {
+ started.TrySetResult();
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return new NodeInvokeResponse { Ok = true };
+ }
+ finally
+ {
+ cancelled.TrySetResult();
+ }
+ }));
+ Configure(adapter);
+ var admission = ParseJson("""
+ {"type":"admission-request","invocation":{"id":"invoke-active","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":0,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ var invocation = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-active","nodeId":"node-1","command":"product.blocking","params":{},"timeoutMs":0,"idempotencyKey":null,"sessionKey":null}}
+ """);
+ _ = await adapter.HandleRuntimeMessageAsync(admission, CancellationToken.None);
+ var active = adapter.HandleRuntimeMessageAsync(invocation, CancellationToken.None);
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ var malformedDuplicate = ParseJson("""
+ {"type":"invoke","invocation":{"id":"invoke-active","nodeId":"node-1","command":"product.blocking","params":{"value":18446744073709551616},"timeoutMs":0,"idempotencyKey":null,"sessionKey":null}}
+ """);
+
+ var rejected = await adapter.HandleRuntimeMessageAsync(malformedDuplicate, CancellationToken.None);
+ _ = await adapter.HandleRuntimeMessageAsync(
+ ParseJson("""{"type":"cancel","invocationId":"invoke-active"}"""),
+ CancellationToken.None);
+
+ Assert.Equal("SIDECAR_NON_PORTABLE_JSON", rejected!["result"]!["code"]!.GetValue());
+ await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ _ = await active.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ [Theory]
+ [InlineData("system.run")]
+ [InlineData("System.Run")]
+ public void Adapter_RecordsCurrentRustSystemNamespaceGapInsteadOfSelectingIt(string command)
+ {
+ var adapter = new WindowsSidecarCapabilityAdapter("node-1", new TestLogger());
+ adapter.RegisterCapability(new TestCapability(
+ "system",
+ command,
+ (_, _) => Task.FromResult(new NodeInvokeResponse { Ok = true })));
+
+ var error = Assert.Throws(() => adapter.BeginConfiguration(
+ 1,
+ new SidecarProtocolSelection(1, 0, 0, new SidecarLimits(4096, 8, 1000))));
+
+ Assert.Contains("does not yet admit the system command namespace", error.Message);
+ Assert.False(adapter.IsConfigured);
+ }
+
+ private static void Configure(
+ WindowsSidecarCapabilityAdapter adapter,
+ ushort maxInFlight = 8,
+ uint maxInputBytes = 1_048_576,
+ uint maxOutputBytes = 1_048_576,
+ uint defaultTimeoutMs = 30_000,
+ uint maxTimeoutMs = 120_000,
+ uint resultGraceMs = 250)
+ {
+ var configure = adapter.BeginConfiguration(
+ 1,
+ new SidecarProtocolSelection(1, 0, 0, new SidecarLimits(4096, maxInFlight, 1000)),
+ maxInputBytes: maxInputBytes,
+ maxOutputBytes: maxOutputBytes,
+ defaultTimeoutMs: defaultTimeoutMs,
+ maxTimeoutMs: maxTimeoutMs,
+ resultGraceMs: resultGraceMs);
+ var configuration = configure["configuration"]!.AsObject();
+ var manifest = new JsonObject
+ {
+ ["manifestGeneration"] = configuration["manifestGeneration"]!.DeepClone(),
+ ["capabilities"] = configuration["capabilities"]!.DeepClone(),
+ ["commands"] = new JsonArray(
+ configuration["commands"]!.AsArray()
+ .Select(command => (JsonNode?)command!["name"]!.DeepClone())
+ .ToArray())
+ };
+ adapter.ConfirmConfigured(ParseJson(new JsonObject
+ {
+ ["type"] = "configured",
+ ["manifest"] = manifest
+ }.ToJsonString()));
+ }
+
+ private static async Task AdmitAsync(
+ WindowsSidecarCapabilityAdapter adapter,
+ JsonElement invocation) =>
+ (await adapter.HandleRuntimeMessageAsync(
+ ParseJson(new JsonObject
+ {
+ ["type"] = "admission-request",
+ ["invocation"] = JsonNode.Parse(invocation.GetRawText())
+ }.ToJsonString()),
+ CancellationToken.None))!;
+
+ private static async Task InvokeAsync(
+ WindowsSidecarCapabilityAdapter adapter,
+ JsonElement invocation) =>
+ (await adapter.HandleRuntimeMessageAsync(
+ ParseJson(new JsonObject
+ {
+ ["type"] = "invoke",
+ ["invocation"] = JsonNode.Parse(invocation.GetRawText())
+ }.ToJsonString()),
+ CancellationToken.None))!;
+
+ private static async Task<(
+ WindowsSidecarSupervisor Supervisor,
+ AuthenticatedSidecarChannel Runtime)> CreateConfiguredSupervisorAsync(
+ WindowsSidecarCapabilityAdapter adapter)
+ {
+ using var fixture = ReadFixture("node-sidecar-handshake-v1.json");
+ var root = fixture.RootElement;
+ var session = root.GetProperty("session");
+ var key = Convert.FromBase64String(session.GetProperty("keyBase64").GetString()!);
+ var supervisor = new WindowsSidecarSupervisor(
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096,
+ ParseOffer(root.GetProperty("supervisorOffer")),
+ adapter,
+ manifestGeneration: 3);
+ var runtime = new AuthenticatedSidecarChannel(
+ SidecarPeerRole.Runtime,
+ session.GetProperty("id").GetString()!,
+ session.GetProperty("generation").GetUInt64(),
+ key,
+ 4096);
+ try
+ {
+ _ = runtime.Open(supervisor.Start());
+ var accept = new JsonObject
+ {
+ ["type"] = "accept",
+ ["offer"] = JsonNode.Parse(root.GetProperty("runtimeOffer").GetRawText()),
+ ["selection"] = JsonNode.Parse(root.GetProperty("selection").GetRawText())
+ };
+ var acceptanceFrame = runtime.Seal(SidecarJson.Serialize(accept));
+ runtime.LowerFrameLimit(2048);
+ var configuration = SidecarJson.Parse(
+ runtime.Open(supervisor.CompleteHandshake(acceptanceFrame)))
+ .GetProperty("configuration");
+ var configured = new JsonObject
+ {
+ ["type"] = "configured",
+ ["manifest"] = new JsonObject
+ {
+ ["manifestGeneration"] = configuration.GetProperty("manifestGeneration").GetUInt64(),
+ ["capabilities"] = JsonNode.Parse(configuration.GetProperty("capabilities").GetRawText()),
+ ["commands"] = new JsonArray(
+ configuration.GetProperty("commands").EnumerateArray()
+ .Select(command => (JsonNode?)command.GetProperty("name").GetString())
+ .ToArray())
+ }
+ };
+ await supervisor.ReceiveAsync(
+ runtime.Seal(SidecarJson.Serialize(configured)),
+ CancellationToken.None);
+ return (supervisor, runtime);
+ }
+ catch
+ {
+ supervisor.Dispose();
+ runtime.Dispose();
+ throw;
+ }
+ }
+
+ private static JsonElement ParseCanonical(JsonElement canonical) =>
+ ParseJson(canonical.GetString()!);
+
+ private static JsonElement ParseJson(string json) =>
+ SidecarJson.Parse(Encoding.UTF8.GetBytes(json));
+
+ private static JsonDocument ReadFixture(string name) => JsonDocument.Parse(
+ File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "RustSidecar", "Fixtures", name)));
+
+ private static SidecarProtocolOffer ParseOffer(JsonElement offer)
+ {
+ var peer = offer.GetProperty("peer");
+ var limits = offer.GetProperty("limits");
+ return new SidecarProtocolOffer(
+ offer.GetProperty("protocolMajor").GetUInt16(),
+ offer.GetProperty("protocolMinor").GetUInt16(),
+ new SidecarPeerIdentity(
+ peer.GetProperty("role").GetString() == "supervisor"
+ ? SidecarPeerRole.Supervisor
+ : SidecarPeerRole.Runtime,
+ peer.GetProperty("name").GetString()!,
+ peer.GetProperty("version").GetString()!,
+ peer.GetProperty("artifactIdentity").GetString()!),
+ offer.GetProperty("featureBits").GetUInt64(),
+ new SidecarLimits(
+ limits.GetProperty("maxFrameBytes").GetUInt32(),
+ limits.GetProperty("maxInFlight").GetUInt16(),
+ limits.GetProperty("bootstrapTimeoutMs").GetUInt32()));
+ }
+
+ private sealed class SlowSerializationPayload(TimeSpan delay)
+ {
+ public string Value
+ {
+ get
+ {
+ Thread.Sleep(delay);
+ return "serialized";
+ }
+ }
+ }
+
+ private sealed class TestCapability(
+ string category,
+ string command,
+ Func> execute)
+ : INodeCapability
+ {
+ public string Category => category;
+ public IReadOnlyList Commands => [command];
+ public bool CanHandle(string value) => string.Equals(value, command, StringComparison.Ordinal);
+ public Task ExecuteAsync(NodeInvokeRequest request) =>
+ execute(request, CancellationToken.None);
+ public Task ExecuteAsync(
+ NodeInvokeRequest request,
+ CancellationToken cancellationToken) => execute(request, cancellationToken);
+ }
+
+ private sealed class TestLogger : IOpenClawLogger
+ {
+ public void Info(string message) { }
+ public void Debug(string message) { }
+ public void Warn(string message) { }
+ public void Error(string message, Exception? ex = null) { }
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
index 96421b7f1..3bd28393a 100644
--- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
@@ -5,6 +5,18 @@ namespace OpenClaw.Tray.Tests;
public sealed class AppRefactorContractTests
{
+ [Fact]
+ public void NodeService_RetiresAnyRuntimeImplementationOnDisposedEvent()
+ {
+ var root = TestRepositoryPaths.GetRepositoryRoot();
+ var source = File.ReadAllText(Path.Combine(
+ root, "src", "OpenClaw.Tray.WinUI", "Services", "NodeService.cs"));
+ var method = ExtractMethod(source, "OnNodeClientDisposed");
+
+ Assert.Contains("sender is INodeRuntimeClient client", method);
+ Assert.DoesNotContain("sender is WindowsNodeClient client", method);
+ }
+
[Fact]
public void Startup_UsesConnectionManagerAsOnlyGatewayClientOwner()
{